@lh8ppl/claude-memory-kit 0.2.2 → 0.2.4

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 CHANGED
@@ -7,8 +7,8 @@
7
7
  - **Cross-project persona — the wedge (v0.2)** — when you state how you work *everywhere* ("always use uv, never pip", "from now on run the linter before committing"), the per-turn auto-extract promotes it into your **user tier** (`~/.claude-memory-kit/`) **that turn**. So a brand-new project **cold-opens already knowing your style** — layered structure, your tooling, your testing discipline — with no hand-curation and no waiting. Carry it between your own machines with `cmk persona export`/`import`, or pin a single fact across projects with `cmk lessons promote`.
8
8
  - **Frozen snapshot at session start** — MEMORY.md + USER.md + SOUL.md + INDEX.md + today's session log inject once at the first tool call, so Claude sees your context every session without you re-telling it.
9
9
  - **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts to memory. Durable project knowledge (setup/config, conventions, workflows, tool quirks) becomes a **rich Why/How fact file** (structured + searchable); lighter signals stay terse `MEMORY.md` bullets. Runs automatically, so the rich tier survives even when the model uses Claude Code's built-in memory instead. No manual writes needed.
10
- - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently.
11
- - **Search + MCP** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads); `cmk mcp` exposes the same to Claude Code as tools.
10
+ - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently. For backtick/quote-heavy rich facts, capture them shell-safe as JSON: `cmk remember --from-file fact.json` (or `--json` from stdin) — content never touches the shell.
11
+ - **Search + MCP — Claude runs every memory op for you, in conversation** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads). `cmk install` registers the kit's **MCP server**, so Claude can do the whole memory surface as tools without you ever typing `cmk`: capture (`mk_remember`, rich Why/How too), recall (`mk_search` / `mk_get` / `mk_timeline` / `mk_cite`), adjust trust (`mk_trust`), promote a fact across projects (`mk_lessons_promote`), forget (`mk_forget` — previews first, then deletes on confirm), and clear the review/conflict queues (`mk_queue_list` / `mk_queue_resolve`). The tools are allow-listed on install, so they run prompt-free.
12
12
  - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
13
13
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
14
14
  - **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
@@ -26,7 +26,7 @@ cmk install # scaffolds context/ + the memory-write skill AND wires the l
26
26
  cmk doctor # verify, then restart Claude Code
27
27
  ```
28
28
 
29
- `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` skill into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. No separate `/plugin` step needed. Use `cmk install --no-hooks` for a scaffold-only install.
29
+ `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` skill into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. It also **registers the kit's MCP server** in `.mcp.json` and allow-lists its tools (`mcp__cmk__*`) in `.claude/settings.json`, so Claude can drive memory as tools with no per-call prompt. No separate `/plugin` step needed. Use `cmk install --no-hooks` to skip the hooks + MCP wiring (scaffold-only).
30
30
 
31
31
  > Installing the package globally adds the `cmk` CLI **and** the installer. It's the `cmk install` *subcommand* that wires the hooks — not the bare `npm install`.
32
32
 
@@ -51,9 +51,10 @@ Most-used commands (full list via `cmk --help`):
51
51
  | `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
52
52
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
53
53
  | `cmk search "<query>" [--mode keyword\|semantic\|hybrid]` | Search accumulated memory (keyword default) |
54
+ | `cmk get <id…>` / `cmk timeline <id>` / `cmk cite <id>` / `cmk recent-activity` | Read the index back — full fact bodies + provenance, sequential context around an observation, a canonical citation link, recent changes (the CLI side of the `mk_*` MCP read tools) |
54
55
  | `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
55
56
  | `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
56
- | `cmk forget <id>` | Tombstone a fact (preserves audit trail) |
57
+ | `cmk forget <id>` | Tombstone a fact — disappears from `cmk search` immediately, no manual reindex (audit trail preserved) |
57
58
  | `cmk lessons promote <id> [--to USER.md\|HABITS.md]` | Promote one captured fact to your cross-project **user tier** (the safe path — sanitized, secret-screened, audited) so it applies in **every** project |
58
59
  | `cmk disable-native-memory` / `enable-native-memory` | Opt out of Claude Code's built-in Auto Memory so the kit is your single, lean memory layer (committable — travels with `git clone`) |
59
60
  | `cmk persona generate` | Run cross-project persona synthesis on demand (instead of waiting for the weekly pass) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audit-log.mjs CHANGED
