claude-mem-lite 5.1.0 → 5.2.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/cli/fts-check.mjs +14 -0
- package/hook-shared.mjs +6 -7
- package/install.mjs +37 -1
- package/lib/binding-probe.mjs +88 -2
- package/lib/delete-core.mjs +9 -1
- package/lib/maintain-core.mjs +21 -0
- package/lib/time-constants.mjs +17 -0
- package/mem-cli.mjs +35 -15
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/setup.sh +14 -1
- package/server.mjs +3 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.2.0",
|
|
14
14
|
"source": "./",
|
|
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)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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/cli/fts-check.mjs
CHANGED
|
@@ -18,6 +18,16 @@ export function cmdFtsCheck(db, args) {
|
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Exit-code contract, matching `memdir-audit` — the other diagnostic in
|
|
22
|
+
// CLI_COMMANDS, whose help documents "Exit 0 if every file is compliant, 1
|
|
23
|
+
// otherwise". Both actions used to print the failure and exit 0, so
|
|
24
|
+
// `fts-check rebuild && echo repaired` announced a repair that had not
|
|
25
|
+
// happened, and an agent reading only the status code was told the index was
|
|
26
|
+
// healthy. `doctor` points users at this command precisely when it is not.
|
|
27
|
+
//
|
|
28
|
+
// Findings stay on STDOUT via out() rather than moving to fail()'s stderr:
|
|
29
|
+
// they are the report the user asked for, not an error trace, and the details
|
|
30
|
+
// are worth piping. Only process.exitCode changes.
|
|
21
31
|
if (action === 'check') {
|
|
22
32
|
const result = checkFTSIntegrity(db);
|
|
23
33
|
if (result.healthy) {
|
|
@@ -25,6 +35,7 @@ export function cmdFtsCheck(db, args) {
|
|
|
25
35
|
} else {
|
|
26
36
|
out(`[mem] FTS5 issues found:`);
|
|
27
37
|
for (const d of result.details) out(` ${d}`);
|
|
38
|
+
process.exitCode = 1;
|
|
28
39
|
}
|
|
29
40
|
return;
|
|
30
41
|
}
|
|
@@ -32,7 +43,10 @@ export function cmdFtsCheck(db, args) {
|
|
|
32
43
|
if (action === 'rebuild') {
|
|
33
44
|
const result = rebuildFTS(db);
|
|
34
45
|
if (result.errors.length > 0) {
|
|
46
|
+
// Partial success is a failure of the requested operation: the caller asked
|
|
47
|
+
// for a rebuild and at least one index still is not rebuilt.
|
|
35
48
|
out(`[mem] Rebuilt: ${result.rebuilt.join(', ')}. Errors: ${result.errors.join(', ')}`);
|
|
49
|
+
process.exitCode = 1;
|
|
36
50
|
} else {
|
|
37
51
|
out(`[mem] Successfully rebuilt: ${result.rebuilt.join(', ')}`);
|
|
38
52
|
}
|
package/hook-shared.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export {
|
|
|
37
37
|
CONTINUE_KEYWORDS,
|
|
38
38
|
} from './lib/handoff-constants.mjs';
|
|
39
39
|
|
|
40
|
-
import { DAY_MS } from './lib/time-constants.mjs';
|
|
40
|
+
import { DAY_MS, ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
41
41
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
43
|
// P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
|
|
@@ -82,12 +82,11 @@ export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
|
|
|
82
82
|
// <memory-context> injection on quiet/adopted projects where nothing renders).
|
|
83
83
|
export const KEY_CONTEXT_LIMIT = 10;
|
|
84
84
|
|
|
85
|
-
// Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
85
|
+
// Orphan-sweep threshold for `ep-flush-*` / `pending-*` runtime artifacts. Defined in
|
|
86
|
+
// lib/time-constants.mjs (the zero-import leaf) because install.mjs's manual `cleanup`
|
|
87
|
+
// needs the same window and may only import from lib/; re-exported here so this module's
|
|
88
|
+
// existing importers are unchanged.
|
|
89
|
+
export { ORPHAN_EPISODE_AGE_MS };
|
|
91
90
|
|
|
92
91
|
// `reads-<project>.txt` (bash fast-path Read tracker) is consumed by flushEpisode's
|
|
93
92
|
// rename-collect on the next edit-flush, NOT by a background worker — so a project
|
package/install.mjs
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
72
72
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
73
73
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
74
|
+
import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
|
|
74
75
|
import { acquireLock } from './lib/proc-lock.mjs';
|
|
75
76
|
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
76
77
|
import { isMemHook, launcherEntryPath } from './lib/hook-prune.mjs';
|
|
@@ -2207,11 +2208,35 @@ function cleanup() {
|
|
|
2207
2208
|
// and holding install.lock across it would block a self-heal for no reason.
|
|
2208
2209
|
if (updateLock) updateLock();
|
|
2209
2210
|
|
|
2210
|
-
// Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override)
|
|
2211
|
+
// Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override).
|
|
2212
|
+
//
|
|
2213
|
+
// AGE-GATED, same window the automatic sweep uses (ORPHAN_EPISODE_AGE_MS, 1h). An
|
|
2214
|
+
// `ep-flush-<ts>-<id>.json` is the episode handed to the summarizer, not residue: the
|
|
2215
|
+
// round-trip is ~60s, and deleting one mid-flight discards that episode's observations
|
|
2216
|
+
// silently while printing "✓ Removed". This block had no age gate at all, which made a
|
|
2217
|
+
// documented maintenance command — the one `doctor` tells users to run — destructive
|
|
2218
|
+
// against live work. Matches the P2-10 fix above (in-flight update residue) and the
|
|
2219
|
+
// fixture sweep below, whose comment already states the principle: a MANUAL cleanup is
|
|
2220
|
+
// the conservative one.
|
|
2211
2221
|
const runtimeDir = MEM_RUNTIME_DIR;
|
|
2212
2222
|
if (existsSync(runtimeDir)) {
|
|
2223
|
+
const epCutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
|
|
2224
|
+
let inFlight = 0;
|
|
2213
2225
|
for (const f of readdirSync(runtimeDir)) {
|
|
2214
2226
|
if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
|
|
2227
|
+
// Unreadable mtime → treat as in-flight and skip. Failing safe here costs one
|
|
2228
|
+
// stale file until the next sweep; failing open costs an episode.
|
|
2229
|
+
let mtimeMs;
|
|
2230
|
+
try {
|
|
2231
|
+
mtimeMs = statSync(join(runtimeDir, f)).mtimeMs;
|
|
2232
|
+
} catch {
|
|
2233
|
+
inFlight++;
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
if (mtimeMs > epCutoff) {
|
|
2237
|
+
inFlight++;
|
|
2238
|
+
continue;
|
|
2239
|
+
}
|
|
2215
2240
|
if (dryRun) {
|
|
2216
2241
|
ok(`Would remove: runtime/${f}`);
|
|
2217
2242
|
removed++;
|
|
@@ -2226,6 +2251,11 @@ function cleanup() {
|
|
|
2226
2251
|
}
|
|
2227
2252
|
}
|
|
2228
2253
|
}
|
|
2254
|
+
if (inFlight > 0) {
|
|
2255
|
+
log(
|
|
2256
|
+
` Kept ${inFlight} episode file(s) newer than 1h — possibly in flight, they sweep automatically once stale.`,
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2229
2259
|
}
|
|
2230
2260
|
|
|
2231
2261
|
// Reap leaked test-fixture sandboxes from temp (mem-e2e-* / mem-audit-* / cite-*
|
|
@@ -2545,6 +2575,12 @@ async function rebuildBinding() {
|
|
|
2545
2575
|
const verify = await ensureBetterSqlite3Working(root);
|
|
2546
2576
|
if (verify.ok) {
|
|
2547
2577
|
ok(`better-sqlite3 binding ${verify.action} for Node ${process.version} — ${label} (${root})`);
|
|
2578
|
+
if (verify.quarantined) {
|
|
2579
|
+
// Say it out loud: the heal renamed a file inside the user's node_modules because
|
|
2580
|
+
// the shipped prebuild was present and would not load. A silent move inside a
|
|
2581
|
+
// dependency is the kind of thing that reads as corruption six months later.
|
|
2582
|
+
log(` the shipped prebuild would not load — moved aside to ${verify.quarantined}.unusable`);
|
|
2583
|
+
}
|
|
2548
2584
|
} else {
|
|
2549
2585
|
fail(`better-sqlite3 binding still unusable in ${label}: ${verify.error}`);
|
|
2550
2586
|
log(`Try manually: ${nativeBindingRepairHint(root)}`);
|
package/lib/binding-probe.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// sufficient — the binding can be present-but-stale after a Node upgrade.
|
|
8
8
|
|
|
9
9
|
import { execSync, spawnSync } from 'node:child_process';
|
|
10
|
+
import { existsSync, renameSync } from 'node:fs';
|
|
10
11
|
import { createRequire } from 'node:module';
|
|
11
12
|
import { join } from 'node:path';
|
|
12
13
|
|
|
@@ -58,7 +59,16 @@ export const NATIVE_BINDING_SOURCE_BUILD_CMD = 'npm run --prefix node_modules/be
|
|
|
58
59
|
export function nativeBindingRepairHint(root) {
|
|
59
60
|
// Quoted: INSTALL_DIR / a plugin-cache root can contain spaces, and an unquoted `cd`
|
|
60
61
|
// hands the user a command that fails on exactly the machines least able to debug it.
|
|
61
|
-
|
|
62
|
+
const npmPair = `cd "${root}" && ${NATIVE_BINDING_REBUILD_CMD} && ${NATIVE_BINDING_SOURCE_BUILD_CMD}`;
|
|
63
|
+
// The pair above heals a platform better-sqlite3 ships NO prebuild for. It cannot heal a
|
|
64
|
+
// prebuild that is present and will not load — 13 selects `prebuilds/<target>.node` on
|
|
65
|
+
// existence alone and prefers it over `build/`, so the addon those two commands compile
|
|
66
|
+
// stays shadowed. Only ensureBetterSqlite3Working moves the dead prebuild out of the way
|
|
67
|
+
// first, and `rebuild-binding` is how a human reaches it — so it goes FIRST, because a user
|
|
68
|
+
// runs the first command they are given and stops looking when it reports success.
|
|
69
|
+
const cli = join(root, 'cli.mjs');
|
|
70
|
+
if (!existsSync(cli)) return npmPair;
|
|
71
|
+
return `node "${cli}" rebuild-binding (or, without the CLI: ${npmPair})`;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
// Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
|
|
@@ -193,6 +203,45 @@ export function probeBindingInFreshProcess(installDir, { timeoutMs = 30_000 } =
|
|
|
193
203
|
};
|
|
194
204
|
}
|
|
195
205
|
|
|
206
|
+
// Suffix for a prebuilt addon that is present and will not load. Renamed, never deleted:
|
|
207
|
+
// the file is evidence for whoever debugs the machine, and `npm install` puts a fresh
|
|
208
|
+
// prebuild back at the original name regardless.
|
|
209
|
+
const UNUSABLE_PREBUILD_SUFFIX = '.unusable';
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The prebuilt addon better-sqlite3 would choose under `installDir`, or null.
|
|
213
|
+
*
|
|
214
|
+
* ASKED OF THE DEPENDENCY, never computed here. Which file gets loaded depends on
|
|
215
|
+
* platform, arch and a musl probe, and this repo has already paid twice for guessing it:
|
|
216
|
+
* `tests/install-bsqlite-probe.test.mjs` matched a v12 filename that v13 does not produce
|
|
217
|
+
* (its main assertion went vacuously true), and both sandbox phases corrupted
|
|
218
|
+
* `build/Release/better_sqlite3.node` for a year of releases while the resolver loaded a
|
|
219
|
+
* prebuild. `lib/binding.js` exports the selector itself, so use it.
|
|
220
|
+
*
|
|
221
|
+
* Out of process, like every other probe here: this runs on a path that is about to dlopen
|
|
222
|
+
* the result, and `getPrebuildPath()` is existence-only today but is not ours to promise.
|
|
223
|
+
* `lib/binding.js` is absent from the package's `exports` map, hence the file path.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} installDir Directory whose node_modules holds better-sqlite3
|
|
226
|
+
* @returns {string|null}
|
|
227
|
+
*/
|
|
228
|
+
function resolvedPrebuildPath(installDir) {
|
|
229
|
+
const bindingJs = join(installDir, 'node_modules', 'better-sqlite3', 'lib', 'binding.js');
|
|
230
|
+
if (!existsSync(bindingJs)) return null;
|
|
231
|
+
const r = spawnSync(
|
|
232
|
+
process.execPath,
|
|
233
|
+
[
|
|
234
|
+
'-e',
|
|
235
|
+
`const b=require(${JSON.stringify(bindingJs)});` +
|
|
236
|
+
`process.stdout.write((b.getPrebuildPath&&b.getPrebuildPath())||'')`,
|
|
237
|
+
],
|
|
238
|
+
{ stdio: 'pipe', encoding: 'utf8', timeout: 10_000 },
|
|
239
|
+
);
|
|
240
|
+
if (r.error || r.status !== 0) return null;
|
|
241
|
+
const p = String(r.stdout || '').trim();
|
|
242
|
+
return p && existsSync(p) ? p : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
196
245
|
/**
|
|
197
246
|
* Verify better-sqlite3 binding works in `installDir`; if not, run
|
|
198
247
|
* `npm rebuild better-sqlite3` and re-probe. Returns
|
|
@@ -295,15 +344,52 @@ export async function ensureBetterSqlite3Working(installDir, deps = {}) {
|
|
|
295
344
|
: () => exec(NATIVE_BINDING_SOURCE_BUILD_CMD, { cwd: installDir, stdio: 'pipe' }));
|
|
296
345
|
if (!sourceBuild) return { ok: false, error: second.error || first.error };
|
|
297
346
|
|
|
347
|
+
// A prebuild that is present and unloadable SHADOWS everything the source build is about
|
|
348
|
+
// to produce: better-sqlite3 13 picks `prebuilds/<target>.node` on existence alone and
|
|
349
|
+
// prefers it over `build/`. Measured 2026-09-06 with a control — corrupt prebuild +
|
|
350
|
+
// healthy build/Release → `wrong ELF class`; the same tree with the prebuild moved aside →
|
|
351
|
+
// opens; with neither → fails. Until this, `rebuild-binding` (the foreground repair doctor
|
|
352
|
+
// prints, deliberately given no time budget) exited 1 on that shape and the manual command
|
|
353
|
+
// it offered instead could not fix it either. Reproduced end-to-end in
|
|
354
|
+
// tests/sandbox/phaseB-npm.mjs before the fix.
|
|
355
|
+
//
|
|
356
|
+
// Deliberately inside this branch and after the npm step: quarantining without a compile to
|
|
357
|
+
// follow it turns "broken addon" into "no addon", which is strictly worse — so the
|
|
358
|
+
// time-budgeted SessionStart path (sourceBuild: false) never reaches this.
|
|
359
|
+
const shadowingPrebuild = resolvedPrebuildPath(installDir);
|
|
360
|
+
let quarantined = null;
|
|
361
|
+
if (shadowingPrebuild) {
|
|
362
|
+
try {
|
|
363
|
+
renameSync(shadowingPrebuild, shadowingPrebuild + UNUSABLE_PREBUILD_SUFFIX);
|
|
364
|
+
quarantined = shadowingPrebuild;
|
|
365
|
+
} catch {
|
|
366
|
+
// A read-only or otherwise unwritable tree. The compile below is still worth trying:
|
|
367
|
+
// on a platform with no prebuild it is the whole fix, and failing here would turn a
|
|
368
|
+
// recoverable case into a hard stop.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Put the tree back exactly as found — an install we could not repair must not lose a file. */
|
|
372
|
+
const restorePrebuild = () => {
|
|
373
|
+
if (!quarantined) return;
|
|
374
|
+
try {
|
|
375
|
+
renameSync(quarantined + UNUSABLE_PREBUILD_SUFFIX, quarantined);
|
|
376
|
+
} catch {
|
|
377
|
+
// Nothing better to do; the error the caller gets already says the heal failed.
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
298
381
|
try {
|
|
299
382
|
await sourceBuild();
|
|
300
383
|
} catch (e) {
|
|
384
|
+
restorePrebuild();
|
|
301
385
|
return { ok: false, error: `source build failed: ${e.message}` };
|
|
302
386
|
}
|
|
303
387
|
|
|
304
388
|
const third = await verify();
|
|
305
|
-
if (third.ok)
|
|
389
|
+
if (third.ok)
|
|
390
|
+
return quarantined ? { ok: true, action: 'compiled', quarantined } : { ok: true, action: 'compiled' };
|
|
306
391
|
|
|
392
|
+
restorePrebuild();
|
|
307
393
|
return { ok: false, error: third.error || second.error || first.error };
|
|
308
394
|
}
|
|
309
395
|
|
package/lib/delete-core.mjs
CHANGED
|
@@ -101,5 +101,13 @@ export function previewDeleteRows(db, ids) {
|
|
|
101
101
|
const lines = rows.map(
|
|
102
102
|
(r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`,
|
|
103
103
|
);
|
|
104
|
-
|
|
104
|
+
// Requested ids with no row. Both faces used to derive this themselves and only
|
|
105
|
+
// in their CONFIRM branch, so the PREVIEW — whose entire job is to show what is
|
|
106
|
+
// about to happen — silently omitted them: `delete 42,43,44` listed two rows and
|
|
107
|
+
// the user learned 43 never existed only after confirming. Computed here so the
|
|
108
|
+
// CLI and mem_delete report the same set from one place. Order follows the
|
|
109
|
+
// caller's id order, which is the order they typed.
|
|
110
|
+
const found = new Set(rows.map((r) => r.id));
|
|
111
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
112
|
+
return { rows, lines, missing };
|
|
105
113
|
}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -65,6 +65,27 @@ export const PINNED_INJ_THRESHOLD = 8;
|
|
|
65
65
|
// Hence: order matters, and `demote_pinned` MUST come after `boost`.
|
|
66
66
|
export const DEFAULT_MAINTAIN_OPS = Object.freeze(['cleanup', 'decay', 'boost', 'demote_pinned']);
|
|
67
67
|
|
|
68
|
+
// Every op `runMaintainOps` dispatches on — the SET, not a run order (the order a run
|
|
69
|
+
// uses is DEFAULT_MAINTAIN_OPS' above, or the caller's array).
|
|
70
|
+
//
|
|
71
|
+
// Lives here because two faces validate against it and they are not allowed to drift:
|
|
72
|
+
// the CLI (`mem-cli.mjs`, which rejects an unknown `--ops` value) and the MCP tool
|
|
73
|
+
// schema (`tool-schemas.mjs`, a Zod `z.enum` the model reads). The MCP copy stays
|
|
74
|
+
// hand-written on purpose — that enum is LLM-visible metadata, so reordering it to
|
|
75
|
+
// derive from this array would be a behavioural change to tool routing for no gain —
|
|
76
|
+
// and `tests/maintain-ops-invariant.test.mjs` diffs the two SETS instead, the same way
|
|
77
|
+
// tests/audit-silent-20260814.test.mjs diffs the two hook sets.
|
|
78
|
+
export const ALL_MAINTAIN_OPS = Object.freeze([
|
|
79
|
+
'cleanup',
|
|
80
|
+
'decay',
|
|
81
|
+
'boost',
|
|
82
|
+
'demote_pinned',
|
|
83
|
+
'dedup',
|
|
84
|
+
'purge_stale',
|
|
85
|
+
'rebuild_vectors',
|
|
86
|
+
'vacuum',
|
|
87
|
+
]);
|
|
88
|
+
|
|
68
89
|
// Opt-out for the v3.76.0 default change. Scoped to the DEFAULT set ONLY — an
|
|
69
90
|
// explicit `--ops demote_pinned` / `operations:["demote_pinned"]` still runs. An
|
|
70
91
|
// accepted value that silently means something else is worse than an unsupported
|
package/lib/time-constants.mjs
CHANGED
|
@@ -19,3 +19,20 @@ const MINUTE_MS = 60 * SECOND_MS;
|
|
|
19
19
|
const HOUR_MS = 60 * MINUTE_MS;
|
|
20
20
|
|
|
21
21
|
export const DAY_MS = 24 * HOUR_MS;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long an `ep-flush-*` / `pending-*` runtime file must sit untouched before any
|
|
25
|
+
* sweeper may treat it as an orphan rather than as work in flight.
|
|
26
|
+
*
|
|
27
|
+
* handleLLMEpisode's worst-case round-trip is ~60s (delay + LLM call + DB write), so 1h
|
|
28
|
+
* is a wide safety margin. Anything older is a crashed worker or a pre-shutdown buffer
|
|
29
|
+
* that no live caller will pick up.
|
|
30
|
+
*
|
|
31
|
+
* It lives HERE, in the zero-import leaf, because it now has two consumers that cannot
|
|
32
|
+
* share a module: the automatic sweep in `hook-shared.mjs` (which re-exports it, so its
|
|
33
|
+
* own importers are unchanged) and `install.mjs`'s manual `cleanup`, which may only
|
|
34
|
+
* import from `lib/`. That split is exactly how the two drifted: `cleanup` deleted every
|
|
35
|
+
* `ep-flush-*` with NO age gate at all, discarding a seconds-old in-flight episode while
|
|
36
|
+
* printing "✓ Removed" — and `doctor` recommends running `cleanup`.
|
|
37
|
+
*/
|
|
38
|
+
export const ORPHAN_EPISODE_AGE_MS = HOUR_MS;
|
package/mem-cli.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
STALE_AGE_MS,
|
|
46
46
|
PINNED_INJ_THRESHOLD,
|
|
47
47
|
resolveDefaultMaintainOps,
|
|
48
|
+
ALL_MAINTAIN_OPS,
|
|
48
49
|
} from './lib/maintain-core.mjs';
|
|
49
50
|
// snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
|
|
50
51
|
// (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
|
|
@@ -1898,7 +1899,7 @@ function cmdDelete(db, args) {
|
|
|
1898
1899
|
|
|
1899
1900
|
const confirm = flags.confirm === true || flags.confirm === 'true';
|
|
1900
1901
|
// Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
|
|
1901
|
-
const { rows, lines: previewLines } = previewDeleteRows(db, ids);
|
|
1902
|
+
const { rows, lines: previewLines, missing } = previewDeleteRows(db, ids);
|
|
1902
1903
|
|
|
1903
1904
|
if (rows.length === 0) {
|
|
1904
1905
|
fail('[mem] No observations found for given IDs');
|
|
@@ -1908,6 +1909,7 @@ function cmdDelete(db, args) {
|
|
|
1908
1909
|
if (!confirm) {
|
|
1909
1910
|
out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
|
|
1910
1911
|
for (const line of previewLines) out(line);
|
|
1912
|
+
if (missing.length > 0) out(`[mem] Note: ID(s) ${missing.join(', ')} not found and will be skipped.`);
|
|
1911
1913
|
out('[mem] Run with --confirm to execute deletion.');
|
|
1912
1914
|
return;
|
|
1913
1915
|
}
|
|
@@ -1916,7 +1918,6 @@ function cmdDelete(db, args) {
|
|
|
1916
1918
|
// transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
|
|
1917
1919
|
// mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
|
|
1918
1920
|
const result = deleteObservations(db, ids);
|
|
1919
|
-
const missing = ids.filter((id) => !rows.some((r) => r.id === id));
|
|
1920
1921
|
const recoveredNote =
|
|
1921
1922
|
result.recoveredChildren > 0
|
|
1922
1923
|
? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`
|
|
@@ -2498,6 +2499,13 @@ function cmdCompress(db, args) {
|
|
|
2498
2499
|
|
|
2499
2500
|
// ─── Maintain ────────────────────────────────────────────────────────────────
|
|
2500
2501
|
|
|
2502
|
+
// Shared by BOTH maintain branches. It used to be a local const inside `execute`,
|
|
2503
|
+
// which is why `scan` — the preview step — silently accepted `--ops purge-stale`
|
|
2504
|
+
// (hyphen for underscore), printed a full report and exited 0, leaving the typo to
|
|
2505
|
+
// surface only on the run the preview was supposed to de-risk. The list itself now
|
|
2506
|
+
// comes from lib/maintain-core.mjs so this face and the MCP schema cannot drift.
|
|
2507
|
+
const VALID_MAINTAIN_OPS = ALL_MAINTAIN_OPS;
|
|
2508
|
+
|
|
2501
2509
|
function cmdMaintain(db, args) {
|
|
2502
2510
|
const { positional, flags } = parseArgs(args);
|
|
2503
2511
|
const action = positional[0];
|
|
@@ -2518,12 +2526,32 @@ function cmdMaintain(db, args) {
|
|
|
2518
2526
|
const baseParams = project ? [project] : [];
|
|
2519
2527
|
|
|
2520
2528
|
if (action === 'scan') {
|
|
2529
|
+
// Validate --ops here too, with the same list and the same message `execute`
|
|
2530
|
+
// uses. Catching the typo in the PREVIEW is the whole point: this is the step a
|
|
2531
|
+
// user runs to find out what would happen, and it used to ignore the flag
|
|
2532
|
+
// wholesale. Only validated when present — a plain `maintain scan` is unchanged.
|
|
2533
|
+
if (flags.ops !== undefined) {
|
|
2534
|
+
const scanOps = String(flags.ops)
|
|
2535
|
+
.split(',')
|
|
2536
|
+
.map((s) => s.trim());
|
|
2537
|
+
const invalid = scanOps.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
|
|
2538
|
+
if (invalid.length > 0) {
|
|
2539
|
+
fail(`[mem] Unknown operation(s): ${invalid.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2521
2544
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
2522
2545
|
const mctx = { projectFilter, baseParams, staleAge };
|
|
2523
2546
|
const duplicates = findDuplicates(db, mctx);
|
|
2524
2547
|
const stats = maintenanceStats(db, mctx);
|
|
2525
2548
|
|
|
2526
2549
|
out(`[mem] Maintenance scan:`);
|
|
2550
|
+
// The ops are valid but scan is not scoped by them — say so instead of letting a
|
|
2551
|
+
// scoped-looking invocation imply a scoped report.
|
|
2552
|
+
if (flags.ops !== undefined) {
|
|
2553
|
+
out(` (--ops is an execute-time filter; this scan reports every category)`);
|
|
2554
|
+
}
|
|
2527
2555
|
out(` Total active: ${stats.total}`);
|
|
2528
2556
|
out(` Near-duplicate pairs: ${duplicates.length}`);
|
|
2529
2557
|
out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
|
|
@@ -2567,27 +2595,17 @@ function cmdMaintain(db, args) {
|
|
|
2567
2595
|
}
|
|
2568
2596
|
|
|
2569
2597
|
// Execute
|
|
2570
|
-
const VALID_OPS = [
|
|
2571
|
-
'cleanup',
|
|
2572
|
-
'decay',
|
|
2573
|
-
'boost',
|
|
2574
|
-
'demote_pinned',
|
|
2575
|
-
'dedup',
|
|
2576
|
-
'purge_stale',
|
|
2577
|
-
'rebuild_vectors',
|
|
2578
|
-
'vacuum',
|
|
2579
|
-
];
|
|
2580
2598
|
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2581
2599
|
// (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
|
|
2582
|
-
// to the destructive default set and EXECUTED it; route it to the
|
|
2600
|
+
// to the destructive default set and EXECUTED it; route it to the VALID_MAINTAIN_OPS check
|
|
2583
2601
|
// below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
|
|
2584
2602
|
// was the literal `cleanup,decay,boost` when this was written; it now comes from
|
|
2585
2603
|
// DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
|
|
2586
2604
|
const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
|
|
2587
2605
|
const ops = opsStr.split(',').map((s) => s.trim());
|
|
2588
|
-
const invalidOps = ops.filter((op) => !
|
|
2606
|
+
const invalidOps = ops.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
|
|
2589
2607
|
if (invalidOps.length > 0) {
|
|
2590
|
-
fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${
|
|
2608
|
+
fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
|
|
2591
2609
|
return;
|
|
2592
2610
|
}
|
|
2593
2611
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
@@ -3182,6 +3200,8 @@ Commands:
|
|
|
3182
3200
|
--json Machine-readable output (plain doctor run)
|
|
3183
3201
|
|
|
3184
3202
|
fts-check <check|rebuild> FTS5 index check or rebuild
|
|
3203
|
+
Exit 0 when every index is healthy / rebuilt, 1 otherwise —
|
|
3204
|
+
so "fts-check rebuild && <next step>" is safe to chain.
|
|
3185
3205
|
|
|
3186
3206
|
stats Show memory statistics
|
|
3187
3207
|
--project P Filter by project
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "5.
|
|
9
|
+
"version": "5.2.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/scripts/setup.sh
CHANGED
|
@@ -190,7 +190,20 @@ if [[ -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
|
190
190
|
# Both commands, `&&` not `||`: `npm rebuild` exits 0 without compiling on
|
|
191
191
|
# better-sqlite3 13 (no install script to run), so it never signals failure and
|
|
192
192
|
# an `||` chain would never reach the source build. A20260906-R8-P1-1.
|
|
193
|
-
|
|
193
|
+
#
|
|
194
|
+
# …and the pair alone is still not enough. It heals a platform 13 ships no prebuild
|
|
195
|
+
# for; it cannot heal a prebuild that is PRESENT and will not load, because 13 prefers
|
|
196
|
+
# `prebuilds/<target>.node` over anything the source build produces. This string is what
|
|
197
|
+
# the SessionStart dashboard prints as `Repair:`, so it leads with the CLI, which is the
|
|
198
|
+
# only path that moves the dead prebuild aside first (lib/binding-probe.mjs). Mirrors
|
|
199
|
+
# nativeBindingRepairHint(); this file may not import lib/, hence the duplication —
|
|
200
|
+
# tests/audit-r8-binding-repair-hint.test.mjs pins the set of files allowed to do that.
|
|
201
|
+
# mark_deps_broken already prefixes `cd <root> && `, so these are root-relative.
|
|
202
|
+
NB_REPAIR="npm rebuild better-sqlite3 --dangerously-allow-all-scripts && npm run --prefix node_modules/better-sqlite3 build-release"
|
|
203
|
+
if [[ -f "$ROOT/cli.mjs" ]]; then
|
|
204
|
+
NB_REPAIR="node cli.mjs rebuild-binding (or, without the CLI: $NB_REPAIR)"
|
|
205
|
+
fi
|
|
206
|
+
mark_deps_broken "better-sqlite3 binding probe/rebuild failed (npm >= 12 blocks compile scripts by default)" "$NB_REPAIR"
|
|
194
207
|
fi
|
|
195
208
|
fi
|
|
196
209
|
|
package/server.mjs
CHANGED
|
@@ -961,7 +961,7 @@ server.registerTool(
|
|
|
961
961
|
},
|
|
962
962
|
safeHandler(async (args) => {
|
|
963
963
|
// Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
|
|
964
|
-
const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
|
|
964
|
+
const { rows, lines: previewLines, missing } = previewDeleteRows(db, args.ids);
|
|
965
965
|
|
|
966
966
|
if (rows.length === 0) {
|
|
967
967
|
return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
|
|
@@ -969,6 +969,8 @@ server.registerTool(
|
|
|
969
969
|
|
|
970
970
|
if (!args.confirm) {
|
|
971
971
|
const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
|
|
972
|
+
if (missing.length > 0)
|
|
973
|
+
lines.push(`\nNote: ID(s) ${missing.join(', ')} not found and will be skipped.`);
|
|
972
974
|
lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
|
|
973
975
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
974
976
|
}
|
|
@@ -978,7 +980,6 @@ server.registerTool(
|
|
|
978
980
|
// with the CLI `delete` path (was inlined + kept in sync by parity comments).
|
|
979
981
|
const result = deleteObservations(db, args.ids);
|
|
980
982
|
|
|
981
|
-
const missing = args.ids.filter((id) => !rows.some((r) => r.id === id));
|
|
982
983
|
const msg = [`Deleted ${result.deleted} observation(s).`];
|
|
983
984
|
if (result.recoveredChildren > 0)
|
|
984
985
|
msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);
|