@lh8ppl/claude-memory-kit 0.1.1 → 0.2.0

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 (58) hide show
  1. package/README.md +8 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-capture-prompt.mjs +0 -0
  4. package/bin/cmk-capture-turn.mjs +0 -0
  5. package/bin/cmk-compress-session.mjs +31 -17
  6. package/bin/cmk-inject-context.mjs +12 -2
  7. package/bin/cmk-observe-edit.mjs +0 -0
  8. package/bin/cmk-weekly-curate.mjs +14 -2
  9. package/package.json +3 -2
  10. package/src/audit-log.mjs +6 -0
  11. package/src/auto-drain.mjs +59 -0
  12. package/src/auto-extract.mjs +117 -6
  13. package/src/auto-persona.mjs +544 -0
  14. package/src/bullet-lookup.mjs +59 -0
  15. package/src/capture-turn.mjs +54 -0
  16. package/src/compress-session.mjs +6 -8
  17. package/src/compressor.mjs +37 -22
  18. package/src/conflict-queue.mjs +8 -1
  19. package/src/daily-distill.mjs +19 -11
  20. package/src/doctor.mjs +79 -26
  21. package/src/forget.mjs +14 -0
  22. package/src/graduate-session.mjs +65 -0
  23. package/src/graduation.mjs +179 -0
  24. package/src/index-rebuild.mjs +26 -4
  25. package/src/inject-context.mjs +352 -65
  26. package/src/install.mjs +52 -7
  27. package/src/lessons-promote.mjs +137 -0
  28. package/src/mcp-server.mjs +17 -0
  29. package/src/memory-write.mjs +20 -7
  30. package/src/native-memory.mjs +98 -0
  31. package/src/persona-portability.mjs +253 -0
  32. package/src/provenance.mjs +23 -5
  33. package/src/read-hook-stdin.mjs +47 -0
  34. package/src/register-crons.mjs +17 -8
  35. package/src/sanitize.mjs +39 -0
  36. package/src/scratchpad.mjs +247 -19
  37. package/src/session-end-tasks.mjs +127 -0
  38. package/src/settings-hooks.mjs +33 -3
  39. package/src/spawn-bin.mjs +83 -0
  40. package/src/subcommands.mjs +472 -26
  41. package/src/weekly-curate.mjs +53 -6
  42. package/src/write-fact.mjs +60 -3
  43. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  44. package/template/.gitignore.fragment +6 -0
  45. package/template/CLAUDE.md.template +17 -7
  46. package/template/local/machine-paths.md.template +1 -12
  47. package/template/local/overrides.md.template +1 -11
  48. package/template/project/MEMORY.md.template +5 -26
  49. package/template/project/SOUL.md.template +1 -10
  50. package/template/user/fragments/INDEX.md.template +1 -1
  51. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  52. package/template/.claude/hooks/transcript-capture.js +0 -69
  53. package/template/.claude/settings.json +0 -27
  54. package/template/support/scripts/auto-extract-memory.sh +0 -102
  55. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  56. package/template/support/scripts/register-crons.py +0 -242
  57. package/template/support/scripts/run-daily-distill.sh +0 -67
  58. package/template/support/scripts/run-weekly-curate.sh +0 -58
