@lh8ppl/claude-memory-kit 0.1.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.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,376 @@
1
+ // CompressorBackend interface + concrete impls (Task 23.6, T-020).
2
+ //
3
+ // The interface (compress + modelId + estimatedCostPerCall) is the
4
+ // pluggable backend boundary referenced by design §8.3. It is used by
5
+ // two callers in v0.1:
6
+ // 1. Task 23 — auto-extract subagent (this PR). Calls compress() with
7
+ // an extraction prompt + the just-captured turn.
8
+ // 2. Task 22 — SessionEnd hook (next PR). Calls compress() with a
9
+ // compression prompt + the live now.md buffer.
10
+ //
11
+ // v0.1 ships ONE production implementation (HaikuViaAnthropicApi) plus
12
+ // a test-only MockHaikuBackend used by every downstream test that needs
13
+ // to inject canned responses without spawning the real `claude` binary.
14
+ // v0.2 candidates per ADR-0008: BedrockHaiku, LocalLlama; selected via
15
+ // settings.json (`compressor.backend`).
16
+ //
17
+ // Sandbox flags (cd /tmp, env -u CLAUDECODE, --allowed-tools "",
18
+ // --max-turns 1, --mcp-config '{"mcpServers":{}}' --strict-mcp-config,
19
+ // stdin from temp file) are absorbed from claude-remember's verified
20
+ // pattern (see docs/research/2026-05-25-claude-remember-code-dive.md
21
+ // and SOURCES.md for the licensing posture — patterns/values absorbed,
22
+ // no code or prompts copied).
23
+ //
24
+ // Note on the allowedTools split: design.md §6.1 documents
25
+ // `--allowed-tools "Read"`; the code-dive note recommended tightening
26
+ // to fully empty per claude-remember's actual pattern. This PR
27
+ // implements empty per Lior's instruction (the auto-extract sub-Claude
28
+ // never needs Read either — the turn content arrives in the prompt).
29
+
30
+ import { spawn as defaultSpawn } from 'node:child_process';
31
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { join } from 'node:path';
34
+
35
+ const HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
36
+
37
+ // On Windows, npm-installed CLI binaries ship as a `.cmd` shim. Node's
38
+ // child_process.spawn does NOT auto-resolve `.cmd`/`.bat` extensions
39
+ // (unlike shell PATH resolution), so `spawn('claude')` fails with
40
+ // ENOENT on Windows even though `where claude` finds it. The
41
+ // documented Node-on-Windows pattern is to either pass the explicit
42
+ // `.cmd` suffix or use `shell: true`. We use the explicit suffix so
43
+ // the args (which include JSON like `'{"mcpServers":{}}'`) pass
44
+ // through unescaped — `shell: true` would let cmd.exe re-tokenize.
45
+ //
46
+ // Live-test surface this bug: HaikuViaAnthropicApi.compress() crashed
47
+ // with ENOENT on every detached auto-extract invocation on Windows
48
+ // because the bin defaulted to 'claude' literally. See
49
+ // docs/journey/2026-05-26-live-test-findings.md.
50
+ const DEFAULT_CLAUDE_BIN = process.platform === 'win32' ? 'claude.cmd' : 'claude';
51
+
52
+ // Conservative cost estimate for Haiku 4.5 (USD per 1K input tokens +
53
+ // USD per 1K output tokens). Anthropic-published pricing as of
54
+ // 2026-05-25; revisit periodically. The estimator assumes 4 bytes/token
55
+ // average — close enough for "is this call going to cost cents or
56
+ // dollars" budgeting.
57
+ const HAIKU_INPUT_USD_PER_1K = 0.001;
58
+ const HAIKU_OUTPUT_USD_PER_1K = 0.005;
59
+ const BYTES_PER_TOKEN_ESTIMATE = 4;
60
+ // Assumed output:input token ratio when the actual output isn't known
61
+ // (used by estimatedCostPerCall before a call is made).
62
+ const ESTIMATED_OUTPUT_FRACTION = 0.25;
63
+
64
+ export class CompressorBackend {
65
+ async compress(_opts) {
66
+ throw new Error('CompressorBackend.compress must be implemented by subclass');
67
+ }
68
+ modelId() {
69
+ throw new Error('CompressorBackend.modelId must be implemented by subclass');
70
+ }
71
+ estimatedCostPerCall(_inputBytes) {
72
+ throw new Error('CompressorBackend.estimatedCostPerCall must be implemented by subclass');
73
+ }
74
+ }
75
+
76
+ // Subprocess timeout error — distinguishes "the call took too long"
77
+ // from "the call exited with a non-zero status" (which produces a
78
+ // generic Error with the subprocess's stderr). Callers route on
79
+ // `err.category` per design §8.5; the kit's ERROR_CATEGORIES enum
80
+ // uses HAIKU_TIMEOUT for this case + HAIKU_FAILED for non-zero exit.
81
+ //
82
+ // Per the design §8.5 contract, every CompressorBackend implementation
83
+ // (HaikuViaAnthropicApi here; v0.2 BedrockHaiku / LocalLlama later)
84
+ // MUST honor the caller-supplied timeoutMs by rejecting with a
85
+ // HaikuTimeoutError (or category-equivalent). The "Haiku" in the
86
+ // name is historical — the contract applies to every backend.
87
+ export class HaikuTimeoutError extends Error {
88
+ constructor(message, { timeoutMs }) {
89
+ super(message);
90
+ this.name = 'HaikuTimeoutError';
91
+ this.category = 'haiku_timeout';
92
+ this.timeoutMs = timeoutMs;
93
+ }
94
+ }
95
+
96
+ // SIGTERM → grace window → SIGKILL escalation. Exported so the kill
97
+ // chain itself is independently testable against real OS processes
98
+ // (see tests/spawn-smoke-kill-chain.test.js) — the production code
99
+ // path in compress() uses it internally.
100
+ //
101
+ // Returns {method: 'already-exited' | 'sigterm' | 'sigkill' |
102
+ // 'sigkill-no-confirm', exitCode}. The 'sigkill-no-confirm' case
103
+ // means we sent SIGKILL but the OS didn't deliver the 'exit' event
104
+ // within the secondary timeout — exceedingly rare; documented for
105
+ // completeness.
106
+ export function terminateSubprocess(child, { killGraceMs = 2000 } = {}) {
107
+ return new Promise((resolve) => {
108
+ if (child.exitCode !== null && child.exitCode !== undefined) {
109
+ resolve({ method: 'already-exited', exitCode: child.exitCode });
110
+ return;
111
+ }
112
+ let settled = false;
113
+ let sigkillSent = false;
114
+ const finish = (method) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ resolve({ method, exitCode: child.exitCode ?? null });
118
+ };
119
+ const onExit = () => {
120
+ finish(sigkillSent ? 'sigkill' : 'sigterm');
121
+ };
122
+ child.once('exit', onExit);
123
+ try {
124
+ child.kill('SIGTERM');
125
+ } catch {
126
+ // Child already gone — onExit will fire (or has fired)
127
+ }
128
+ setTimeout(() => {
129
+ if (settled) return;
130
+ sigkillSent = true;
131
+ try {
132
+ child.kill('SIGKILL');
133
+ } catch {
134
+ // Best-effort
135
+ }
136
+ setTimeout(() => finish('sigkill-no-confirm'), 1000);
137
+ }, killGraceMs);
138
+ });
139
+ }
140
+
141
+ export class HaikuViaAnthropicApi extends CompressorBackend {
142
+ constructor({ claudeBin, model, spawnFn } = {}) {
143
+ super();
144
+ this._bin = claudeBin ?? DEFAULT_CLAUDE_BIN;
145
+ this._model = model ?? HAIKU_MODEL_ID;
146
+ this._spawn = spawnFn ?? defaultSpawn;
147
+ }
148
+
149
+ modelId() {
150
+ return this._model;
151
+ }
152
+
153
+ estimatedCostPerCall(inputBytes) {
154
+ const inputTokens = Math.ceil(inputBytes / BYTES_PER_TOKEN_ESTIMATE);
155
+ const estOutputTokens = Math.ceil(inputTokens * ESTIMATED_OUTPUT_FRACTION);
156
+ return (
157
+ (inputTokens / 1000) * HAIKU_INPUT_USD_PER_1K +
158
+ (estOutputTokens / 1000) * HAIKU_OUTPUT_USD_PER_1K
159
+ );
160
+ }
161
+
162
+ async compress({ input, maxOutputBytes, preserveCitationIds, instructions, timeoutMs, killGraceMs } = {}) {
163
+ if (typeof input !== 'string') {
164
+ throw new Error('HaikuViaAnthropicApi.compress: input must be a string');
165
+ }
166
+ // The kit hands the model the prompt body via stdin (not argv) so
167
+ // any `$`, backtick, `<`, `>` in the body is preserved verbatim —
168
+ // shell interpolation can't reach it.
169
+ const promptBody = instructions ? `${instructions}\n\n${input}` : input;
170
+
171
+ // Write empty MCP config to a temp file rather than passing inline
172
+ // JSON. Inline JSON via argv survives Linux/macOS shells but cmd.exe
173
+ // strips the double-quotes when shell:true is set on Windows,
174
+ // mangling `{"mcpServers":{}}` to `{mcpServers:{}}` and breaking
175
+ // --mcp-config parsing. Tempfile + path arg is the cross-platform
176
+ // pattern (live-test surface: see
177
+ // docs/journey/2026-05-26-live-test-findings.md).
178
+ const sandbox = mkdtempSync(join(tmpdir(), 'cmk-haiku-'));
179
+ const mcpConfigPath = join(sandbox, 'empty-mcp.json');
180
+ writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), 'utf8');
181
+
182
+ // Build claude --print invocation with the documented sandbox flags.
183
+ // Empty allowedTools + empty MCP config = tightest possible sandbox;
184
+ // the sub-Claude can only respond, not act.
185
+ const args = [
186
+ '--print',
187
+ '--model',
188
+ this._model,
189
+ '--allowed-tools',
190
+ '',
191
+ '--max-turns',
192
+ '1',
193
+ '--mcp-config',
194
+ mcpConfigPath,
195
+ '--strict-mcp-config',
196
+ '--output-format',
197
+ 'text',
198
+ ];
199
+
200
+ // Strip CLAUDECODE env var (the marker Claude Code sets to identify
201
+ // itself to subagents) so Haiku doesn't pick up an "I'm inside
202
+ // Claude Code" assumption.
203
+ const env = { ...process.env };
204
+ delete env.CLAUDECODE;
205
+
206
+ // shell:true required on Windows so that .cmd shims (claude.cmd)
207
+ // resolve through cmd.exe. Without it, node's spawn fails with
208
+ // EINVAL/ENOENT because it won't auto-resolve .cmd extensions
209
+ // (CVE-2024-27980 hardening). On Linux/macOS shell:true is a
210
+ // no-op for argv-style invocation when the arguments don't contain
211
+ // shell metacharacters — ours don't (the prompt goes via stdin).
212
+ // 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 = this._spawn(this._bin, args, {
214
+ cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
215
+ env,
216
+ stdio: ['pipe', 'pipe', 'pipe'],
217
+ shell: true,
218
+ // Suppress the transient cmd.exe console window on Windows —
219
+ // every shell:true spawn flashes a window otherwise (visible
220
+ // to the user when auto-extract / compress-session fires
221
+ // dozens of times per session). stdio is piped so we still
222
+ // capture the child's output through the regular handlers.
223
+ windowsHide: true,
224
+ });
225
+
226
+ const cleanupSandbox = () => {
227
+ // Single-use sandbox: the directory and the empty-mcp.json file
228
+ // inside it are created per-call; nothing else references them
229
+ // after the subprocess dies. On the timeout path,
230
+ // `terminateSubprocess` runs in the background AFTER we call
231
+ // `rmSync` here — if the dying subprocess is mid-read of
232
+ // mcpConfigPath when SIGTERM hits, Windows can emit a benign
233
+ // EBUSY which is swallowed by the catch. The ordering
234
+ // (rm-then-kill) is intentional: the kill chain only touches
235
+ // the child PID, never the sandbox path.
236
+ try {
237
+ rmSync(sandbox, { recursive: true, force: true });
238
+ } catch {
239
+ // Best-effort; OS cleans tmpdir eventually.
240
+ }
241
+ };
242
+
243
+ return await new Promise((resolve, reject) => {
244
+ let stdout = '';
245
+ let stderr = '';
246
+ let settled = false;
247
+ // Timeout timer (set up below if caller supplied timeoutMs).
248
+ // Cleared on close/error so a child that exits cleanly within
249
+ // the window doesn't trigger the kill chain.
250
+ let timeoutTimer = null;
251
+
252
+ const settleReject = (err) => {
253
+ if (settled) return;
254
+ settled = true;
255
+ if (timeoutTimer) clearTimeout(timeoutTimer);
256
+ cleanupSandbox();
257
+ reject(err);
258
+ };
259
+ const settleResolve = (value) => {
260
+ if (settled) return;
261
+ settled = true;
262
+ if (timeoutTimer) clearTimeout(timeoutTimer);
263
+ cleanupSandbox();
264
+ resolve(value);
265
+ };
266
+
267
+ child.stdout.on('data', (chunk) => {
268
+ stdout += chunk.toString('utf8');
269
+ });
270
+ child.stderr.on('data', (chunk) => {
271
+ stderr += chunk.toString('utf8');
272
+ });
273
+ child.on('error', (err) => {
274
+ settleReject(err);
275
+ });
276
+ child.on('close', (code) => {
277
+ if (settled) return; // timeout already fired
278
+ if (code !== 0) {
279
+ settleReject(
280
+ new Error(
281
+ `HaikuViaAnthropicApi: claude --print exit ${code}: ${stderr.trim() || '(no stderr)'}`,
282
+ ),
283
+ );
284
+ return;
285
+ }
286
+ const outputText = stdout.trim();
287
+ // Honor maxOutputBytes by truncating defensively — Haiku is
288
+ // instructed in the prompt to stay under the cap, but a
289
+ // misbehaved response shouldn't blow downstream consumers.
290
+ const trimmed =
291
+ typeof maxOutputBytes === 'number' && Buffer.byteLength(outputText, 'utf8') > maxOutputBytes
292
+ ? outputText.slice(0, maxOutputBytes)
293
+ : outputText;
294
+ const preservedIds = preserveCitationIds ? extractIds(trimmed) : [];
295
+ settleResolve({
296
+ outputText: trimmed,
297
+ inputTokens: Math.ceil(Buffer.byteLength(promptBody, 'utf8') / BYTES_PER_TOKEN_ESTIMATE),
298
+ outputTokens: Math.ceil(Buffer.byteLength(trimmed, 'utf8') / BYTES_PER_TOKEN_ESTIMATE),
299
+ costUSD: this.estimatedCostPerCall(Buffer.byteLength(promptBody, 'utf8')),
300
+ preservedIds,
301
+ });
302
+ });
303
+
304
+ // Optional timeout. Default (no timeoutMs supplied) preserves
305
+ // prior behavior — wait forever for the subprocess. Callers
306
+ // SHOULD pass timeoutMs in production paths per design §8.5
307
+ // (auto-extract 25_000, compress-session 50_000); the no-
308
+ // timeout default exists only for backwards compatibility with
309
+ // existing test fixtures that don't expect a timer.
310
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
311
+ timeoutTimer = setTimeout(() => {
312
+ if (settled) return;
313
+ // Fire the kill chain. Don't await it — settle the Promise
314
+ // immediately with the timeout error so the caller doesn't
315
+ // also have to wait the kill-grace window. The kill chain
316
+ // runs in the background to clean up the OS-level subprocess
317
+ // (terminateSubprocess returns a Promise we ignore here; the
318
+ // sandbox cleanup happens in settleReject).
319
+ terminateSubprocess(child, { killGraceMs: killGraceMs ?? 2000 }).catch(() => {
320
+ // Best-effort; can't do anything further
321
+ });
322
+ settleReject(
323
+ new HaikuTimeoutError(
324
+ `HaikuViaAnthropicApi: claude --print did not return within ${timeoutMs}ms`,
325
+ { timeoutMs },
326
+ ),
327
+ );
328
+ }, timeoutMs);
329
+ }
330
+
331
+ // Send the prompt body via stdin and close.
332
+ child.stdin.write(promptBody);
333
+ child.stdin.end();
334
+ });
335
+ }
336
+ }
337
+
338
+ function extractIds(text) {
339
+ // Fixed 8-char IDs per design §3.1. Previously `{6,8}` for slop
340
+ // tolerance, but the kit only emits 8-char IDs; the {6,8} range
341
+ // was a documented inconsistency. Tightened per PR-21 review.
342
+ const re = /[ULP]-[A-Za-z0-9]{8}/g;
343
+ const set = new Set();
344
+ let m;
345
+ while ((m = re.exec(text)) !== null) set.add(m[0]);
346
+ return [...set];
347
+ }
348
+
349
+ // Test-only: deterministic stub for unit tests. Records every compress
350
+ // call on `this.calls` so downstream tests can spy on what was sent.
351
+ export class MockHaikuBackend extends CompressorBackend {
352
+ constructor({ responses = [], throwError = null, model = 'mock-haiku' } = {}) {
353
+ super();
354
+ this._responses = [...responses];
355
+ this._throw = throwError;
356
+ this._model = model;
357
+ this.calls = [];
358
+ }
359
+
360
+ modelId() {
361
+ return this._model;
362
+ }
363
+
364
+ estimatedCostPerCall(inputBytes) {
365
+ return inputBytes * 1e-6; // arbitrary; tests don't usually assert on this
366
+ }
367
+
368
+ async compress(opts) {
369
+ this.calls.push(opts);
370
+ if (this._throw) throw this._throw;
371
+ if (this._responses.length === 0) {
372
+ throw new Error('MockHaikuBackend: no more canned responses');
373
+ }
374
+ return this._responses.shift();
375
+ }
376
+ }