@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
package/src/scratchpad.mjs
CHANGED
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
// this module will call instead. The handoff is clean: format stays identical;
|
|
18
18
|
// only the location of the formatter moves.
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
appendFileSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
21
28
|
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
22
29
|
import {
|
|
23
30
|
VALID_TIERS,
|
|
@@ -28,7 +35,8 @@ import {
|
|
|
28
35
|
} from './tier-paths.mjs';
|
|
29
36
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
30
37
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
31
|
-
import { writeBullet, parseBulletProvenance } from './provenance.mjs';
|
|
38
|
+
import { writeBullet, parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
|
|
39
|
+
import { graduateForCapRelief } from './graduation.mjs';
|
|
32
40
|
|
|
33
41
|
const VALID_TRUST = new Set(['high', 'medium', 'low']);
|
|
34
42
|
const VALID_WRITE_SOURCES = new Set([
|
|
@@ -188,9 +196,33 @@ function insertIntoSection(text, sectionTitle, bullet) {
|
|
|
188
196
|
return lines.join('\n');
|
|
189
197
|
}
|
|
190
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Ensure a `## <sectionTitle>` heading exists in a scratchpad file, creating
|
|
201
|
+
* it (appended at EOF, with clean spacing) if absent. Used by the persona
|
|
202
|
+
* promoter (Task 64 / F2) so a cross-project candidate routed to a sane-but-
|
|
203
|
+
* new section lands instead of schema-failing to the review queue. The CALLER
|
|
204
|
+
* owns the name-safety guard — this only writes the heading.
|
|
205
|
+
*
|
|
206
|
+
* @returns {{created: boolean, error?: string}}
|
|
207
|
+
*/
|
|
208
|
+
export function ensureSectionExists(scratchpadPath, sectionTitle) {
|
|
209
|
+
if (!existsSync(scratchpadPath)) return { created: false, error: 'no-file' };
|
|
210
|
+
const text = readFileSync(scratchpadPath, 'utf8');
|
|
211
|
+
if (findSectionRange(text.split('\n'), sectionTitle)) return { created: false };
|
|
212
|
+
const body = text.trimEnd(); // drop trailing whitespace/blank lines (no `\s+$` regex — trips ReDoS heuristics)
|
|
213
|
+
// No leading blank lines for an empty/whitespace-only file (the scaffolded
|
|
214
|
+
// scratchpads are never empty, but keep the output clean if one ever is).
|
|
215
|
+
const prefix = body ? `${body}\n\n` : '';
|
|
216
|
+
writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n`, 'utf8');
|
|
217
|
+
return { created: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const EVICTED_ID_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)/;
|
|
221
|
+
|
|
191
222
|
function consolidate(text, { nowDate }) {
|
|
192
223
|
const lines = text.split('\n');
|
|
193
224
|
const removeIdx = new Set();
|
|
225
|
+
const evicted = [];
|
|
194
226
|
const staleCutoff = new Date(nowDate.getTime() - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000);
|
|
195
227
|
let bulletsRemoved = 0;
|
|
196
228
|
|
|
@@ -199,7 +231,7 @@ function consolidate(text, { nowDate }) {
|
|
|
199
231
|
const bulletLine = lines[i];
|
|
200
232
|
const commentLine = lines[i + 1];
|
|
201
233
|
if (!bulletLine.startsWith('- (')) continue;
|
|
202
|
-
if (!
|
|
234
|
+
if (!isProvenanceCommentLine(commentLine)) continue;
|
|
203
235
|
|
|
204
236
|
const prov = parseBulletProvenance(commentLine);
|
|
205
237
|
if (!prov || !prov.at || !prov.trust) continue;
|
|
@@ -211,14 +243,47 @@ function consolidate(text, { nowDate }) {
|
|
|
211
243
|
|
|
212
244
|
removeIdx.add(i);
|
|
213
245
|
removeIdx.add(i + 1);
|
|
246
|
+
// Task 91.2: capture the dropped bullet so the caller can ARCHIVE it
|
|
247
|
+
// (recoverable, per the §6.5 tombstone principle) instead of hard-deleting.
|
|
248
|
+
const idMatch = bulletLine.match(EVICTED_ID_RE);
|
|
249
|
+
evicted.push({ id: idMatch ? idMatch[1] : 'unknown', block: `${bulletLine}\n${commentLine}` });
|
|
214
250
|
bulletsRemoved++;
|
|
215
251
|
}
|
|
216
252
|
|
|
217
253
|
if (removeIdx.size === 0) {
|
|
218
|
-
return { text, bulletsRemoved: 0 };
|
|
254
|
+
return { text, bulletsRemoved: 0, evicted: [] };
|
|
219
255
|
}
|
|
220
256
|
const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
|
|
221
|
-
return { text: out, bulletsRemoved };
|
|
257
|
+
return { text: out, bulletsRemoved, evicted };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Task 91.2: archive bullets that consolidate() dropped, so eviction is
|
|
261
|
+
// recoverable rather than silent (mirrors `cmk forget`'s tombstone, §6.5).
|
|
262
|
+
// Append-only log under the tier's archive dir; one audit entry per bullet.
|
|
263
|
+
const EVICTED_ARCHIVE_HEADER =
|
|
264
|
+
'# Evicted scratchpad bullets\n\n<!-- Bullets dropped by cap-consolidation (low/medium trust, >14 days old). Kept here so eviction is recoverable, not silent. To restore one, re-capture it via `cmk remember`. -->\n\n';
|
|
265
|
+
|
|
266
|
+
function archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now }) {
|
|
267
|
+
const archiveDir = join(tierRoot, 'memory', 'archive');
|
|
268
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
269
|
+
const archivePath = join(archiveDir, 'evicted-bullets.md');
|
|
270
|
+
const ts = now ?? nowIso();
|
|
271
|
+
const header = existsSync(archivePath) ? '' : EVICTED_ARCHIVE_HEADER;
|
|
272
|
+
const block = `## Evicted ${ts} — consolidate(${scratchpad})\n${evicted
|
|
273
|
+
.map((e) => e.block)
|
|
274
|
+
.join('\n')}\n\n`;
|
|
275
|
+
appendFileSync(archivePath, header + block, 'utf8');
|
|
276
|
+
for (const e of evicted) {
|
|
277
|
+
appendAuditEntry(tierRoot, {
|
|
278
|
+
ts,
|
|
279
|
+
action: 'evicted',
|
|
280
|
+
tier,
|
|
281
|
+
id: e.id,
|
|
282
|
+
reasonCode: REASON_CODES.SCRATCHPAD_EVICTED,
|
|
283
|
+
paths: { archive: archivePath },
|
|
284
|
+
extra: { scratchpad },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
export function appendScratchpadBullet(opts = {}) {
|
|
@@ -275,6 +340,7 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
275
340
|
// 2. Cap check: would the write push to >95%? If yes, consolidate.
|
|
276
341
|
let consolidationRan = false;
|
|
277
342
|
let bulletsConsolidated = 0;
|
|
343
|
+
let evictedBullets = [];
|
|
278
344
|
let finalContent = candidate;
|
|
279
345
|
const candidateBytes = Buffer.byteLength(candidate, 'utf8');
|
|
280
346
|
|
|
@@ -284,28 +350,72 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
284
350
|
const consolidated = consolidate(candidate, { nowDate });
|
|
285
351
|
bulletsConsolidated = consolidated.bulletsRemoved;
|
|
286
352
|
finalContent = consolidated.text;
|
|
353
|
+
evictedBullets = consolidated.evicted ?? [];
|
|
287
354
|
}
|
|
288
355
|
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
356
|
+
// 2b. Graduation (Task 91, generalized to all tiers by Task 94 / §19.2). If
|
|
357
|
+
// still over the LOAD-cap after stale-drop — which is what happens when the
|
|
358
|
+
// bullets are high-trust (consolidate() never drops those) — graduate the
|
|
359
|
+
// oldest high-trust bullets OUT of the hot index into the tier's permanent
|
|
360
|
+
// fact store, keeping the injected slice small (the write already succeeded
|
|
361
|
+
// via the load-cap; graduation is about injection budget, not write success).
|
|
362
|
+
// ALL FACT-BEARING TIERS (D-61): project (MEMORY.md + SOUL.md) AND the user-
|
|
363
|
+
// tier persona (USER/HABITS/LESSONS) — graduating into the tier's existing
|
|
364
|
+
// fact store (project context/memory/, user <userDir>/fragments/; writeFact
|
|
365
|
+
// already routes tier-U facts there). Local tier (machine-paths/overrides) is
|
|
366
|
+
// excluded: it's machine-specific config, not durable facts.
|
|
367
|
+
let bulletsGraduated = 0;
|
|
368
|
+
let graduatedIds = [];
|
|
369
|
+
let finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
370
|
+
if (finalBytes > cap && (tier === 'P' || tier === 'U')) {
|
|
371
|
+
const grad = graduateForCapRelief({
|
|
372
|
+
text: finalContent,
|
|
373
|
+
capBytes: cap,
|
|
374
|
+
tier,
|
|
375
|
+
projectRoot,
|
|
376
|
+
userDir,
|
|
377
|
+
now,
|
|
303
378
|
});
|
|
379
|
+
finalContent = grad.text;
|
|
380
|
+
graduatedIds = grad.graduated;
|
|
381
|
+
bulletsGraduated = graduatedIds.length;
|
|
382
|
+
finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
304
383
|
}
|
|
305
384
|
|
|
385
|
+
// 3. Load-cap, NOT write-cap (Task 94 / D-61 / design §19). The write ALWAYS
|
|
386
|
+
// succeeds — the cap governs only how much is injected, never whether content
|
|
387
|
+
// can be saved (the never-lose-memory invariant). When consolidate + graduation
|
|
388
|
+
// can't bring the file under cap (e.g. an absurdly small cap, or a single large
|
|
389
|
+
// bullet), the file is allowed to GROW past the inject budget; inject-context
|
|
390
|
+
// load-caps the snapshot (§7.1.1) and the overflow stays searchable on disk.
|
|
391
|
+
// The old `cap_exceeded` reject path was removed here — see §19.5 for the
|
|
392
|
+
// superseded write-cap design and why it changed.
|
|
393
|
+
|
|
306
394
|
// 4. Write + audit
|
|
307
395
|
writeFileSync(path, finalContent, 'utf8');
|
|
308
396
|
const ts = now ?? nowIso();
|
|
397
|
+
|
|
398
|
+
// 4a. Task 91.2 — archive evicted bullets now that the drop is durable on
|
|
399
|
+
// disk (only on the success path, so we never archive a bullet that's still
|
|
400
|
+
// live in the unchanged on-disk scratchpad).
|
|
401
|
+
if (evictedBullets.length > 0) {
|
|
402
|
+
archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted: evictedBullets, now: ts });
|
|
403
|
+
}
|
|
404
|
+
// 4b. Task 91.4 (Door 4) — one audit entry per graduated bullet, so the
|
|
405
|
+
// bullet→fact-file move is traceable in the audit log (writeFact also logs
|
|
406
|
+
// the fact create; this records the graduation that triggered it).
|
|
407
|
+
for (const gid of graduatedIds) {
|
|
408
|
+
appendAuditEntry(tierRoot, {
|
|
409
|
+
ts,
|
|
410
|
+
action: 'graduated',
|
|
411
|
+
tier,
|
|
412
|
+
id: gid,
|
|
413
|
+
reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
|
|
414
|
+
paths: { after: path },
|
|
415
|
+
extra: { scratchpad },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
309
419
|
appendAuditEntry(tierRoot, {
|
|
310
420
|
ts,
|
|
311
421
|
action: 'appended',
|
|
@@ -320,6 +430,7 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
320
430
|
bytes: finalBytes,
|
|
321
431
|
consolidationRan,
|
|
322
432
|
bulletsConsolidated,
|
|
433
|
+
bulletsGraduated,
|
|
323
434
|
},
|
|
324
435
|
});
|
|
325
436
|
|
|
@@ -331,5 +442,122 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
331
442
|
bytes: finalBytes,
|
|
332
443
|
consolidationRan,
|
|
333
444
|
bulletsConsolidated,
|
|
445
|
+
bulletsGraduated,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Proactive cap-relief for a SINGLE scratchpad, run OUTSIDE the append path
|
|
451
|
+
* (Task 94.3). The reactive relief inside appendScratchpadBullet only fires when
|
|
452
|
+
* a write triggers cap pressure; this lets a SessionEnd sweep keep the injected
|
|
453
|
+
* slice under its load-cap even in a read-only session (no new bullets) and catch
|
|
454
|
+
* low/medium bullets that AGED past the 14-day stale window between sessions.
|
|
455
|
+
*
|
|
456
|
+
* Runs the SAME relief sequence as the append path — consolidate (stale-drop +
|
|
457
|
+
* archive) then graduate (high-trust overflow → the tier's fact store) — but only
|
|
458
|
+
* when the scratchpad is already over its load-cap, and it writes back ONLY if
|
|
459
|
+
* the content actually changed (no churn / no audit noise on a comfortable
|
|
460
|
+
* scratchpad, satisfying the over-mutation guard). The WHOLE relief (consolidate
|
|
461
|
+
* AND graduate) is gated to fact-bearing tiers P + U; the L tier (machine config)
|
|
462
|
+
* is left untouched even when over cap — its bullets are not durable facts.
|
|
463
|
+
*
|
|
464
|
+
* Cost note (hook-ceiling composition): each graduated bullet flows through
|
|
465
|
+
* writeFact, which reindexes — so a sweep that graduates N bullets does N reindex
|
|
466
|
+
* passes. That mirrors the reactive append path's existing per-graduating-bullet
|
|
467
|
+
* cost; at SessionEnd it runs AFTER the ~50s concurrent Haiku block but is local
|
|
468
|
+
* file I/O (no spawn/network), and the SessionEnd hook is best-effort (exits 0 on
|
|
469
|
+
* overrun), so it does not threaten the 60s ceiling in practice.
|
|
470
|
+
*
|
|
471
|
+
* @returns {{action:'relieved'|'noop'|'skipped', reason?:string, tier:string,
|
|
472
|
+
* scratchpad:string, bulletsConsolidated:number, bulletsGraduated:number,
|
|
473
|
+
* graduatedIds:string[], bytes:number}}
|
|
474
|
+
*/
|
|
475
|
+
export function sweepScratchpadForCapRelief({
|
|
476
|
+
tier,
|
|
477
|
+
scratchpad,
|
|
478
|
+
projectRoot,
|
|
479
|
+
userDir,
|
|
480
|
+
now,
|
|
481
|
+
settings,
|
|
482
|
+
}) {
|
|
483
|
+
const base = {
|
|
484
|
+
tier,
|
|
485
|
+
scratchpad,
|
|
486
|
+
bulletsConsolidated: 0,
|
|
487
|
+
bulletsGraduated: 0,
|
|
488
|
+
graduatedIds: [],
|
|
489
|
+
bytes: 0,
|
|
490
|
+
};
|
|
491
|
+
const path = resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir });
|
|
492
|
+
if (!existsSync(path)) {
|
|
493
|
+
return { ...base, action: 'skipped', reason: 'no-file' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const original = readFileSync(path, 'utf8');
|
|
497
|
+
const cap = resolveCap({ tier, scratchpad, projectRoot, userDir, settings });
|
|
498
|
+
const originalBytes = Buffer.byteLength(original, 'utf8');
|
|
499
|
+
if (originalBytes <= cap) {
|
|
500
|
+
// Load-cap respected already — leave it alone (no churn).
|
|
501
|
+
return { ...base, action: 'noop', bytes: originalBytes };
|
|
502
|
+
}
|
|
503
|
+
// Relief (both consolidate AND graduate) applies only to fact-bearing tiers.
|
|
504
|
+
// Gate here so the consolidate stale-drop can never touch L-tier machine config
|
|
505
|
+
// even if a future caller passes it (graduateAllScratchpads never does today).
|
|
506
|
+
if (tier !== 'P' && tier !== 'U') {
|
|
507
|
+
return { ...base, action: 'noop', reason: 'tier-excluded', bytes: originalBytes };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Over cap — run the same relief sequence as appendScratchpadBullet.
|
|
511
|
+
const ts = now ?? nowIso();
|
|
512
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
513
|
+
|
|
514
|
+
const consolidated = consolidate(original, { nowDate: new Date(ts) });
|
|
515
|
+
let working = consolidated.text;
|
|
516
|
+
const evicted = consolidated.evicted ?? [];
|
|
517
|
+
|
|
518
|
+
let graduatedIds = [];
|
|
519
|
+
if (Buffer.byteLength(working, 'utf8') > cap) {
|
|
520
|
+
// tier is already guaranteed P||U by the gate above.
|
|
521
|
+
const grad = graduateForCapRelief({
|
|
522
|
+
text: working,
|
|
523
|
+
capBytes: cap,
|
|
524
|
+
tier,
|
|
525
|
+
projectRoot,
|
|
526
|
+
userDir,
|
|
527
|
+
now: ts,
|
|
528
|
+
});
|
|
529
|
+
working = grad.text;
|
|
530
|
+
graduatedIds = grad.graduated;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (working === original) {
|
|
534
|
+
// Nothing relievable (no stale bullets to drop; graduation infeasible or no
|
|
535
|
+
// eligible high-trust bullets). Load-cap means over-cap is allowed — leave
|
|
536
|
+
// the file untouched rather than rewriting it identically.
|
|
537
|
+
return { ...base, action: 'noop', reason: 'irreducible', bytes: originalBytes };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
writeFileSync(path, working, 'utf8');
|
|
541
|
+
if (evicted.length > 0) {
|
|
542
|
+
archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now: ts });
|
|
543
|
+
}
|
|
544
|
+
for (const gid of graduatedIds) {
|
|
545
|
+
appendAuditEntry(tierRoot, {
|
|
546
|
+
ts,
|
|
547
|
+
action: 'graduated',
|
|
548
|
+
tier,
|
|
549
|
+
id: gid,
|
|
550
|
+
reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
|
|
551
|
+
paths: { after: path },
|
|
552
|
+
extra: { scratchpad, trigger: 'session-end' },
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
...base,
|
|
557
|
+
action: 'relieved',
|
|
558
|
+
bulletsConsolidated: evicted.length,
|
|
559
|
+
bulletsGraduated: graduatedIds.length,
|
|
560
|
+
graduatedIds,
|
|
561
|
+
bytes: Buffer.byteLength(working, 'utf8'),
|
|
334
562
|
};
|
|
335
563
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SessionEnd orchestrator (Task 86b + D-42). The shared brain behind both
|
|
2
|
+
// cmk-compress-session bins (npm: packages/cli/bin/; plugin: plugin/bin/) —
|
|
3
|
+
// extracted so the orchestration lives in ONE testable place instead of being
|
|
4
|
+
// duplicated across the twin bins (the twin-bin drift hazard CLAUDE.md warns
|
|
5
|
+
// about).
|
|
6
|
+
//
|
|
7
|
+
// What it does: at session end we run TWO independent Haiku passes —
|
|
8
|
+
// 1. compressSession — reads sessions/now.md (the session buffer) → writes
|
|
9
|
+
// sessions/today-{date}.md, truncates now.md, appends compress.log.
|
|
10
|
+
// 2. autoPersona — reads the context/memory/ fact corpus (written per-turn by
|
|
11
|
+
// auto-extract, NOT by compressSession) → promotes cross-project doctrine
|
|
12
|
+
// into the user-tier persona scratchpads.
|
|
13
|
+
//
|
|
14
|
+
// They have DISJOINT inputs and DISJOINT outputs (compress touches the project
|
|
15
|
+
// sessions/ tree; persona touches the user-tier scratchpads + audit.log; neither
|
|
16
|
+
// reads the other's writes; neither takes a lock the other needs). So we run them
|
|
17
|
+
// CONCURRENTLY via Promise.allSettled.
|
|
18
|
+
//
|
|
19
|
+
// Why concurrent, not sequential (the D-42 composition fix): each pass carries a
|
|
20
|
+
// 50s inner Haiku timeout, and the SessionEnd hook ceiling is 60s (design §8.5 /
|
|
21
|
+
// plugin/hooks/hooks.json). Run sequentially, the worst case is 50s + 50s = 100s
|
|
22
|
+
// — well over the ceiling, so the OS would kill the hook mid-persona-write,
|
|
23
|
+
// dropping {"continue": true} AND risking a half-written user-tier INDEX (HC-5
|
|
24
|
+
// corruption, shared across every project). Run concurrently, the wall-clock is
|
|
25
|
+
// max(50s, 50s) ≈ 50s — comfortably inside 60s. compressSession is correct alone
|
|
26
|
+
// (50<60); autoPersona is correct alone (50<60); only their SEQUENTIAL composition
|
|
27
|
+
// was broken. Concurrency is the composition fix.
|
|
28
|
+
//
|
|
29
|
+
// allSettled (not all): both passes are best-effort. A failure in one must never
|
|
30
|
+
// discard the other's result, and must never reject up into the hook (a thrown
|
|
31
|
+
// SessionEnd hook blocks the user from closing their terminal). Each pass gets its
|
|
32
|
+
// OWN backend instance (makeBackend factory) so there is zero shared mutable state
|
|
33
|
+
// across the two concurrent calls.
|
|
34
|
+
|
|
35
|
+
import { compressSession } from './compress-session.mjs';
|
|
36
|
+
import { autoPersona } from './auto-persona.mjs';
|
|
37
|
+
import { graduateAllScratchpads } from './graduate-session.mjs';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the two independent SessionEnd Haiku passes concurrently.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.projectRoot - resolved project root (CMK_PROJECT_DIR or cwd).
|
|
44
|
+
* @param {string} opts.userDir - user-tier root (~/.claude-memory-kit or override).
|
|
45
|
+
* @param {() => object} opts.makeBackend - factory returning a fresh CompressorBackend
|
|
46
|
+
* per call (each concurrent pass gets its own instance — no shared state).
|
|
47
|
+
* @param {string} [opts.now] - ISO timestamp override (tests).
|
|
48
|
+
* @returns {Promise<{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult, graduationOutcome: PromiseSettledResult}>}
|
|
49
|
+
*/
|
|
50
|
+
export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, now }) {
|
|
51
|
+
const [compressOutcome, personaOutcome] = await Promise.allSettled([
|
|
52
|
+
compressSession({ projectRoot, backend: makeBackend(), now }),
|
|
53
|
+
// cooldownMs:0 — compressSession runs concurrently and would otherwise trip the
|
|
54
|
+
// shared 120s Haiku cooldown gate; at SessionEnd we explicitly want persona to run.
|
|
55
|
+
// source:'transcript' (Task 86c / D-44) — classify the RAW recent conversation,
|
|
56
|
+
// where standing-rule statements survive verbatim, NOT the distilled fact corpus
|
|
57
|
+
// (which strips the cross-project signal). This is what makes the cold-open work.
|
|
58
|
+
autoPersona({ projectRoot, userDir, backend: makeBackend(), cooldownMs: 0, now, source: 'transcript' }),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// Task 94.3: proactive graduation sweep. SEQUENTIAL, AFTER the concurrent block —
|
|
62
|
+
// autoPersona WRITES the user-tier persona scratchpads and graduation READS+
|
|
63
|
+
// rewrites them, so they share inputs and must NOT overlap (the §6.8/§7.1
|
|
64
|
+
// disjoint-input rule). Running it here means the sweep sees the freshly-promoted
|
|
65
|
+
// persona, then trims any overflow so the next session's injected slice stays
|
|
66
|
+
// under its load-cap. Pure local file I/O (no Haiku/network) → adds <<1s, no
|
|
67
|
+
// hook-ceiling risk. Wrapped so a synchronous throw can't reject up into the hook.
|
|
68
|
+
let graduationOutcome;
|
|
69
|
+
try {
|
|
70
|
+
graduationOutcome = {
|
|
71
|
+
status: 'fulfilled',
|
|
72
|
+
value: graduateAllScratchpads({ projectRoot, userDir, now }),
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
graduationOutcome = { status: 'rejected', reason: err };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { compressOutcome, personaOutcome, graduationOutcome };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render the two outcomes into stderr diagnostic lines (shared by both bins so
|
|
83
|
+
* the log shape can't drift between them). Pure — returns an array of lines, each
|
|
84
|
+
* already newline-terminated.
|
|
85
|
+
*
|
|
86
|
+
* @param {{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult}} outcomes
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduationOutcome }) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
|
|
92
|
+
if (compressOutcome.status === 'fulfilled') {
|
|
93
|
+
const r = compressOutcome.value ?? {};
|
|
94
|
+
const reason = r.reason ? ` (${r.reason})` : '';
|
|
95
|
+
const bytes = r.bytesIn ? ` (in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : '';
|
|
96
|
+
lines.push(`cmk-compress-session: ${r.action}${reason}${bytes} ms: ${r.duration_ms ?? 0}\n`);
|
|
97
|
+
} else {
|
|
98
|
+
const e = compressOutcome.reason;
|
|
99
|
+
lines.push(`cmk-compress-session: unexpected error: ${e?.message ?? e}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (personaOutcome.status === 'fulfilled') {
|
|
103
|
+
const p = personaOutcome.value ?? {};
|
|
104
|
+
lines.push(
|
|
105
|
+
`cmk-compress-session: persona ${p.action} (promoted: ${p.promoted?.length ?? 0}, queued: ${p.queued?.length ?? 0})\n`,
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
const e = personaOutcome.reason;
|
|
109
|
+
lines.push(`cmk-compress-session: persona refresh failed: ${e?.message ?? e}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// graduationOutcome is optional so pre-94.3 callers (and the orchestrator tests
|
|
113
|
+
// that pass only the two Haiku outcomes) still render exactly two lines.
|
|
114
|
+
if (graduationOutcome) {
|
|
115
|
+
if (graduationOutcome.status === 'fulfilled') {
|
|
116
|
+
const g = graduationOutcome.value ?? {};
|
|
117
|
+
lines.push(
|
|
118
|
+
`cmk-compress-session: graduation (graduated: ${g.totalGraduated ?? 0}, consolidated: ${g.totalConsolidated ?? 0})\n`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
const e = graduationOutcome.reason;
|
|
122
|
+
lines.push(`cmk-compress-session: graduation failed: ${e?.message ?? e}\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
package/src/settings-hooks.mjs
CHANGED
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
//
|
|
35
35
|
// **Original block (pre-2026-05-29, repair.mjs)**: the PLUGIN form,
|
|
36
36
|
// `bash "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup →
|
|
37
|
-
// cmk-version-check. That form
|
|
38
|
-
//
|
|
39
|
-
//
|
|
37
|
+
// cmk-version-check. That form required bash to be present. It lives in
|
|
38
|
+
// plugin/hooks/hooks.json for the PLUGIN route (Route B) — and as of
|
|
39
|
+
// Task 62 (2026-05-31) that route was converted to the node form
|
|
40
|
+
// `node "${CLAUDE_PLUGIN_ROOT}/bin/<name>.mjs"`, so BOTH routes are now
|
|
41
|
+
// bash-free (node-only, cross-OS). version-check was ported to a node
|
|
42
|
+
// .mjs stub at that point (see below).
|
|
40
43
|
//
|
|
41
44
|
// **Task 49 (2026-05-29)**: the npm route (Route A) needs hooks that
|
|
42
45
|
// work with NO plugin loaded. This block is that form. It drops the
|
|
@@ -174,6 +177,33 @@ export function writeKitHooks(settingsPath) {
|
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
// Task 79 + 90: allow-list the kit's own surfaces so the agent's EXPLICIT
|
|
181
|
+
// captures stay seamless (the AUTO hook path already is). Two prompts to
|
|
182
|
+
// suppress, because Task 69 made the SKILL the capture delivery path:
|
|
183
|
+
// - `Bash(cmk:*)` (Task 79) — stops "Allow this bash command?" when the
|
|
184
|
+
// agent runs `cmk remember` / `cmk lessons promote` (prefix-wildcard;
|
|
185
|
+
// matches any `cmk <subcommand> …`).
|
|
186
|
+
// - `Skill(memory-write)` (Task 90) — stops "Use skill /memory-write?" when
|
|
187
|
+
// the model INVOKES the capture skill. The bash rule alone doesn't cover
|
|
188
|
+
// this: the skill-invocation gate is a separate Claude Code permission
|
|
189
|
+
// surface (`Skill(<name>)` rule, per code.claude.com/docs/en/permissions).
|
|
190
|
+
// Surfaced by the v0.2.0 cut-gate live run — the friction Task 79 killed
|
|
191
|
+
// returned one layer up once capture moved into the skill.
|
|
192
|
+
// Idempotent + over-mutation safe: preserve the user's existing allow entries;
|
|
193
|
+
// only append ours if absent.
|
|
194
|
+
const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)'];
|
|
195
|
+
if (!settings.permissions || typeof settings.permissions !== 'object') {
|
|
196
|
+
settings.permissions = {};
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(settings.permissions.allow)) {
|
|
199
|
+
settings.permissions.allow = [];
|
|
200
|
+
}
|
|
201
|
+
for (const rule of KIT_ALLOW) {
|
|
202
|
+
if (!settings.permissions.allow.includes(rule)) {
|
|
203
|
+
settings.permissions.allow.push(rule);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
const after = JSON.stringify(settings);
|
|
178
208
|
const changed = before !== after;
|
|
179
209
|
|