@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.
- package/README.md +8 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-capture-prompt.mjs +0 -0
- package/bin/cmk-capture-turn.mjs +0 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-observe-edit.mjs +0 -0
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +37 -22
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +79 -26
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/index-rebuild.mjs +26 -4
- package/src/inject-context.mjs +352 -65
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/mcp-server.mjs +17 -0
- package/src/memory-write.mjs +20 -7
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/sanitize.mjs +39 -0
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/spawn-bin.mjs +83 -0
- package/src/subcommands.mjs +472 -26
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +60 -3
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +17 -7
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
package/src/inject-context.mjs
CHANGED
|
@@ -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
|
|
88
|
-
//
|
|
105
|
+
// tier (per SCRATCHPADS_BY_TIER) plus — for the project tier — the 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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
function
|
|
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
|
-
|
|
245
|
-
const sections = headingIdxs.map((startIdx, i) =>
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|