@lh8ppl/claude-memory-kit 0.2.3 → 0.3.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 CHANGED
@@ -5,13 +5,14 @@
5
5
  ## What it does
6
6
 
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
- - **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.
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. The snapshot opens with an **authority instruction** ("when injected memory contradicts your assumptions, injected memory wins"), so the agent leads with its memory instead of re-deriving answers from the code.
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
+ - **Claude knows WHEN to recall** — the auto-invoked `memory-search` skill fires on "what did we decide about X" / "have we seen this error before" and searches the deep archive in a forked side-context, returning a curated citation-backed summary. Read-only by contract.
10
11
  - **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
+ - **Search + MCP — Claude runs every memory op for you, in conversation** — `cmk search "<term>"` (keyword over facts + scratchpads; with the optional local embedder, **semantic + hybrid recall**: ask in your own words and get the fact even with zero keyword overlap — measured R@5 0.941 / paraphrase 1.000 on the kit's benchmark, no API calls). `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
13
  - **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
14
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
14
- - **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
15
+ - **7 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, and stale locks — each failure with a repair command.
15
16
 
16
17
  ## Install — pick ONE route
17
18
 
@@ -22,11 +23,13 @@ Each route is complete on its own. **Don't run both** — they wire the same hoo
22
23
  ```bash
23
24
  npm install -g @lh8ppl/claude-memory-kit
24
25
  cd ~/my-project
25
- cmk install # scaffolds context/ + the memory-write skill AND wires the lifecycle hooks into .claude/settings.json
26
+ cmk install # scaffolds context/ + the memory-write + memory-search skills AND wires the lifecycle hooks into .claude/settings.json
27
+ cmk install --with-semantic # (optional) local semantic recall — one-time ~260 MB, search defaults to hybrid
28
+ cmk register-crons # (optional) scheduled background compression — otherwise self-heals lazily
26
29
  cmk doctor # verify, then restart Claude Code
27
30
  ```
28
31
 
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).
32
+ `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` + `memory-search` skills 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
33
 
31
34
  > 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
35
 
@@ -39,7 +42,7 @@ Inside Claude Code:
39
42
  /plugin install claude-memory-kit
40
43
  ```
41
44
 
42
- Then say *"bootstrap the memory system"* to scaffold this project's `context/`. The plugin bundles the hooks + the `bootstrap` and `memory-write` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
45
+ Then say *"bootstrap the memory system"* to scaffold this project's `context/`. The plugin bundles the hooks + the `bootstrap`, `memory-write`, and `memory-search` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
43
46
 
44
47
  ## CLI
45
48
 
@@ -47,10 +50,10 @@ Most-used commands (full list via `cmk --help`):
47
50
 
48
51
  | Command | Purpose |
49
52
  | --- | --- |
50
- | `cmk install` | Scaffold `context/` + the `memory-write` skill + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
51
- | `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
53
+ | `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
54
+ | `cmk doctor` | Run HC-1..HC-7 health checks, surface repair commands |
52
55
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
53
- | `cmk search "<query>" [--mode keyword\|semantic\|hybrid]` | Search accumulated memory (keyword default) |
56
+ | `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record |
54
57
  | `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) |
55
58
  | `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
