claude-mem-lite 6.11.0 → 6.12.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.11.0",
12
+ "version": "6.12.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.11.0",
3
+ "version": "6.12.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -405,6 +405,7 @@ surface — reach them through the CLI column in the second table.
405
405
  /mem <query> # Shorthand for search
406
406
  /lesson <text> # Save a non-obvious lesson to the events table (v2.31.0)
407
407
  /bug <text> # Log a known bug + repro steps to the events table (v2.31.0)
408
+ /verify # Check memories against the current code; correct stale ones after you approve
408
409
  ```
409
410
 
410
411
  ### Efficient Search Workflow
package/README.zh-CN.md CHANGED
@@ -341,6 +341,7 @@ README 和 `docs/ARCHITECTURE.md` 都钉在它上面。)
341
341
  /mem <query> # search 的简写
342
342
  /lesson <text> # 保存非显而易见的经验到 events 表(v2.31.0)
343
343
  /bug <text> # 记录已知 bug + 复现步骤到 events 表(v2.31.0)
344
+ /verify # 对照当前代码核查记忆;你确认后才改正过期的记忆
344
345
  ```
345
346
 
346
347
  ### 高效搜索工作流
package/cli/common.mjs CHANGED
@@ -399,6 +399,13 @@ export const KNOWN_CLI_FLAGS = new Set([
399
399
  // warn-on-every-unknown-flag flip turned the omission into a false warning on a
400
400
  // documented, working command.
401
401
  'prompts-limit',
402
+ // `verify-apply --apply --digest <d>` / `--undo <backup>` — read as flags.apply / .digest / .undo in
403
+ // cli/verify-apply.mjs. Missing here, a working apply printed "Unknown flag --apply — ignored,
404
+ // it had no effect" beside its own read-back. Pinned by tests/verify-apply-cli.test.mjs.
405
+ 'apply',
406
+ 'digest',
407
+ 'print-project',
408
+ 'undo',
402
409
  // Entries here MUST be read by a `claude-mem-lite` subcommand. A flag that no
403
410
  // command reads is worse than an absent one: it converts the "ignored, it had no
404
411
  // effect" warning into silence, so the user's dropped flag reads as accepted.
@@ -0,0 +1,197 @@
1
+ // cli/verify-apply.mjs — `claude-mem-lite verify-apply <proposals.json> [--project P]`
2
+ // (dry run), `... --apply --digest <d>`, and `claude-mem-lite verify-apply --undo <backup.json>`.
3
+ //
4
+ // The write step of /verify (commands/verify.md). The agent proposes; this command is the only
5
+ // thing that writes. It defaults to a dry run that prints the new text itself and a digest, and
6
+ // --apply refuses unless it is handed that digest back: what the user approved is what lands,
7
+ // and a proposals file or a row that changed in between is refused rather than applied. All
8
+ // policy lives in lib/verify-apply-core.mjs; this file is I/O, formatting and exit codes.
9
+
10
+ import { readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { DB_DIR } from '../lib/data-paths.mjs';
13
+ import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
14
+ import { inferProject } from '../utils.mjs';
15
+ import { resolveProject } from '../project-utils.mjs';
16
+ import {
17
+ parseProposals,
18
+ planVerifyApply,
19
+ planDigest,
20
+ priorVerifyApplies,
21
+ runVerifyApply,
22
+ undoVerifyBackup,
23
+ markUndone,
24
+ } from '../lib/verify-apply-core.mjs';
25
+ import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
26
+
27
+ const USAGE =
28
+ '[mem] Usage: claude-mem-lite verify-apply <proposals.json> [--project P] (dry run)\n' +
29
+ ' claude-mem-lite verify-apply <proposals.json> [--project P] --apply --digest <d>\n' +
30
+ ' claude-mem-lite verify-apply --undo <backup.json>';
31
+
32
+ const SNIPPET_CONTEXT = 60;
33
+ const BACKUP_DIR = join(DB_DIR, 'backups');
34
+ // The commands this prints must run as printed. `claude-mem-lite` is on PATH only after an
35
+ // optional global npm install (which may also be a different, stale code home), so name the
36
+ // node binary and THIS cli.mjs — the one that produced the plan.
37
+ /** A path as one shell word: as-is when it is plain, single-quoted otherwise. */
38
+ const shellWord = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
39
+ const SELF = process.argv[1] ? `node ${shellWord(process.argv[1])}` : 'claude-mem-lite';
40
+
41
+ function readJson(path, what) {
42
+ try {
43
+ return { value: JSON.parse(readFileSync(path, 'utf8')) };
44
+ } catch (e) {
45
+ return { error: `[mem] Cannot read ${what} ${path}: ${e.message}` };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The changed region of a field, with context. The changed part is printed IN FULL, never
51
+ * clipped: the digest covers the whole text, so any cap here would let the user approve text
52
+ * they were never shown (re-review of dcc8f72, P2-2). Only unchanged context is elided.
53
+ */
54
+ function snippet(before, after) {
55
+ const a = before === null || before === undefined ? '' : String(before);
56
+ const b = String(after);
57
+ if (a === b) return [' (unchanged)'];
58
+ let start = 0;
59
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
60
+ let endA = a.length;
61
+ let endB = b.length;
62
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
63
+ endA--;
64
+ endB--;
65
+ }
66
+ const from = Math.max(0, start - SNIPPET_CONTEXT);
67
+ const lead = from > 0 ? '…' : '';
68
+ const tail = (s, end) => (end + SNIPPET_CONTEXT < s.length ? '…' : '');
69
+ return [
70
+ ` - ${lead}${a.slice(from, Math.min(a.length, endA + SNIPPET_CONTEXT))}${tail(a, endA)}`,
71
+ ` + ${lead}${b.slice(from, Math.min(b.length, endB + SNIPPET_CONTEXT))}${tail(b, endB)}`,
72
+ ];
73
+ }
74
+
75
+ function describe(p) {
76
+ const head = ` #${p.id} [${p.verdict}] ${p.action} — evidence: ${p.evidence}`;
77
+ if (p.action === 'retire') return [head, ' retired with no replacement (kept as history)'];
78
+ const fields =
79
+ p.action === 'edit'
80
+ ? Object.entries(p.set)
81
+ : ['title', 'narrative', 'lesson_learned', 'importance', 'facts', 'concepts']
82
+ .filter((k) => p[k] !== undefined)
83
+ .map((k) => [k, p[k]]);
84
+ const lines = [head];
85
+ if (p.action === 'replace') lines.push(' new memory supersedes this one; unlisted fields are copied');
86
+ for (const [k, v] of fields) lines.push(` ${k}:`, ...snippet(p.before[k], v));
87
+ return lines;
88
+ }
89
+
90
+ function undo(db, path) {
91
+ const { value, error } = readJson(path, 'backup');
92
+ if (error) return fail(error);
93
+ const { errors, restored } = undoVerifyBackup(db, value);
94
+ if (restored.length === 0 && errors.length)
95
+ return fail(`[mem] Undo refused, nothing written:\n ${errors.join('\n ')}`);
96
+ for (const r of restored) {
97
+ out(
98
+ ` #${r.id} restored${r.replacementRetired ? ` (replacement #${r.replacementRetired} retired)` : ''}`,
99
+ );
100
+ }
101
+ if (errors.length) return fail(`[mem] Undo read-back found problems:\n ${errors.join('\n ')}`);
102
+ out(`[mem] Undo complete: ${restored.length} row(s) restored.`);
103
+ // The restore is committed; marking the file only stops a second run early. If the mark
104
+ // cannot be written, a second undo is still refused (the rows no longer match the apply's
105
+ // record), so this is a warning, not a failure of the undo that already happened.
106
+ try {
107
+ atomicWriteFileSync(path, JSON.stringify(markUndone(value), null, 1));
108
+ } catch (e) {
109
+ process.stderr.write(
110
+ `[mem] Warning: undo is done, but ${path} could not be marked as undone: ${e.message}\n`,
111
+ );
112
+ }
113
+ }
114
+
115
+ export function cmdVerifyApply(db, args) {
116
+ const { positional, flags } = parseArgs(args);
117
+ if (rejectBareStringFlags(flags, ['project', 'undo', 'digest'])) return;
118
+ // The project verify-apply targets when --project is omitted, printed so /verify can export
119
+ // exactly that project (export's --project matching is fuzzy; this is not).
120
+ if (flags['print-project'] === true) return out(inferProject());
121
+ // Boolean means boolean: `--apply=false` / `--apply no` must not apply.
122
+ if (flags.apply !== undefined && flags.apply !== true)
123
+ return fail(`[mem] --apply takes no value.\n${USAGE}`);
124
+
125
+ if (flags.undo !== undefined) {
126
+ if (positional.length || flags.apply || flags.digest) return fail(USAGE);
127
+ return undo(db, flags.undo);
128
+ }
129
+
130
+ const file = positional[0];
131
+ if (!file || positional.length > 1) return fail(USAGE);
132
+ const { value, error } = readJson(file, 'proposals');
133
+ if (error) return fail(error);
134
+
135
+ const parsed = parseProposals(value);
136
+ if (parsed.errors.length)
137
+ return fail(`[mem] Invalid proposals, nothing written:\n ${parsed.errors.join('\n ')}`);
138
+
139
+ const project = flags.project ? resolveProject(db, flags.project, { mode: 'write' }) : inferProject();
140
+ const { plan, errors } = planVerifyApply(db, parsed.entries, { project });
141
+ if (errors.length) return fail(`[mem] Refused, nothing written:\n ${errors.join('\n ')}`);
142
+ const digest = planDigest(
143
+ plan,
144
+ project,
145
+ priorVerifyApplies(
146
+ BACKUP_DIR,
147
+ plan.map((p) => p.id),
148
+ ),
149
+ );
150
+
151
+ if (!flags.apply) {
152
+ out(`[mem] verify-apply plan — project ${project}, ${plan.length} change(s):`);
153
+ for (const p of plan) for (const line of describe(p)) out(line);
154
+ out(`[mem] Plan digest: ${digest}`);
155
+ out('[mem] Dry run — nothing written. After the user approves exactly this plan, run:');
156
+ out(
157
+ ` ${SELF} verify-apply ${shellWord(file)} --project ${shellWord(project)} --apply --digest ${digest}`,
158
+ );
159
+ return;
160
+ }
161
+
162
+ if (!flags.digest) {
163
+ return fail('[mem] --apply requires --digest <d> from the dry run the user approved. Nothing written.');
164
+ }
165
+ if (flags.digest !== digest) {
166
+ return fail(
167
+ '[mem] Plan digest mismatch: the proposals file or the memories changed since the dry run. ' +
168
+ 'Nothing written — re-run the dry run and get the new plan approved.',
169
+ );
170
+ }
171
+
172
+ let run;
173
+ try {
174
+ run = runVerifyApply(db, plan, { backupDir: BACKUP_DIR });
175
+ } catch (e) {
176
+ // One failure happens AFTER the commit: the undo record could not be written. The changes
177
+ // are in the database then, and saying "nothing written" would be false.
178
+ if (e.message.startsWith('applied, but')) {
179
+ return fail(
180
+ `[mem] APPLIED — the changes are in the database, but ${e.message.slice('applied, but '.length)}`,
181
+ );
182
+ }
183
+ return fail(`[mem] ${e.message}`);
184
+ }
185
+ for (const c of run.checks) {
186
+ out(
187
+ ` #${c.id} ${c.action}${c.newId ? ` → #${c.newId}` : ''}: ${c.ok ? 'ok' : `MISMATCH (${c.problems.join('; ')})`}`,
188
+ );
189
+ }
190
+ out(`[mem] Backup: ${run.backupPath}`);
191
+ out(
192
+ `[mem] To undo (only while these rows are untouched): ${SELF} verify-apply --undo ${shellWord(run.backupPath)}`,
193
+ );
194
+ if (run.checks.some((c) => !c.ok)) {
195
+ fail('[mem] Applied, but read-back found mismatches — show the MISMATCH lines above to the user.');
196
+ }
197
+ }
package/cli.mjs CHANGED
@@ -14,6 +14,7 @@ const CLI_COMMANDS = new Set([
14
14
  'update',
15
15
  'export',
16
16
  'restore',
17
+ 'verify-apply',
17
18
  'compress',
18
19
  'maintain',
19
20
  'optimize',
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: verify
3
+ description: "Use when: the user explicitly asks to verify, audit or correct their stored memories against the current code (e.g. \"check my memories for stale ones\", /verify). You check each memory with read-only repo tools and propose corrections; nothing is written until the user approves the exact plan. Not for routine recall or saving."
4
+ ---
5
+
6
+ # /verify — check memories against the code, then correct the stale ones
7
+
8
+ Memories go stale: a bug recorded as open gets fixed, a measurement gets retracted, a
9
+ mechanism is replaced. Measured on 118 live memories across 7 repos, ~10% were STALE and
10
+ ~14% PARTIAL, and most went stale within a day of being saved. No automatic pass catches
11
+ this — cheap single-shot models misjudged it (precision 0.36) and model-written corrections
12
+ were false in 28 of 72 cases. What works is YOU reading the code: you propose, the user
13
+ approves the exact plan, and `verify-apply` is the only thing that writes.
14
+
15
+ (If another plugin also defines `/verify`, this one is `/claude-mem-lite:verify`.)
16
+
17
+ ## Arguments
18
+
19
+ - `--from <YYYY-MM-DD>`: only memories saved on or after this date (default: all live).
20
+ - `--ids 12,34`: only these memories.
21
+ - `--project <name>`: a project other than the current one.
22
+
23
+ ## Step 1 — Select
24
+
25
+ Get the exact project name first. For the current project (the usual case), run from the
26
+ project's directory:
27
+
28
+ ```bash
29
+ node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply --print-project
30
+ ```
31
+
32
+ It prints the canonical name (e.g. `dev--my-app`) that verify-apply itself will target. For
33
+ another project, use the name the user gave only if it already has the `parent--name` shape;
34
+ otherwise ask. Always pass that exact name — `export --project` matches loosely and can pick a
35
+ neighbouring project from a short name.
36
+
37
+ ```bash
38
+ node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" export --project <project> [--from <date>] > <scratch>/memories.json
39
+ ```
40
+
41
+ Every row carries `id`, `project`, `type`, `title`, `narrative`, `facts`, `lesson_learned`,
42
+ `files_modified`, `created_at`. Check that every row's `project` is the name you passed. With
43
+ `--ids`, filter the file to those ids. The repository to check them against is the current
44
+ directory for the current project; for another project, ask the user where it lives.
45
+ `files_modified` holds both absolute and repo-relative paths. Tell the user how many memories
46
+ you are about to check.
47
+
48
+ ## Step 2 — Verify (read-only)
49
+
50
+ For EACH memory, answer: *if this memory were shown to an agent working in this repo today,
51
+ would anything it asserts mislead that agent?*
52
+
53
+ 1. List its concrete, checkable claims: identifiers, file paths, `file:line` references,
54
+ constants, counts, behaviours, "X is still broken", "Y was measured at Z".
55
+ 2. Check each against the current tree (Grep, Read) and, where useful, the history since
56
+ `created_at` (`git log --since=<created_at> -- <files>`, `git log -S<token>`, `git show`).
57
+ 3. Pick one verdict:
58
+ - **VALID** — every checkable claim still holds.
59
+ - **STALE** — at least one specific claim is contradicted by the tree or history: a
60
+ renamed/removed symbol, a changed value or default, a changed behaviour, a bug presented
61
+ as open that has since been fixed, a count that moved, a measurement later retracted.
62
+ - **PARTIAL** — the main point holds; a secondary detail (a line number, a count, a minor
63
+ mechanism) is out of date.
64
+ - **UNVERIFIABLE** / **NO_CODE_CLAIM** — leave these alone.
65
+
66
+ Rules that the measurement showed matter:
67
+ - A file having changed is NOT a contradiction. You need a line, a diff hunk, a commit, or a
68
+ command's output that contradicts a specific claim.
69
+ - Output you did not see is not a contradiction. A grep that printed nothing, or output cut
70
+ off by `head` or a size limit, proves nothing — re-run it untruncated before calling a claim
71
+ stale.
72
+ - A number only a test run could confirm (a test count, a coverage figure) is a dated
73
+ measurement: stale only if a later commit or document reports a different value, not
74
+ because you cannot re-run it here.
75
+ - A record of what happened (what was measured then, what a review found) stays true as
76
+ history. It is stale only if it would mislead about the PRESENT.
77
+ - A note that already records its own fix is not stale because the fix commit landed later.
78
+ - Do not write, stash, check out or run tests while verifying.
79
+
80
+ More than ~25 memories: split them into batches and give each batch to a read-only subagent
81
+ with the rubric above; have each subagent write its results to a file with a bash heredoc and
82
+ reply with only the path. Treat a subagent's verdict as a lead: before proposing anything,
83
+ re-open every cited `file:line`, commit or command yourself.
84
+
85
+ ## Step 3 — Draft proposals (STALE and PARTIAL only)
86
+
87
+ If every memory is VALID (or UNVERIFIABLE / NO_CODE_CLAIM), stop here: report the counts to
88
+ the user and say nothing needs changing. Do not run Step 4 with an empty proposals file.
89
+
90
+ Write `<scratch>/proposals.json` — a JSON array, one entry per memory to change:
91
+
92
+ ```json
93
+ [
94
+ { "id": 52, "action": "replace", "verdict": "STALE",
95
+ "title": "...", "narrative": "...", "lesson_learned": "...",
96
+ "evidence": "7bc8ba9; hook-optimize.mjs:1371-1383" },
97
+ { "id": 64, "action": "edit", "verdict": "PARTIAL",
98
+ "set": { "narrative": "..." }, "evidence": "hook-llm.mjs:1359-1361" },
99
+ { "id": 201, "action": "retire", "verdict": "STALE", "evidence": "600c744" }
100
+ ]
101
+ ```
102
+
103
+ - **edit** — the stale part is one detail in `title`, `narrative`, `lesson_learned`,
104
+ `importance` or `concepts`. Copy the original text into `set` and change only the stale words.
105
+ - **replace** — the memory's claim is wrong but its lesson is still worth keeping, OR the
106
+ stale detail sits in `facts` (edit cannot change `facts`). Write the corrected memory; give
107
+ `facts` (an empty string drops them) or `concepts` when those are what is stale. Omitted
108
+ fields are copied from the original, which stays as history.
109
+ - **retire** — nothing in it is worth keeping (e.g. a mid-debug note about a failure fixed
110
+ minutes later).
111
+ - `evidence` is required: the commit, the `file:line`, or the command and its output (for
112
+ something outside the repo, e.g. a tool version) that shows the memory is out of date.
113
+ - `lesson_learned` is at most 500 characters. Keep the memory's language.
114
+
115
+ ## Step 4 — Show the plan and get approval
116
+
117
+ ```bash
118
+ node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project <project>
119
+ ```
120
+
121
+ This is a dry run: it validates every entry against the database, writes nothing, prints each
122
+ change as `-` old / `+` new text (the changed text in full), and ends with a **plan digest**
123
+ and the exact apply command.
124
+ Show the user that output as it is (do not summarise the changes away), plus your counts of
125
+ VALID / STALE / PARTIAL, and ask for approval. If the dry run refuses an entry, fix the
126
+ proposal — never work around it with `update`, `save` or `delete`. If you change the proposals
127
+ after the user saw them, run the dry run again and show the new plan: the digest changes, and
128
+ the old one will be refused.
129
+
130
+ ## Step 5 — Apply, only after the user approves that plan
131
+
132
+ Run exactly the command the dry run printed:
133
+
134
+ ```bash
135
+ node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project <project> --apply --digest <digest>
136
+ ```
137
+
138
+ It refuses if the proposals or the memories changed since the dry run. Otherwise it backs up
139
+ every target row, applies all entries in one transaction (all or nothing), reads each row back
140
+ and prints `ok` or `MISMATCH`, then prints the backup path and the undo command. Report the
141
+ result and the undo command to the user. Reading the exit status:
142
+ - exit 0 — applied, every row read back `ok`.
143
+ - exit 1 with `MISMATCH` lines — applied, but a row did not read back as planned. Show those
144
+ lines; do not re-run the apply.
145
+ - exit 1 with a message starting `APPLIED` — the changes are in the database, but no undo
146
+ record could be written. Say exactly that.
147
+ - any other exit 1 — nothing was written. Say why.
148
+
149
+ Undo (`verify-apply --undo <backup>`) only works while the changed rows' content and state are
150
+ as the apply left them (usage counters such as access counts do not count): it refuses once any
151
+ of them changes again — an edit, a supersede, or a routine background pass (importance decay,
152
+ alias or concept backfill) — and it runs at most once. It then prints `Undo complete`; a `Warning: … could not be marked as undone` line means
153
+ the undo still happened. After an undo, the earlier apply command is refused: applying the
154
+ same changes again needs a new dry run and the user's approval of its new digest. That refusal
155
+ rests on the backup file: once it is deleted, the earlier command would match again, so never
156
+ re-run an apply command from an earlier approval.
package/hook-optimize.mjs CHANGED
@@ -101,7 +101,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
101
101
  // injects lesson-bearing rows — classifying those first is what makes the
102
102
  // lever usable before the backlog is fully drained.
103
103
  const stmt = db.prepare(`
104
- SELECT id, title, narrative, type, lesson_learned, importance, project
104
+ SELECT id, title, narrative, type, lesson_learned, importance, project, text, optimized_at
105
105
  FROM observations
106
106
  WHERE ${liveObsFilterSql('')}
107
107
  AND scope IS NULL
@@ -124,7 +124,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
124
124
  // deliberately NOT gated on optimized_at, so a lesson-less row can still be
125
125
  // picked up by wide scope for lesson enrichment afterward.
126
126
  const stmt = db.prepare(`
127
- SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project
127
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project, optimized_at
128
128
  FROM observations
129
129
  WHERE ${liveObsFilterSql('')}
130
130
  AND (search_aliases IS NULL OR search_aliases = '')
@@ -157,7 +157,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
157
157
  // strand exactly those rows — the R10 P2-2 shape, where one pass's bookkeeping
158
158
  // evicts a row from a backfill it never visited.
159
159
  const stmt = db.prepare(`
160
- SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project
160
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project, optimized_at
161
161
  FROM observations
162
162
  WHERE ${liveObsFilterSql('')}
163
163
  AND (concepts IS NULL OR concepts = '')
@@ -302,10 +302,16 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
302
302
  }
303
303
  // `AND scope IS NULL` is the fill-only-empty guard: a save-enrich worker or
304
304
  // an episode upgrade can land between candidate selection and this write,
305
- // and a classifier round-trip is long enough for that to be real.
305
+ // and a classifier round-trip is long enough for that to be real. The live guard
306
+ // and `text IS ? AND optimized_at IS ?` are the aliases branch's, for its reasons:
307
+ // the classification was made from the text read before the call, so it may land
308
+ // only on a live row still holding that text (a /verify approval changes both).
306
309
  const res = db
307
- .prepare('UPDATE observations SET scope = ? WHERE id = ? AND scope IS NULL')
308
- .run(scopeValue, cand.id);
310
+ .prepare(
311
+ `UPDATE observations SET scope = ?
312
+ WHERE id = ? AND scope IS NULL AND ${liveObsFilterSql('')} AND text IS ? AND optimized_at IS ?`,
313
+ )
314
+ .run(scopeValue, cand.id, cand.text, cand.optimized_at);
309
315
  if (res.changes === 0) {
310
316
  skipped++;
311
317
  continue;
@@ -354,12 +360,25 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
354
360
  // D#6. This was the one branch of the four without it. `changes === 0` is a SKIP,
355
361
  // not a success: it must not count as processed. (It also used to guard a vector
356
362
  // rebuild on a dead row; that rebuild is gone, the liveness reason is not.)
363
+ // `text IS ? AND optimized_at IS ?`: the new text is the text read BEFORE the call plus
364
+ // the aliases, so it may only land on a row still holding that text. A /verify approval
365
+ // during the call rebuilds text and stamps optimized_at; without the compare the stale
366
+ // text came back and the corrected row matched searches for the claim it had just
367
+ // dropped (pre-ship review of 9786874, P2-2). A skip here is retried next cycle,
368
+ // against the row as it is then.
357
369
  const res = db
358
370
  .prepare(
359
371
  `UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope)
360
- WHERE id = ? AND ${liveObsFilterSql('')}`,
372
+ WHERE id = ? AND ${liveObsFilterSql('')} AND text IS ? AND optimized_at IS ?`,
361
373
  )
362
- .run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
374
+ .run(
375
+ safe.search_aliases,
376
+ safe.text,
377
+ normalizeScope(parsed.scope),
378
+ cand.id,
379
+ cand.text,
380
+ cand.optimized_at,
381
+ );
363
382
  if (res.changes === 0) {
364
383
  skipped++;
365
384
  continue;
@@ -395,7 +414,12 @@ facts: 1-4 specific, checkable statements the narrative actually asserts. Omit r
395
414
  skipped++;
396
415
  continue;
397
416
  }
398
- const factArr = pickStrings(parsed && parsed.facts);
417
+ // Model facts only on a row no pass has stamped. `facts` is displayed as the memory's
418
+ // own claims, and a stamped row is either one the general re-enrich pass already wrote
419
+ // facts for, or one a user approved through /verify — where this pass replaced the
420
+ // approved facts with model text (pre-ship review of 9786874, P1-1). Concepts are
421
+ // search keywords and still fill.
422
+ const factArr = cand.optimized_at === null ? pickStrings(parsed && parsed.facts) : [];
399
423
  const conceptsOnly = conceptArr.slice(0, 10).join(' ');
400
424
  const factsOnly = factArr.slice(0, 10).join(' ');
401
425
  const appendedText = [
@@ -416,12 +440,15 @@ facts: 1-4 specific, checkable statements the narrative actually asserts. Omit r
416
440
  // concurrent hook to supersede or compress the row (R10 P3-3) or for save-enrich
417
441
  // to fill it. `facts` rides along with preserve-on-empty for the same reason the
418
442
  // general pass preserves it — a partial answer must not wipe a filled column.
443
+ // `text IS ? AND optimized_at IS ?` for the reason the alias branch gives, and one
444
+ // more: it is what makes the facts decision above still true at write time.
419
445
  const res = db
420
446
  .prepare(
421
447
  `UPDATE observations SET concepts = ?, facts = COALESCE(NULLIF(?, ''), facts), text = ?
422
- WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}`,
448
+ WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}
449
+ AND text IS ? AND optimized_at IS ?`,
423
450
  )
424
- .run(safe.concepts, safe.facts, safe.text, cand.id);
451
+ .run(safe.concepts, safe.facts, safe.text, cand.id, cand.text, cand.optimized_at);
425
452
  if (res.changes === 0) {
426
453
  skipped++;
427
454
  continue;
@@ -468,7 +495,7 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
468
495
  const res = db
469
496
  .prepare(
470
497
  `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
471
- WHERE id = ? AND ${liveObsFilterSql('')}`,
498
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
472
499
  )
473
500
  .run(Date.now(), cand.id);
474
501
  if (res.changes === 0) {
@@ -552,13 +579,19 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
552
579
  // scopes branch already guards with `AND scope IS NULL`; this is the same idea.
553
580
  // 0 changes is a skip, not a success: it must not count as processed and must not
554
581
  // rebuild a vector for a row that is no longer live.
582
+ // `optimized_at IS NULL` is the same idea for the pool's OTHER predicate: narrow and wide
583
+ // select only unstamped rows, and a /verify approval stamps the row it approves. Without
584
+ // the re-check an edit approved during the call was overwritten — narrow wrote model
585
+ // text over it, wide wrote back the pre-edit narrative it read before the call
586
+ // (tests/verify-reenrich-race.test.mjs). It also stops two overlapping runs rewriting a
587
+ // row twice.
555
588
  const res = db
556
589
  .prepare(
557
590
  `
558
591
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
559
592
  text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
560
593
  scope=COALESCE(?, scope)
561
- WHERE id = ? AND ${liveObsFilterSql('')}
594
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL
562
595
  `,
563
596
  )
564
597
  .run(
@@ -1248,6 +1281,20 @@ Return ONLY valid JSON:
1248
1281
  .prepare(`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`)
1249
1282
  .get(keeper.id);
1250
1283
  if (!keeperLive) return false;
1284
+ // findMergeCandidates selects only live rows with optimized_at NULL. A member stamped
1285
+ // since then was edited through /verify (or rewritten by another pass) during the model
1286
+ // call; a member no longer live was retired or replaced (a /verify retire or replace
1287
+ // supersedes the original without stamping it), or superseded by another writer. The
1288
+ // merged text was written from all of them as they were, so folding it in would bring
1289
+ // a withdrawn claim back inside the keeper (tests/verify-reenrich-race.test.mjs).
1290
+ // Abort the whole cluster rather than merge part.
1291
+ const clusterIds = cluster.map((o) => o.id);
1292
+ const unchanged = db
1293
+ .prepare(
1294
+ `SELECT COUNT(*) AS n FROM observations WHERE id IN (${clusterIds.map(() => '?').join(',')}) AND optimized_at IS NULL AND ${liveObsFilterSql('')}`,
1295
+ )
1296
+ .get(...clusterIds).n;
1297
+ if (unchanged !== clusterIds.length) return false;
1251
1298
 
1252
1299
  // Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
1253
1300
  // full text survives as a recoverable compressed_into child (mirroring
@@ -1296,7 +1343,11 @@ Return ONLY valid JSON:
1296
1343
  return true;
1297
1344
  })();
1298
1345
  if (!mergeApplied) {
1299
- debugLog('DEBUG', 'llm-optimize', `cluster-merge aborted: keeper #${keeper.id} no longer live`);
1346
+ debugLog(
1347
+ 'DEBUG',
1348
+ 'llm-optimize',
1349
+ `cluster-merge aborted: keeper #${keeper.id} no longer live, or a member changed during the call`,
1350
+ );
1300
1351
  return { merged: false };
1301
1352
  }
1302
1353
 
@@ -1331,7 +1382,7 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
1331
1382
  const cutoff = Date.now() - ageDays * DAY_MS;
1332
1383
  const projectClause = project ? 'AND project = ?' : '';
1333
1384
  const stmt = db.prepare(`
1334
- SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
1385
+ SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch, optimized_at
1335
1386
  FROM observations
1336
1387
  -- liveObsFilterSql, not compressed_into alone (audit 2026-09-02 P0-3): auto-dedup losers
1337
1388
  -- carry superseded_at with compressed_into=0 and match this predicate exactly (imp=1,
@@ -1488,6 +1539,25 @@ export async function executeSmartCompressCluster(db, observations, project) {
1488
1539
  const medianEpoch = epochs[Math.floor(epochs.length / 2)];
1489
1540
 
1490
1541
  const summaryId = db.transaction(() => {
1542
+ // The summary was written from the members as they were BEFORE the Sonnet call. A member
1543
+ // whose stamp changed since was edited through /verify (or rewritten by another pass);
1544
+ // a member no longer live was retired or replaced (neither stamps the original) or
1545
+ // superseded by another writer. A summary of their earlier text would put the withdrawn
1546
+ // claim back as a live row and hide the rest behind it (pre-ship review of 9786874,
1547
+ // P2-1; delta review of the first repair). Abort the whole cluster, as cluster-merge does.
1548
+ // Callers pass rows as findSmartCompressCandidates selects them, optimized_at included:
1549
+ // a row without the column reads as unstamped, so a stamped member would abort (safe).
1550
+ const nowRow = db.prepare(
1551
+ `SELECT optimized_at FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
1552
+ );
1553
+ if (
1554
+ observations.some((o) => {
1555
+ const cur = nowRow.get(o.id);
1556
+ return !cur || (cur.optimized_at ?? null) !== (o.optimized_at ?? null);
1557
+ })
1558
+ ) {
1559
+ return null;
1560
+ }
1491
1561
  const sessionId = `compress-${project}`;
1492
1562
  const now = new Date();
1493
1563
  db.prepare(
@@ -1551,7 +1621,8 @@ export async function executeSmartCompressCluster(db, observations, project) {
1551
1621
  // Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
1552
1622
  // by a Sonnet round-trip, so a member may already be compressed into another summary or
1553
1623
  // tombstoned. Re-pointing it here would silently remove a row from that summary's child
1554
- // set. Members that lost liveness stay where they are; the summary still lands.
1624
+ // set. The check at the top of this transaction now aborts in that case; the guard stays
1625
+ // so this UPDATE can never re-point a dead row on its own.
1555
1626
  db.prepare(
1556
1627
  `UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`,
1557
1628
  ).run(sId, ...obsIds);
@@ -1559,6 +1630,10 @@ export async function executeSmartCompressCluster(db, observations, project) {
1559
1630
  return sId;
1560
1631
  })();
1561
1632
 
1633
+ if (summaryId === null) {
1634
+ debugLog('DEBUG', 'llm-optimize', 'smart-compress aborted: a member changed during the call');
1635
+ return { compressed: false };
1636
+ }
1562
1637
  debugLog(
1563
1638
  'DEBUG',
1564
1639
  'llm-optimize',
package/install.mjs CHANGED
@@ -3053,9 +3053,10 @@ function cleanup() {
3053
3053
  // Reap leaked test-fixture sandboxes from temp (mem-e2e-* / mem-audit-* / cite-*
3054
3054
  // etc.) left by interrupted vitest runs — the §8.V4 disposal gap the audit found
3055
3055
  // (~795MB). 24h age here (vs 1h in the test reaper) is conservative for a manual
3056
- // cleanup. Scans os.tmpdir() and the Claude Code temp root, depth-1, mem-prefixes
3056
+ // cleanup. Scans os.tmpdir(), the Claude Code temp root and ~/.cache/tmp (where
3057
+ // `npm test` points TMPDIR, off the RAM-backed /tmp), depth-1, mem-prefixes
3057
3058
  // only — never touches other tools' temp dirs.
3058
- const fixtureRoots = [tmpdir(), join(homedir(), '.claude', 'tmp')];
3059
+ const fixtureRoots = [tmpdir(), join(homedir(), '.claude', 'tmp'), join(homedir(), '.cache', 'tmp')];
3059
3060
  const swept = sweepStaleTestFixtures({ dirs: fixtureRoots, ageMs: 24 * 60 * 60 * 1000, dryRun });
3060
3061
  for (const p of swept.names) ok(`${dryRun ? 'Would remove' : 'Removed'}: ${p}`);
3061
3062
  removed += swept.removed;