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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/cli/common.mjs +7 -0
- package/cli/verify-apply.mjs +197 -0
- package/cli.mjs +1 -0
- package/commands/verify.md +156 -0
- package/hook-optimize.mjs +91 -16
- package/install.mjs +3 -2
- package/lib/atomic-write.mjs +5 -3
- package/lib/get-core.mjs +11 -3
- package/lib/observation-write.mjs +1 -1
- package/lib/verify-apply-core.mjs +641 -0
- package/mem-cli.mjs +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -4
- package/source-files.mjs +5 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
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
|
@@ -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(
|
|
308
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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()
|
|
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;
|