@lh8ppl/claude-memory-kit 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +16 -10
  2. package/bin/cmk-capture-prompt.mjs +21 -1
  3. package/package.json +2 -1
  4. package/src/audit-log.mjs +1 -0
  5. package/src/auto-drain.mjs +17 -1
  6. package/src/auto-extract.mjs +72 -16
  7. package/src/auto-persona.mjs +86 -1
  8. package/src/capture-prompt.mjs +34 -1
  9. package/src/capture-turn.mjs +64 -6
  10. package/src/config-core.mjs +161 -0
  11. package/src/conflict-queue.mjs +20 -3
  12. package/src/content-hash.mjs +30 -0
  13. package/src/doctor.mjs +62 -3
  14. package/src/forget.mjs +13 -0
  15. package/src/frontmatter.mjs +4 -1
  16. package/src/import-anthropic-memory.mjs +25 -1
  17. package/src/import-claude-md.mjs +333 -0
  18. package/src/index-db.mjs +39 -0
  19. package/src/index-rebuild.mjs +48 -4
  20. package/src/index.mjs +10 -0
  21. package/src/inject-context.mjs +179 -7
  22. package/src/install.mjs +180 -1
  23. package/src/mcp-server.mjs +63 -8
  24. package/src/memory-health.mjs +229 -0
  25. package/src/memory-write.mjs +32 -10
  26. package/src/merge-facts.mjs +12 -0
  27. package/src/native-binding.mjs +142 -0
  28. package/src/poison-guard.mjs +55 -0
  29. package/src/provenance.mjs +4 -0
  30. package/src/remember-core.mjs +53 -8
  31. package/src/repair.mjs +20 -3
  32. package/src/result-shapes.mjs +1 -1
  33. package/src/scratchpad.mjs +5 -3
  34. package/src/search.mjs +96 -9
  35. package/src/semantic-backend.mjs +599 -0
  36. package/src/settings-hooks.mjs +4 -1
  37. package/src/subcommands.mjs +359 -42
  38. package/src/transcript-index.mjs +165 -0
  39. package/src/turn-tools.mjs +179 -0
  40. package/src/write-fact.mjs +34 -3
  41. package/template/.claude/skills/memory-search/SKILL.md +86 -0
  42. package/template/.gitattributes.fragment +16 -0
  43. package/template/CLAUDE.md.template +3 -1