56
59
  | `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
@@ -65,7 +68,7 @@ Most-used commands (full list via `cmk --help`):
65
68
 
66
69
  - Node.js ≥ 20
67
70
  - Claude Code (for the hook-driven auto-memory loop)
68
- - Optional: Python 3.12+ for Layer 5b semantic search (deferred to a later release; keyword search ships today)
71
+ - Optional: `cmk install --with-semantic` for semantic/hybrid recall (installs the local `@huggingface/transformers` embedder, ~260 MB once — no API, no Python)
69
72
 
70
73
  ## Three-tier model
71
74
 
@@ -25,9 +25,10 @@ const modulePath = join(__dirname, '..', 'src', 'capture-prompt.mjs');
25
25
 
26
26
  let readHookStdin;
27
27
  let capturePrompt;
28
+ let buildMemoryHint;
28
29
  try {
29
30
  ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
30
- ({ capturePrompt } = await import(pathToFileURL(modulePath).href));
31
+ ({ capturePrompt, buildMemoryHint } = await import(pathToFileURL(modulePath).href));
31
32
  } catch (err) {
32
33
  process.stderr.write(
33
34
  `cmk-capture-prompt: failed to load modules: ${err?.message ?? err}\n`,
@@ -61,5 +62,24 @@ try {
61
62
  );
62
63
  }
63
64
 
65
+ // Task 75.2 — emit the "memory available" recall nudge as additionalContext
66
+ // (the MODEL-facing UserPromptSubmit field per Anthropic's hooks doc;
67
+ // systemMessage is user-display). Best-effort: a hint failure must never
68
+ // break the capture protocol.
69
+ try {
70
+ const hint = buildMemoryHint({ projectRoot: process.cwd(), prompt: payload?.prompt });
71
+ if (hint) {
72
+ process.stdout.write(
73
+ JSON.stringify({
74
+ continue: true,
75
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: hint },
76
+ }),
77
+ );
78
+ process.exit(0);
79
+ }
80
+ } catch (err) {
81
+ process.stderr.write(`cmk-capture-prompt: hint failed: ${err?.message ?? err}\n`);
82
+ }
83
+
64
84
  emitContinue();
65
85
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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": {
@@ -35,6 +35,7 @@
35
35
  "chokidar": "^5.0.0",
36
36
  "commander": "^15.0.0",
37
37
  "js-yaml": "^4.1.0",
38
+ "sqlite-vec": "^0.1.9",
38
39
  "zod": "^4.4.3"
39
40
  },
40
41
  "engines": {
@@ -196,7 +196,14 @@ function extractRetainSegments(text) {
196
196
 
197
197
  // --- Dedup context --------------------------------------------------
198
198
 
199
- function readLastEntryFromNowMd(projectRoot) {
199
+ // Task 132 (D-122): exported for capture-turn, which snapshots the dedup
200
+ // context BEFORE appending the current turn to now.md and passes it here
201
+ // inside the turn file. Reading now.md from THIS module (after the append)
202
+ // was the self-poisoning bug: the "last entry" was the very turn being
203
+ // extracted, so Haiku was told "do not re-emit facts already here" about
204
+ // its own input → nothing_durable on every organic turn (since Task 87;
205
+ // live A/B repro 2026-06-11, cut-gate8).
206
+ export function readLastEntryFromNowMd(projectRoot) {
200
207
  const nowMd = join(projectRoot, ...NOW_MD_RELATIVE);
201
208
  if (!existsSync(nowMd)) return '';
202
209
  let body;
@@ -221,29 +228,38 @@ function readLastEntryFromNowMd(projectRoot) {
221
228
  // --- Turn-file parser (bi-turn) -------------------------------------
222
229
 
223
230
  // Parse the temp-file format Task 21's capture-turn writes:
231
+ // DEDUP_CONTEXT: ← Task 132: optional; the last now.md entry
232
+ // <previous entry> as it stood BEFORE the current turn was
233
+ // appended (capture-turn snapshots it)
224
234
  // USER_TURN:
225
235
  // <user body>
226
236
  //
227
237
  // ASSISTANT_TURN:
228
238
  // <assistant body>
229
- // Either section may be empty. If no USER_TURN: / ASSISTANT_TURN:
239
+ // Any section may be empty. If no USER_TURN: / ASSISTANT_TURN:
230
240
  // markers are present, fall back to "the whole file is the assistant
231
241
  // turn" so old-format temp files (pre-2026-05-26) still work — useful
232
242
  // when running auto-extract against a turn buffer that pre-dates this
233
- // amendment (unlikely after the rollout, but defensive).
243
+ // amendment (unlikely after the rollout, but defensive). A missing
244
+ // DEDUP_CONTEXT marker means NO dedup section — never a now.md re-read
245
+ // (that re-read was the Task 132 self-poisoning bug).
234
246
  const USER_TURN_RE = /^[ \t]*USER_TURN:\s*\n([\s\S]*?)(?=^[ \t]*ASSISTANT_TURN:|\Z)/m;
235
247
  const ASSISTANT_TURN_RE = /^[ \t]*ASSISTANT_TURN:\s*\n([\s\S]*)$/m;
248
+ const DEDUP_CONTEXT_RE =
249
+ /^[ \t]*DEDUP_CONTEXT:\s*\n([\s\S]*?)(?=^[ \t]*USER_TURN:|^[ \t]*ASSISTANT_TURN:)/m;
236
250
 
237
251
  function parseTurnFile(rawTurn) {
252
+ const dedupMatch = rawTurn.match(DEDUP_CONTEXT_RE);
238
253
  const userMatch = rawTurn.match(USER_TURN_RE);
239
254
  const assistantMatch = rawTurn.match(ASSISTANT_TURN_RE);
240
255
  if (!userMatch && !assistantMatch) {
241
256
  // Old-format / unlabeled — treat whole content as assistant.
242
- return { userTurn: '', assistantTurn: rawTurn.trim() };
257
+ return { userTurn: '', assistantTurn: rawTurn.trim(), dedupContext: '' };
243
258
  }
244
259
  return {
245
260
  userTurn: (userMatch?.[1] ?? '').trim(),
246
261
  assistantTurn: (assistantMatch?.[1] ?? '').trim(),
262
+ dedupContext: (dedupMatch?.[1] ?? '').trim(),
247
263
  };
248
264
  }
249
265
 
@@ -441,10 +457,11 @@ function parseRichFactBlock(blockLines) {
441
457
  // Exported for direct unit-testing (cli-rich-fact.test.js) — the BEGIN_FACT
442
458
  // format is the extraction prompt's contract, pinned independently of a live
443
459
  // Haiku call.
444
- export function parseRichFacts(haikuOutput) {
460
+ export function parseRichFacts(haikuOutput, { onClipped } = {}) {
445
461
  if (!haikuOutput || typeof haikuOutput !== 'string') return [];
446
462
  const lines = haikuOutput.split('\n');
447
463
  const facts = [];
464
+ let clipped = 0;
448
465
  let i = 0;
449
466
  while (i < lines.length) {
450
467
  if (lines[i].trim().toUpperCase() !== 'BEGIN_FACT') {
@@ -455,19 +472,36 @@ export function parseRichFacts(haikuOutput) {
455
472
  // don't let it swallow the following block), or end-of-output.
456
473
  i++;
457
474
  const blockLines = [];
475
+ let terminated = false;
458
476
  while (i < lines.length) {
459
477
  const marker = lines[i].trim().toUpperCase();
460
478
  if (marker === 'END_FACT') {
479
+ terminated = true;
461
480
  i++;
462
481
  break;
463
482
  }
464
- if (marker === 'BEGIN_FACT') break; // close here; leave i for the outer loop
483
+ if (marker === 'BEGIN_FACT') {
484
+ // Implicit close by the next block — the body up to here is whole.
485
+ terminated = true;
486
+ break;
487
+ }
465
488
  blockLines.push(lines[i]);
466
489
  i++;
467
490
  }
491
+ // Task 136 (D-124): a block that ran into END-OF-OUTPUT without any
492
+ // terminator is the signature of the compressor's maxOutputBytes slice
493
+ // cutting Haiku's reply mid-fact. Writing it would persist a corrupted
494
+ // stub (cut-gate9's P-BaTM3L42: body "The `clau"). Drop it — losing the
495
+ // clipped fact beats storing a mangled one; the count reaches
496
+ // extract.log via onClipped for observability.
497
+ if (!terminated) {
498
+ clipped++;
499
+ continue;
500
+ }
468
501
  const fact = parseRichFactBlock(blockLines);
