@lh8ppl/claude-memory-kit 0.2.1 → 0.2.3
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 +7 -6
- package/bin/cmk-capture-prompt.mjs +17 -17
- package/bin/cmk-capture-turn.mjs +22 -21
- package/bin/cmk-compress-session.mjs +2 -2
- package/bin/cmk-inject-context.mjs +11 -11
- package/bin/cmk-observe-edit.mjs +17 -16
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-extract.mjs +258 -6
- package/src/auto-persona.mjs +40 -8
- package/src/capture-turn.mjs +48 -1
- package/src/compress-session.mjs +89 -26
- package/src/compressor.mjs +1 -1
- package/src/conflict-queue.mjs +14 -0
- package/src/doctor.mjs +3 -3
- package/src/forget.mjs +29 -0
- package/src/graduation.mjs +1 -1
- package/src/index-rebuild.mjs +42 -0
- package/src/inject-context.mjs +5 -1
- package/src/install.mjs +29 -6
- package/src/lazy-compress.mjs +58 -9
- package/src/mcp-server.mjs +353 -124
- package/src/merge-facts.mjs +4 -0
- package/src/persona-portability.mjs +24 -1
- package/src/read-core.mjs +87 -0
- package/src/register-crons.mjs +64 -33
- package/src/remember-core.mjs +91 -0
- package/src/review-queue.mjs +13 -0
- package/src/rich-fact.mjs +46 -0
- package/src/settings-hooks.mjs +56 -2
- package/src/subcommands.mjs +419 -182
- package/src/weekly-curate.mjs +5 -0
- package/src/write-fact.mjs +25 -1
- package/template/.claude/skills/memory-write/SKILL.md +52 -35
- package/template/.gitignore.fragment +9 -3
- package/template/CLAUDE.md.template +2 -2
- package/template/docs/journey/journey-log.md.template +1 -1
package/src/compress-session.mjs
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
readFileSync,
|
|
31
31
|
writeFileSync,
|
|
32
32
|
appendFileSync,
|
|
33
|
-
|
|
33
|
+
renameSync,
|
|
34
|
+
unlinkSync,
|
|
34
35
|
} from 'node:fs';
|
|
35
36
|
import { join, dirname } from 'node:path';
|
|
36
37
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -46,6 +47,10 @@ const DEFAULT_MAX_OUTPUT_BYTES = 4096;
|
|
|
46
47
|
|
|
47
48
|
const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
|
|
48
49
|
const SESSIONS_DIR_RELATIVE = ['context', 'sessions'];
|
|
50
|
+
// Task 106 (§16.27): the live buffer is CLAIMED by an atomic rename to this
|
|
51
|
+
// suffix before compression, so concurrent PostToolUse/capture-turn appends
|
|
52
|
+
// land on a fresh now.md without racing the truncate.
|
|
53
|
+
const ROLLING_SUFFIX = '.rolling-';
|
|
49
54
|
|
|
50
55
|
// Compression prompt (design §8.4). Written from scratch per the
|
|
51
56
|
// licensing posture in SOURCES.md (claude-remember's prompts are not
|
|
@@ -126,13 +131,74 @@ function dateFromIso(ts) {
|
|
|
126
131
|
return ts.slice(0, 10);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
// Task 106 (§16.27 file-rename pattern). ATOMICALLY claim the live buffer:
|
|
135
|
+
// rename now.md → now.md.rolling-{ts}, then read the claimed copy. The rename is
|
|
136
|
+
// atomic on POSIX (rename(2)) + NTFS (MoveFileEx), so a concurrent appender
|
|
137
|
+
// (PostToolUse/capture-turn) that fires DURING the ~5–10s Haiku call lands on a
|
|
138
|
+
// fresh now.md with zero contention — its content is never inside the
|
|
139
|
+
// read→clear window the old `read then truncate(0)` left open. Returns the
|
|
140
|
+
// claimed buffer + the rolling path (null when now.md is absent / the rename
|
|
141
|
+
// raced, which the caller treats as an empty buffer).
|
|
142
|
+
//
|
|
143
|
+
// Bonus property — the rename also SERIALIZES concurrent rolls. compressSession
|
|
144
|
+
// is gated by the 120s cooldown, but the marker is only touched on success, so
|
|
145
|
+
// two callers (a SessionEnd + the Task 105 SessionStart-lazy roll) can both pass
|
|
146
|
+
// the cooldown gate and reach here. Only ONE renameSync wins; the other gets
|
|
147
|
+
// ENOENT (now.md already claimed) → returns an empty buffer → skips. No lock
|
|
148
|
+
// needed; the atomic rename IS the mutex.
|
|
149
|
+
function claimNowBuffer(projectRoot, ts) {
|
|
150
|
+
const nowPath = readNowMdPath(projectRoot);
|
|
151
|
+
if (!existsSync(nowPath)) return { buffer: '', rollingPath: null };
|
|
152
|
+
const rollingPath = nowPath + ROLLING_SUFFIX + String(ts).replace(/[:.]/g, '-');
|
|
153
|
+
try {
|
|
154
|
+
renameSync(nowPath, rollingPath);
|
|
155
|
+
} catch {
|
|
156
|
+
// now.md vanished or the rename lost a race — nothing to roll.
|
|
157
|
+
return { buffer: '', rollingPath: null };
|
|
158
|
+
}
|
|
159
|
+
let buffer = '';
|
|
160
|
+
try {
|
|
161
|
+
buffer = readFileSync(rollingPath, 'utf8');
|
|
162
|
+
} catch {
|
|
163
|
+
buffer = '';
|
|
164
|
+
}
|
|
165
|
+
return { buffer, rollingPath };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Success path: the claimed buffer is safely compressed into today-{date}.md.
|
|
169
|
+
// Drop the rolling file. now.md is owned by the (new) session's appenders now —
|
|
170
|
+
// we do NOT recreate or touch it, so a concurrent append is never clobbered.
|
|
171
|
+
function discardRolling(rollingPath) {
|
|
172
|
+
if (!rollingPath) return;
|
|
132
173
|
try {
|
|
133
|
-
|
|
174
|
+
unlinkSync(rollingPath);
|
|
134
175
|
} catch {
|
|
135
|
-
|
|
176
|
+
// best-effort; a leaked rolling file is inert (the next roll claims now.md,
|
|
177
|
+
// not now.md.rolling-*) and harmless beyond disk noise.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Error/timeout path: the claimed buffer was NOT compressed — restore it so the
|
|
182
|
+
// next roll retries it (the old impl's "leave now.md intact" contract). Prepend
|
|
183
|
+
// it to anything a concurrent session appended to the fresh now.md (the claimed
|
|
184
|
+
// content is OLDER, so it leads), preserving both with no truncate. Best-effort:
|
|
185
|
+
// if the restore write fails, the rolling file stays as a recovery breadcrumb.
|
|
186
|
+
function restoreRolling(projectRoot, rollingPath) {
|
|
187
|
+
if (!rollingPath || !existsSync(rollingPath)) return;
|
|
188
|
+
const nowPath = readNowMdPath(projectRoot);
|
|
189
|
+
try {
|
|
190
|
+
const claimed = readFileSync(rollingPath, 'utf8');
|
|
191
|
+
const current = existsSync(nowPath) ? readFileSync(nowPath, 'utf8') : '';
|
|
192
|
+
// Guarantee a newline boundary between the claimed (older) buffer and any
|
|
193
|
+
// concurrent appends. String op, not a regex — a trailing-anchored `\n*$`
|
|
194
|
+
// trips static-analysis's ReDoS heuristic (same convention as slugify in
|
|
195
|
+
// rich-fact.mjs / graduation.mjs).
|
|
196
|
+
const sep = claimed.endsWith('\n') ? '' : '\n';
|
|
197
|
+
const merged = current ? claimed + sep + current : claimed;
|
|
198
|
+
writeFileSync(nowPath, merged, 'utf8');
|
|
199
|
+
unlinkSync(rollingPath);
|
|
200
|
+
} catch {
|
|
201
|
+
// best-effort — see above
|
|
136
202
|
}
|
|
137
203
|
}
|
|
138
204
|
|
|
@@ -146,18 +212,6 @@ function appendToTodayMd({ projectRoot, date, body }) {
|
|
|
146
212
|
return path;
|
|
147
213
|
}
|
|
148
214
|
|
|
149
|
-
function truncateNowMd(projectRoot) {
|
|
150
|
-
const p = readNowMdPath(projectRoot);
|
|
151
|
-
if (!existsSync(p)) return;
|
|
152
|
-
try {
|
|
153
|
-
truncateSync(p, 0);
|
|
154
|
-
} catch {
|
|
155
|
-
// Best-effort. If truncate fails (perm error etc.), the next
|
|
156
|
-
// session compresses a slightly-larger buffer — not a data-loss
|
|
157
|
-
// event.
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
215
|
function writeCompressLogEntry({ projectRoot, date, entry }) {
|
|
162
216
|
const path = compressLogPath(projectRoot, date);
|
|
163
217
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -231,9 +285,13 @@ export async function compressSession({
|
|
|
231
285
|
};
|
|
232
286
|
}
|
|
233
287
|
|
|
234
|
-
// 2.
|
|
235
|
-
|
|
288
|
+
// 2. CLAIM the live buffer by atomic rename (Task 106 / §16.27), then read it;
|
|
289
|
+
// no-op if empty (tasks.md 22.1). Claiming before the Haiku call is what
|
|
290
|
+
// closes the race — a concurrent append during compression lands on a
|
|
291
|
+
// fresh now.md, never inside a read→truncate window.
|
|
292
|
+
const { buffer, rollingPath } = claimNowBuffer(projectRoot, ts);
|
|
236
293
|
if (buffer.trim() === '') {
|
|
294
|
+
discardRolling(rollingPath); // drop the (empty) claimed file if one was renamed
|
|
237
295
|
const duration_ms = Date.now() - t0;
|
|
238
296
|
const entry = {
|
|
239
297
|
ts,
|
|
@@ -257,13 +315,14 @@ export async function compressSession({
|
|
|
257
315
|
const input_bytes = Buffer.byteLength(buffer, 'utf8');
|
|
258
316
|
const instructions = buildCompressionInstructions(maxOutputBytes);
|
|
259
317
|
|
|
260
|
-
// 3. Invoke backend. On throw:
|
|
318
|
+
// 3. Invoke backend. On throw: RESTORE the claimed buffer to now.md (22.5) so
|
|
319
|
+
// the next session-end retries it — the file-rename analogue of the old
|
|
320
|
+
// "leave now.md intact".
|
|
261
321
|
//
|
|
262
322
|
// Subprocess timeout: 50_000 ms. Sits under the 60s SessionEnd
|
|
263
323
|
// hook ceiling (design §5.1) so on timeout the catch + log write
|
|
264
|
-
// complete BEFORE Claude Code kills the parent
|
|
265
|
-
//
|
|
266
|
-
// on the success path), so the next session-end retries naturally.
|
|
324
|
+
// complete BEFORE Claude Code kills the parent — including the
|
|
325
|
+
// restoreRolling call, so the buffer is never stranded in the rolling file.
|
|
267
326
|
// See design §8.5 for the composition rationale.
|
|
268
327
|
let result;
|
|
269
328
|
try {
|
|
@@ -285,6 +344,8 @@ export async function compressSession({
|
|
|
285
344
|
const errorCategory = err instanceof HaikuTimeoutError
|
|
286
345
|
? ERROR_CATEGORIES.HAIKU_TIMEOUT
|
|
287
346
|
: ERROR_CATEGORIES.COMPRESS_FAILED;
|
|
347
|
+
// The claimed buffer wasn't compressed — put it back so it isn't lost.
|
|
348
|
+
restoreRolling(projectRoot, rollingPath);
|
|
288
349
|
const duration_ms = Date.now() - t0;
|
|
289
350
|
const entry = {
|
|
290
351
|
ts,
|
|
@@ -316,8 +377,10 @@ export async function compressSession({
|
|
|
316
377
|
body: output,
|
|
317
378
|
});
|
|
318
379
|
|
|
319
|
-
// 5.
|
|
320
|
-
|
|
380
|
+
// 5. The claimed buffer is safely in today-{date}.md — drop the rolling file
|
|
381
|
+
// (Task 106/§16.27). now.md is untouched: any turn the new session appended
|
|
382
|
+
// while we compressed stays put.
|
|
383
|
+
discardRolling(rollingPath);
|
|
321
384
|
|
|
322
385
|
// 6. Touch cooldown marker so the next caller within 120s skips.
|
|
323
386
|
touchCooldownMarker({ projectRoot, now: ts });
|
package/src/compressor.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// Note on the allowedTools split: design.md §6.1 documents
|
|
25
25
|
// `--allowed-tools "Read"`; the code-dive note recommended tightening
|
|
26
26
|
// to fully empty per claude-remember's actual pattern. This PR
|
|
27
|
-
// implements empty per
|
|
27
|
+
// implements empty per the user's instruction (the auto-extract sub-Claude
|
|
28
28
|
// never needs Read either — the turn content arrives in the prompt).
|
|
29
29
|
|
|
30
30
|
import { spawn as defaultSpawn } from 'node:child_process';
|
package/src/conflict-queue.mjs
CHANGED
|
@@ -427,6 +427,20 @@ function parseQueue(queueText) {
|
|
|
427
427
|
*
|
|
428
428
|
* Returns { resolved: N, kept_old: N, kept_new: N, merged: N, skipped: N }.
|
|
429
429
|
*/
|
|
430
|
+
/**
|
|
431
|
+
* Pure-read list of PENDING conflict-queue entries (no mutation). Used by the MCP
|
|
432
|
+
* `mk_queue_list` tool so a "list" never rewrites the queue file — unlike
|
|
433
|
+
* resolveConflictQueue, which reserializes on every call. Returns `[]` when the
|
|
434
|
+
* queue file doesn't exist.
|
|
435
|
+
*/
|
|
436
|
+
export function listConflictQueue({ tier = 'P', projectRoot, userDir } = {}) {
|
|
437
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
438
|
+
const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
|
|
439
|
+
if (!existsSync(queuePath)) return [];
|
|
440
|
+
const { entries } = parseQueue(readFileSync(queuePath, 'utf8'));
|
|
441
|
+
return entries.filter((e) => e.fields.resolution === 'pending');
|
|
442
|
+
}
|
|
443
|
+
|
|
430
444
|
export async function resolveConflictQueue({
|
|
431
445
|
tier,
|
|
432
446
|
projectRoot,
|
package/src/doctor.mjs
CHANGED
|
@@ -87,9 +87,9 @@ async function hc1Memsearch() {
|
|
|
87
87
|
} catch {
|
|
88
88
|
// fall through to skip
|
|
89
89
|
}
|
|
90
|
-
//
|
|
90
|
+
// The user (2026-05-28): make the feature impact explicit so users
|
|
91
91
|
// understand WHAT THEY LOSE by skipping the install, not just that
|
|
92
|
-
// a check failed. Matches
|
|
92
|
+
// a check failed. Matches the user's directive: "ask before we do
|
|
93
93
|
// anything, explain if they dont install they dont get certain
|
|
94
94
|
// features".
|
|
95
95
|
return {
|
|
@@ -345,7 +345,7 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
345
345
|
// Two false-positives this must avoid (both real):
|
|
346
346
|
// 1. id-shaped names — the pre-Task-85 regex matched `[PUL]-XXXXXXXX.md`,
|
|
347
347
|
// which the kit NEVER generates, so HC-5 false-FAILED "missing" on every
|
|
348
|
-
// real fact the moment one existed (
|
|
348
|
+
// real fact the moment one existed (live-test-7 2026-06-03).
|
|
349
349
|
// 2. non-fact links — a broad `](...md)` match also catches the scaffold's
|
|
350
350
|
// own example `- [type] [Title](filename.md)` (inside an HTML comment) and
|
|
351
351
|
// any prose link like `(design.md)`, which would false-FAIL "stale" on a
|
package/src/forget.mjs
CHANGED
|
@@ -27,6 +27,8 @@ import { parse, format } from './frontmatter.mjs';
|
|
|
27
27
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
28
28
|
import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
|
|
29
29
|
import { findBulletScratchpad } from './bullet-lookup.mjs';
|
|
30
|
+
import { openIndexDb } from './index-db.mjs';
|
|
31
|
+
import { reindexBoot } from './index-rebuild.mjs';
|
|
30
32
|
|
|
31
33
|
// Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
|
|
32
34
|
// minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
|
|
@@ -290,6 +292,32 @@ export function forget(opts = {}) {
|
|
|
290
292
|
},
|
|
291
293
|
});
|
|
292
294
|
|
|
295
|
+
// Task 110 (F-7 / D-84): reindex the project tier IN-BAND so the just-
|
|
296
|
+
// tombstoned fact stops surfacing in `cmk search` immediately — no manual
|
|
297
|
+
// `cmk reindex`, no forgotten fact resurfacing (D-85: the action completes
|
|
298
|
+
// automatically; the regular user runs no follow-up command). reindexBoot's
|
|
299
|
+
// orphan-prune drops the unlinked fact's index rows and re-reads the scrubbed
|
|
300
|
+
// scratchpads in one pass. Both `cmk forget` (CLI) and `mk_forget` (MCP) call
|
|
301
|
+
// this same forget(), so both surfaces get it. Best-effort: the fact is
|
|
302
|
+
// ALREADY tombstoned + scrubbed on disk, so an index error must not fail the
|
|
303
|
+
// forget — every index reader lazy-reindexes (also orphan-pruning) and self-
|
|
304
|
+
// heals on the next read. A pure user-tier forget (no projectRoot) has no
|
|
305
|
+
// project index to touch and skips this.
|
|
306
|
+
let reindexed = false;
|
|
307
|
+
if (projectRoot) {
|
|
308
|
+
try {
|
|
309
|
+
const db = openIndexDb({ projectRoot });
|
|
310
|
+
try {
|
|
311
|
+
reindexBoot({ projectRoot, userDir, db });
|
|
312
|
+
reindexed = true;
|
|
313
|
+
} finally {
|
|
314
|
+
db.close();
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// best-effort — the on-disk tombstone is authoritative; search self-heals.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
293
321
|
return {
|
|
294
322
|
action: 'tombstoned',
|
|
295
323
|
id: match.id,
|
|
@@ -297,6 +325,7 @@ export function forget(opts = {}) {
|
|
|
297
325
|
originalPath: match.path,
|
|
298
326
|
tombstonePath,
|
|
299
327
|
scratchpadEdits,
|
|
328
|
+
reindexed,
|
|
300
329
|
};
|
|
301
330
|
}
|
|
302
331
|
|
package/src/graduation.mjs
CHANGED
|
@@ -35,7 +35,7 @@ const VALID_WRITE_SOURCES = new Set([
|
|
|
35
35
|
|
|
36
36
|
function slugify(s) {
|
|
37
37
|
// Collapse non-alphanumerics to single dashes, cap, trim edges (string ops,
|
|
38
|
-
// no trailing-dash quantifier — matches
|
|
38
|
+
// no trailing-dash quantifier — matches rich-fact.slugifyFact's ReDoS-safe
|
|
39
39
|
// shape).
|
|
40
40
|
let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
|
|
41
41
|
if (base.startsWith('-')) base = base.slice(1);
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -403,10 +403,52 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
403
403
|
observationsAffected += n;
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// Prune orphans (Task 110 / F-7). The walk above only ADDS/UPDATES files that
|
|
407
|
+
// still exist; a file removed since the last index (e.g. a fact `cmk forget`
|
|
408
|
+
// moved to archive/tombstones/, or a queue-discard) leaves its observation
|
|
409
|
+
// rows behind, so the forgotten fact keeps surfacing in `cmk search` until a
|
|
410
|
+
// manual `reindex --full`. Drop any `files` checkpoint whose source is no
|
|
411
|
+
// longer on disk, plus its observations (the FTS5 delete trigger fires per
|
|
412
|
+
// row). This makes boot a full sync (add/update/DELETE), so every index
|
|
413
|
+
// reader — all of which lazy-call reindexBoot first — self-heals after any
|
|
414
|
+
// removal with no manual command (the D-85 "everything automatic" contract).
|
|
415
|
+
//
|
|
416
|
+
// SAFETY (composition guard): the prune deletes any known row NOT in the
|
|
417
|
+
// current live-set, so it is only sound when the live-set is COMPLETE across
|
|
418
|
+
// every tier the index covers. The U tier is walked only when `userDir` is
|
|
419
|
+
// provided; without it, U sources are absent from `liveRelPaths` and a real
|
|
420
|
+
// U-tier row would be mis-pruned as an orphan. So we prune ONLY when userDir
|
|
421
|
+
// is present (P + L + U all walked). When it's absent we skip — the next
|
|
422
|
+
// reader that passes userDir (every `cmk search`/`get`/… does) self-heals.
|
|
423
|
+
// (projectRoot is always present here — it's required to open the db.)
|
|
424
|
+
let filesPruned = 0;
|
|
425
|
+
let observationsPruned = 0;
|
|
426
|
+
if (userDir) {
|
|
427
|
+
const liveRelPaths = new Set(
|
|
428
|
+
sources.map((s) => relativeSource(s.path, { projectRoot, userDir })),
|
|
429
|
+
);
|
|
430
|
+
const pruneTxn = db.transaction((relPath, obsCount) => {
|
|
431
|
+
db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(relPath);
|
|
432
|
+
db.prepare('DELETE FROM files WHERE path = ?').run(relPath);
|
|
433
|
+
filesPruned++;
|
|
434
|
+
observationsPruned += obsCount;
|
|
435
|
+
});
|
|
436
|
+
const knownPaths = db.prepare('SELECT path FROM files').all();
|
|
437
|
+
for (const { path: relPath } of knownPaths) {
|
|
438
|
+
if (liveRelPaths.has(relPath)) continue;
|
|
439
|
+
const obsCount = db
|
|
440
|
+
.prepare('SELECT COUNT(*) AS n FROM observations WHERE source_file = ?')
|
|
441
|
+
.get(relPath).n;
|
|
442
|
+
pruneTxn(relPath, obsCount);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
406
446
|
return {
|
|
407
447
|
filesScanned,
|
|
408
448
|
filesReindexed,
|
|
409
449
|
observationsAffected,
|
|
450
|
+
filesPruned,
|
|
451
|
+
observationsPruned,
|
|
410
452
|
durationMs: Date.now() - t0,
|
|
411
453
|
skipped,
|
|
412
454
|
};
|
package/src/inject-context.mjs
CHANGED
|
@@ -740,7 +740,11 @@ export function injectContext({
|
|
|
740
740
|
try {
|
|
741
741
|
const verdict = detectStaleness({ projectRoot, now: ts });
|
|
742
742
|
lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
|
|
743
|
-
if (
|
|
743
|
+
if (
|
|
744
|
+
verdict.action === 'stale-now' ||
|
|
745
|
+
verdict.action === 'stale-daily' ||
|
|
746
|
+
verdict.action === 'stale-weekly'
|
|
747
|
+
) {
|
|
744
748
|
const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
|
|
745
749
|
const spawnResult = spawner(projectRoot, compressLazyPath);
|
|
746
750
|
lazyTrigger = { ...lazyTrigger, ...spawnResult };
|
package/src/install.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { homedir } from 'node:os';
|
|
|
42
42
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
43
43
|
import { fileURLToPath } from 'node:url';
|
|
44
44
|
import { injectClaudeMdBlock } from './claude-md.mjs';
|
|
45
|
-
import { writeKitHooks } from './settings-hooks.mjs';
|
|
45
|
+
import { writeKitHooks, writeKitMcpServer } from './settings-hooks.mjs';
|
|
46
46
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
47
47
|
|
|
48
48
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -51,9 +51,17 @@ const CLI_SRC_DIR = dirname(__filename);
|
|
|
51
51
|
const REPO_ROOT_DEV = resolve(CLI_SRC_DIR, '..', '..', '..');
|
|
52
52
|
const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// The start marker carries the install version (matching the CLAUDE.md block,
|
|
55
|
+
// which is load-bearing for upgrade detection). The replace-regex in
|
|
56
|
+
// injectGitignore ignores the version, so it's cosmetic for idempotency — but
|
|
57
|
+
// it must not show a stale hardcode (was `v0.1.0` in every install). Built per
|
|
58
|
+
// install from the kit version; see gitignoreStartMarker().
|
|
55
59
|
const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
|
|
56
60
|
|
|
61
|
+
function gitignoreStartMarker(version) {
|
|
62
|
+
return `# claude-memory-kit:gitignore:start v${version}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/**
|
|
58
66
|
* Read the kit version from the cli package's package.json.
|
|
59
67
|
* Used as the default version for the CLAUDE.md marker.
|
|
@@ -216,12 +224,12 @@ function installTier(srcDir, destDir, { created, skipped, errors, vars }) {
|
|
|
216
224
|
* Build the canonical .gitignore managed block from template/.gitignore.fragment.
|
|
217
225
|
* Adds start/end markers around the fragment so we can refresh in place.
|
|
218
226
|
*/
|
|
219
|
-
function buildGitignoreBlock(templateDir) {
|
|
227
|
+
function buildGitignoreBlock(templateDir, version = getKitVersion()) {
|
|
220
228
|
const fragmentPath = join(templateDir, '.gitignore.fragment');
|
|
221
229
|
const fragment = existsSync(fragmentPath)
|
|
222
230
|
? readFileSync(fragmentPath, 'utf8').trim()
|
|
223
231
|
: 'context.local/\ncontext/.index/\ncontext/.locks/';
|
|
224
|
-
return `${
|
|
232
|
+
return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
/**
|
|
@@ -318,7 +326,7 @@ export async function install(options = {}) {
|
|
|
318
326
|
});
|
|
319
327
|
}
|
|
320
328
|
|
|
321
|
-
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
|
|
329
|
+
const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
|
|
322
330
|
|
|
323
331
|
// CLAUDE.md loader block — Task 4. Read the block content from the kit's
|
|
324
332
|
// template/ and inject (or refresh) it inside marker delimiters. Never
|
|
@@ -352,6 +360,11 @@ export async function install(options = {}) {
|
|
|
352
360
|
// hooks is a no-op. Opt out with {noHooks:true} (CLI: --no-hooks) for
|
|
353
361
|
// scaffold-only installs.
|
|
354
362
|
let hooks = { action: 'skipped', path: join(projectRoot, '.claude', 'settings.json') };
|
|
363
|
+
// Task 108b — register the kit's MCP server (.mcp.json) so the model can drive
|
|
364
|
+
// memory ops as allow-listed tools (the `mcp__cmk__*` rule writeKitHooks adds),
|
|
365
|
+
// not just `cmk` bash. Same {noHooks} opt-out as the hooks (it's Claude Code
|
|
366
|
+
// wiring). R2 / D-80 fix.
|
|
367
|
+
let mcpServer = { action: 'skipped', path: join(projectRoot, '.mcp.json') };
|
|
355
368
|
if (!options.noHooks) {
|
|
356
369
|
const settingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
357
370
|
const r = writeKitHooks(settingsPath);
|
|
@@ -388,7 +401,17 @@ export async function install(options = {}) {
|
|
|
388
401
|
}
|
|
389
402
|
}
|
|
390
403
|
|
|
391
|
-
|
|
404
|
+
if (!options.noHooks) {
|
|
405
|
+
const r = writeKitMcpServer(projectRoot);
|
|
406
|
+
if (r.error) {
|
|
407
|
+
errors.push({ path: r.path, error: r.error });
|
|
408
|
+
mcpServer = { action: 'error', path: r.path, error: r.error };
|
|
409
|
+
} else {
|
|
410
|
+
mcpServer = { action: r.changed ? 'registered' : 'unchanged', path: r.path };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, mcpServer, errors };
|
|
392
415
|
}
|
|
393
416
|
|
|
394
417
|
/**
|
package/src/lazy-compress.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
existsSync,
|
|
30
30
|
mkdirSync,
|
|
31
31
|
readdirSync,
|
|
32
|
+
readFileSync,
|
|
32
33
|
statSync,
|
|
33
34
|
writeFileSync,
|
|
34
35
|
unlinkSync,
|
|
@@ -42,11 +43,13 @@ import {
|
|
|
42
43
|
} from './cooldown.mjs';
|
|
43
44
|
import { dailyDistill } from './daily-distill.mjs';
|
|
44
45
|
import { weeklyCurate } from './weekly-curate.mjs';
|
|
46
|
+
import { compressSession } from './compress-session.mjs';
|
|
45
47
|
|
|
46
48
|
const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
47
49
|
const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
48
50
|
const SESSIONS_REL = ['context', 'sessions'];
|
|
49
51
|
const LOCKS_REL = ['context', '.locks'];
|
|
52
|
+
const NOW_MD_REL = ['context', 'sessions', 'now.md'];
|
|
50
53
|
const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
|
|
51
54
|
const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
|
|
52
55
|
const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
|
|
@@ -100,6 +103,25 @@ function listTodayFiles(projectRoot) {
|
|
|
100
103
|
return matches;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
// Task 105 (D-75): does now.md carry prior-session content? The now→today
|
|
107
|
+
// roll (compressSession) fires only at SessionEnd, and Claude Code fires
|
|
108
|
+
// SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
|
|
109
|
+
// leaves now.md growing unbounded with no today-*.md/recent.md built. We detect
|
|
110
|
+
// a non-empty now.md at SessionStart and let the lazy worker roll it. At
|
|
111
|
+
// SessionStart now.md can only hold PRIOR-session turns (this session's
|
|
112
|
+
// capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
|
|
113
|
+
// match compressSession's own `buffer.trim() === ''` check so the spawn verdict
|
|
114
|
+
// and the actual roll agree (else we'd spawn for a roll that immediately skips).
|
|
115
|
+
function nowMdHasContent(projectRoot) {
|
|
116
|
+
const p = join(projectRoot, ...NOW_MD_REL);
|
|
117
|
+
if (!existsSync(p)) return false;
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(p, 'utf8').trim() !== '';
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
function recentMdMtimeMs(projectRoot) {
|
|
104
126
|
const p = join(projectRoot, ...RECENT_MD_REL);
|
|
105
127
|
if (!existsSync(p)) return null;
|
|
@@ -113,9 +135,11 @@ function recentMdMtimeMs(projectRoot) {
|
|
|
113
135
|
/**
|
|
114
136
|
* Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
|
|
115
137
|
*
|
|
116
|
-
* Verdict semantics:
|
|
138
|
+
* Verdict semantics (precedence: cron > no-context-dir > now > weekly > daily > fresh):
|
|
117
139
|
* - 'cron-active' : sentinel exists; cron will handle staleness. No-op.
|
|
118
140
|
* - 'no-context-dir': context/sessions/ doesn't exist. No-op (kit not installed).
|
|
141
|
+
* - 'stale-now' : now.md carries prior-session content (Task 105/D-75) — the
|
|
142
|
+
* now→today roll the SessionEnd hook would have done.
|
|
119
143
|
* - 'stale-weekly' : ANY today-*.md older than 7d exists. Weekly curate needed.
|
|
120
144
|
* - 'stale-daily' : no OLD today files, but recent.md is missing OR older than dailyTtlMs.
|
|
121
145
|
* - 'fresh' : recent.md exists + younger than dailyTtlMs AND no OLD today files.
|
|
@@ -141,6 +165,16 @@ export function detectStaleness({
|
|
|
141
165
|
return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
|
|
142
166
|
}
|
|
143
167
|
|
|
168
|
+
// Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
|
|
169
|
+
// hook would have done. It takes PRECEDENCE over daily/weekly because it's
|
|
170
|
+
// the FIRST pipeline level (now → today → recent → archive) — roll it this
|
|
171
|
+
// SessionStart; the today→recent + weekly levels cascade on subsequent
|
|
172
|
+
// SessionStarts once now.md is drained. (cron-active above still wins — a
|
|
173
|
+
// registered cron owns the whole pipeline.)
|
|
174
|
+
if (nowMdHasContent(projectRoot)) {
|
|
175
|
+
return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
const ts = now ?? nowIso();
|
|
145
179
|
const nowMs = new Date(ts).getTime();
|
|
146
180
|
const files = listTodayFiles(projectRoot);
|
|
@@ -281,12 +315,24 @@ export async function runLazyCompress({
|
|
|
281
315
|
return { action: 'skipped', reason: verdict.reason, duration_ms };
|
|
282
316
|
}
|
|
283
317
|
|
|
284
|
-
// verdict.action is 'stale-daily' or 'stale-weekly'.
|
|
285
|
-
// Delegate to the
|
|
286
|
-
// already gated above; the inner call shouldn't gate a second time on
|
|
287
|
-
//
|
|
318
|
+
// verdict.action is 'stale-now', 'stale-daily', or 'stale-weekly'.
|
|
319
|
+
// Delegate to the matching pipeline stage, passing cooldownMs=0 because we
|
|
320
|
+
// already gated above; the inner call shouldn't gate a second time on the
|
|
321
|
+
// same marker. Task 105: 'stale-now' rolls now.md → today-*.md via
|
|
322
|
+
// compressSession (the level the SessionEnd hook owns); the today→recent +
|
|
323
|
+
// weekly levels cascade on the next SessionStart once now.md is drained.
|
|
288
324
|
let result;
|
|
289
|
-
|
|
325
|
+
let delegatedTo;
|
|
326
|
+
if (verdict.action === 'stale-now') {
|
|
327
|
+
delegatedTo = 'compress-session';
|
|
328
|
+
result = await compressSession({
|
|
329
|
+
projectRoot,
|
|
330
|
+
backend,
|
|
331
|
+
now: ts,
|
|
332
|
+
cooldownMs: 0,
|
|
333
|
+
});
|
|
334
|
+
} else if (verdict.action === 'stale-weekly') {
|
|
335
|
+
delegatedTo = 'weekly-curate';
|
|
290
336
|
result = await weeklyCurate({
|
|
291
337
|
projectRoot,
|
|
292
338
|
backend,
|
|
@@ -294,6 +340,7 @@ export async function runLazyCompress({
|
|
|
294
340
|
cooldownMs: 0,
|
|
295
341
|
});
|
|
296
342
|
} else {
|
|
343
|
+
delegatedTo = 'daily-distill';
|
|
297
344
|
result = await dailyDistill({
|
|
298
345
|
projectRoot,
|
|
299
346
|
backend,
|
|
@@ -310,17 +357,19 @@ export async function runLazyCompress({
|
|
|
310
357
|
scope: 'lazy-compress',
|
|
311
358
|
action: result?.action ?? 'unknown',
|
|
312
359
|
verdict: verdict.action,
|
|
313
|
-
delegated_to:
|
|
360
|
+
delegated_to: delegatedTo,
|
|
314
361
|
duration_ms,
|
|
315
362
|
success: result?.action !== 'error',
|
|
316
363
|
...(result?.errorCategory ? { error_category: result.errorCategory } : {}),
|
|
364
|
+
// compress-session reports its error via error_category (snake) — pass it
|
|
365
|
+
// through too so the lazy log captures either shape.
|
|
366
|
+
...(result?.error_category ? { error_category: result.error_category } : {}),
|
|
317
367
|
},
|
|
318
368
|
});
|
|
319
369
|
return {
|
|
320
370
|
...result,
|
|
321
371
|
verdict: verdict.action,
|
|
322
|
-
delegatedTo
|
|
323
|
-
verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
|
|
372
|
+
delegatedTo,
|
|
324
373
|
duration_ms,
|
|
325
374
|
};
|
|
326
375
|
}
|