@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
@@ -33,6 +33,24 @@ import { homedir } from 'node:os';
33
33
  import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
34
34
  import { nowIso } from './audit-log.mjs';
35
35
  import { detectStaleness } from './lazy-compress.mjs';
36
+ import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
37
+
38
+ // Importance ranking for value-ordered inject eviction (Task 93 / design §19.3).
39
+ // When a tier exceeds its budget we drop the LOWEST-value sections first, not the
40
+ // tail. Trust dominates; recency (newest `at`) breaks ties; a section with no
41
+ // resolvable provenance ranks as UNKNOWN (between low and medium) so genuinely
42
+ // scored content outranks it.
43
+ const TRUST_RANK = Object.freeze({ low: 0, medium: 1, high: 2 });
44
+ const UNKNOWN_TRUST_RANK = 0.5; // a bullet whose provenance we can't read
45
+ function trustRank(trust) {
46
+ return TRUST_RANK[trust] ?? UNKNOWN_TRUST_RANK;
47
+ }
48
+ function trustLabel(rank) {
49
+ if (rank >= TRUST_RANK.high) return 'high';
50
+ if (rank >= TRUST_RANK.medium) return 'medium';
51
+ if (rank >= UNKNOWN_TRUST_RANK) return 'unknown';
52
+ return 'low';
53
+ }
36
54
 
37
55
  // 13,000 bytes = sum of all per-file caps (12,275 from Task 12/14) + 725
38
56
  // bytes of headroom for inter-tier markers + future modest growth.
@@ -84,17 +102,20 @@ const TIER_BUDGETS = Object.freeze({
84
102
  });
85
103
 
86
104
  // Per-tier reading plan. The hook reads the scratchpads allowed at that