@@ -0,0 +1,161 @@
1
+ // `cmk config get/set/--show-origin` core (Task 129, D-121).
2
+ //
3
+ // The v0.1.0 stub became real the day `--with-semantic` shipped:
4
+ // context/settings.json now carries a user-facing setting
5
+ // (search.default_mode) and hand-editing JSON was the only path. This is
6
+ // the read-merge-write surface over the kit's settings files.
7
+ //
8
+ // Settings live in `<tier-root>/settings.json` for each of the three tiers
9
+ // (resolveTierRoot — the shared module, not re-derived). Resolution
10
+ // precedence mirrors the kit's memory model + git config semantics:
11
+ // local (context.local/) > project (context/) > user (~/.claude-memory-kit/)
12
+ // A `get` returns the highest-precedence tier that defines the dotted key;
13
+ // `--show-origin` lists every tier that defines it (winner + shadowed), the
14
+ // direnv lesson (design §7.2: "without --show-origin, users rage-quit when
15
+ // settings appear from nowhere"). `set` writes one tier (project default),
16
+ // preserving every sibling key (the mergeProjectSettings discipline,
17
+ // generalized per tier).
18
+ //
19
+ // Scope (D-121): the kit's own JSON settings files. NOT the richer
20
+ // settings-or-observation `--show-origin` sketch in design §7.2's example
21
+ // (observations have their own provenance/shadowed_by surface, §6); this is
22
+ // the concrete settings half the semantic default forced into existence.
23
+
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { resolveTierRoot } from './tier-paths.mjs';
27
+
28
+ // Highest-precedence first.
29
+ const TIERS = Object.freeze([
30
+ { name: 'local', tier: 'L' },
31
+ { name: 'project', tier: 'P' },
32
+ { name: 'user', tier: 'U' },
33
+ ]);
34
+
35
+ // Keys that would pollute the prototype chain — rejected on both read and
36
+ // write. `cmk config set __proto__.x y` must never reach Object.prototype
37
+ // (skill-review blocking finding); a key path containing any of these is
38
+ // invalid, not a silent no-op.
39
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
40
+ function hasForbiddenSegment(dottedKey) {
41
+ return dottedKey.split('.').some((p) => FORBIDDEN_KEYS.has(p));
42
+ }
43
+
44
+ function settingsPathFor(tierName, { projectRoot, userDir }) {
45
+ const tier = TIERS.find((t) => t.name === tierName)?.tier;
46
+ return join(resolveTierRoot({ tier, projectRoot, userDir }), 'settings.json');
47
+ }
48
+
49
+ function readSettings(path) {
50
+ if (!existsSync(path)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch {
54
+ // A malformed settings file is treated as absent for resolution — never
55
+ // throw on a read (a hand-broken JSON shouldn't crash `cmk config get`).
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Walk a dotted path; returns {found, value}. `found` distinguishes a key
61
+ // set to `undefined`-ish from a key that isn't there (the honesty contract).
62
+ function dig(obj, dottedKey) {
63
+ if (obj == null || typeof obj !== 'object') return { found: false };
64
+ const parts = dottedKey.split('.');
65
+ let cur = obj;
66
+ for (const p of parts) {
67
+ if (cur == null || typeof cur !== 'object' || !(p in cur)) return { found: false };
68
+ cur = cur[p];
69
+ }
70
+ return { found: true, value: cur };
71
+ }
72
+
73
+ /**
74
+ * Resolve a dotted setting key across tiers (local > project > user).
75
+ *
76
+ * @returns {{found: boolean, value?: *, tier?: 'local'|'project'|'user'}}
77
+ */
78
+ export function configGet(key, { projectRoot, userDir } = {}) {
79
+ if (!key || !String(key).trim()) return { found: false };
80
+ if (hasForbiddenSegment(key)) return { found: false };
81
+ for (const { name } of TIERS) {
82
+ const settings = readSettings(settingsPathFor(name, { projectRoot, userDir }));
83
+ const hit = dig(settings, key);
84
+ if (hit.found) return { found: true, value: hit.value, tier: name };
85
+ }
86
+ return { found: false };
87
+ }
88
+
89
+ /** Scalar coercion: true/false/null → primitives, integer/float strings →
90
+ * numbers, everything else stays a string. JSON settings are typed, and a
91
+ * CLI arg is always a string — `cmk config set x true` should write a bool. */
92
+ function coerce(raw) {
93
+ if (raw === 'true') return true;
94
+ if (raw === 'false') return false;
95
+ if (raw === 'null') return null;
96
+ if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
97
+ if (/^-?\d*\.\d+$/.test(raw)) return Number.parseFloat(raw);
98
+ return raw;
99
+ }
100
+
101
+ function setDeep(obj, dottedKey, value) {
102
+ const parts = dottedKey.split('.');
103
+ let cur = obj;
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ const p = parts[i];
106
+ if (cur[p] == null || typeof cur[p] !== 'object' || Array.isArray(cur[p])) cur[p] = {};
107
+ cur = cur[p];
108
+ }
109
+ cur[parts[parts.length - 1]] = value;
110
+ }
111
+
112
+ /**
113
+ * Set a dotted key in one tier's settings.json (project default), preserving
114
+ * every sibling key (read-merge-write).
115
+ *
116
+ * @returns {{ok: boolean, tier?: string, path?: string, error?: string}}
117
+ */
118
+ export function configSet(key, rawValue, { projectRoot, userDir, tier = 'project' } = {}) {
119
+ if (!key || !String(key).trim()) return { ok: false, error: 'key is required (dotted path)' };
120
+ if (hasForbiddenSegment(key)) {
121
+ return { ok: false, error: `key contains a forbidden segment (${[...FORBIDDEN_KEYS].join('/')}) — prototype-pollution guard` };
122
+ }
123
+ if (!TIERS.some((t) => t.name === tier)) {
124
+ return { ok: false, error: `tier must be one of local/project/user (got ${tier})` };
125
+ }
126
+ const path = settingsPathFor(tier, { projectRoot, userDir });
127
+ try {
128
+ const current = readSettings(path) ?? {};
129
+ setDeep(current, key, coerce(String(rawValue)));
130
+ mkdirSync(dirname(path), { recursive: true });
131
+ writeFileSync(path, JSON.stringify(current, null, 2) + '\n', 'utf8');
132
+ return { ok: true, tier, path };
133
+ } catch (err) {
134
+ return { ok: false, error: err?.message ?? String(err) };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Every tier that defines the key, highest-precedence first. The winner is
140
+ * the first; the rest carry `shadowedBy` = the winning tier (the direnv
141
+ * "where did this come from?" surface).
142
+ *
143
+ * @returns {{found: boolean, entries: Array<{tier, value, path, winner, shadowedBy?}>}}
144
+ */
145
+ export function configShowOrigin(key, { projectRoot, userDir } = {}) {
146
+ const entries = [];
147
+ if (!key || !String(key).trim()) return { found: false, entries };
148
+ if (hasForbiddenSegment(key)) return { found: false, entries };
149
+ for (const { name } of TIERS) {
150
+ const path = settingsPathFor(name, { projectRoot, userDir });
151
+ const hit = dig(readSettings(path), key);
152
+ if (hit.found) entries.push({ tier: name, value: hit.value, path });
153
+ }
154
+ if (entries.length === 0) return { found: false, entries: [] };
155
+ const winnerTier = entries[0].tier;
156
+ for (let i = 0; i < entries.length; i++) {
157
+ entries[i].winner = i === 0;
158
+ if (i > 0) entries[i].shadowedBy = winnerTier;
159
+ }
160
+ return { found: true, entries };
161
+ }
@@ -49,6 +49,8 @@ import {
49
49
  } from 'node:fs';
50
50
  import { join } from 'node:path';
51
51
  import { resolveTierRoot, VALID_TIERS } from './tier-paths.mjs';
52
+ import { writeBullet } from './provenance.mjs';
53
+ import { hashContent } from './content-hash.mjs';
52
54
  import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
53
55
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
54
56
  import { generateId } from '@lh8ppl/cmk-canonicalize';
@@ -786,9 +788,24 @@ export function mergeScratchpadBullets({
786
788
  const effectiveSection = section ?? discoverSectionAt(lines, matchA.bulletIdx);
787
789
  const range = effectiveSection ? findSectionRange(updatedLines, effectiveSection) : null;
788
790
  const insertAt = range ? range.endIdx : updatedLines.length;
789
- const newBullet = `- (${newId}) ${combinedText}`;
790
- const newProvenance = `<!-- source: merge-both, merged_from: [${idA}, ${idB}], merged_at: ${ts}, trust: ${mergedTrust} -->`;
791
- updatedLines.splice(insertAt, 0, newBullet, newProvenance, '');
791
+ // D-125 class (Task 138 review finding): the old hand-rolled comment had
792
+ // no `write:` key, so the first reindex after a merge-both resolution hit
793
+ // the NOT-NULL observations.write_source constraint. Canonical shape via
794
+ // the shared builder; the merged_from trail lives in the audit entry below.
795
+ const sha1 = hashContent(combinedText);
796
+ const formatted = writeBullet({
797
+ id: newId,
798
+ text: combinedText,
799
+ provenance: {
800
+ source: 'merge-both',
801
+ source_line: 1,
802
+ sha1,
803
+ write: 'merged',
804
+ trust: mergedTrust,
805
+ at: ts,
806
+ },
807
+ });
808
+ updatedLines.splice(insertAt, 0, ...formatted.lines.split('\n'), '');
792
809
 
793
810
  writeFileSync(scratchpadPath, updatedLines.join('\n'), 'utf8');
794
811
 
@@ -0,0 +1,30 @@
1
+ // Content-fingerprint helper — the single home for the kit's content hash.
2
+ //
3
+ // Every "fingerprint this text/file content" site (provenance source_sha1,
4
+ // the `files` checkpoint diff key, transcript dedup, conflict-merge keys)
5
+ // MUST route through hashContent so the algorithm is defined in exactly one
6
+ // place. Eight modules previously rolled their own `createHash('sha1')`,
7
+ // which (a) let the algorithm drift per-site and (b) tripped CodeQL's
8
+ // js/weak-cryptographic-algorithm on each one independently.
9
+ //
10
+ // SHA-256, not SHA-1: the digests are non-cryptographic content fingerprints
11
+ // (dedup + change-detection), so SHA-1 was never a security flaw here — but a
12
+ // weak-hash sink on every site is noise that hides real findings, and the
13
+ // whole-convention move to SHA-256 (the user's call, D-149) removes the sink
14
+ // kit-wide while keeping the digest consistent across writers. The on-disk
15
+ // FIELD name stays `source_sha1` / `sha1` for back-compat (renaming the YAML
16
+ // key + db column would break existing fact files + checkpoints); only the
17
+ // algorithm changes. Existing `files`-table checkpoints mismatch once on the
18
+ // first boot after upgrade and self-heal via the normal reindex.
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ /**
23
+ * Hash text/file content to a hex digest used as a non-cryptographic
24
+ * fingerprint (dedup, drift-detection, provenance). UTF-8 input.
25
+ * @param {string} content
26
+ * @returns {string} 64-char lowercase hex SHA-256 digest
27
+ */
28
+ export function hashContent(content) {
29
+ return createHash('sha256').update(content, 'utf8').digest('hex');
30
+ }
package/src/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `cmk doctor` — health checks HC-1..HC-7 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120).
1
+ // `cmk doctor` — health checks HC-1..HC-8 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120; HC-8 native bindings added in Task 141a).
2
2
  //
3
3
  // Public boundary:
4
4
  // async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
@@ -44,6 +44,8 @@ import { nowIso } from './audit-log.mjs';
44
44
  import { detectStaleLocks } from './lock-discipline.mjs';
45
45
  import { cronSentinelPath } from './lazy-compress.mjs';
46
46
  import { getNativeAutoMemoryState } from './native-memory.mjs';
47
+ import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
+ import { resolveDefaultSearchMode } from './semantic-backend.mjs';
47
49
 
48
50
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
49
51
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -470,13 +472,67 @@ function hc7StaleLocks({ projectRoot, userDir }) {
470
472
  };
471
473
  }
472
474
 
475
+ // --- HC-8: native bindings present (npm 12 readiness, Task 141a) -------
476
+ // The BACKSTOP, not the primary UX: `cmk install` probes + asks inline
477
+ // (the user's 2026-06-12 steer); HC-8 catches the after-the-fact states
478
+ // (npm upgraded later, package reinstalled without the allow flag).
479
+ // The repair is an `npm install -g` → requiresInstall per the design §14
480
+ // ask-before-install rule.
481
+ async function hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe }) {
482
+ const kitProbe = kitBindingProbe ?? checkKitBinding;
483
+ const kit = kitProbe();
484
+ if (!kit.ok) {
485
+ return {
486
+ id: 'HC-8',
487
+ name: 'Native bindings present (npm 12 readiness)',
488
+ status: 'fail',
489
+ message: `better-sqlite3 native binding unavailable (${kit.reason}) — most common cause: npm 12 blocks dependency install scripts by default, so a fresh install skips the binding build (a Node major upgrade is the other); search/reindex will crash until it is rebuilt`,
490
+ recoveryCommand: kit.remedy,
491
+ requiresInstall: true,
492
+ };
493
+ }
494
+ // The embedder matters only when this project actually defaults to it.
495
+ const mode = resolveDefaultSearchMode({ projectRoot });
496
+ if (mode === 'keyword') {
497
+ return {
498
+ id: 'HC-8',
499
+ name: 'Native bindings present (npm 12 readiness)',
500
+ status: 'pass',
501
+ message: 'better-sqlite3 binding healthy (semantic not configured — embedder not checked)',
502
+ };
503
+ }
504
+ const embedderProbe = embedderBindingProbe ?? checkEmbedderBinding;
505
+ const embedder = await embedderProbe();
506
+ if (!embedder.ok) {
507
+ const state = embedder.installed
508
+ ? `installed but its native binding failed (${embedder.reason}) — npm 12 blocks onnxruntime-node's install script by default`
509
+ : `not installed, but search.default_mode is '${mode}'`;
510
+ return {
511
+ id: 'HC-8',
512
+ name: 'Native bindings present (npm 12 readiness)',
513
+ status: 'fail',
514
+ message: `semantic embedder ${state}; searches degrade to keyword until fixed`,
515
+ recoveryCommand: embedder.remedy,
516
+ requiresInstall: true,
517
+ };
518
+ }
519
+ return {
520
+ id: 'HC-8',
521
+ name: 'Native bindings present (npm 12 readiness)',
522
+ status: 'pass',
523
+ message: `better-sqlite3 binding healthy; embedder import OK (default mode: ${mode}; the deep pipeline check runs at --with-semantic warm)`,
524
+ };
525
+ }
526
+
473
527
  /**
474
- * Run the full 7-check health audit.
528
+ * Run the full 8-check health audit.
475
529
  *
476
530
  * @param {object} opts
477
531
  * @param {string} opts.projectRoot
478
532
  * @param {string} [opts.userDir]
479
533
  * @param {string} [opts.now]
534
+ * @param {Function} [opts.kitBindingProbe] - HC-8 test seam.
535
+ * @param {Function} [opts.embedderBindingProbe] - HC-8 test seam.
480
536
  * @returns {Promise<{action, checks, duration_ms}>}
481
537
  *
482
538
  * Note: M3 fix (skill-review 2026-05-28) dropped the v0.1.0 `promptUser`
@@ -489,6 +545,8 @@ export async function runDoctor({
489
545
  projectRoot,
490
546
  userDir,
491
547
  now,
548
+ kitBindingProbe,
549
+ embedderBindingProbe,
492
550
  } = {}) {
493
551
  const t0 = Date.now();
494
552
  if (!projectRoot) {
@@ -510,10 +568,11 @@ export async function runDoctor({
510
568
  const c5 = hc5CronRegistered({ projectRoot });
511
569
  const c6 = hc6NativeAutoMemory({ projectRoot, now: ts });
512
570
  const c7 = hc7StaleLocks({ projectRoot, userDir: resolvedUserDir });
571
+ const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
513
572
 
514
573
  return {
515
574
  action: 'completed',
516
- checks: [c1, c2, c3, c4, c5, c6, c7],
575
+ checks: [c1, c2, c3, c4, c5, c6, c7, c8],
517
576
  duration_ms: Date.now() - t0,
518
577
  };
519
578
  }
package/src/forget.mjs CHANGED
@@ -29,6 +29,7 @@ import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.m
29
29
  import { findBulletScratchpad } from './bullet-lookup.mjs';
30
30
  import { openIndexDb } from './index-db.mjs';
31
31
  import { reindexBoot } from './index-rebuild.mjs';
32
+ import { reindex } from './reindex.mjs';
32
33
 
33
34
  // Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
34
35
  // minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
@@ -292,6 +293,18 @@ export function forget(opts = {}) {
292
293
  },
293
294
  });
294
295
 
296
+ // Task 124 (D-112): the writer owns the derived view on the DELETE path
297
+ // too — writeFact refreshes INDEX.md on every create (the Task-85 lesson);
298
+ // without this, the tombstoned fact stayed listed in INDEX.md and doctor
299
+ // HC-4 failed until a manual `cmk reindex` (dogfood-found 2026-06-10).
300
+ // Best-effort, same contract as writeFact's: the tombstone is already
301
+ // durable on disk, so an index hiccup must not fail the forget.
302
+ try {
303
+ reindex({ tier: match.tier, projectRoot, userDir, warn: () => {} });
304
+ } catch {
305
+ // index rebuild is best-effort; the tombstone already succeeded
306
+ }
307
+
295
308
  // Task 110 (F-7 / D-84): reindex the project tier IN-BAND so the just-
296
309
  // tombstoned fact stops surfacing in `cmk search` immediately — no manual
297
310
  // `cmk reindex`, no forgotten fact resurfacing (D-85: the action completes
@@ -43,7 +43,10 @@ const LOAD_OPTIONS = Object.freeze({
43
43
 
44
44
  export function parse(text) {
45
45
  if (typeof text !== 'string') return { frontmatter: null, body: '' };
46
- const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
46
+ // Task 139 (D-126): \r? tolerance — a Windows clone with autocrlf=true
47
+ // rewrites committed memory files to CRLF, and a strict-\n boundary made
48
+ // every fact file invisible (cut-gate9 H1: clone reindex found 0 facts).
49
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
47
50
  if (!m) return { frontmatter: null, body: text };
48
51
  let frontmatter;
49
52
  try {
@@ -39,6 +39,8 @@ import {
39
39
  REASON_CODES,
40
40
  } from './audit-log.mjs';
41
41
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
42
+ import { writeBullet } from './provenance.mjs';
43
+ import { hashContent } from './content-hash.mjs';
42
44
 
43
45
  const MEMORY_REL = ['context', 'MEMORY.md'];
44
46
 
@@ -227,7 +229,29 @@ export async function importAnthropicMemory({
227
229
  // deduplication of section headers is a v0.1.x candidate per design §16.
228
230
  const today = ts.slice(0, 10);
229
231
  const sectionHeader = `\n## Imported (Anthropic auto-memory, ${today})\n`;
230
- const bulletLines = proposals.map((p) => `- (${p.id}) ${p.text}\n<!-- write_source: imported, trust: medium, source: anthropic-auto-memory, imported_at: ${ts} -->`).join('\n');
232
+ // Task 138 (D-125): emit the CANONICAL provenance comment via the shared
233
+ // writeBullet builder — the hand-rolled `write_source:`-keyed comment was
234
+ // invisible to the reindex parser (it maps the `write:` key to the
235
+ // NOT-NULL observations.write_source column), so the first reindex after
236
+ // an import failed and search degraded to the stale index (cut-gate9 F-13).
237
+ const bulletLines = proposals
238
+ .map((p) => {
239
+ const sha1 = hashContent(p.text);
240
+ const formatted = writeBullet({
241
+ id: p.id,
242
+ text: p.text,
243
+ provenance: {
244
+ source: 'anthropic-auto-memory',
245
+ source_line: 1,
246
+ sha1,
247
+ write: 'imported',
248
+ trust: 'medium',
249
+ at: ts,
250
+ },
251
+ });
252
+ return formatted.lines;
253
+ })
254
+ .join('\n');
231
255
  mkdirSync(join(projectRoot, 'context'), { recursive: true });
232
256
  appendFileSync(targetPath, sectionHeader + '\n' + bulletLines + '\n', 'utf8');
233
257