@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,349 @@
1
+ // SessionEnd compression (Task 22, T-019).
2
+ //
3
+ // Public boundary: compressSession({projectRoot, backend, now,
4
+ // cooldownMs, maxOutputBytes}) — invoked by the SessionEnd hook
5
+ // (plugin/bin/cmk-compress-session.mjs). Reads
6
+ // context/sessions/now.md, compresses via the injected
7
+ // CompressorBackend using the design §8.4 prompt, appends the result
8
+ // to context/sessions/today-{YYYY-MM-DD}.md, then truncates now.md
9
+ // so the next session starts fresh.
10
+ //
11
+ // The CompressorBackend interface (see compressor.mjs) lets tests
12
+ // inject a MockHaikuBackend without spawning the real `claude`
13
+ // binary; the real-binary spawn smoke for the SessionEnd code path
14
+ // lives in tests/spawn-smoke-compress-session.test.js per design §17.
15
+ //
16
+ // Cooldown (design §8.2): if `<projectRoot>/context/.locks/
17
+ // last-haiku-call.ts` mtime is within `cooldownMs` of `now`, skip
18
+ // the compression (the auto-extract subagent may have just spent the
19
+ // budget on a Stop-hook fire). Default cooldownMs = 120_000.
20
+ //
21
+ // Error semantics (tasks.md 22.5): backend.compress() throw → action
22
+ // 'error', now.md UNTOUCHED, today-{date}.md NOT written, log entry
23
+ // with success:false + error_category:'compress_failed'. The bin
24
+ // wrapper exits 0 either way — a crashed SessionEnd hook would block
25
+ // the user from closing their terminal.
26
+
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ writeFileSync,
32
+ appendFileSync,
33
+ truncateSync,
34
+ } from 'node:fs';
35
+ import { join, dirname } from 'node:path';
36
+ import { nowIso } from './audit-log.mjs';
37
+ import { ERROR_CATEGORIES } from './result-shapes.mjs';
38
+ import { HaikuTimeoutError } from './compressor.mjs';
39
+ import {
40
+ DEFAULT_COOLDOWN_MS,
41
+ isCooldownActive,
42
+ touchCooldownMarker,
43
+ } from './cooldown.mjs';
44
+
45
+ const DEFAULT_MAX_OUTPUT_BYTES = 4096;
46
+
47
+ const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
48
+ const SESSIONS_DIR_RELATIVE = ['context', 'sessions'];
49
+
50
+ // Compression prompt (design §8.4). Written from scratch per the
51
+ // licensing posture in SOURCES.md (claude-remember's prompts are not
52
+ // copied verbatim — only the structural pattern of "instructions go
53
+ // in --append-system-prompt / our `instructions` field; the live
54
+ // buffer goes in the user message / our `input` field" is absorbed).
55
+ //
56
+ // The four-section structure (Decisions / Open Questions / Files
57
+ // Touched / Active Threads) is the §8.4 contract. The citation-ID
58
+ // preservation rule (`/#[ULP]-[A-Z0-9]{6,8}/`) is the §3.1 contract
59
+ // — Haiku must NEVER invent IDs and must preserve any it sees.
60
+ //
61
+ // Prompt-engineering note: compressor.mjs concatenates
62
+ // `${instructions}\n\n${input}` into a single user-side message. The
63
+ // earlier prompt phrasing ("You receive a live session buffer...")
64
+ // invited Haiku to read the whole thing as a meta-configuration
65
+ // conversation and respond with "OK, ready to compress — send me the
66
+ // buffer." Surfaced by tests/spawn-smoke-compress-session.test.js in
67
+ // the full-suite run on 2026-05-25 (the isolated run got lucky on
68
+ // stochastic Haiku output; under cache-cold/full-suite conditions it
69
+ // took the meta-conversation path). Fix: (a) imperative voice with
70
+ // an explicit forward-reference to the buffer that follows, (b)
71
+ // SESSION_BUFFER_DELIMITER markers around the buffer so the model
72
+ // has an unambiguous boundary between directive and input, and
73
+ // (c) explicit ban on preamble / clarifying questions / "I
74
+ // understand" acknowledgments.
75
+ const SESSION_BUFFER_DELIMITER = '=== BEGIN SESSION BUFFER (compress this) ===';
76
+ const SESSION_BUFFER_END_DELIMITER = '=== END SESSION BUFFER ===';
77
+
78
+ function buildCompressionInstructions(maxOutputBytes) {
79
+ return [
80
+ 'You are a memory compressor for claude-memory-kit. Your task is to compress the session buffer that appears below into a four-section Markdown summary.',
81
+ '',
82
+ 'Output ONLY the compressed Markdown. Do not write preamble. Do not acknowledge the task. Do not ask clarifying questions. Do not include any meta-commentary. Begin your response with the first applicable section heading.',
83
+ '',
84
+ 'REQUIRED FORMAT (emit these section headings exactly, in this order; omit any heading whose section would have no entries):',
85
+ '',
86
+ '## Decisions',
87
+ '- <one bullet per concrete decision the session reached, ≤80 chars>',
88
+ '',
89
+ '## Open Questions',
90
+ '- <one bullet per unresolved question raised during the session, ≤80 chars>',
91
+ '',
92
+ '## Files Touched',
93
+ '- path: <relative path> — <verb summary> (cites: [#P-XXXXXXXX])',
94
+ '',
95
+ '## Active Threads',
96
+ '- <one bullet per work-in-progress thread the next session should resume, ≤80 chars>',
97
+ '',
98
+ 'HARD RULES:',
99
+ ' 1. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
100
+ ` 2. Total output ≤ ${maxOutputBytes} bytes.`,
101
+ ' 3. If a section has no entries, omit the heading entirely (do not emit an empty heading).',
102
+ ' 4. No prose around the headings — only the bulleted list per section.',
103
+ ' 5. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself, do not narrate.',
104
+ '',
105
+ `The session buffer to compress appears below between the ${SESSION_BUFFER_DELIMITER} and ${SESSION_BUFFER_END_DELIMITER} markers.`,
106
+ ].join('\n');
107
+ }
108
+
109
+ function wrapBufferForPrompt(buffer) {
110
+ return `${SESSION_BUFFER_DELIMITER}\n${buffer}\n${SESSION_BUFFER_END_DELIMITER}`;
111
+ }
112
+
113
+ function readNowMdPath(projectRoot) {
114
+ return join(projectRoot, ...NOW_MD_RELATIVE);
115
+ }
116
+
117
+ function todayMdPath(projectRoot, date) {
118
+ return join(projectRoot, ...SESSIONS_DIR_RELATIVE, `today-${date}.md`);
119
+ }
120
+
121
+ function compressLogPath(projectRoot, date) {
122
+ return join(projectRoot, ...SESSIONS_DIR_RELATIVE, `${date}.compress.log`);
123
+ }
124
+
125
+ function dateFromIso(ts) {
126
+ // ISO 8601 first 10 chars are YYYY-MM-DD; safe for both
127
+ // '2026-05-26T10:00:00Z' and the nowIso() shape.
128
+ return ts.slice(0, 10);
129
+ }
130
+
131
+ function readNowBuffer(projectRoot) {
132
+ const p = readNowMdPath(projectRoot);
133
+ if (!existsSync(p)) return '';
134
+ try {
135
+ return readFileSync(p, 'utf8');
136
+ } catch {
137
+ return '';
138
+ }
139
+ }
140
+
141
+ function appendToTodayMd({ projectRoot, date, body }) {
142
+ const path = todayMdPath(projectRoot, date);
143
+ mkdirSync(dirname(path), { recursive: true });
144
+ // Append with a trailing newline so successive same-day appends
145
+ // don't collide on a missing terminator.
146
+ const suffix = body.endsWith('\n') ? '' : '\n';
147
+ appendFileSync(path, body + suffix, 'utf8');
148
+ return path;
149
+ }
150
+
151
+ function truncateNowMd(projectRoot) {
152
+ const p = readNowMdPath(projectRoot);
153
+ if (!existsSync(p)) return;
154
+ try {
155
+ truncateSync(p, 0);
156
+ } catch {
157
+ // Best-effort. If truncate fails (perm error etc.), the next
158
+ // session compresses a slightly-larger buffer — not a data-loss
159
+ // event.
160
+ }
161
+ }
162
+
163
+ function writeCompressLogEntry({ projectRoot, date, entry }) {
164
+ const path = compressLogPath(projectRoot, date);
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
167
+ return path;
168
+ }
169
+
170
+ export async function compressSession({
171
+ projectRoot,
172
+ backend,
173
+ now,
174
+ cooldownMs = DEFAULT_COOLDOWN_MS,
175
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
176
+ } = {}) {
177
+ const ts = now ?? nowIso();
178
+ const date = dateFromIso(ts);
179
+ const t0 = Date.now();
180
+
181
+ if (!projectRoot) {
182
+ return {
183
+ action: 'error',
184
+ error_category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
185
+ duration_ms: Date.now() - t0,
186
+ };
187
+ }
188
+ if (!backend || typeof backend.compress !== 'function') {
189
+ return {
190
+ action: 'error',
191
+ error_category: ERROR_CATEGORIES.MISSING_BACKEND,
192
+ duration_ms: Date.now() - t0,
193
+ };
194
+ }
195
+
196
+ // Project must have been `cmk install`-ed (context/sessions/
197
+ // exists). If not, this is a no-op — we don't create directories
198
+ // in projects that haven't opted in. Crucially, this skip does
199
+ // NOT write a log entry; that would create the very directory we
200
+ // just decided not to create. The scaffold test relies on this
201
+ // (it invokes the bin handler from the repo root without a
202
+ // context/ tree and expects no side effects).
203
+ const sessionsDir = join(projectRoot, ...SESSIONS_DIR_RELATIVE);
204
+ if (!existsSync(sessionsDir)) {
205
+ return {
206
+ action: 'skipped',
207
+ reason: 'no-context-dir',
208
+ duration_ms: Date.now() - t0,
209
+ };
210
+ }
211
+
212
+ // 1. Cooldown gate (design §8.2). Checked BEFORE reading now.md so
213
+ // a stale buffer doesn't get retried within the 120s window —
214
+ // the next SessionEnd will re-trigger naturally.
215
+ if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
216
+ const duration_ms = Date.now() - t0;
217
+ const entry = {
218
+ ts,
219
+ scope: 'session-end',
220
+ input_bytes: 0,
221
+ output_bytes: 0,
222
+ model_id: typeof backend.modelId === 'function' ? backend.modelId() : null,
223
+ cost_usd: 0,
224
+ duration_ms,
225
+ success: true,
226
+ skipped_reason: 'cooldown',
227
+ };
228
+ writeCompressLogEntry({ projectRoot, date, entry });
229
+ return {
230
+ action: 'skipped',
231
+ reason: 'cooldown',
232
+ duration_ms,
233
+ };
234
+ }
235
+
236
+ // 2. Read live buffer; no-op if empty (tasks.md 22.1).
237
+ const buffer = readNowBuffer(projectRoot);
238
+ if (buffer.trim() === '') {
239
+ const duration_ms = Date.now() - t0;
240
+ const entry = {
241
+ ts,
242
+ scope: 'session-end',
243
+ input_bytes: 0,
244
+ output_bytes: 0,
245
+ model_id: typeof backend.modelId === 'function' ? backend.modelId() : null,
246
+ cost_usd: 0,
247
+ duration_ms,
248
+ success: true,
249
+ skipped_reason: 'empty',
250
+ };
251
+ writeCompressLogEntry({ projectRoot, date, entry });
252
+ return {
253
+ action: 'skipped',
254
+ reason: 'empty',
255
+ duration_ms,
256
+ };
257
+ }
258
+
259
+ const input_bytes = Buffer.byteLength(buffer, 'utf8');
260
+ const instructions = buildCompressionInstructions(maxOutputBytes);
261
+
262
+ // 3. Invoke backend. On throw: leave now.md intact (22.5).
263
+ //
264
+ // Subprocess timeout: 50_000 ms. Sits under the 60s SessionEnd
265
+ // hook ceiling (design §5.1) so on timeout the catch + log write
266
+ // complete BEFORE Claude Code kills the parent. now.md is left
267
+ // intact in the timeout case (the truncate step is reached only
268
+ // on the success path), so the next session-end retries naturally.
269
+ // See design §8.5 for the composition rationale.
270
+ let result;
271
+ try {
272
+ result = await backend.compress({
273
+ input: wrapBufferForPrompt(buffer),
274
+ instructions,
275
+ preserveCitationIds: true,
276
+ maxOutputBytes,
277
+ timeoutMs: 50_000,
278
+ });
279
+ } catch (err) {
280
+ // Distinguish HAIKU_TIMEOUT (slow Anthropic) from COMPRESS_FAILED
281
+ // (non-zero subprocess exit / spawn ENOENT / etc). Analytics
282
+ // treat them differently — timeouts retry naturally on the
283
+ // next SessionEnd; failed exits often need investigation.
284
+ // `instanceof HaikuTimeoutError` (not string match on
285
+ // err.category) so the routing contract is type-anchored —
286
+ // see compressor.mjs HaikuTimeoutError docstring for rationale.
287
+ const errorCategory = err instanceof HaikuTimeoutError
288
+ ? ERROR_CATEGORIES.HAIKU_TIMEOUT
289
+ : ERROR_CATEGORIES.COMPRESS_FAILED;
290
+ const duration_ms = Date.now() - t0;
291
+ const entry = {
292
+ ts,
293
+ scope: 'session-end',
294
+ input_bytes,
295
+ output_bytes: 0,
296
+ model_id: typeof backend.modelId === 'function' ? backend.modelId() : null,
297
+ cost_usd: 0,
298
+ duration_ms,
299
+ success: false,
300
+ error_category: errorCategory,
301
+ };
302
+ writeCompressLogEntry({ projectRoot, date, entry });
303
+ return {
304
+ action: 'error',
305
+ error_category: errorCategory,
306
+ duration_ms,
307
+ errorMessage: err?.message ?? String(err),
308
+ };
309
+ }
310
+
311
+ const output = result?.outputText ?? '';
312
+ const output_bytes = Buffer.byteLength(output, 'utf8');
313
+
314
+ // 4. Write compressed output to today-{date}.md (append for same-day).
315
+ const outputPath = appendToTodayMd({
316
+ projectRoot,
317
+ date,
318
+ body: output,
319
+ });
320
+
321
+ // 5. Truncate now.md (22.3).
322
+ truncateNowMd(projectRoot);
323
+
324
+ // 6. Touch cooldown marker so the next caller within 120s skips.
325
+ touchCooldownMarker({ projectRoot, now: ts });
326
+
327
+ const duration_ms = Date.now() - t0;
328
+ const entry = {
329
+ ts,
330
+ scope: 'session-end',
331
+ input_bytes,
332
+ output_bytes,
333
+ model_id:
334
+ result?.modelId ??
335
+ (typeof backend.modelId === 'function' ? backend.modelId() : null),
336
+ cost_usd: result?.costUSD ?? 0,
337
+ duration_ms,
338
+ success: true,
339
+ };
340
+ writeCompressLogEntry({ projectRoot, date, entry });
341
+
342
+ return {
343
+ action: 'compressed',
344
+ outputPath,
345
+ bytesIn: input_bytes,
346
+ bytesOut: output_bytes,
347
+ duration_ms,
348
+ };
349
+ }