469
502
  if (fact) facts.push(fact);
470
503
  }
504
+ if (clipped > 0 && typeof onClipped === 'function') onClipped(clipped);
471
505
  return facts;
472
506
  }
473
507
 
@@ -710,6 +744,14 @@ export async function runAutoExtract({
710
744
  duration_ms: Date.now() - t0,
711
745
  };
712
746
  const logPath = writeExtractLogEntry({ projectRoot, ts, entry });
747
+ // Task 132: this early return bypasses the lock-held finally that
748
+ // normally unlinks the turn file — clean it up here or every
749
+ // concurrent rejection leaks one .extract-*.tmp (cut-gate8 finding).
750
+ try {
751
+ if (turnFile && existsSync(turnFile)) unlinkSync(turnFile);
752
+ } catch {
753
+ // best-effort; the sweepStaleTurnFiles janitor catches stragglers
754
+ }
713
755
  return {
714
756
  action: 'concurrent',
715
757
  error_category: ERROR_CATEGORIES.CONCURRENT_RUN,
@@ -764,10 +806,12 @@ export async function runAutoExtract({
764
806
  // override.
765
807
  const retainSegments = extractRetainSegments(rawTurn);
766
808
  const sanitized = stripNoiseTags(rawTurn);
767
- const { userTurn, assistantTurn } = parseTurnFile(sanitized);
809
+ const { userTurn, assistantTurn, dedupContext } = parseTurnFile(sanitized);
768
810
 
769
- // 3. Build prompt with dedup context (last `## ` entry from now.md).
770
- const dedupContext = readLastEntryFromNowMd(projectRoot);
811
+ // 3. Dedup context comes from the TURN FILE (Task 132) capture-turn
812
+ // snapshotted the last now.md entry BEFORE appending the current
813
+ // turn. Re-reading now.md here would see the current turn as
814
+ // "already captured" and suppress every extraction (D-122).
771
815
  const instructions = buildExtractionInstructions();
772
816
  const promptBody = buildExtractionPrompt({
773
817
  userTurn,
@@ -788,7 +832,12 @@ export async function runAutoExtract({
788
832
  haikuResult = await haikuBackend.compress({
789
833
  input: promptBody,
790
834
  instructions,
791
- maxOutputBytes: 2000,
835
+ // Task 136 (D-124): 8192, was 2000. A dense turn legitimately yields
836
+ // 3-4 rich facts (~700-900 bytes each) + terse/persona lines — the old
837
+ // cap clipped the 3rd fact mid-word and a corrupted stub reached disk
838
+ // (cut-gate9). The parser now also DROPS clipped trailing blocks; the
839
+ // raised budget makes the drop rare instead of routine.
840
+ maxOutputBytes: 8192,
792
841
  preserveCitationIds: false,
793
842
  // 90s, not 25s: the real `claude --print` extraction (full turn +
794
843
  // instructions) consistently exceeded a 25s ceiling on a live machine
@@ -854,7 +903,12 @@ export async function runAutoExtract({
854
903
  // SAME Haiku output may carry BEGIN_FACT blocks (durable project KNOWLEDGE)
855
904
  // alongside the terse TRUST_ lines; route them to the fact store via
856
905
  // writeFact (richer + searchable). No second LLM call — same outputText.
857
- const richFacts = parseRichFacts(haikuResult.outputText);
906
+ let clippedFactsDropped = 0;
907
+ const richFacts = parseRichFacts(haikuResult.outputText, {
908
+ onClipped: (n) => {
909
+ clippedFactsDropped = n;
910
+ },
911
+ });
858
912
  // XOR safety net: the prompt asks Haiku to emit a fact as EITHER a rich
859
913
  // block OR a terse line, never both. If it does both for the same fact, the
860
914
  // rich block wins — drop any terse candidate whose canonical id matches a
@@ -1069,6 +1123,9 @@ export async function runAutoExtract({
1069
1123
  ...baseEntry,
1070
1124
  ...personaLogFields,
1071
1125
  rich_facts_written: richFactsWritten,
1126
+ // Task 136: only present when the output cap clipped a trailing fact —
1127
+ // the signal to consider raising the budget further.
1128
+ ...(clippedFactsDropped > 0 ? { clipped_facts_dropped: clippedFactsDropped } : {}),
1072
1129
  success: true,
1073
1130
  observation_count,
1074
1131
  duration_ms: Date.now() - t0,
@@ -24,7 +24,7 @@
24
24
  // One heading per turn so downstream tools can scan by ## markers
25
25
  // (matches claude-remember's compaction strategy).
26
26
 
27
- import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import { sanitizePrivacyTags } from './privacy.mjs';
30
30
 
@@ -35,6 +35,38 @@ function dateFromIso(iso) {
35
35
  return String(iso).slice(0, 10);
36
36
  }
37
37
 
38
+ // Task 75.2 — the per-prompt "memory available" recall nudge (memsearch's
39
+ // UserPromptSubmit hint, D-115's 75.2 half). The SessionStart snapshot +
40
+ // its authority preamble cover the session OPEN; this keeps the agent
41
+ // aware MID-session (after the snapshot scrolls into history) that a deep,
42
+ // searchable archive exists behind the bounded snapshot. Conditions keep
43
+ // it noise-free: substantive prompts only (≥10 chars — "ok"/"go" never pay
44
+ // the hint; memsearch's heuristic) and only when there IS an archive to
45
+ // recall from (a granular INDEX.md). One line — the per-prompt token cost
46
+ // stays negligible, and it rides the EXISTING hook (no extra spawn).
47
+ const HINT_MIN_PROMPT_CHARS = 10;
48
+
49
+ export function buildMemoryHint({ projectRoot, prompt } = {}) {
50
+ if (typeof prompt !== 'string' || prompt.trim().length < HINT_MIN_PROMPT_CHARS) {
51
+ return null;
52
+ }
53
+ try {
54
+ const indexPath = join(projectRoot, 'context', 'memory', 'INDEX.md');
55
+ if (!existsSync(indexPath)) return null;
56
+ // `cmk install` scaffolds INDEX.md on every project, so existence alone
57
+ // is always true post-install (skill-review finding). Require at least
58
+ // one real entry — a fresh, empty project must not advertise recorded
59
+ // memory it does not have. Entry lines start "- (" (the reindex format).
60
+ if (!readFileSync(indexPath, 'utf8').includes('\n- (')) return null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ return (
65
+ '[claude-memory-kit] Recorded memory available beyond the session snapshot — ' +
66
+ 'use the memory-search skill when the answer may already be recorded (prior decisions, history, conventions).'
67
+ );
68
+ }
69
+
38
70
  export function capturePrompt({ payload, projectRoot, now } = {}) {
39
71
  if (!payload || typeof payload !== 'object') {
40
72
  return { action: 'noop', reason: 'no-payload' };
@@ -26,14 +26,22 @@
26
26
  // <projectRoot>/context/transcripts/.extract-<ts>.tmp
27
27
  // so the detached child can read it without sharing stdin.
28
28
  //
29
- // Both-turns temp-file shape (design §6.4 amendment, 2026-05-26):
30
- // The temp file now contains BOTH the prior user prompt AND the
31
- // just-captured assistant turn, separated by literal markers:
29
+ // Both-turns temp-file shape (design §6.4 amendment, 2026-05-26;
30
+ // DEDUP_CONTEXT added by Task 132 / D-122, 2026-06-11):
31
+ // The temp file contains the dedup snapshot, the prior user prompt,
32
+ // AND the just-captured assistant turn, separated by literal markers:
33
+ // DEDUP_CONTEXT:
34
+ // <the last now.md entry BEFORE this turn was appended — may be empty>
35
+ //
32
36
  // USER_TURN:
33
37
  // <user body>
34
38
  //
35
39
  // ASSISTANT_TURN:
36
40
  // <assistant body>
41
+ // The dedup snapshot MUST be taken before appendConversationToNowMd
42
+ // runs: auto-extract used to re-read now.md after the append and saw
43
+ // the current turn as "already captured" → suppressed every organic
44
+ // extraction (the D-122 self-poisoning bug, found by cut-gate8).
37
45
  // This lets auto-extract identify candidate-origin (user-stated vs
38
46
  // assistant-inferred) and apply the demotion rule from design §6.4
39
47
  // (assistant-origin facts demote one trust level so user review is
@@ -55,6 +63,8 @@ import {
55
63
  import { join } from 'node:path';
56
64
  import { spawn } from 'node:child_process';
57
65
  import { sanitizePrivacyTags } from './privacy.mjs';
66
+ import { extractTurnToolActivity, readTranscriptTail } from './turn-tools.mjs';
67
+ import { readLastEntryFromNowMd } from './auto-extract.mjs';
58
68
 
59
69
  function dateFromIso(iso) {
60
70
  return String(iso).slice(0, 10);
@@ -226,8 +236,11 @@ function appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn })
226
236
  // capture-prompt sanitized when writing it) and the assistant body
227
237
  // is the now-sanitized argument. Markers are literal-prefix lines so
228
238
  // auto-extract's parser can split cleanly.
229
- function assembleBothTurnsBody({ userTurn, assistantTurn }) {
239
+ function assembleBothTurnsBody({ userTurn, assistantTurn, dedupContext = '' }) {
230
240
  return [
241
+ 'DEDUP_CONTEXT:',
242
+ dedupContext,
243
+ '',
231
244
  'USER_TURN:',
232
245
  userTurn,
233
246
  '',
@@ -299,9 +312,34 @@ export function captureTurn({
299
312
  mkdirSync(transcriptsDir, { recursive: true });
300
313
  }
301
314
  const sanitized = sanitizePrivacyTags(turnText);
315
+
316
+ // Task 104.1 (D-117) — enrich the assistant entry with the turn's TOOL
317
+ // ACTIVITY, read from Anthropic's live session JSONL (the Stop payload's
318
+ // transcript_path). The payload itself carries only the assistant TEXT;
319
+ // the JSONL is the only record of tool calls/results — and it expires
320
+ // (~30 days, machine-local), so this is the moment to extract the current
321
+ // turn into the kit's own durable format (the L3 raw tier, design §19).
322
+ // Best-effort by contract: a missing path, unreadable file, or shifted
323
+ // format degrades to a text-only entry, never a capture failure. The
324
+ // block is privacy-sanitized like everything else that reaches disk.
325
+ // The now.md buffer + the auto-extract turn file deliberately stay
326
+ // TEXT-ONLY (tool noise would bloat the compressor/extractor inputs).
327
+ let toolsSection = '';
328
+ try {
329
+ if (typeof payload?.transcript_path === 'string' && payload.transcript_path !== '') {
330
+ const tail = readTranscriptTail(payload.transcript_path);
331
+ const activity = tail ? extractTurnToolActivity(tail) : null;
332
+ if (activity) {
333
+ toolsSection = `\n**Tools:**\n\n${sanitizePrivacyTags(activity)}\n`;
334
+ }
335
+ }
336
+ } catch {
337
+ // enrichment is best-effort; the text entry below is the durable record
338
+ }
339
+
302
340
  appendFileSync(
303
341
  transcriptPath,
304
- `## ${ts} — assistant\n\n${sanitized}\n\n`,
342
+ `## ${ts} — assistant\n\n${sanitized}\n${toolsSection}\n`,
305
343
  'utf8',
306
344
  );
307
345
 
@@ -315,6 +353,26 @@ export function captureTurn({
315
353
  // `sanitized` text we just appended above.
316
354
  const userTurn = readLastUserTurnFromTranscript(transcriptPath);
317
355
 
356
+ // Task 132 (D-122): snapshot the dedup context BEFORE the now.md append
357
+ // below — after the append, "the last now.md entry" IS the current turn,
358
+ // and feeding that to auto-extract as "do not re-emit facts already
359
+ // here" suppressed every organic extraction. Best-effort: an unreadable
360
+ // now.md just means no dedup section this turn.
361
+ let dedupContext = '';
362
+ try {
363
+ // Skill-review I1: neutralize line-start section markers INSIDE the
364
+ // snapshot — conversation text can legitimately contain "USER_TURN:"
365
+ // (this repo's own sessions discuss the turn-file format), and the
366
+ // parser anchors on the first line-start marker it sees. The dedup is
367
+ // advisory context for Haiku; a cosmetic "· " prefix is harmless.
368
+ dedupContext = readLastEntryFromNowMd(projectRoot).replace(
369
+ /^([ ]*)(DEDUP_CONTEXT:|USER_TURN:|ASSISTANT_TURN:)/gm,
370
+ '$1· $2',
371
+ );
372
+ } catch {
373
+ // no dedup context — extraction still runs, worst case re-emits a dup
374
+ }
375
+
318
376
  // Task 87: buffer the conversation into now.md so the SessionEnd compressor
319
377
  // summarizes the DIALOGUE, not observe-edit's filename log. Best-effort.
320
378
  appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn: sanitized });
@@ -327,7 +385,7 @@ export function captureTurn({
327
385
  try {
328
386
  writeFileSync(
329
387
  turnFile,
330
- assembleBothTurnsBody({ userTurn, assistantTurn: sanitized }),
388
+ assembleBothTurnsBody({ userTurn, assistantTurn: sanitized, dedupContext }),
331
389
  'utf8',
332
390
  );
333
391
  } catch (err) {
@@ -49,6 +49,8 @@ import {
49
49
  } from 'node:fs';
50
50
  import { join } from 'node:path';
51
51
  import { resolveTierRoot, VALID_TIERS } from './tier-paths.mjs';
52
+ import { writeBullet } from './provenance.mjs';
53
+ import { createHash } from 'node:crypto';
52
54
  import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
53
55
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
54
56
  import { generateId } from '@lh8ppl/cmk-canonicalize';
@@ -786,9 +788,24 @@ export function mergeScratchpadBullets({
786
788
  const effectiveSection = section ?? discoverSectionAt(lines, matchA.bulletIdx);
787
789
  const range = effectiveSection ? findSectionRange(updatedLines, effectiveSection) : null;
788
790
  const insertAt = range ? range.endIdx : updatedLines.length;
789
- const newBullet = `- (${newId}) ${combinedText}`;
790
- const newProvenance = `<!-- source: merge-both, merged_from: [${idA}, ${idB}], merged_at: ${ts}, trust: ${mergedTrust} -->`;
791
- updatedLines.splice(insertAt, 0, newBullet, newProvenance, '');
791
+ // D-125 class (Task 138 review finding): the old hand-rolled comment had
792
+ // no `write:` key, so the first reindex after a merge-both resolution hit
793
+ // the NOT-NULL observations.write_source constraint. Canonical shape via
794
+ // the shared builder; the merged_from trail lives in the audit entry below.
795
+ const sha1 = createHash('sha1').update(combinedText, 'utf8').digest('hex');
796
+ const formatted = writeBullet({
797
+ id: newId,
798
+ text: combinedText,
799
+ provenance: {
800
+ source: 'merge-both',
801
+ source_line: 1,
802
+ sha1,
803
+ write: 'merged',
804
+ trust: mergedTrust,
805
+ at: ts,
806
+ },
807
+ });
808
+ updatedLines.splice(insertAt, 0, ...formatted.lines.split('\n'), '');
792
809
 
793
810
  writeFileSync(scratchpadPath, updatedLines.join('\n'), 'utf8');
794
811