@lh8ppl/claude-memory-kit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-capture-prompt.mjs +0 -0
- package/bin/cmk-capture-turn.mjs +0 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-observe-edit.mjs +0 -0
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +37 -22
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +79 -26
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/index-rebuild.mjs +26 -4
- package/src/inject-context.mjs +352 -65
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/mcp-server.mjs +17 -0
- package/src/memory-write.mjs +20 -7
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/sanitize.mjs +39 -0
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/spawn-bin.mjs +83 -0
- package/src/subcommands.mjs +472 -26
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +60 -3
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +17 -7
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
package/src/capture-turn.mjs
CHANGED
|
@@ -139,6 +139,50 @@ function readLastUserTurnFromTranscript(transcriptPath) {
|
|
|
139
139
|
return body.join('\n');
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// Task 87: append the turn's CONVERSATION to the compression buffer
|
|
143
|
+
// (context/sessions/now.md). Before this, now.md was fed ONLY by observe-edit's
|
|
144
|
+
// file-write lines ("[ts] Write file=X lines=N"), so the SessionEnd compressor
|
|
145
|
+
// summarized a list of filenames and hallucinated content the dialogue never
|
|
146
|
+
// contained (lior-test-6: "Flask app: app.py" — inferred a framework from a
|
|
147
|
+
// filename). Buffering the actual user+assistant turns here means the summary
|
|
148
|
+
// reflects what was DISCUSSED. Same `## <ts> — speaker` shape as the transcript
|
|
149
|
+
// so the compressor reads it as dialogue; now.md is truncated after each compress
|
|
150
|
+
// (compress-session), so this is a transient session buffer, not a second store.
|
|
151
|
+
// Best-effort: a now.md write failure must not abort the capture (the transcript
|
|
152
|
+
// is already the durable record).
|
|
153
|
+
// Per-turn cap on the ASSISTANT contribution to now.md (skill-review I1). compress
|
|
154
|
+
// returns on cooldown BEFORE truncating now.md (compress-session §8.2), and
|
|
155
|
+
// auto-extract refreshes the shared cooldown every turn — so during an active
|
|
156
|
+
// session now.md is NOT truncated and accumulates. A verbose assistant response
|
|
157
|
+
// (code dumps, long explanations) is far larger than the old file-write line, so
|
|
158
|
+
// we bound each assistant turn's footprint. The USER turn is left FULL — it's
|
|
159
|
+
// short and carries the standing rules/decisions we must not truncate. The
|
|
160
|
+
// residual many-short-turns growth is bounded by the eventual truncate +
|
|
161
|
+
// compress's 50s-timeout/degradation backstop; offset-based compression is the
|
|
162
|
+
// v0.2.x escalation if mega-sessions ever bite.
|
|
163
|
+
const NOW_MD_ASSISTANT_CAP = 4000;
|
|
164
|
+
|
|
165
|
+
function appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn }) {
|
|
166
|
+
const sessionsDir = join(projectRoot, 'context', 'sessions');
|
|
167
|
+
const nowMdPath = join(sessionsDir, 'now.md');
|
|
168
|
+
const blocks = [];
|
|
169
|
+
if (userTurn && userTurn.trim() !== '') {
|
|
170
|
+
blocks.push(`## ${ts} — user\n\n${userTurn}\n`);
|
|
171
|
+
}
|
|
172
|
+
let asst = assistantTurn ?? '';
|
|
173
|
+
if (asst.length > NOW_MD_ASSISTANT_CAP) {
|
|
174
|
+
asst = asst.slice(0, NOW_MD_ASSISTANT_CAP) + '\n…[assistant turn truncated for the session buffer]';
|
|
175
|
+
}
|
|
176
|
+
blocks.push(`## ${ts} — assistant\n\n${asst}\n`);
|
|
177
|
+
try {
|
|
178
|
+
if (!existsSync(sessionsDir)) mkdirSync(sessionsDir, { recursive: true });
|
|
179
|
+
appendFileSync(nowMdPath, blocks.join('\n') + '\n', 'utf8');
|
|
180
|
+
} catch {
|
|
181
|
+
// Best-effort — the transcript is the durable record; a missing now.md
|
|
182
|
+
// entry only means this turn isn't in the next session summary.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
142
186
|
// Assemble the both-turns temp-file body. Both turns are sanitized
|
|
143
187
|
// upstream — the user body comes from the transcript (which
|
|
144
188
|
// capture-prompt sanitized when writing it) and the assistant body
|
|
@@ -176,6 +220,11 @@ function spawnAutoExtract(autoExtractPath, turnFile, projectRoot) {
|
|
|
176
220
|
detached: true,
|
|
177
221
|
stdio: 'ignore',
|
|
178
222
|
cwd: projectRoot,
|
|
223
|
+
// Task 81: suppress the Windows console window the detached child
|
|
224
|
+
// would otherwise flash. (This site spawns `node` directly already,
|
|
225
|
+
// so windowsHide is effective here — unlike the legacy shell:true
|
|
226
|
+
// lazy-compress spawn, which also needed the node-direct fix.)
|
|
227
|
+
windowsHide: true,
|
|
179
228
|
env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
|
|
180
229
|
},
|
|
181
230
|
);
|
|
@@ -227,6 +276,11 @@ export function captureTurn({
|
|
|
227
276
|
// wrote it before this Stop fired); the assistant portion is the
|
|
228
277
|
// `sanitized` text we just appended above.
|
|
229
278
|
const userTurn = readLastUserTurnFromTranscript(transcriptPath);
|
|
279
|
+
|
|
280
|
+
// Task 87: buffer the conversation into now.md so the SessionEnd compressor
|
|
281
|
+
// summarizes the DIALOGUE, not observe-edit's filename log. Best-effort.
|
|
282
|
+
appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn: sanitized });
|
|
283
|
+
|
|
230
284
|
const turnFile = join(transcriptsDir, `.extract-${Date.now()}.tmp`);
|
|
231
285
|
try {
|
|
232
286
|
writeFileSync(
|
package/src/compress-session.mjs
CHANGED
|
@@ -89,18 +89,16 @@ function buildCompressionInstructions(maxOutputBytes) {
|
|
|
89
89
|
'## Open Questions',
|
|
90
90
|
'- <one bullet per unresolved question raised during the session, ≤80 chars>',
|
|
91
91
|
'',
|
|
92
|
-
'## Files Touched',
|
|
93
|
-
'- path: <relative path> — <verb summary> (cites: [#P-XXXXXXXX])',
|
|
94
|
-
'',
|
|
95
92
|
'## Active Threads',
|
|
96
93
|
'- <one bullet per work-in-progress thread the next session should resume, ≤80 chars>',
|
|
97
94
|
'',
|
|
98
95
|
'HARD RULES:',
|
|
99
|
-
' 1.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
' 4.
|
|
103
|
-
' 5.
|
|
96
|
+
' 1. Every bullet must be grounded in the session buffer below. Do not infer or guess any fact not explicitly stated in the buffer. Do not carry forward content from earlier summaries. When the buffer corrects, replaces, or reverses something stated earlier, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
|
|
97
|
+
' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
|
|
98
|
+
` 3. Total output ≤ ${maxOutputBytes} bytes.`,
|
|
99
|
+
' 4. If a section has no entries, omit the heading entirely (do not emit an empty heading).',
|
|
100
|
+
' 5. No prose around the headings — only the bulleted list per section.',
|
|
101
|
+
' 6. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself, do not narrate.',
|
|
104
102
|
'',
|
|
105
103
|
`The session buffer to compress appears below between the ${SESSION_BUFFER_DELIMITER} and ${SESSION_BUFFER_END_DELIMITER} markers.`,
|
|
106
104
|
].join('\n');
|
package/src/compressor.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// v0.2 candidates per ADR-0008: BedrockHaiku, LocalLlama; selected via
|
|
15
15
|
// settings.json (`compressor.backend`).
|
|
16
16
|
//
|
|
17
|
-
// Sandbox flags (cd /tmp, env -u CLAUDECODE, --allowed-tools "",
|
|
17
|
+
// Sandbox flags (cd /tmp, env -u CLAUDECODE, --tools "" + --allowed-tools "",
|
|
18
18
|
// --max-turns 1, --mcp-config '{"mcpServers":{}}' --strict-mcp-config,
|
|
19
19
|
// stdin from temp file) are absorbed from claude-remember's verified
|
|
20
20
|
// pattern (see docs/research/2026-05-25-claude-remember-code-dive.md
|
|
@@ -28,6 +28,7 @@
|
|
|
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';
|
|
31
|
+
import { spawnBin } from './spawn-bin.mjs';
|
|
31
32
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
32
33
|
import { tmpdir } from 'node:os';
|
|
33
34
|
import { join } from 'node:path';
|
|
@@ -179,13 +180,28 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
|
|
|
179
180
|
const mcpConfigPath = join(sandbox, 'empty-mcp.json');
|
|
180
181
|
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), 'utf8');
|
|
181
182
|
|
|
182
|
-
// Build claude --print invocation with the documented sandbox flags
|
|
183
|
-
//
|
|
184
|
-
//
|
|
183
|
+
// Build claude --print invocation with the documented sandbox flags (D-43,
|
|
184
|
+
// verified against code.claude.com/docs/en/cli-reference, Task 88).
|
|
185
|
+
// --tools "" → disables ALL built-in tools. This is the flag that
|
|
186
|
+
// actually restricts what the sub-Claude can DO ("Use
|
|
187
|
+
// `""` to disable all" per the CLI reference). It is the
|
|
188
|
+
// real sandbox: the model can only emit text.
|
|
189
|
+
// --allowed-tools "" → an AUTO-APPROVE allowlist (tools that run WITHOUT a
|
|
190
|
+
// permission prompt), NOT an availability restriction.
|
|
191
|
+
// Empty = nothing auto-approves; kept as defense-in-depth
|
|
192
|
+
// (redundant once --tools "" removes all tools, harmless).
|
|
193
|
+
// --max-turns 1 + empty --mcp-config + --strict-mcp-config → one turn, no MCP
|
|
194
|
+
// tools, so even absent an approver the model can't act.
|
|
195
|
+
// (Earlier this relied on `--allowed-tools ""` alone with a comment claiming it
|
|
196
|
+
// was the sandbox — wrong per primary source: that flag is the allowlist, not
|
|
197
|
+
// the restriction. Live exposure was ~nil under --print+--max-turns 1, but the
|
|
198
|
+
// flag now matches the stated intent.)
|
|
185
199
|
const args = [
|
|
186
200
|
'--print',
|
|
187
201
|
'--model',
|
|
188
202
|
this._model,
|
|
203
|
+
'--tools',
|
|
204
|
+
'',
|
|
189
205
|
'--allowed-tools',
|
|
190
206
|
'',
|
|
191
207
|
'--max-turns',
|
|
@@ -203,25 +219,24 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
|
|
|
203
219
|
const env = { ...process.env };
|
|
204
220
|
delete env.CLAUDECODE;
|
|
205
221
|
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
222
|
+
// spawnBin handles the Windows .cmd-shim problem WITHOUT the
|
|
223
|
+
// `shell:true` + args-array combo that broke paths with spaces (#4):
|
|
224
|
+
// POSIX spawns argv-style (shell:false); Windows builds a single
|
|
225
|
+
// pre-quoted command string so `--mcp-config C:\Users\First Last\…`
|
|
226
|
+
// survives cmd.exe tokenization. See spawn-bin.mjs. `windowsHide`
|
|
227
|
+
// still suppresses the transient cmd.exe console flash on Windows.
|
|
212
228
|
// spawn-discipline: caller-managed terminateSubprocess (kit's kill-chain helper) + setTimeout (per design §8.5; PR-A composition-verification instance #4; substance pinned by tests/cli-compressor-timeout.test.js + tests/spawn-smoke-kill-chain.test.js). The function signature `timeoutMs` parameter (line 162) is the caller-supplied bound; the setTimeout below (search "Timeout timer") fires the kill chain.
|
|
213
|
-
const child =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
229
|
+
const child = spawnBin(
|
|
230
|
+
this._bin,
|
|
231
|
+
args,
|
|
232
|
+
{
|
|
233
|
+
cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
|
|
234
|
+
env,
|
|
235
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
236
|
+
windowsHide: true,
|
|
237
|
+
},
|
|
238
|
+
{ spawnImpl: this._spawn },
|
|
239
|
+
);
|
|
225
240
|
|
|
226
241
|
const cleanupSandbox = () => {
|
|
227
242
|
// Single-use sandbox: the directory and the empty-mcp.json file
|
package/src/conflict-queue.mjs
CHANGED
|
@@ -111,7 +111,13 @@ export function tokenJaccardSimilarity(a, b) {
|
|
|
111
111
|
|
|
112
112
|
const BULLET_LINE_RE = /^- \(([PUL])-([A-Za-z0-9]{8})\)\s+(.+)$/;
|
|
113
113
|
const HEADING_LINE_RE = /^##\s+(.+?)\s*$/;
|
|
114
|
-
|
|
114
|
+
// Leading `\s*` is load-bearing: scratchpad provenance comments are written
|
|
115
|
+
// INDENTED (` <!-- … trust: high … -->`) by writeBullet/appendScratchpadBullet.
|
|
116
|
+
// Without the leading-whitespace tolerance, collectExistingBullets failed to
|
|
117
|
+
// parse trust and defaulted EVERY existing bullet to 'medium' — so a new
|
|
118
|
+
// medium fact would wrongly 'supersede' an existing trust:high hand-curated
|
|
119
|
+
// bullet instead of routing to the conflict queue (B-1, surfaced by Task 45).
|
|
120
|
+
const PROVENANCE_RE = /^\s*<!--\s*(.*?)\s*-->\s*$/;
|
|
115
121
|
|
|
116
122
|
function findSectionRange(lines, sectionTitle) {
|
|
117
123
|
let startIdx = -1;
|
|
@@ -244,6 +250,7 @@ export function detectConflicts({
|
|
|
244
250
|
conflict: true,
|
|
245
251
|
action: 'supersede',
|
|
246
252
|
existingId: best.id,
|
|
253
|
+
existingText: best.text,
|
|
247
254
|
similarity: best.similarity,
|
|
248
255
|
similarityBackend,
|
|
249
256
|
};
|
package/src/daily-distill.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
isCooldownActive,
|
|
34
34
|
touchCooldownMarker,
|
|
35
35
|
} from './cooldown.mjs';
|
|
36
|
+
import { autoDrainQueues } from './auto-drain.mjs';
|
|
36
37
|
|
|
37
38
|
const DEFAULT_MAX_OUTPUT_BYTES = 4096;
|
|
38
39
|
const SESSIONS_REL = ['context', 'sessions'];
|
|
@@ -56,19 +57,17 @@ function buildDistillInstructions(maxOutputBytes) {
|
|
|
56
57
|
'## Open Questions',
|
|
57
58
|
'- <one bullet per unresolved question, ≤80 chars>',
|
|
58
59
|
'',
|
|
59
|
-
'## Files Touched',
|
|
60
|
-
'- path: <relative path> — <verb summary across days>',
|
|
61
|
-
'',
|
|
62
60
|
'## Active Threads',
|
|
63
61
|
'- <one bullet per active work-in-progress thread, ≤80 chars>',
|
|
64
62
|
'',
|
|
65
63
|
'HARD RULES:',
|
|
66
|
-
' 1.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
' 4.
|
|
70
|
-
' 5.
|
|
71
|
-
' 6.
|
|
64
|
+
' 1. Every bullet must be grounded in the daily summaries below. Do not infer or add any fact not explicitly present in them. When the summaries show a fact was later corrected, replaced, or reversed, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
|
|
65
|
+
' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
|
|
66
|
+
` 3. Total output ≤ ${maxOutputBytes} bytes.`,
|
|
67
|
+
' 4. If a section has no entries, omit the heading entirely.',
|
|
68
|
+
' 5. No prose around the headings — only the bulleted list per section.',
|
|
69
|
+
' 6. Deduplicate aggressively: if the same decision appears across multiple days, list it ONCE.',
|
|
70
|
+
' 7. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself.',
|
|
72
71
|
'',
|
|
73
72
|
'=== BEGIN DAILY SUMMARIES TO CONSOLIDATE ===',
|
|
74
73
|
].join('\n');
|
|
@@ -125,6 +124,7 @@ export async function dailyDistill({
|
|
|
125
124
|
now,
|
|
126
125
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
127
126
|
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
|
127
|
+
skipDrain = false,
|
|
128
128
|
} = {}) {
|
|
129
129
|
const ts = now ?? nowIso();
|
|
130
130
|
const date = ts.slice(0, 10);
|
|
@@ -143,6 +143,13 @@ export async function dailyDistill({
|
|
|
143
143
|
return { action: 'skipped', reason: 'no-context-dir', duration_ms: Date.now() - t0 };
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Auto-drain the project-tier review + conflict queues (v0.2 Phase 2, D-6).
|
|
147
|
+
// Non-Haiku file IO — runs on EVERY pass regardless of the Haiku cooldown
|
|
148
|
+
// below, so queues drain even when the distill itself is cooled down.
|
|
149
|
+
// skipDrain: weeklyCurate calls dailyDistill internally AFTER it already
|
|
150
|
+
// drained, so it passes skipDrain to avoid a redundant (idempotent) drain.
|
|
151
|
+
const drained = skipDrain ? null : await autoDrainQueues({ tier: 'P', projectRoot });
|
|
152
|
+
|
|
146
153
|
// Cooldown gate per design §8.2.
|
|
147
154
|
if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
|
|
148
155
|
const duration_ms = Date.now() - t0;
|
|
@@ -153,7 +160,7 @@ export async function dailyDistill({
|
|
|
153
160
|
cost_usd: 0, duration_ms, success: true, skipped_reason: 'cooldown',
|
|
154
161
|
};
|
|
155
162
|
writeDistillLogEntry({ projectRoot, date, entry });
|
|
156
|
-
return { action: 'skipped', reason: 'cooldown', duration_ms };
|
|
163
|
+
return { action: 'skipped', reason: 'cooldown', drained, duration_ms };
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
// Read last 7 days of today-*.md.
|
|
@@ -180,7 +187,7 @@ export async function dailyDistill({
|
|
|
180
187
|
skipped_reason: 'no-input',
|
|
181
188
|
},
|
|
182
189
|
});
|
|
183
|
-
return { action: 'skipped', reason: 'no-input', duration_ms };
|
|
190
|
+
return { action: 'skipped', reason: 'no-input', drained, duration_ms };
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
const buffer = readBuffer(files);
|
|
@@ -247,6 +254,7 @@ export async function dailyDistill({
|
|
|
247
254
|
bytesIn: input_bytes,
|
|
248
255
|
bytesOut: output_bytes,
|
|
249
256
|
sourceDays: files.length,
|
|
257
|
+
drained,
|
|
250
258
|
duration_ms,
|
|
251
259
|
};
|
|
252
260
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -38,12 +38,13 @@ import {
|
|
|
38
38
|
statSync,
|
|
39
39
|
writeFileSync,
|
|
40
40
|
} from 'node:fs';
|
|
41
|
-
import {
|
|
41
|
+
import { spawnBinSync } from './spawn-bin.mjs';
|
|
42
42
|
import { homedir } from 'node:os';
|
|
43
43
|
import { basename, join } from 'node:path';
|
|
44
44
|
import { nowIso } from './audit-log.mjs';
|
|
45
45
|
import { detectStaleLocks } from './lock-discipline.mjs';
|
|
46
46
|
import { cronSentinelPath } from './lazy-compress.mjs';
|
|
47
|
+
import { getNativeAutoMemoryState } from './native-memory.mjs';
|
|
47
48
|
|
|
48
49
|
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
|
|
49
50
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
|
@@ -62,7 +63,10 @@ async function hc1Memsearch() {
|
|
|
62
63
|
// semantic requires a separate `pip install memsearch[onnx]`.
|
|
63
64
|
// `requiresInstall: true` so the CLI prompts before auto-installing.
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
+
// spawnBinSync resolves the Windows .cmd shim without `shell:true`+args
|
|
67
|
+
// (no DEP0190; #4). memsearch's only arg is `--version` (no spaces), so
|
|
68
|
+
// the quoting is a no-op here — the win is dropping the deprecated combo.
|
|
69
|
+
const r = spawnBinSync('memsearch', ['--version'], {
|
|
66
70
|
encoding: 'utf8',
|
|
67
71
|
// M1 fix (skill-review 2026-05-28): 3.5s tolerates Windows
|
|
68
72
|
// cold-Python startup (AV scan + .pyc generation on first hit
|
|
@@ -71,7 +75,6 @@ async function hc1Memsearch() {
|
|
|
71
75
|
// still fits comfortably inside the 5s NFR budget. Timeout →
|
|
72
76
|
// 'skip' so cmk doctor completes regardless.
|
|
73
77
|
timeout: 3_500,
|
|
74
|
-
shell: process.platform === 'win32',
|
|
75
78
|
});
|
|
76
79
|
if (r.status === 0) {
|
|
77
80
|
return {
|
|
@@ -186,12 +189,15 @@ function hc2Hooks({ projectRoot }) {
|
|
|
186
189
|
function hc3DistillFreshness({ projectRoot, now }) {
|
|
187
190
|
const recentPath = join(projectRoot, ...RECENT_MD_REL);
|
|
188
191
|
if (!existsSync(recentPath)) {
|
|
192
|
+
// Not a failure: on a fresh project there's nothing distilled yet, and
|
|
193
|
+
// when there is, the lazy-on-read path runs the distill at SessionStart
|
|
194
|
+
// (or `cmk daily-distill`). "Stale recent.md" below IS a real fail; a
|
|
195
|
+
// never-built one is just "not yet" — mirror HC-5's skip-on-fresh.
|
|
189
196
|
return {
|
|
190
197
|
id: 'HC-3',
|
|
191
198
|
name: 'Daily distill is fresh (≤2 days)',
|
|
192
|
-
status: '
|
|
193
|
-
message: '
|
|
194
|
-
recoveryCommand: 'cmk daily-distill',
|
|
199
|
+
status: 'skip',
|
|
200
|
+
message: 'recent.md not built yet — nothing to distill. Runs automatically (lazy-on-read at SessionStart, or `cmk daily-distill`) once there is session content.',
|
|
195
201
|
};
|
|
196
202
|
}
|
|
197
203
|
let mtimeMs;
|
|
@@ -229,19 +235,22 @@ function hc3DistillFreshness({ projectRoot, now }) {
|
|
|
229
235
|
function hc4Transcripts({ projectRoot, now }) {
|
|
230
236
|
const transcriptsDir = join(projectRoot, ...TRANSCRIPTS_REL);
|
|
231
237
|
if (!existsSync(transcriptsDir)) {
|
|
238
|
+
// Fresh project, never had a Claude Code session here → nothing to fire
|
|
239
|
+
// yet. Skip, don't fail (the dir + first transcript appear on the first turn).
|
|
232
240
|
return {
|
|
233
241
|
id: 'HC-4',
|
|
234
242
|
name: 'Transcripts firing (≤3 days)',
|
|
235
|
-
status: '
|
|
236
|
-
message: '
|
|
237
|
-
recoveryCommand: 'check that this project is the primary cwd in Claude Code, then reopen the project',
|
|
243
|
+
status: 'skip',
|
|
244
|
+
message: 'no transcripts yet — they appear after your first Claude Code turn in this project.',
|
|
238
245
|
};
|
|
239
246
|
}
|
|
240
247
|
const nowMs = new Date(now ?? nowIso()).getTime();
|
|
241
248
|
const cutoffMs = nowMs - THREE_DAYS_MS;
|
|
249
|
+
let totalCount = 0;
|
|
242
250
|
let recentCount = 0;
|
|
243
251
|
for (const name of readdirSync(transcriptsDir)) {
|
|
244
252
|
if (!/\.md$/.test(name)) continue;
|
|
253
|
+
totalCount += 1;
|
|
245
254
|
try {
|
|
246
255
|
const mtimeMs = statSync(join(transcriptsDir, name)).mtimeMs;
|
|
247
256
|
if (mtimeMs >= cutoffMs) recentCount += 1;
|
|
@@ -249,12 +258,24 @@ function hc4Transcripts({ projectRoot, now }) {
|
|
|
249
258
|
// skip unreadable
|
|
250
259
|
}
|
|
251
260
|
}
|
|
261
|
+
if (totalCount === 0) {
|
|
262
|
+
// Dir exists (scaffolded) but no transcripts captured yet → still "not
|
|
263
|
+
// yet", not a failure.
|
|
264
|
+
return {
|
|
265
|
+
id: 'HC-4',
|
|
266
|
+
name: 'Transcripts firing (≤3 days)',
|
|
267
|
+
status: 'skip',
|
|
268
|
+
message: 'no transcripts yet — they appear after your first Claude Code turn in this project.',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
252
271
|
if (recentCount === 0) {
|
|
272
|
+
// Transcripts EXIST but none recent → the kit was capturing and stopped.
|
|
273
|
+
// That IS a real signal (the Stop hook may not be firing here).
|
|
253
274
|
return {
|
|
254
275
|
id: 'HC-4',
|
|
255
276
|
name: 'Transcripts firing (≤3 days)',
|
|
256
277
|
status: 'fail',
|
|
257
|
-
message: '
|
|
278
|
+
message: 'transcripts exist but none within 3 days — the Stop hook may have stopped firing (is this project Claude Code\'s primary cwd?)',
|
|
258
279
|
recoveryCommand: 'reopen this project as the primary cwd in Claude Code',
|
|
259
280
|
};
|
|
260
281
|
}
|
|
@@ -316,16 +337,28 @@ function hc5IndexConsistency({ projectRoot }) {
|
|
|
316
337
|
recoveryCommand: 'cmk reindex',
|
|
317
338
|
};
|
|
318
339
|
}
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
340
|
+
// Fact-file references in INDEX.md are markdown LINK TARGETS whose filename
|
|
341
|
+
// follows the kit's `<type>_<slug>.md` convention (e.g. feedback_layered.md) —
|
|
342
|
+
// that's what `cmk reindex`'s formatIndexLine writes: `[slug](type_slug.md)`.
|
|
343
|
+
// Match the link target's *.md basename, THEN keep only fact-file-shaped names.
|
|
344
|
+
//
|
|
345
|
+
// Two false-positives this must avoid (both real):
|
|
346
|
+
// 1. id-shaped names — the pre-Task-85 regex matched `[PUL]-XXXXXXXX.md`,
|
|
347
|
+
// which the kit NEVER generates, so HC-5 false-FAILED "missing" on every
|
|
348
|
+
// real fact the moment one existed (lior-test-7 2026-06-03).
|
|
349
|
+
// 2. non-fact links — a broad `](...md)` match also catches the scaffold's
|
|
350
|
+
// own example `- [type] [Title](filename.md)` (inside an HTML comment) and
|
|
351
|
+
// any prose link like `(design.md)`, which would false-FAIL "stale" on a
|
|
352
|
+
// FRESH install (skill-review 2026-06-03). The `<type>_<slug>` shape
|
|
353
|
+
// (a `type_` underscore prefix) excludes `filename.md` / `design.md` /
|
|
354
|
+
// `0001.md` while matching every real fact file.
|
|
355
|
+
const FACT_FILE_RE = /^[a-z]+_[a-z0-9][a-z0-9-]*\.md$/i;
|
|
324
356
|
const indexEntries = new Set();
|
|
325
|
-
const re = /\
|
|
357
|
+
const re = /\]\(([^)]+\.md)\)/g;
|
|
326
358
|
let m;
|
|
327
359
|
while ((m = re.exec(indexText)) !== null) {
|
|
328
|
-
|
|
360
|
+
const fname = m[1].split(/[\\/]/).pop(); // basename, tolerate ./ or path-prefixed links
|
|
361
|
+
if (fname && fname !== 'INDEX.md' && FACT_FILE_RE.test(fname)) indexEntries.add(fname);
|
|
329
362
|
}
|
|
330
363
|
const factSet = new Set(factFiles);
|
|
331
364
|
const inFactsNotIndex = [...factSet].filter((f) => !indexEntries.has(f));
|
|
@@ -360,12 +393,15 @@ function hc6CronRegistered({ projectRoot }) {
|
|
|
360
393
|
message: 'cron-registered sentinel present',
|
|
361
394
|
};
|
|
362
395
|
}
|
|
396
|
+
// Cron is OPTIONAL by design (README + design §… the lazy-on-read fallback
|
|
397
|
+
// compresses at SessionStart without any scheduler). Absence is therefore a
|
|
398
|
+
// SKIP, not a FAIL — flagging an optional, working-by-fallback feature as a
|
|
399
|
+
// failure made a healthy fresh install read as broken.
|
|
363
400
|
return {
|
|
364
401
|
id: 'HC-6',
|
|
365
402
|
name: 'Cron jobs registered with host scheduler',
|
|
366
|
-
status: '
|
|
367
|
-
message: '
|
|
368
|
-
recoveryCommand: 'cmk register-crons',
|
|
403
|
+
status: 'skip',
|
|
404
|
+
message: 'cron not registered (optional) — using the lazy-on-read fallback (compresses at SessionStart). Run `cmk register-crons` for scheduled background compression.',
|
|
369
405
|
};
|
|
370
406
|
}
|
|
371
407
|
|
|
@@ -441,6 +477,13 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
|
|
|
441
477
|
// NOW" checks, not trend analysis. Trend logging is a v0.1.x
|
|
442
478
|
// candidate (would require append + rotation or a separate
|
|
443
479
|
// history.ndjson file).
|
|
480
|
+
// ADR-0011 / Task 60: the project's committable `autoMemoryEnabled` setting
|
|
481
|
+
// is what actually governs native memory going forward. Fold it into both
|
|
482
|
+
// the snapshot log (Door 4 observability) and the message so the
|
|
483
|
+
// `cmk disable-native-memory` opt-in is DISCOVERABLE here, not just at install.
|
|
484
|
+
const { state: settingState } = getNativeAutoMemoryState({ projectRoot });
|
|
485
|
+
entry.setting_state = settingState;
|
|
486
|
+
|
|
444
487
|
const logPath = join(projectRoot, ...NATIVE_MEMORY_LOG_REL);
|
|
445
488
|
try {
|
|
446
489
|
mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
|
|
@@ -448,16 +491,26 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
|
|
|
448
491
|
} catch {
|
|
449
492
|
// best-effort
|
|
450
493
|
}
|
|
494
|
+
|
|
495
|
+
let message;
|
|
496
|
+
if (settingState === 'disabled') {
|
|
497
|
+
// The user opted out — native won't write here regardless of any old files.
|
|
498
|
+
message = 'Anthropic auto-memory DISABLED for this project via .claude/settings.json — the kit is the sole memory layer.';
|
|
499
|
+
} else if (entry.active === true) {
|
|
500
|
+
// Native is writing AND not disabled → both layers run (context bloat).
|
|
501
|
+
// Surface the one-command opt-out (the coexistence choice, discoverable late).
|
|
502
|
+
message = `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'}) — running ALONGSIDE the kit (both inject at session start). Run \`cmk disable-native-memory\` to use one lean layer.`;
|
|
503
|
+
} else if (entry.active === false) {
|
|
504
|
+
message = 'Anthropic auto-memory not active for this project (kit is the sole memory source).';
|
|
505
|
+
} else {
|
|
506
|
+
message = 'Anthropic auto-memory state unknown (directory unreadable).';
|
|
507
|
+
}
|
|
508
|
+
|
|
451
509
|
return {
|
|
452
510
|
id: 'HC-8',
|
|
453
511
|
name: 'Native Anthropic Auto Memory status detected',
|
|
454
512
|
status: 'pass',
|
|
455
|
-
message
|
|
456
|
-
entry.active === true
|
|
457
|
-
? `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'})`
|
|
458
|
-
: entry.active === false
|
|
459
|
-
? 'Anthropic auto-memory not active for this project (kit is the sole memory source)'
|
|
460
|
-
: 'Anthropic auto-memory state unknown (directory unreadable)',
|
|
513
|
+
message,
|
|
461
514
|
};
|
|
462
515
|
}
|
|
463
516
|
|
package/src/forget.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
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
|
+
import { findBulletScratchpad } from './bullet-lookup.mjs';
|
|
29
30
|
|
|
30
31
|
// Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
|
|
31
32
|
// minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
|
|
@@ -208,6 +209,19 @@ export function forget(opts = {}) {
|
|
|
208
209
|
: resolveByQuery(idOrQuery, { projectRoot, userDir });
|
|
209
210
|
|
|
210
211
|
if (resolved.matches.length === 0) {
|
|
212
|
+
// If the id is actually a scratchpad BULLET (the `cmk search` id mix-up —
|
|
213
|
+
// search lists bullet ids too, but forget tombstones FACTS), say so instead
|
|
214
|
+
// of the flat "no matching fact".
|
|
215
|
+
const bulletIn = ID_PATTERN.test(idOrQuery)
|
|
216
|
+
? findBulletScratchpad(idOrQuery, { projectRoot, userDir })
|
|
217
|
+
: null;
|
|
218
|
+
if (bulletIn) {
|
|
219
|
+
return notFoundResult({
|
|
220
|
+
errors: [
|
|
221
|
+
`'${idOrQuery}' is a scratchpad bullet in ${bulletIn}, not a fact — \`cmk forget\` tombstones facts in context/memory/. A high-trust bullet becomes forgettable by this same id once it graduates to a fact (under cap pressure / at session end); a low/medium one ages out via consolidation. To remove it now, edit ${bulletIn} directly.`,
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
211
225
|
return notFoundResult({
|
|
212
226
|
errors: [`no matching fact for "${idOrQuery}"`],
|
|
213
227
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Proactive SessionEnd graduation sweep (Task 94.3, D-61 / design §19).
|
|
2
|
+
//
|
|
3
|
+
// The reactive relief inside appendScratchpadBullet only fires when a write
|
|
4
|
+
// triggers cap pressure. This sweep runs the same relief OUTSIDE the append path,
|
|
5
|
+
// across every fact-bearing scratchpad, so each scratchpad's INJECTED slice stays
|
|
6
|
+
// under its load-cap even in a read-only session — and low/medium bullets that
|
|
7
|
+
// AGED past the 14-day stale window between sessions get caught.
|
|
8
|
+
//
|
|
9
|
+
// Composition (the §6.8/§7.1 disjoint-input rule): this is invoked by
|
|
10
|
+
// runSessionEndTasks SEQUENTIALLY, AFTER the concurrent compress+persona block —
|
|
11
|
+
// because autoPersona WRITES the user-tier persona scratchpads (USER/HABITS/
|
|
12
|
+
// LESSONS) and this READS+rewrites them, so the two must not overlap. Running
|
|
13
|
+
// after means the sweep sees the freshly-promoted persona, then trims overflow.
|
|
14
|
+
// It is pure local file I/O (no Haiku/network), so it adds <<1s on top of the
|
|
15
|
+
// ~50s concurrent block — no SessionEnd hook-ceiling risk.
|
|
16
|
+
|
|
17
|
+
import { sweepScratchpadForCapRelief } from './scratchpad.mjs';
|
|
18
|
+
|
|
19
|
+
// The fact-bearing scratchpads, per tier. The L tier (machine-paths/overrides) is
|
|
20
|
+
// machine-specific config, not durable facts — never graduated (matches the
|
|
21
|
+
// `tier === 'P' || tier === 'U'` gate in the reactive path).
|
|
22
|
+
const GRADUATION_TARGETS = Object.freeze([
|
|
23
|
+
{ tier: 'P', scratchpad: 'MEMORY.md' },
|
|
24
|
+
{ tier: 'P', scratchpad: 'SOUL.md' },
|
|
25
|
+
{ tier: 'U', scratchpad: 'USER.md' },
|
|
26
|
+
{ tier: 'U', scratchpad: 'HABITS.md' },
|
|
27
|
+
{ tier: 'U', scratchpad: 'LESSONS.md' },
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sweep every fact-bearing scratchpad, graduating overflow so each stays under
|
|
32
|
+
* its load-cap. Best-effort: a failure on one scratchpad is captured as an
|
|
33
|
+
* `error` result and never aborts the rest (a SessionEnd hook must never throw).
|
|
34
|
+
*
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.projectRoot - resolved project root.
|
|
37
|
+
* @param {string} opts.userDir - user-tier root.
|
|
38
|
+
* @param {string} [opts.now] - ISO timestamp override (tests).
|
|
39
|
+
* @param {object} [opts.settings] - test-injected cap override.
|
|
40
|
+
* @returns {{results: object[], totalGraduated: number, totalConsolidated: number}}
|
|
41
|
+
*/
|
|
42
|
+
export function graduateAllScratchpads({ projectRoot, userDir, now, settings } = {}) {
|
|
43
|
+
const results = [];
|
|
44
|
+
for (const t of GRADUATION_TARGETS) {
|
|
45
|
+
try {
|
|
46
|
+
results.push(
|
|
47
|
+
sweepScratchpadForCapRelief({ ...t, projectRoot, userDir, now, settings }),
|
|
48
|
+
);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
results.push({
|
|
51
|
+
tier: t.tier,
|
|
52
|
+
scratchpad: t.scratchpad,
|
|
53
|
+
action: 'error',
|
|
54
|
+
error: err?.message ?? String(err),
|
|
55
|
+
bulletsConsolidated: 0,
|
|
56
|
+
bulletsGraduated: 0,
|
|
57
|
+
graduatedIds: [],
|
|
58
|
+
bytes: 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const totalGraduated = results.reduce((s, r) => s + (r.bulletsGraduated || 0), 0);
|
|
63
|
+
const totalConsolidated = results.reduce((s, r) => s + (r.bulletsConsolidated || 0), 0);
|
|
64
|
+
return { results, totalGraduated, totalConsolidated };
|
|
65
|
+
}
|