@lh8ppl/claude-memory-kit 0.1.2 → 0.2.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.
- package/README.md +12 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- 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 +19 -4
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +74 -23
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/inject-context.mjs +206 -59
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/memory-write.mjs +2 -2
- 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/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/subcommands.mjs +339 -16
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +14 -0
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +15 -9
- 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
|
@@ -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
|
+
}
|
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.
|
|
@@ -275,13 +293,38 @@ function hasRealContent(cleaned) {
|
|
|
275
293
|
.some((l) => l.trim() !== '' && !/^#{1,6}\s/.test(l));
|
|
276
294
|
}
|
|
277
295
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
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).
|
|
283
325
|
function readTierBlock(tier, tierRoot) {
|
|
284
|
-
|
|
326
|
+
const valueById = new Map();
|
|
327
|
+
if (!tierDirExists(tier, tierRoot)) return { text: '', valueById };
|
|
285
328
|
const sections = [];
|
|
286
329
|
for (const path of plannedFilesForTier(tier, tierRoot)) {
|
|
287
330
|
if (!existsSync(path)) continue;
|
|
@@ -292,13 +335,21 @@ function readTierBlock(tier, tierRoot) {
|
|
|
292
335
|
continue;
|
|
293
336
|
}
|
|
294
337
|
if (body.trim() === '') continue;
|
|
338
|
+
collectBulletValues(body, valueById); // raw body — provenance still present
|
|
295
339
|
const cleaned = cleanScratchpadBody(body);
|
|
296
340
|
if (!hasRealContent(cleaned)) continue;
|
|
297
341
|
sections.push(cleaned);
|
|
298
342
|
}
|
|
299
|
-
if (sections.length === 0) return '';
|
|
343
|
+
if (sections.length === 0) return { text: '', valueById };
|
|
300
344
|
const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
|
|
301
|
-
|
|
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 };
|
|
302
353
|
}
|
|
303
354
|
|
|
304
355
|
// Strip duplicate-ID lines from a tier block. Mutates by returning a new
|
|
@@ -318,8 +369,7 @@ function stripShadowedIds(tier, block, seenIds, shadowedEvents, ts) {
|
|
|
318
369
|
if (prior && prior !== tier) {
|
|
319
370
|
// Drop this line + (if next is the indented provenance) the next.
|
|
320
371
|
const next = lines[i + 1];
|
|
321
|
-
const isComment =
|
|
322
|
-
typeof next === 'string' && /^\s*<!--.*-->\s*$/.test(next);
|
|
372
|
+
const isComment = isProvenanceCommentLine(next);
|
|
323
373
|
// Record the shadowing once per (id, shadowed-tier).
|
|
324
374
|
let event = shadowedEvents.find((e) => e.id === id);
|
|
325
375
|
if (!event) {
|
|
@@ -351,27 +401,58 @@ function writeNdjsonLine(logPath, entry) {
|
|
|
351
401
|
appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
352
402
|
}
|
|
353
403
|
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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.
|
|
410
|
+
//
|
|
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 }.
|
|
359
447
|
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// (
|
|
363
|
-
|
|
364
|
-
// fits the budget OR no sections remain (preamble-only). If the
|
|
365
|
-
// preamble alone exceeds budget, we return it unchanged — that's a
|
|
366
|
-
// configuration problem (preamble shouldn't be that big) but
|
|
367
|
-
// preferable to dropping the file header.
|
|
368
|
-
function truncateTierToBudget(blockText, budget) {
|
|
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()) {
|
|
369
452
|
const preBytes = Buffer.byteLength(blockText, 'utf8');
|
|
370
453
|
if (preBytes <= budget) {
|
|
371
|
-
return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
|
|
454
|
+
return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
|
|
372
455
|
}
|
|
373
|
-
// Find every `## ` heading position. Each section runs from one
|
|
374
|
-
// heading line to the next (or EOF).
|
|
375
456
|
const lines = blockText.split('\n');
|
|
376
457
|
const headingIdxs = [];
|
|
377
458
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -379,27 +460,51 @@ function truncateTierToBudget(blockText, budget) {
|
|
|
379
460
|
}
|
|
380
461
|
if (headingIdxs.length === 0) {
|
|
381
462
|
// No sections — nothing to drop. Return as-is.
|
|
382
|
-
return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
|
|
463
|
+
return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
|
|
383
464
|
}
|
|
384
|
-
|
|
385
|
-
const sections = headingIdxs.map((startIdx, i) =>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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();
|
|
398
500
|
}
|
|
399
|
-
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 }));
|
|
400
504
|
return {
|
|
401
505
|
text: finalText,
|
|
402
|
-
sectionsDropped:
|
|
506
|
+
sectionsDropped: dropped.size,
|
|
507
|
+
droppedSections,
|
|
403
508
|
preBytes,
|
|
404
509
|
postBytes: Buffer.byteLength(finalText, 'utf8'),
|
|
405
510
|
};
|
|
@@ -421,7 +526,7 @@ function enforceCap(orderedBlocks, capBytes, ts) {
|
|
|
421
526
|
for (const block of orderedBlocks) {
|
|
422
527
|
const budget = TIER_BUDGETS[block.tier];
|
|
423
528
|
if (typeof budget !== 'number') continue; // unknown tier; pass through
|
|
424
|
-
const r = truncateTierToBudget(block.text, budget);
|
|
529
|
+
const r = truncateTierToBudget(block.text, budget, block.valueById);
|
|
425
530
|
if (r.sectionsDropped > 0) {
|
|
426
531
|
tierEvents.push({
|
|
427
532
|
ts,
|
|
@@ -431,6 +536,11 @@ function enforceCap(orderedBlocks, capBytes, ts) {
|
|
|
431
536
|
pre_bytes: r.preBytes,
|
|
432
537
|
post_bytes: r.postBytes,
|
|
433
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,
|
|
434
544
|
});
|
|
435
545
|
block.text = r.text;
|
|
436
546
|
}
|
|
@@ -470,23 +580,52 @@ function enforceCap(orderedBlocks, capBytes, ts) {
|
|
|
470
580
|
* Exposed so injectContext can override via dependency injection in tests
|
|
471
581
|
* (testSpawnLazy parameter) — production callers pass nothing.
|
|
472
582
|
*/
|
|
473
|
-
|
|
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) {
|
|
474
607
|
try {
|
|
475
608
|
// The lazy-compress child intentionally outlives this hook process;
|
|
476
609
|
// parent-side timeout is incorrect by design — the child carries its
|
|
477
610
|
// own internal timeout via runLazyCompress → daily-distill /
|
|
478
611
|
// weekly-curate → HaikuViaAnthropicApi.compress({timeoutMs: 50_000}).
|
|
479
|
-
// shell:true so the Windows .cmd shim is found via PATH (same pattern
|
|
480
|
-
// register-crons.mjs uses for cmk-daily-distill).
|
|
481
612
|
// spawn-discipline: ignore detached-fire-and-forget per design §8.5 — same posture as capture-turn.mjs's auto-extract spawn (Task 23).
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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);
|
|
490
629
|
child.unref();
|
|
491
630
|
return { spawned: true, pid: child.pid };
|
|
492
631
|
} catch (err) {
|
|
@@ -527,6 +666,11 @@ export function injectContext({
|
|
|
527
666
|
// uses spawnLazyCompress directly). Tests pass a fake to assert
|
|
528
667
|
// "lazy-compress was/was-not triggered" without touching the host.
|
|
529
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,
|
|
530
674
|
} = {}) {
|
|
531
675
|
const ts = now ?? nowIso();
|
|
532
676
|
const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
|
|
@@ -537,13 +681,16 @@ export function injectContext({
|
|
|
537
681
|
process.env.MEMORY_KIT_USER_DIR ??
|
|
538
682
|
join(homedir(), '.claude-memory-kit');
|
|
539
683
|
|
|
540
|
-
// 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.
|
|
541
687
|
const rawBlocks = TIER_ORDER.map((tier) => {
|
|
542
688
|
const tierRoot =
|
|
543
689
|
tier === 'U'
|
|
544
690
|
? resolvedUserDir
|
|
545
691
|
: resolveTierRoot({ tier, projectRoot, userDir: resolvedUserDir });
|
|
546
|
-
|
|
692
|
+
const { text, valueById } = readTierBlock(tier, tierRoot);
|
|
693
|
+
return { tier, tierRoot, text, valueById };
|
|
547
694
|
}).filter((b) => b.text !== '');
|
|
548
695
|
|
|
549
696
|
// 2. Dedup IDs across tiers (highest-priority first).
|
|
@@ -595,7 +742,7 @@ export function injectContext({
|
|
|
595
742
|
lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
|
|
596
743
|
if (verdict.action === 'stale-daily' || verdict.action === 'stale-weekly') {
|
|
597
744
|
const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
|
|
598
|
-
const spawnResult = spawner(projectRoot);
|
|
745
|
+
const spawnResult = spawner(projectRoot, compressLazyPath);
|
|
599
746
|
lazyTrigger = { ...lazyTrigger, ...spawnResult };
|
|
600
747
|
}
|
|
601
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
|
|