@@ -0,0 +1,179 @@
1
+ // graduation.mjs — Task 91 (D-54 / D-57). The 3rd MEMORY.md shrink mechanism.
2
+ //
3
+ // When appendScratchpadBullet() hits cap pressure and consolidate() (stale-drop)
4
+ // can't free enough — because high-trust bullets are NEVER dropped — graduation
5
+ // moves the OLDEST high-trust bullets OUT of the byte-capped hot index into the
6
+ // permanent, indexed fact store (context/memory/<type>_<slug>.md via writeFact),
7
+ // so the new write lands instead of returning CAP_EXCEEDED.
8
+ //
9
+ // Decision A (D-57): SEARCH-ONLY (graduated facts are not injected; reliable
10
+ // recall of them is Task 75, v0.3) and PROJECT MEMORY.md ONLY — the caller gates
11
+ // the tier/scratchpad so this never fires on user-tier persona scratchpads.
12
+ //
13
+ // Reuses writeFact(), which gives four lifecycle-edge guarantees for free:
14
+ // - cross-store dedup (content-id keyed → re-graduating the same fact is a
15
+ // no-op `skipped`, not a duplicate file),
16
+ // - home-path sanitization + Poison_Guard (the safe write path),
17
+ // - reindex-on-write (the FTS5/INDEX.md view stays consistent — map edge #8).
18
+
19
+ import { unlinkSync } from 'node:fs';
20
+ import { writeFact } from './write-fact.mjs';
21
+ import { reindex } from './reindex.mjs';
22
+ import { parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
23
+
24
+ // Loose enough to match whatever id a bullet carries (graduation moves the
25
+ // bullet regardless of id-alphabet validity; that's the writer's concern).
26
+ const BULLET_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)\s*(.*)$/;
27
+
28
+ const VALID_WRITE_SOURCES = new Set([
29
+ 'user-explicit',
30
+ 'auto-extract',
31
+ 'compressor',
32
+ 'manual-edit',
33
+ 'imported',
34
+ ]);
35
+
36
+ function slugify(s) {
37
+ // Collapse non-alphanumerics to single dashes, cap, trim edges (string ops,
38
+ // no trailing-dash quantifier — matches subcommands.slugifyFact's ReDoS-safe
39
+ // shape).
40
+ let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
41
+ if (base.startsWith('-')) base = base.slice(1);
42
+ if (base.endsWith('-')) base = base.slice(0, -1);
43
+ return base || 'fact';
44
+ }
45
+
46
+ function deriveTitle(text) {
47
+ const t = String(text).trim().slice(0, 80).trim();
48
+ return t || 'graduated fact';
49
+ }
50
+
51
+ // One bullet → one project-tier fact file. writeFact action 'error' means it
52
+ // could NOT be stored (collision / poison / schema) → the caller must keep the
53
+ // bullet; anything else ('created' or a dedup 'skipped') means it's safely in
54
+ // the permanent store → the caller removes the bullet from the hot index.
55
+ function graduateOne({ id, text, prov, tier, projectRoot, userDir, now }) {
56
+ // Slug carries a content-derived tail so two distinct facts never collide on
57
+ // `project_<slug>.md`, while the SAME fact re-graduates to the same filename
58
+ // (→ writeFact dedups by id instead of creating a duplicate).
59
+ const slugTail = id.replace(/^[PUL]-/, '').toLowerCase();
60
+ return writeFact({
61
+ tier,
62
+ // User-tier graduated content is cross-project doctrine → 'user'; project
63
+ // (and any other) tier → 'project'. Both are valid fact-file types; this
64
+ // just keeps the on-disk filename + frontmatter semantically honest.
65
+ type: tier === 'U' ? 'user' : 'project',
66
+ slug: `${slugify(text)}-${slugTail}`,
67
+ title: deriveTitle(text),
68
+ body: text,
69
+ writeSource: VALID_WRITE_SOURCES.has(prov.write) ? prov.write : 'manual-edit',
70
+ trust: 'high',
71
+ sourceFile: prov.source || 'MEMORY.md',
72
+ sourceLine: prov.source_line || 1,
73
+ sourceSha1: prov.sha1,
74
+ id, // preserve the citation id across graduation; also the dedup key
75
+ createdAt: prov.at, // keep the original capture time
76
+ projectRoot,
77
+ userDir,
78
+ now,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Graduate oldest high-trust bullets out of `text` until it fits `capBytes`.
84
+ *
85
+ * @returns {{ text: string, graduated: string[] }} the new scratchpad content
86
+ * (graduated bullets removed) and the ids that were graduated.
87
+ */
88
+ export function graduateForCapRelief({
89
+ text,
90
+ capBytes,
91
+ tier,
92
+ projectRoot,
93
+ userDir,
94
+ now,
95
+ }) {
96
+ const lines = text.split('\n');
97
+ const entries = [];
98
+ for (let i = 0; i < lines.length - 1; i++) {
99
+ const m = lines[i].match(BULLET_RE);
100
+ if (!m) continue;
101
+ if (!isProvenanceCommentLine(lines[i + 1])) continue;
102
+ const prov = parseBulletProvenance(lines[i + 1]);
103
+ if (!prov || prov.trust !== 'high' || !prov.at) continue;
104
+ entries.push({ bulletIdx: i, commentIdx: i + 1, id: m[1], text: m[2], prov });
105
+ }
106
+ // Feasibility gate (composition safety): if graduating EVERY eligible bullet
107
+ // still wouldn't get under cap, graduate NOTHING. Otherwise writeFact would
108
+ // persist fact files that the failed-append error path then strands — the
109
+ // bullets stay in the unchanged on-disk MEMORY.md AND now also exist as fact
110
+ // files: the exact double-capture this task exists to kill. Returning early
111
+ // lets CAP_EXCEEDED fire cleanly with zero side effects.
112
+ const bulletBytes = (e) =>
113
+ Buffer.byteLength(`${lines[e.bulletIdx]}\n${lines[e.commentIdx]}\n`, 'utf8');
114
+ const totalGraduatable = entries.reduce((sum, e) => sum + bulletBytes(e), 0);
115
+ const startBytes = Buffer.byteLength(text, 'utf8');
116
+ if (startBytes - totalGraduatable > capBytes) {
117
+ return { text, graduated: [] };
118
+ }
119
+
120
+ // Oldest first: graduate aged durable facts before recent ones. The just-
121
+ // appended bullet (newest `at`) sorts last and only graduates if still needed.
122
+ entries.sort(
123
+ (a, b) => new Date(a.prov.at).getTime() - new Date(b.prov.at).getTime(),
124
+ );
125
+
126
+ const removeIdx = new Set();
127
+ const graduated = [];
128
+ const createdPaths = []; // files writeFact NEWLY created (for transactional rollback)
129
+ let curBytes = Buffer.byteLength(text, 'utf8');
130
+ for (const e of entries) {
131
+ if (curBytes <= capBytes) break;
132
+ const res = graduateOne({
133
+ id: e.id,
134
+ text: e.text,
135
+ prov: e.prov,
136
+ tier,
137
+ projectRoot,
138
+ userDir,
139
+ now,
140
+ });
141
+ if (res.action === 'error') continue; // couldn't store → keep the bullet
142
+ // 'created' made a NEW file (track for rollback); 'skipped' deduped against
143
+ // a pre-existing fact file (don't track — deleting it would lose a real fact).
144
+ if (res.action === 'created' && res.path) createdPaths.push(res.path);
145
+ removeIdx.add(e.bulletIdx);
146
+ removeIdx.add(e.commentIdx);
147
+ graduated.push(e.id);
148
+ curBytes -= bulletBytes(e);
149
+ }
150
+
151
+ // Transactional guard (composition safety): the feasibility gate assumed every
152
+ // eligible bullet would graduate. If a graduateOne unexpectedly errored
153
+ // (poison/schema on a resident bullet) we may still be over cap — the append
154
+ // will then CAP_EXCEEDED and leave MEMORY.md unchanged, which would STRAND the
155
+ // fact files we created (the bullets stay live AND now exist as facts = the
156
+ // double-capture this task kills). Roll the created files back so the failure
157
+ // path has zero side effects, and let CAP_EXCEEDED fire cleanly.
158
+ if (curBytes > capBytes) {
159
+ for (const p of createdPaths) {
160
+ try {
161
+ unlinkSync(p);
162
+ } catch {
163
+ // best-effort; a leaked fact file is recoverable, a lost bullet is not
164
+ }
165
+ }
166
+ if (createdPaths.length > 0) {
167
+ try {
168
+ reindex({ tier, projectRoot, userDir, warn: () => {} });
169
+ } catch {
170
+ // index rebuild is best-effort; the next reindex/search self-heals
171
+ }
172
+ }
173
+ return { text, graduated: [] };
174
+ }
175
+
176
+ if (removeIdx.size === 0) return { text, graduated: [] };
177
+ const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
178
+ return { text: out, graduated };
179
+ }
@@ -367,14 +367,36 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
367
367
 
368
368
  for (const source of sources) {
369
369
  filesScanned++;
370
- const content = readFileSync(source.path, 'utf8');
371
- const sha1 = sha1OfContent(content);
372
370
  const relPath = relativeSource(source.path, { projectRoot, userDir });
373
371
  const existing = db
374
- .prepare('SELECT sha1 FROM files WHERE path = ?')
372
+ .prepare('SELECT mtime, sha1 FROM files WHERE path = ?')
375
373
  .get(relPath);
374
+ // Fast path: if the file's mtime matches the checkpoint, the content is
375
+ // unchanged — skip the read + sha1 entirely. This realizes design §9.2's
376
+ // "mtime+sha1 diff" intent (the prior impl sha1'd every file on every
377
+ // call) and is what makes reindexBoot cheap enough to run before every
378
+ // `cmk search` (finding #0) even as the memory corpus grows.
379
+ let mtime = null;
380
+ try {
381
+ mtime = Math.floor(statSync(source.path).mtimeMs);
382
+ } catch {
383
+ // stat failed (file vanished mid-walk); fall through to the read,
384
+ // which surfaces the error naturally.
385
+ }
386
+ if (existing && mtime !== null && existing.mtime === mtime) {
387
+ continue; // unchanged (mtime match — no read needed)
388
+ }
389
+ // Caveat: a content change that PRESERVES the old mtime (e.g. a restore
390
+ // tool that sets --times) is missed until the next real change or a
391
+ // `reindex --full`. Negligible in practice — the kit always writes a
392
+ // fresh mtime after the indexed one — and standard for mtime-based diffs.
393
+ //
394
+ // mtime differs (or no checkpoint) — confirm via sha1 so a mere mtime
395
+ // touch (content identical) doesn't trigger a needless reindex.
396
+ const content = readFileSync(source.path, 'utf8');
397
+ const sha1 = sha1OfContent(content);
376
398
  if (existing && existing.sha1 === sha1) {
377
- continue; // unchanged
399
+ continue; // content unchanged despite mtime touch
378
400
  }
379
401
  const n = txn(source);
380
402
  filesReindexed++;