@@ -30,6 +30,7 @@ export const AUDIT_LOG_SCHEMA_VERSION = 1;
30
30
  // they come online; the rule is one canonical machine-parseable token per
31
31
  // kind of audit event (not a free-text reason field).
32
32
  export const REASON_CODES = Object.freeze({
33
+ FACT_CREATED: 'fact-created', // writeFact: a new fact file was written (Task 123.A — the default create audit; callers emitting a richer code opt out via audit:false)
33
34
  DUPLICATE: 'duplicate', // writeFact: same path + same id
34
35
  DUPLICATE_ELSEWHERE: 'duplicate-elsewhere', // writeFact: different path + same id
35
36
  USER_REQUESTED: 'user-requested', // forget: user-initiated tombstone
@@ -796,7 +796,7 @@ export async function runAutoExtract({
796
796
  // duration ≈ 25000ms = hitting the cap, not finishing) → automatic
797
797
  // capture + persona promotion (F2) silently never ran. This call is
798
798
  // DETACHED (fire-and-forget, never blocks the session), so a generous
799
- // ceiling is free. Live-test finding (2026-06-01, lior-test-4 baseline).
799
+ // ceiling is free. Live-test finding (2026-06-01, live-test-4 baseline).
800
800
  timeoutMs: 90_000,
801
801
  });
802
802
  // Touch the cooldown marker IMMEDIATELY after the Haiku call
@@ -4,11 +4,11 @@
4
4
  // reproduced design §16.16's predicted failure: cross-project doctrine
5
5
  // ("how I work everywhere" — venv-3.13, layered-backend) was captured
6
6
  // but filed PROJECT-tier; the USER tier stayed empty, collapsing the
7
- // 3-tier value prop to project+local. Lior won't hand-curate the user
7
+ // 3-tier value prop to project+local. The user won't hand-curate the user
8
8
  // tier ("too much of a hassle"), so the user tier must fill itself.
9
9
  //
10
10
  // Posture (tasks.md 45.6 — supersedes 45.2/45.3's manual gate):
11
- // OPTIMISTIC AUTO-PROMOTE. Lior 2026-05-30: "i dont want to do
11
+ // OPTIMISTIC AUTO-PROMOTE. The user (2026-05-30): "i dont want to do
12
12
  // anything, i want it to be automatic." A synthesized doctrine that
13
13
  // applies beyond the current project is auto-promoted to the user tier
14
14
  // at trust:medium — no manual `cmk persona accept` step. A confidence
@@ -75,6 +75,11 @@ export const PERSONA_CANDIDATE_RE =
75
75
  // userDir is passed through to listObservationSources purely to keep the
76
76
  // U-tier resolution sandbox-scoped (never walk the real home dir —
77
77
  // design §16.36); we then filter to tier P, the synthesis SOURCE.
78
+ // Byte budget for the `facts` persona corpus (Task 111 / F-2). Bounds the Haiku
79
+ // classifier input so a large project's whole-memory sweep can't blow the timeout.
80
+ // Generous (facts are high-signal) but bounded; whole facts only (see below).
81
+ export const PERSONA_CORPUS_BYTES = 60_000;
82
+
78
83
  function assembleProjectCorpus({ projectRoot, userDir }) {
79
84
  const sources = listObservationSources({ projectRoot, userDir });
80
85
  const parts = [];
@@ -94,7 +99,30 @@ function assembleProjectCorpus({ projectRoot, userDir }) {
94
99
  parts.push((content ?? '').trim());
95
100
  }
96
101
  }
97
- return parts.filter(Boolean).join('\n\n');
102
+ // Task 111 (F-2): BOUND the corpus. Previously this joined EVERY tier-P fact
103
+ // + scratchpad with no cap, so on a real project with substantial memory the
104
+ // classifier prompt grew unbounded and the Haiku `claude --print` call blew the
105
+ // timeout (the reported "did not return within 50000ms"). Accumulate WHOLE
106
+ // facts up to a byte budget (never split a fact mid-body) and mark truncation.
107
+ // KNOWN LIMITATION (mirrors TRANSCRIPT_WINDOW_BYTES): facts past the budget are
108
+ // dropped in file-iteration order — a doctrine fact in the tail can be missed
109
+ // on one pass, but the weekly janitor re-runs, and some doctrine beats a
110
+ // timed-out zero. A value-ordered (trust/recency-first) accumulation is the
111
+ // follow-up if a large corpus drops doctrine.
112
+ const out = [];
113
+ let used = 0;
114
+ let truncated = false;
115
+ for (const part of parts.filter(Boolean)) {
116
+ const cost = Buffer.byteLength(part, 'utf8') + 2; // +2 for the '\n\n' join
117
+ if (used + cost > PERSONA_CORPUS_BYTES) {
118
+ truncated = true;
119
+ break;
120
+ }
121
+ out.push(part);
122
+ used += cost;
123
+ }
124
+ if (truncated) out.push('### …\n(corpus truncated — additional project facts omitted for this pass)');
125
+ return out.join('\n\n');
98
126
  }
99
127
 
100
128
  // Default size of the recent-transcript window handed to the SessionEnd persona
@@ -111,7 +139,7 @@ function assembleProjectCorpus({ projectRoot, userDir }) {
111
139
  // 40k chars ≈ a long session's worth of turns ≈ ~10k tokens — trivial cost for a
112
140
  // once-per-session call, and the classifier prompt's "IGNORE anything specific to
113
141
  // this ONE project" instruction guards precision at the larger size (live test:
114
- // clean 2/2, no false promotes). The exact bound is a lior-test-9 tuning item.
142
+ // clean 2/2, no false promotes). The exact bound is a live-test-9 tuning item.
115
143
  // KNOWN LIMITATION (documented, not yet fixed): only the most-recent date-named
116
144
  // file is read, so a session spanning midnight loses the pre-midnight turns. Rare;
117
145
  // a multi-file read is the follow-up if it bites.
@@ -250,7 +278,7 @@ export function parsePersonaCandidates(outputText) {
250
278
  */
251
279
  export async function autoPersona(opts = {}) {
252
280
  const t0 = Date.now();
253
- const { projectRoot, userDir, backend, now, settings, cooldownMs = DEFAULT_COOLDOWN_MS, source = 'facts' } = opts;
281
+ const { projectRoot, userDir, backend, now, settings, cooldownMs = DEFAULT_COOLDOWN_MS, source = 'facts', timeoutMs = 50_000 } = opts;
254
282
 
255
283
  if (!projectRoot) {
256
284
  return errorResult({
@@ -302,7 +330,11 @@ export async function autoPersona(opts = {}) {
302
330
  instructions: buildClassifierInstructions(source),
303
331
  preserveCitationIds: false,
304
332
  maxOutputBytes: 4096,
305
- timeoutMs: 50_000,
333
+ // Task 111 (F-2): the timeout is caller-supplied. The SessionEnd hook path
334
+ // keeps the 50_000 default (it composes with the 60s SessionEnd ceiling per
335
+ // design §8.5 / D-42). The CLI `cmk persona generate` has NO outer hook
336
+ // ceiling, so it passes a generous value — the explicit command can wait.
337
+ timeoutMs,
306
338
  });
307
339
  // Spent a Haiku call — refresh the shared cooldown marker so the next
308
340
  // gated caller backs off. (touch even on cooldownMs:0 cycles: the call
@@ -349,7 +381,7 @@ export async function autoPersona(opts = {}) {
349
381
  * inferred noise. This still holds for every medium/inferred write.
350
382
  * - trust:'high' (explicit path — Task 76 `cmk lessons promote` + Task 78
351
383
  * inline grading of an EXPLICITLY-STATED rule). **45.4 REFINEMENT
352
- * (2026-06-02, D-32 — Lior chose "latest explicit wins"):** an explicit,
384
+ * (2026-06-02, D-32 — the user chose "latest explicit wins"):** an explicit,
353
385
  * user-attested rule at trust:high MAY supersede an equal-trust same-topic
354
386
  * entry (high >= high → supersede). The newest explicit statement wins,
355
387
  * even over a hand-curated high. The original protection is unchanged for
@@ -359,7 +391,7 @@ export async function autoPersona(opts = {}) {
359
391
  */
360
392
  // Persist low/medium-confidence (and otherwise-not-promoted) candidates to a
361
393
  // durable review-queue FILE at <userDir>/queues/persona-review.md, so they are
362
- // not lost when only returned in the response (Lior 2026-05-31: "response
394
+ // not lost when only returned in the response (the user, 2026-05-31: "response
363
395
  // object can get lost — i dont like it"). Dedup by canonical id against what's
364
396
  // already in the file so repeated synthesis passes don't pile up duplicates.
365
397
  // Returns the queue path (or null when there's nothing to write).
@@ -48,6 +48,9 @@ import {
48
48
  appendFileSync,
49
49
  readFileSync,
50
50
  writeFileSync,
51
+ readdirSync,
52
+ statSync,
53
+ unlinkSync,
51
54
  } from 'node:fs';
52
55
  import { join } from 'node:path';
53
56
  import { spawn } from 'node:child_process';
@@ -57,6 +60,41 @@ function dateFromIso(iso) {
57
60
  return String(iso).slice(0, 10);
58
61
  }
59
62
 
63
+ // A `.extract-<ts>.tmp` turn-file lives only for the duration of one
64
+ // auto-extract run (bounded by the Stop-hook ceiling, design §8.5). The owning
65
+ // child unlinks it in its `finally`; capture-turn unlinks it here when the spawn
66
+ // fails. But a child KILLED before its finally (hook ceiling), or a Windows
67
+ // unlink refused by a scanner, leaks the temp (cut-gate7 found 2 lingering —
68
+ // D-103 finding E). This janitor sweeps any `.extract-*.tmp` older than the
69
+ // threshold — far longer than any live run, so it can't race an in-flight child.
70
+ // Best-effort: a sweep hiccup must never block the capture.
71
+ const STALE_TURN_FILE_MS = 10 * 60 * 1000; // 10 min — well beyond the hook ceiling
72
+
73
+ export function sweepStaleTurnFiles(transcriptsDir, maxAgeMs = STALE_TURN_FILE_MS, now = Date.now()) {
74
+ let swept = 0;
75
+ if (!existsSync(transcriptsDir)) return swept;
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(transcriptsDir);
79
+ } catch {
80
+ return swept;
81
+ }
82
+ for (const name of entries) {
83
+ if (!name.startsWith('.extract-') || !name.endsWith('.tmp')) continue;
84
+ const p = join(transcriptsDir, name);
85
+ try {
86
+ if (now - statSync(p).mtimeMs > maxAgeMs) {
87
+ unlinkSync(p);
88
+ swept += 1;
89
+ }
90
+ } catch {
91
+ // best-effort: a stat/unlink failure (already gone, or briefly locked)
92
+ // must not abort the sweep or the capture.
93
+ }
94
+ }
95
+ return swept;
96
+ }
97
+
60
98
  // Write a `phase: 'spawn'` NDJSON entry to `<projectRoot>/context/sessions/{date}.extract.log`
61
99
  // when the auto-extract spawn fails. This closes PR-A's class-1 audit
62
100
  // deferral (capture-turn Door 5 observability gap). Auto-extract's own
@@ -143,7 +181,7 @@ function readLastUserTurnFromTranscript(transcriptPath) {
143
181
  // (context/sessions/now.md). Before this, now.md was fed ONLY by observe-edit's
144
182
  // file-write lines ("[ts] Write file=X lines=N"), so the SessionEnd compressor
145
183
  // 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
184
+ // contained (live-test-6: "Flask app: app.py" — inferred a framework from a
147
185
  // filename). Buffering the actual user+assistant turns here means the summary
148
186
  // reflects what was DISCUSSED. Same `## <ts> — speaker` shape as the transcript
149
187
  // so the compressor reads it as dialogue; now.md is truncated after each compress
@@ -281,6 +319,10 @@ export function captureTurn({
281
319
  // summarizes the DIALOGUE, not observe-edit's filename log. Best-effort.
282
320
  appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn: sanitized });
283
321
 
322
+ // Janitor: clear any orphaned turn-files from a prior killed/crashed child
323
+ // before writing this turn's (D-103 finding E). Best-effort.
324
+ sweepStaleTurnFiles(transcriptsDir);
325
+
284
326
  const turnFile = join(transcriptsDir, `.extract-${Date.now()}.tmp`);
285
327
  try {
286
328
  writeFileSync(
@@ -316,6 +358,11 @@ export function captureTurn({
316
358
  reason: spawnResult.reason,
317
359
  error: spawnResult.error,
318
360
  });
361
+ // NB: we do NOT unlink the turn-file here. Ownership is clean — auto-extract
362
+ // owns deletion (its `finally`); when the spawn fails (or a child is killed
363
+ // before its finally), the file becomes an orphan that the entry-sweep above
364
+ // reaps once it's stale (D-103 finding E). capture-turn never deletes a file
365
+ // it handed off, so tests can still inspect the IPC shape on the no-spawn path.
319
366
  }
320
367
 
321
368
  return {
@@ -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 Lior's instruction (the auto-extract sub-Claude
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';
@@ -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,