@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 +13 -10
- package/bin/cmk-capture-prompt.mjs +21 -1
- package/package.json +2 -1
- package/src/auto-extract.mjs +68 -11
- package/src/capture-prompt.mjs +33 -1
- package/src/capture-turn.mjs +64 -6
- package/src/conflict-queue.mjs +20 -3
- package/src/doctor.mjs +52 -125
- package/src/forget.mjs +13 -0
- package/src/frontmatter.mjs +4 -1
- package/src/import-anthropic-memory.mjs +25 -1
- package/src/index-db.mjs +39 -0
- package/src/index-rebuild.mjs +42 -2
- package/src/inject-context.mjs +49 -6
- package/src/install.mjs +107 -1
- package/src/mcp-server.mjs +57 -7
- package/src/merge-facts.mjs +12 -0
- package/src/provenance.mjs +4 -0
- package/src/result-shapes.mjs +2 -2
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +100 -12
- package/src/semantic-backend.mjs +485 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/spawn-bin.mjs +7 -2
- package/src/subcommands.mjs +95 -18
- package/src/transcript-index.mjs +162 -0
- package/src/turn-tools.mjs +179 -0
- package/template/.claude/skills/memory-search/SKILL.md +86 -0
- package/template/CLAUDE.md.template +2 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +0 -17
- package/template/support/milvus-deploy/README.md +0 -57
- package/template/support/milvus-deploy/docker-compose.yml +0 -66
- package/template/support/scripts/memsearch-index-with-flush.sh +0 -59
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
|
|
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
|
-
- **
|
|
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
|
|
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`
|
|
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-
|
|
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`
|
|
51
|
-
| `cmk doctor` | Run HC-1..HC-
|
|
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
|
|
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:
|
|
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.
|
|
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": {
|
package/src/auto-extract.mjs
CHANGED
|
@@ -196,7 +196,14 @@ function extractRetainSegments(text) {
|
|
|
196
196
|
|
|
197
197
|
// --- Dedup context --------------------------------------------------
|
|
198
198
|
|
|
199
|
-
|
|
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
|
-
//
|
|
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')
|
|
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.
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/capture-prompt.mjs
CHANGED
|
@@ -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' };
|
package/src/capture-turn.mjs
CHANGED
|
@@ -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
|
-
//
|
|
31
|
-
//
|
|
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) {
|
package/src/conflict-queue.mjs
CHANGED
|
@@ -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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|