87
- // tier (per SCRATCHPADS_BY_TIER) plus the tier's INDEX file, plusfor
88
- // the project tier — the most recent rolling-window day file.
105
+ // tier (per SCRATCHPADS_BY_TIER) plus for the project tierthe most
106
+ // recent rolling-window day file.
107
+ //
108
+ // INDEX.md is deliberately NOT in the snapshot (#R, 2026-05-30). It is a
109
+ // pointer/reference doc that self-declares "NOT auto-loaded at session
110
+ // start" in its own template body — injecting it both violated that
111
+ // contract and pushed ~2 KB of reference prose into Claude's context,
112
+ // crowding out real facts. It stays on disk for lookup via `cmk search` /
113
+ // the granular archive; it is not session-start content.
89
114
  function plannedFilesForTier(tier, tierRoot) {
90
115
  const files = [];
91
116
  for (const name of SCRATCHPADS_BY_TIER[tier]) {
92
117
  files.push(join(tierRoot, name));
93
118
  }
94
- // INDEX: P/L use memory/INDEX.md; U uses fragments/INDEX.md (per
95
- // resolveFactDir asymmetry in tier-paths.mjs).
96
- const indexDir = tier === 'U' ? 'fragments' : 'memory';
97
- files.push(join(tierRoot, indexDir, 'INDEX.md'));
98
119
  if (tier === 'P') {
99
120
  const sessionsDir = join(tierRoot, 'sessions');
100
121
  const latest = latestDaySession(sessionsDir);
@@ -138,12 +159,172 @@ function tierDirExists(tier, tierRoot) {
138
159
  return existsSync(tierRoot) && statSync(tierRoot).isDirectory();
139
160
  }
140
161
 
141
- // Read the snapshot-eligible content for one tier as a single string. If
142
- // no tier files exist (or the tier dir itself is absent), returns ''. The
143
- // per-file content is wrapped in a fenced header so the snapshot is
144
- // self-describing to whoever reads Claude's context window.
162
+ // The all-zero sha1 is the kit's template-seed sentinel: every scaffolded
163
+ // placeholder bullet (in machine-paths/overrides/SOUL/USER/HABITS/LESSONS)
164
+ // carries `sha1: 0000…0000` + `at: 2020-01-01T…`. A real captured fact
165
+ // always has a real content sha1. We use this to distinguish "scaffolding
166
+ // the user never replaced" from "a fact worth injecting".
167
+ const SEED_SHA1_RE = /sha1:\s*0{40}/;
168
+
169
+ // All HTML-comment handling below uses STRING SCANNING (indexOf/startsWith),
170
+ // never a regex tag-filter. Regex-based HTML-comment stripping is fragile by
171
+ // nature (it can't see newlines, leaves partial `<!--`, etc. — flagged by
172
+ // CodeQL's js/bad-tag-filter). String scanning is both more robust and not a
173
+ // tag-filter, so it sidesteps that whole class.
174
+
175
+ // True if `line`, ignoring surrounding whitespace, is exactly one self-
176
+ // contained HTML comment (`<!-- … -->`) — e.g. a per-bullet provenance line.
177
+ function isCommentOnlyLine(line) {
178
+ if (typeof line !== 'string') return false;
179
+ const t = line.trim();
180
+ return t.startsWith('<!--') && t.endsWith('-->') && t.length >= 7;
181
+ }
182
+
183
+ // Remove every self-contained `<!-- … -->` span WITHIN a single line, by
184
+ // scanning for delimiter pairs. An unterminated `<!--` (no `-->` on this
185
+ // line) is left in place for the multi-line state machine to handle.
186
+ function stripInlineComments(line) {
187
+ let out = '';
188
+ let i = 0;
189
+ for (;;) {
190
+ const open = line.indexOf('<!--', i);
191
+ if (open === -1) return out + line.slice(i);
192
+ const close = line.indexOf('-->', open + 4);
193
+ if (close === -1) return out + line.slice(i); // unterminated; leave it
194
+ out += line.slice(i, open);
195
+ i = close + 3;
196
+ }
197
+ }
198
+
199
+ // Is `bulletLine` a placeholder/seed bullet that should NOT be injected?
200
+ // Primary signal: a following provenance comment carrying the all-zero seed
201
+ // sha1 (every scaffolded template bullet has it; a real captured fact never
202
+ // does). Secondary: the `(example)` marker — but ONLY in the template's
203
+ // exact `(P-XXXXXXXX) (example) …` shape (right after the citation id), so a
204
+ // real fact whose text merely mentions "(example)" is not mis-dropped.
205
+ function isSeedBullet(bulletLine, nextLine) {
206
+ if (/^\s*-\s+\([PUL]-[A-Za-z0-9]{8}\)\s+\(example\)/.test(bulletLine)) {
207
+ return true;
208
+ }
209
+ const prov = isCommentOnlyLine(nextLine) ? nextLine : '';
210
+ return SEED_SHA1_RE.test(prov);
211
+ }
212
+
213
+ // Remove HTML comments robustly, including the kit templates' multi-line
214
+ // format-explanation headers that ILLUSTRATIVELY embed a single-line
215
+ // `<!-- source… -->` example inside the outer `<!-- … -->` block (a naive
216
+ // "first <!-- to first -->" pass closes on that inner `-->` and orphans the
217
+ // tail). We strip inline comments first (killing the nested one) and only
218
+ // then walk the now-cleanly-delimited multi-line blocks. All string-scan.
219
+ function stripHtmlComments(text) {
220
+ // Pass 1 — remove every self-contained `<!-- … -->` on a single line.
221
+ const lines = text.split('\n').map(stripInlineComments);
222
+ // Pass 2 — remove multi-line blocks (each now free of any inner `-->`).
223
+ const out = [];
224
+ let inBlock = false;
225
+ for (let line of lines) {
226
+ if (inBlock) {
227
+ const close = line.indexOf('-->');
228
+ if (close === -1) continue; // still inside the block; drop the line
229
+ inBlock = false;
230
+ line = line.slice(close + 3);
231
+ }
232
+ const open = line.indexOf('<!--');
233
+ if (open !== -1) {
234
+ inBlock = true;
235
+ line = line.slice(0, open);
236
+ }
237
+ if (line.trim() !== '' || out.length === 0 || out[out.length - 1] !== '') {
238
+ out.push(line.replace(/[ \t]+$/, ''));
239
+ }
240
+ }
241
+ return out.join('\n');
242
+ }
243
+
244
+ // Clean a scratchpad body for INJECTION (not for on-disk storage — the
245
+ // files keep their human-editing headers). Self-test finding #R: the raw
246
+ // bodies are ~70% template-comment noise + placeholder seed bullets that
247
+ // bury (and crowd out) the real captured facts, so the model concludes
248
+ // "no real facts populated yet". This strips:
249
+ // 1. placeholder seed bullets (all-zero sha1 / `(example)`) + their
250
+ // provenance comment line, and
251
+ // 2. ALL remaining `<!-- -->` comments (multi-line format-explanation
252
+ // headers AND per-bullet provenance — the fact text + its `(P-…)`
253
+ // citation id carry everything the model needs to read & cite).
254
+ // Whitespace is normalized so stripped regions don't leave holes.
255
+ //
256
+ // Known limitation (rare): a captured fact whose TEXT contains a literal
257
+ // `<!--`/`-->` (e.g. a note about HTML/templating) has that fragment
258
+ // stripped from the INJECTED view. The on-disk fact and the search index
259
+ // are unaffected — only the session-start snapshot loses the literal
260
+ // comment markers. Accepted as a rare edge vs. the cost of distinguishing
261
+ // real comments from comment-shaped fact text.
262
+ function cleanScratchpadBody(body) {
263
+ // Normalize CRLF so user-edited (Windows) scratchpads don't leave stray
264
+ // \r after comment/seed stripping.
265
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
266
+ const kept = [];
267
+ for (let i = 0; i < lines.length; i++) {
268
+ const line = lines[i];
269
+ if (
270
+ /^\s*-\s/.test(line) &&
271
+ ID_TOKEN_RE.test(line) &&
272
+ isSeedBullet(line, lines[i + 1])
273
+ ) {
274
+ if (isCommentOnlyLine(lines[i + 1])) i++;
275
+ continue;
276
+ }
277
+ kept.push(line);
278
+ }
279
+ // Step 2 — strip all remaining comments (format headers + real-bullet
280
+ // provenance), then normalize whitespace.
281
+ return stripHtmlComments(kept.join('\n'))
282
+ .replace(/\n{3,}/g, '\n\n')
283
+ .replace(/^\n+|\n+$/g, '');
284
+ }
285
+
286
+ // After cleaning, does a body carry any real content — i.e. a non-blank
287
+ // line that isn't a markdown heading? A body of only headings (every
288
+ // bullet was a stripped seed) is pure scaffolding and must NOT contribute
289
+ // a tier block (otherwise the model sees an empty "## …" skeleton).
290
+ function hasRealContent(cleaned) {
291
+ return cleaned
292
+ .split('\n')
293
+ .some((l) => l.trim() !== '' && !/^#{1,6}\s/.test(l));
294
+ }
295
+
296
+ // Scan a RAW scratchpad body for bullet+provenance pairs, recording each
297
+ // cited id's trust + capture time into `valueById`. Run on the raw body
298
+ // BEFORE cleanScratchpadBody strips the provenance comments — that's the only
299
+ // place the trust/recency signal exists, and the importance-aware truncator
300
+ // (truncateTierToBudget) needs it to rank sections by value, not file order.
301
+ // Note: this records EVERY bullet+provenance pair, including seed bullets (later
302
+ // stripped by cleanScratchpadBody) and ids later removed by cross-tier shadowing.
303
+ // Those stale entries are inert — truncateTierToBudget only resolves ids on block
304
+ // lines that are actually PRESENT, so an orphaned valueById entry is never used.
305
+ function collectBulletValues(body, valueById) {
306
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
307
+ for (let i = 0; i < lines.length - 1; i++) {
308
+ if (!/^\s*-\s/.test(lines[i])) continue;
309
+ const m = lines[i].match(ID_TOKEN_RE);
310
+ if (!m) continue;
311
+ if (!isProvenanceCommentLine(lines[i + 1])) continue;
312
+ const prov = parseBulletProvenance(lines[i + 1]);
313
+ if (!prov) continue;
314
+ valueById.set(`${m[1]}-${m[2]}`, { trust: prov.trust, at: prov.at });
315
+ }
316
+ }
317
+
318
+ // Read the snapshot-eligible content for one tier. Returns { text, valueById }.
319
+ // `text` is the cleaned, injection-ready block (or '' if the tier contributes
320
+ // nothing); `valueById` maps each cited id → {trust, at} parsed from the RAW
321
+ // bodies (used by the importance-aware budget truncator). Each file body is
322
+ // cleaned for injection (see cleanScratchpadBody); files that reduce to
323
+ // scaffolding-only contribute nothing, and a tier whose every file is
324
+ // scaffolding-only is excluded entirely (no header, no skeleton).
145
325
  function readTierBlock(tier, tierRoot) {
146
- if (!tierDirExists(tier, tierRoot)) return '';
326
+ const valueById = new Map();
327
+ if (!tierDirExists(tier, tierRoot)) return { text: '', valueById };
147
328
  const sections = [];
148
329
  for (const path of plannedFilesForTier(tier, tierRoot)) {
149
330
  if (!existsSync(path)) continue;
@@ -154,11 +335,21 @@ function readTierBlock(tier, tierRoot) {
154
335
  continue;
155
336
  }
156
337
  if (body.trim() === '') continue;
157
- sections.push(body);
338
+ collectBulletValues(body, valueById); // raw body — provenance still present
339
+ const cleaned = cleanScratchpadBody(body);
340
+ if (!hasRealContent(cleaned)) continue;
341
+ sections.push(cleaned);
158
342
  }
159
- if (sections.length === 0) return '';
343
+ if (sections.length === 0) return { text: '', valueById };
160
344
  const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
161
- return [header, ...sections].join('\n\n').replace(/\n+$/, '') + '\n';
345
+ // Trailing-newline strip via string scan (NOT a `/\n+$/` regex the `+$`
346
+ // shape trips the ReDoS heuristic, per CLAUDE.md; string-scan is linear and
347
+ // strips only newlines, faithful to the original intent).
348
+ const joined = [header, ...sections].join('\n\n');
349
+ let end = joined.length;
350
+ while (end > 0 && joined[end - 1] === '\n') end--;
351
+ const text = joined.slice(0, end) + '\n';
352
+ return { text, valueById };
162
353
  }
163
354
 
164
355
  // Strip duplicate-ID lines from a tier block. Mutates by returning a new
@@ -178,8 +369,7 @@ function stripShadowedIds(tier, block, seenIds, shadowedEvents, ts) {
178
369
  if (prior && prior !== tier) {
179
370
  // Drop this line + (if next is the indented provenance) the next.
180
371
  const next = lines[i + 1];
181
- const isComment =
182
- typeof next === 'string' && /^\s*<!--.*-->\s*$/.test(next);
372
+ const isComment = isProvenanceCommentLine(next);
183
373
  // Record the shadowing once per (id, shadowed-tier).
184
374
  let event = shadowedEvents.find((e) => e.id === id);
185
375
  if (!event) {
@@ -211,27 +401,58 @@ function writeNdjsonLine(logPath, entry) {
211
401
  appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
212
402
  }
213
403
 
214
- // Truncate one tier block to fit its budget by dropping whole `## `
215
- // sections from the END. Section-granular (not bullet- or byte-
216
- // granular) per design §7.1.1: structural shape preservation matters
217
- // more than maximum byte utilization. Returns { text, sectionsDropped,
218
- // preBytes, postBytes }.
404
+ // Compute one section's aggregate value from its bullets' provenance.
405
+ // aggregate trust = the MAX bullet trust in the section (so a section holding
406
+ // ANY high-trust bullet is protected before a section that holds none — the
407
+ // §19.3 "never evict a high-trust bullet before a lower one" invariant, at
408
+ // section granularity). aggregate recency = the NEWEST `at`. A section with no
409
+ // resolvable bullets ranks lowest (value -1) so it drops first.
219
410
  //
220
- // Algorithm: split into sections delimited by `## ` (level-2 markdown
221
- // heading) anywhere in the tier block. Anything BEFORE the first `## `
222
- // (file headers, comments, top-level title) is the "preamble" and is
223
- // always kept. Sections are popped from the END until the kept text
224
- // fits the budget OR no sections remain (preamble-only). If the
225
- // preamble alone exceeds budget, we return it unchanged — that's a
226
- // configuration problem (preamble shouldn't be that big) but
227
- // preferable to dropping the file header.
228
- function truncateTierToBudget(blockText, budget) {
411
+ // Known limitation (section-granularity, accepted per §7.1.1 + the Task 93
412
+ // "whole sections by aggregate value" sanction): MAX-aggregate protects high-
413
+ // trust content, but a LOW-trust bullet bundled in the same section as a high-
414
+ // trust one survives, while a standalone MEDIUM-trust section can be dropped
415
+ // first a bullet-level inversion. Note the asymmetry with 94.3 graduation,
416
+ // which evicts per-BULLET (oldest-first). Bullet-granular inject eviction is the
417
+ // stricter v-next option if this matters; for now it keeps §7.1.1 structural
418
+ // shape + costs less re-rendering.
419
+ function sectionValue(lines, startIdx, endIdx, valueById) {
420
+ let maxTrust = -1;
421
+ let maxAtMs = -1;
422
+ const ids = [];
423
+ for (let i = startIdx; i < endIdx; i++) {
424
+ if (!/^\s*-\s/.test(lines[i])) continue;
425
+ const m = lines[i].match(ID_TOKEN_RE);
426
+ if (!m) continue;
427
+ const id = `${m[1]}-${m[2]}`;
428
+ ids.push(id);
429
+ const v = valueById.get(id);
430
+ const t = v ? trustRank(v.trust) : UNKNOWN_TRUST_RANK;
431
+ if (t > maxTrust) maxTrust = t;
432
+ const atMs = v && v.at ? Date.parse(v.at) : NaN;
433
+ if (!Number.isNaN(atMs) && atMs > maxAtMs) maxAtMs = atMs;
434
+ }
435
+ return { maxTrust, maxAtMs, ids };
436
+ }
437
+
438
+ // Truncate one tier block to fit its budget by dropping whole `## ` sections,
439
+ // LOWEST-VALUE first (Task 93 / design §19.3) — superseding the old tail-order
440
+ // drop. Section-granular per design §7.1.1 (structural-shape preservation), but
441
+ // the eviction ORDER is now importance-aware: lowest aggregate trust first, then
442
+ // oldest, then — as a tiebreak among equal-value sections — later-in-file first.
443
+ // That tiebreak makes this a strict generalization of the legacy tail-drop: when
444
+ // no provenance is present (every section ranks equal) it drops from the end,
445
+ // exactly as before. Returns { text, sectionsDropped, droppedSections, preBytes,
446
+ // postBytes }.
447
+ //
448
+ // Anything BEFORE the first `## ` (file headers, top-level title) is the
449
+ // "preamble" and always kept; if the preamble alone exceeds budget it's returned
450
+ // unchanged (a config problem, but preferable to dropping the header).
451
+ function truncateTierToBudget(blockText, budget, valueById = new Map()) {
229
452
  const preBytes = Buffer.byteLength(blockText, 'utf8');
230
453
  if (preBytes <= budget) {
231
- return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
454
+ return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
232
455
  }
233
- // Find every `## ` heading position. Each section runs from one
234
- // heading line to the next (or EOF).
235
456
  const lines = blockText.split('\n');
236
457
  const headingIdxs = [];
237
458
  for (let i = 0; i < lines.length; i++) {
@@ -239,27 +460,51 @@ function truncateTierToBudget(blockText, budget) {
239
460
  }
240
461
  if (headingIdxs.length === 0) {
241
462
  // No sections — nothing to drop. Return as-is.
242
- return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
463
+ return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
243
464
  }
244
- // Build section boundaries: [start..end) for each section.
245
- const sections = headingIdxs.map((startIdx, i) => ({
246
- startIdx,
247
- endIdx: i + 1 < headingIdxs.length ? headingIdxs[i + 1] : lines.length,
248
- }));
249
- // Pop from the end while over budget.
250
- let droppedCount = 0;
251
- let keptEndLine = lines.length;
252
- while (sections.length > 0) {
253
- const candidateText = lines.slice(0, keptEndLine).join('\n');
254
- if (Buffer.byteLength(candidateText, 'utf8') <= budget) break;
255
- const last = sections.pop();
256
- keptEndLine = last.startIdx;
257
- droppedCount++;
465
+ const firstHeading = headingIdxs[0];
466
+ const sections = headingIdxs.map((startIdx, i) => {
467
+ const endIdx = i + 1 < headingIdxs.length ? headingIdxs[i + 1] : lines.length;
468
+ return {
469
+ origIndex: i,
470
+ startIdx,
471
+ endIdx,
472
+ heading: lines[startIdx].replace(/^##\s+/, '').trim(),
473
+ ...sectionValue(lines, startIdx, endIdx, valueById),
474
+ };
475
+ });
476
+ // Drop order: lowest aggregate trust first → oldest first → later-in-file
477
+ // first (the legacy tail tiebreak, so equal-value blocks still drop from the
478
+ // end). High-value sections are evicted only after everything cheaper is gone.
479
+ const dropOrder = [...sections].sort(
480
+ (a, b) =>
481
+ a.maxTrust - b.maxTrust ||
482
+ a.maxAtMs - b.maxAtMs ||
483
+ b.origIndex - a.origIndex,
484
+ );
485
+ const dropped = new Set();
486
+ const render = () => {
487
+ const keep = [];
488
+ for (let i = 0; i < firstHeading; i++) keep.push(lines[i]); // preamble
489
+ for (const s of sections) {
490
+ if (dropped.has(s.origIndex)) continue;
491
+ for (let i = s.startIdx; i < s.endIdx; i++) keep.push(lines[i]);
492
+ }
493
+ return keep.join('\n');
494
+ };
495
+ let finalText = render();
496
+ for (const s of dropOrder) {
497
+ if (Buffer.byteLength(finalText, 'utf8') <= budget) break;
498
+ dropped.add(s.origIndex);
499
+ finalText = render();
258
500
  }
259
- const finalText = lines.slice(0, keptEndLine).join('\n');
501
+ const droppedSections = sections
502
+ .filter((s) => dropped.has(s.origIndex))
503
+ .map((s) => ({ heading: s.heading, max_trust: trustLabel(s.maxTrust), ids: s.ids }));
260
504
  return {
261
505
  text: finalText,
262
- sectionsDropped: droppedCount,
506
+ sectionsDropped: dropped.size,
507
+ droppedSections,
263
508
  preBytes,
264
509
  postBytes: Buffer.byteLength(finalText, 'utf8'),
265
510
  };
@@ -281,7 +526,7 @@ function enforceCap(orderedBlocks, capBytes, ts) {
281
526
  for (const block of orderedBlocks) {
282
527
  const budget = TIER_BUDGETS[block.tier];
283
528
  if (typeof budget !== 'number') continue; // unknown tier; pass through
284
- const r = truncateTierToBudget(block.text, budget);
529
+ const r = truncateTierToBudget(block.text, budget, block.valueById);
285
530
  if (r.sectionsDropped > 0) {
286
531
  tierEvents.push({
287
532
  ts,
@@ -291,6 +536,11 @@ function enforceCap(orderedBlocks, capBytes, ts) {
291
536
  pre_bytes: r.preBytes,
292
537
  post_bytes: r.postBytes,
293
538
  sections_dropped: r.sectionsDropped,
539
+ // Door 4 (Task 93): WHICH sections were evicted + WHY (lowest-value
540
+ // first). dropped_sections carries each evicted section's heading, its
541
+ // aggregate trust, and the cited ids it contained.
542
+ strategy: 'importance-ordered',
543
+ dropped_sections: r.droppedSections,
294
544
  });
295
545
  block.text = r.text;
296
546
  }
@@ -330,23 +580,52 @@ function enforceCap(orderedBlocks, capBytes, ts) {
330
580
  * Exposed so injectContext can override via dependency injection in tests
331
581
  * (testSpawnLazy parameter) — production callers pass nothing.
332
582
  */
333
- function spawnLazyCompress(projectRoot) {
583
+ /**
584
+ * Pure spawn descriptor for the lazy-compress child (Task 81). Separated so the
585
+ * Door-3 contract (node-direct + windowsHide, no shell, when the path is known)
586
+ * is unit-assertable without a real spawn. Path known + present → `node <path>`
587
+ * directly; otherwise the PATH-resolved `.cmd` bin via shell:true (the corrupt-
588
+ * install fallback that may still flash a console on Windows).
589
+ */
590
+ export function lazyCompressSpawnDescriptor(projectRoot, compressLazyPath) {
591
+ const baseEnv = { ...process.env, CMK_PROJECT_DIR: projectRoot };
592
+ if (compressLazyPath && existsSync(compressLazyPath)) {
593
+ return {
594
+ command: process.execPath,
595
+ args: [compressLazyPath],
596
+ options: { detached: true, stdio: 'ignore', cwd: projectRoot, windowsHide: true, env: baseEnv },
597
+ };
598
+ }
599
+ return {
600
+ command: 'cmk-compress-lazy',
601
+ args: [],
602
+ options: { detached: true, stdio: 'ignore', shell: true, cwd: projectRoot, windowsHide: true, env: baseEnv },
603
+ };
604
+ }
605
+
606
+ function spawnLazyCompress(projectRoot, compressLazyPath) {
334
607
  try {
335
608
  // The lazy-compress child intentionally outlives this hook process;
336
609
  // parent-side timeout is incorrect by design — the child carries its
337
610
  // own internal timeout via runLazyCompress → daily-distill /
338
611
  // weekly-curate → HaikuViaAnthropicApi.compress({timeoutMs: 50_000}).
339
- // shell:true so the Windows .cmd shim is found via PATH (same pattern
340
- // register-crons.mjs uses for cmk-daily-distill).
341
612
  // spawn-discipline: ignore detached-fire-and-forget per design §8.5 — same posture as capture-turn.mjs's auto-extract spawn (Task 23).
342
- const child = spawn('cmk-compress-lazy', [], {
343
- detached: true,
344
- stdio: 'ignore',
345
- shell: true,
346
- cwd: projectRoot,
347
- windowsHide: true,
348
- env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
349
- });
613
+ //
614
+ // Task 81 (Windows console-popup fix): spawn `node` DIRECTLY on the
615
+ // resolved .mjs. The legacy `shell:true` path resolved the npm `.cmd`
616
+ // shim via cmd.exe (cmd.exe → cmk-compress-lazy.cmd → node), and on
617
+ // Windows `windowsHide:true` hid only the cmd.exe window — NOT the
618
+ // detached `node` grandchild the shim launched, which flashed a visible
619
+ // console at every SessionStart. `process.execPath` + `windowsHide`
620
+ // suppresses it. The shell:true bin-name spawn survives only as a
621
+ // fallback when the path is unknown (corrupt install) — better the
622
+ // legacy popup than losing compression entirely.
623
+ const { command, args, options } = lazyCompressSpawnDescriptor(
624
+ projectRoot,
625
+ compressLazyPath,
626
+ );
627
+ // spawn-discipline: ignore detached fire-and-forget per design §8.5 — the child carries its own internal timeout (runLazyCompress → compress({timeoutMs})); parent-side timeout is incorrect by design.
628
+ const child = spawn(command, args, options);
350
629
  child.unref();
351
630
  return { spawned: true, pid: child.pid };
352
631
  } catch (err) {
@@ -387,6 +666,11 @@ export function injectContext({
387
666
  // uses spawnLazyCompress directly). Tests pass a fake to assert
388
667
  // "lazy-compress was/was-not triggered" without touching the host.
389
668
  testSpawnLazy,
669
+ // Resolved path to cmk-compress-lazy.mjs (passed by the bin wrapper, which
670
+ // knows the install layout). Lets spawnLazyCompress run `node <path>`
671
+ // directly instead of the shell:true `.cmd` shim — the Windows
672
+ // console-popup fix (Task 81). Absent → graceful shell:true fallback.
673
+ compressLazyPath,
390
674
  } = {}) {
391
675
  const ts = now ?? nowIso();
392
676
  const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
@@ -397,13 +681,16 @@ export function injectContext({
397
681
  process.env.MEMORY_KIT_USER_DIR ??
398
682
  join(homedir(), '.claude-memory-kit');
399
683
 
400
- // 1. Read each tier's block in priority order.
684
+ // 1. Read each tier's block in priority order. readTierBlock also returns a
685
+ // per-tier value map (id → trust/recency) parsed from the raw bodies, which
686
+ // the importance-aware budget truncator uses to evict lowest-value first.
401
687
  const rawBlocks = TIER_ORDER.map((tier) => {
402
688
  const tierRoot =
403
689
  tier === 'U'
404
690
  ? resolvedUserDir
405
691
  : resolveTierRoot({ tier, projectRoot, userDir: resolvedUserDir });
406
- return { tier, tierRoot, text: readTierBlock(tier, tierRoot) };
692
+ const { text, valueById } = readTierBlock(tier, tierRoot);
693
+ return { tier, tierRoot, text, valueById };
407
694
  }).filter((b) => b.text !== '');
408
695
 
409
696
  // 2. Dedup IDs across tiers (highest-priority first).
@@ -455,7 +742,7 @@ export function injectContext({
455
742
  lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
456
743
  if (verdict.action === 'stale-daily' || verdict.action === 'stale-weekly') {
457
744
  const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
458
- const spawnResult = spawner(projectRoot);
745
+ const spawnResult = spawner(projectRoot, compressLazyPath);
459
746
  lazyTrigger = { ...lazyTrigger, ...spawnResult };
460
747
  }
461
748
  } catch (err) {
package/src/install.mjs CHANGED
@@ -37,10 +37,9 @@ import {
37
37
  readdirSync,
38
38
  statSync,
39
39
  writeFileSync,
40
- copyFileSync,
41
40
  } from 'node:fs';
42
41
  import { homedir } from 'node:os';
43
- import { dirname, join, relative, resolve } from 'node:path';
42
+ import { basename, dirname, join, relative, resolve } from 'node:path';
44
43
  import { fileURLToPath } from 'node:url';
45
44
  import { injectClaudeMdBlock } from './claude-md.mjs';
46
45
  import { writeKitHooks } from './settings-hooks.mjs';
@@ -155,7 +154,27 @@ function targetName(srcName) {
155
154
  * - Writes new files
156
155
  * - Mutates the supplied `created` / `skipped` / `errors` arrays
157
156
  */
158
- function installTier(srcDir, destDir, { created, skipped, errors }) {
157
+ // Substitute the kit's template placeholders. Templates ship with
158
+ // `{{TODAY}}` / `{{PROJECT_NAME}}` / `{{VERSION}}`; without this, the
159
+ // scaffolded scratchpads leaked a literal `{{TODAY}}` into MEMORY.md et al.
160
+ // (live-test finding #4). Only the three known tokens are replaced.
161
+ function renderTemplate(content, vars) {
162
+ return content
163
+ .replaceAll('{{TODAY}}', vars.today)
164
+ .replaceAll('{{PROJECT_NAME}}', vars.projectName)
165
+ .replaceAll('{{VERSION}}', vars.version);
166
+ }
167
+
168
+ function installTier(srcDir, destDir, { created, skipped, errors, vars }) {
169
+ // Self-sufficient default so no caller can crash renderTemplate by omitting
170
+ // vars (e.g. initUserTier). install() passes an explicit vars with the real
171
+ // projectName; standalone callers fall back to a sensible default (the user
172
+ // tier's scratchpads only carry {{TODAY}} anyway).
173
+ const v = vars ?? {
174
+ today: new Date().toISOString().slice(0, 10),
175
+ projectName: basename(destDir),
176
+ version: getKitVersion(),
177
+ };
159
178
  if (!existsSync(srcDir)) {
160
179
  errors.push({ path: srcDir, error: 'template tier missing from kit' });
161
180
  return;
@@ -182,7 +201,10 @@ function installTier(srcDir, destDir, { created, skipped, errors }) {
182
201
 
183
202
  try {
184
203
  mkdirSync(dirname(targetAbs), { recursive: true });
185
- copyFileSync(file.absSrc, targetAbs);
204
+ // Read → render placeholders → write (was a raw copyFileSync, which left
205
+ // `{{TODAY}}` literal in the scaffolded scratchpads). All template files
206
+ // are text (.gitkeep is handled above), so utf8 round-trip is safe.
207
+ writeFileSync(targetAbs, renderTemplate(readFileSync(file.absSrc, 'utf8'), v), 'utf8');
186
208
  created.push(targetAbs);
187
209
  } catch (err) {
188
210
  errors.push({ path: targetAbs, error: err && err.message ? err.message : String(err) });
@@ -269,9 +291,32 @@ export async function install(options = {}) {
269
291
  const skipped = [];
270
292
  const errors = [];
271
293
 
272
- installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors });
273
- installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors });
274
- installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
294
+ const vars = {
295
+ today: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
296
+ projectName: basename(projectRoot),
297
+ version,
298
+ };
299
+
300
+ installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors, vars });
301
+ installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors, vars });
302
+ installTier(join(templateDir, 'user'), userTier, { created, skipped, errors, vars });
303
+
304
+ // Skills — Task 69. Scaffold the kit's Claude Code skills from
305
+ // template/.claude/skills/ into <projectRoot>/.claude/skills/. This is what
306
+ // makes model-invoked capture (the memory-write skill) ship with the npm
307
+ // `cmk install` route, not only the plugin route — route-equivalence per
308
+ // design §1.3. Same boundary as the tiers: idempotent skip-existing +
309
+ // over-mutation-safe (a hand-edited skill survives a re-install). The skill
310
+ // files carry no {{placeholders}}, so renderTemplate is a byte-passthrough.
311
+ const skillsSrc = join(templateDir, '.claude', 'skills');
312
+ if (existsSync(skillsSrc)) {
313
+ installTier(skillsSrc, join(projectRoot, '.claude', 'skills'), {
314
+ created,
315
+ skipped,
316
+ errors,
317
+ vars,
318
+ });
319
+ }
275
320
 
276
321
  const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
277
322