claude-mem-lite 5.1.1 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -4
- package/cli/fts-check.mjs +14 -0
- package/deep-search.mjs +60 -0
- package/hook-shared.mjs +6 -7
- package/hook.mjs +9 -2
- package/install.mjs +31 -1
- 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 +55 -16
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server.mjs +22 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.3.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.3.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
|
@@ -120,7 +120,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
|
|
|
120
120
|
- **Schema auto-migration** -- Idempotent `ALTER TABLE` migrations run on every startup, safely adding new columns and indexes without data loss
|
|
121
121
|
- **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
|
|
122
122
|
- **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
|
|
123
|
-
- **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/
|
|
123
|
+
- **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm does not currently fire** (tracked as R10-P1-1). The measurement, first: on the maintainer's own install `session_handoffs` holds 4 `exit` rows and **0** `clear` rows — the `clear` snapshot has never once been written. The mechanism, as a hypothesis: SessionStart treats a session file left on disk as the marker of a previous session that ended without `Stop` (`hook.mjs:2393-2405`, whose own comment reads "Normal `/exit` deletes the file, so this only triggers for `/clear`, `/compact`, or crash recovery"), and that file is exactly what `Stop` deletes (`hook.mjs:1542`) — which would make the branch unreachable if `Stop` runs at the end of every assistant turn rather than once per session. **That last clause is NOT verified**, and it is the whole question: the fix differs depending on whether Claude Code rotates its session id across `/clear`, so a real `/clear` stdin capture comes before any code change
|
|
124
124
|
- **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
|
|
125
125
|
- **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
|
|
126
126
|
- **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
|
|
@@ -431,7 +431,9 @@ FTS5 indexes: `observations_fts` (title, subtitle, narrative, text, facts, conce
|
|
|
431
431
|
|
|
432
432
|
```
|
|
433
433
|
SessionStart
|
|
434
|
-
-> Generate session ID
|
|
434
|
+
-> Generate session ID
|
|
435
|
+
(the /clear|/compact handoff branch here is currently unreachable — R10-P1-1,
|
|
436
|
+
see Cross-session handoff above)
|
|
435
437
|
-> Mark stale sessions (>24h active) as abandoned
|
|
436
438
|
-> Clean orphaned/stale lock files
|
|
437
439
|
-> Query recent observations (24h)
|
|
@@ -458,8 +460,9 @@ UserPromptSubmit (two parallel paths)
|
|
|
458
460
|
|
|
459
461
|
Stop
|
|
460
462
|
-> Flush final episode buffer
|
|
461
|
-
-> Save handoff snapshot (
|
|
463
|
+
-> Save handoff snapshot (type 'exit')
|
|
462
464
|
-> Mark session completed
|
|
465
|
+
-> Delete the session file <- what makes the SessionStart /clear branch unreachable
|
|
463
466
|
-> Spawn LLM summary worker (poll-based wait)
|
|
464
467
|
```
|
|
465
468
|
|
|
@@ -796,6 +799,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
796
799
|
| `MEM_OR_FALLBACK_MAX_TOKENS` | Max query tokens allowed into the OR fallback (∈ [0,50]). | `8` |
|
|
797
800
|
| `CLAUDE_MEM_CJK_PREC_MIN` | Precision floor for CJK segmentation candidates. | `0.2` |
|
|
798
801
|
| `CLAUDE_MEM_AUTO_DEEP` | `0` disables automatic deep-search escalation (one Haiku call rewriting a weak query into keyword/concept/HyDE variants). Explicit `deep: true` still works. | _(auto)_ |
|
|
802
|
+
| `CLAUDE_MEM_DEEP_DISCLOSURE` | `off` suppresses the one-line caveat appended to a multi-variant deep result. The caveat exists because deep search fills the page even when the corpus cannot answer — measured at 10 of 10 slots on queries whose answers had been removed (`benchmark/deep-search-holdout.mjs`) — and `deep` is AUTO by default on the MCP surface, i.e. it escalates precisely when the honest answer is "nothing". It does not change retrieval, ranking, or which rows are returned. | _(on)_ |
|
|
799
803
|
| `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
|
|
800
804
|
| `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
|
|
801
805
|
| `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
|
|
@@ -823,7 +827,7 @@ what is already stored — only whether new work runs.
|
|
|
823
827
|
|
|
824
828
|
| Variable | Description | Default |
|
|
825
829
|
|----------|-------------|---------|
|
|
826
|
-
| `CLAUDE_MEM_SKIP_SUMMARY` | Skip the LLM session summary at Stop. | _(runs)_ |
|
|
830
|
+
| `CLAUDE_MEM_SKIP_SUMMARY` | Skip the background LLM session summary at **both** of its spawn sites — `Stop`, and the SessionStart `/clear`-handoff path. Until v5.3.0 only the `Stop` one honoured it. | _(runs)_ |
|
|
827
831
|
| `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush — observations are still batched, just not summarized. | _(runs)_ |
|
|
828
832
|
| `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
|
|
829
833
|
| `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
|
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/deep-search.mjs
CHANGED
|
@@ -185,6 +185,66 @@ export function resolveDeepMode(explicitDeep, { surface, env = process.env } = {
|
|
|
185
185
|
return surface === 'mcp' ? 'auto' : 'normal';
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* One-line disclosure for a deep result set, written to the channel the CALLER reads.
|
|
190
|
+
*
|
|
191
|
+
* D#3. benchmark/deep-search-holdout.mjs asks the suite's own queries of a corpus with
|
|
192
|
+
* their relevant_ids deleted, so the correct answer is zero rows and every returned row is
|
|
193
|
+
* a false positive by construction. It reads mean FP@10 = 10.00 across 12/12 queries: deep
|
|
194
|
+
* fills every slot, every time. The single-query baseline returns 1-2 rows on the same
|
|
195
|
+
* negatives — the flood is the UNION across paraphrase variants, which is also where deep's
|
|
196
|
+
* recall win comes from, so this is not a bug to be thresholded away. Three gates were
|
|
197
|
+
* tested against both arms and rejected; suppressing OR-fallback on rewrites takes deep
|
|
198
|
+
* R@10 from 0.7383 to 0.3962, because the vocab-mismatch win IS that fallback. rrfFuseN
|
|
199
|
+
* fuses by RANK, so no magnitude signal survives the merge for a downstream floor to read.
|
|
200
|
+
*
|
|
201
|
+
* The discrimination is not available at this layer, so the honest move is to hand the
|
|
202
|
+
* caller what the caller cannot otherwise see. Two things were missing:
|
|
203
|
+
* 1. `escalated` — that the widening happened BECAUSE the plain search was weak — was
|
|
204
|
+
* announced on stderr only. On the MCP surface stderr never reaches the model, which
|
|
205
|
+
* reads tool results; auto is the default there (resolveDeepMode, surface 'mcp'), so
|
|
206
|
+
* the one caller who most needs the caveat was the one who could not see it.
|
|
207
|
+
* 2. Nothing said a full page can be entirely adjacent rows.
|
|
208
|
+
*
|
|
209
|
+
* Silent in two cases, and both silences are load-bearing:
|
|
210
|
+
* - `variantCount <= 1`: with no usable rewrite, deep IS the baseline, and the existing
|
|
211
|
+
* "== baseline" note already says so. Crying flood there would train callers to ignore
|
|
212
|
+
* the line on the runs where it matters.
|
|
213
|
+
* - `rowCount <= 0`: "rows above may be adjacent" is nonsense with no rows above, and
|
|
214
|
+
* both zero-result faces ALREADY say the rewrite ran and found nothing — so the note
|
|
215
|
+
* would restate the page's own conclusion in more words. Caught in pre-ship review,
|
|
216
|
+
* which is also why `rowCount` is a parameter rather than a check in each face: two
|
|
217
|
+
* surfaces deciding this separately is how they drift.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} [opts]
|
|
220
|
+
* @param {boolean} [opts.escalated] the result came from auto-escalation, not an explicit ask
|
|
221
|
+
* @param {number} [opts.escalatedObsCount] hits the plain search returned before widening
|
|
222
|
+
* @param {number} [opts.variantCount] query variants fused (1 = rewrite produced nothing)
|
|
223
|
+
* @param {number} [opts.rowCount] rows actually shown to the caller
|
|
224
|
+
* @param {object} [opts.env=process.env] opt-out: CLAUDE_MEM_DEEP_DISCLOSURE=off
|
|
225
|
+
* @returns {string} the note, or '' when it should not be shown
|
|
226
|
+
*/
|
|
227
|
+
export function deepDisclosureNote({
|
|
228
|
+
escalated = false,
|
|
229
|
+
escalatedObsCount = 0,
|
|
230
|
+
variantCount = 0,
|
|
231
|
+
rowCount = 0,
|
|
232
|
+
env = process.env,
|
|
233
|
+
} = {}) {
|
|
234
|
+
if (String(env.CLAUDE_MEM_DEEP_DISCLOSURE || '').toLowerCase() === 'off') return '';
|
|
235
|
+
if (!(variantCount > 1)) return '';
|
|
236
|
+
if (!(rowCount > 0)) return '';
|
|
237
|
+
const why = escalated
|
|
238
|
+
? `auto-escalated after the plain search returned ${escalatedObsCount} hit(s)`
|
|
239
|
+
: 'explicitly requested';
|
|
240
|
+
return (
|
|
241
|
+
`[deep search: ${why}. Rows above may be ADJACENT to the query rather than answers to it — ` +
|
|
242
|
+
`on a corpus that cannot answer, the widened query still fills the page (measured: 10 of 10 ` +
|
|
243
|
+
`slots on queries whose answers had been removed). Judge each row by its own text; ` +
|
|
244
|
+
`"nothing here actually answers this" is a valid conclusion.]`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
188
248
|
// Echoes hook-llm.mjs MEMORY_INPUT_GUARD (kept inline rather than imported so
|
|
189
249
|
// this module — and the tests that import it — never pull in hook-llm's
|
|
190
250
|
// native-heavy chain; see #8729). Same security intent: the query is untrusted.
|
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/hook.mjs
CHANGED
|
@@ -2038,8 +2038,15 @@ function saveHandoffAndFastSummary(
|
|
|
2038
2038
|
.get(prevProject || project, 'clear', handoffScopeId);
|
|
2039
2039
|
} catch {}
|
|
2040
2040
|
|
|
2041
|
-
// Generate session summary for previous session (background Haiku — richer version)
|
|
2042
|
-
|
|
2041
|
+
// Generate session summary for previous session (background Haiku — richer version).
|
|
2042
|
+
// Honours CLAUDE_MEM_SKIP_SUMMARY like the handleStop site does. This is the SAME
|
|
2043
|
+
// worker, and the flag's whole purpose (see the comment at its other call site) is
|
|
2044
|
+
// that llm-summary recreates a test's sandbox tree behind its cleanup — timed at
|
|
2045
|
+
// 432ms there. Gating one of two call sites left the flag unable to do the one job
|
|
2046
|
+
// it exists for whenever this branch is reached.
|
|
2047
|
+
if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) {
|
|
2048
|
+
spawnBackground('llm-summary', prevSessionId, prevProject || project);
|
|
2049
|
+
}
|
|
2043
2050
|
|
|
2044
2051
|
// Build fast synchronous summary for immediate context availability.
|
|
2045
2052
|
// Background llm-summary will produce a richer Haiku version later;
|
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-*
|
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
|
@@ -35,7 +35,13 @@ import {
|
|
|
35
35
|
BROWSE_TIERS,
|
|
36
36
|
BROWSE_TIER_LABELS,
|
|
37
37
|
} from './lib/browse-core.mjs';
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
deepSearch,
|
|
40
|
+
resolveDeepMode,
|
|
41
|
+
shouldEscalateToDeep,
|
|
42
|
+
autoDeepLlmReady,
|
|
43
|
+
deepDisclosureNote,
|
|
44
|
+
} from './deep-search.mjs';
|
|
39
45
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
40
46
|
import {
|
|
41
47
|
runMaintainOps,
|
|
@@ -45,6 +51,7 @@ import {
|
|
|
45
51
|
STALE_AGE_MS,
|
|
46
52
|
PINNED_INJ_THRESHOLD,
|
|
47
53
|
resolveDefaultMaintainOps,
|
|
54
|
+
ALL_MAINTAIN_OPS,
|
|
48
55
|
} from './lib/maintain-core.mjs';
|
|
49
56
|
// snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
|
|
50
57
|
// (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
|
|
@@ -432,6 +439,19 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
432
439
|
: '[mem] Deep search: rerank produced no usable order; kept fused order\n',
|
|
433
440
|
);
|
|
434
441
|
}
|
|
442
|
+
// D#3. Same disclosure the MCP surface puts in its payload, on the channel this face
|
|
443
|
+
// already uses for deep notes — a Bash caller reads stderr alongside stdout, so unlike
|
|
444
|
+
// MCP there is no split here. One home for the text (deep-search.mjs) because two faces
|
|
445
|
+
// writing their own wording is this repo's most-paid-for defect class.
|
|
446
|
+
if (isDeep) {
|
|
447
|
+
const disclosure = deepDisclosureNote({
|
|
448
|
+
escalated: res.escalated,
|
|
449
|
+
escalatedObsCount: res.escalatedObsCount,
|
|
450
|
+
variantCount: deepVariants?.length ?? 0,
|
|
451
|
+
rowCount: paged?.length ?? 0,
|
|
452
|
+
});
|
|
453
|
+
if (disclosure) process.stderr.write(`${disclosure}\n`);
|
|
454
|
+
}
|
|
435
455
|
|
|
436
456
|
// "nothing matched" (no offset) vs "this page is empty" (with offset) — the two
|
|
437
457
|
// CLI messages. preFinalizeCount is the pre-pagination population (post-tier).
|
|
@@ -1898,7 +1918,7 @@ function cmdDelete(db, args) {
|
|
|
1898
1918
|
|
|
1899
1919
|
const confirm = flags.confirm === true || flags.confirm === 'true';
|
|
1900
1920
|
// Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
|
|
1901
|
-
const { rows, lines: previewLines } = previewDeleteRows(db, ids);
|
|
1921
|
+
const { rows, lines: previewLines, missing } = previewDeleteRows(db, ids);
|
|
1902
1922
|
|
|
1903
1923
|
if (rows.length === 0) {
|
|
1904
1924
|
fail('[mem] No observations found for given IDs');
|
|
@@ -1908,6 +1928,7 @@ function cmdDelete(db, args) {
|
|
|
1908
1928
|
if (!confirm) {
|
|
1909
1929
|
out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
|
|
1910
1930
|
for (const line of previewLines) out(line);
|
|
1931
|
+
if (missing.length > 0) out(`[mem] Note: ID(s) ${missing.join(', ')} not found and will be skipped.`);
|
|
1911
1932
|
out('[mem] Run with --confirm to execute deletion.');
|
|
1912
1933
|
return;
|
|
1913
1934
|
}
|
|
@@ -1916,7 +1937,6 @@ function cmdDelete(db, args) {
|
|
|
1916
1937
|
// transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
|
|
1917
1938
|
// mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
|
|
1918
1939
|
const result = deleteObservations(db, ids);
|
|
1919
|
-
const missing = ids.filter((id) => !rows.some((r) => r.id === id));
|
|
1920
1940
|
const recoveredNote =
|
|
1921
1941
|
result.recoveredChildren > 0
|
|
1922
1942
|
? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`
|
|
@@ -2498,6 +2518,13 @@ function cmdCompress(db, args) {
|
|
|
2498
2518
|
|
|
2499
2519
|
// ─── Maintain ────────────────────────────────────────────────────────────────
|
|
2500
2520
|
|
|
2521
|
+
// Shared by BOTH maintain branches. It used to be a local const inside `execute`,
|
|
2522
|
+
// which is why `scan` — the preview step — silently accepted `--ops purge-stale`
|
|
2523
|
+
// (hyphen for underscore), printed a full report and exited 0, leaving the typo to
|
|
2524
|
+
// surface only on the run the preview was supposed to de-risk. The list itself now
|
|
2525
|
+
// comes from lib/maintain-core.mjs so this face and the MCP schema cannot drift.
|
|
2526
|
+
const VALID_MAINTAIN_OPS = ALL_MAINTAIN_OPS;
|
|
2527
|
+
|
|
2501
2528
|
function cmdMaintain(db, args) {
|
|
2502
2529
|
const { positional, flags } = parseArgs(args);
|
|
2503
2530
|
const action = positional[0];
|
|
@@ -2518,12 +2545,32 @@ function cmdMaintain(db, args) {
|
|
|
2518
2545
|
const baseParams = project ? [project] : [];
|
|
2519
2546
|
|
|
2520
2547
|
if (action === 'scan') {
|
|
2548
|
+
// Validate --ops here too, with the same list and the same message `execute`
|
|
2549
|
+
// uses. Catching the typo in the PREVIEW is the whole point: this is the step a
|
|
2550
|
+
// user runs to find out what would happen, and it used to ignore the flag
|
|
2551
|
+
// wholesale. Only validated when present — a plain `maintain scan` is unchanged.
|
|
2552
|
+
if (flags.ops !== undefined) {
|
|
2553
|
+
const scanOps = String(flags.ops)
|
|
2554
|
+
.split(',')
|
|
2555
|
+
.map((s) => s.trim());
|
|
2556
|
+
const invalid = scanOps.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
|
|
2557
|
+
if (invalid.length > 0) {
|
|
2558
|
+
fail(`[mem] Unknown operation(s): ${invalid.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2521
2563
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
2522
2564
|
const mctx = { projectFilter, baseParams, staleAge };
|
|
2523
2565
|
const duplicates = findDuplicates(db, mctx);
|
|
2524
2566
|
const stats = maintenanceStats(db, mctx);
|
|
2525
2567
|
|
|
2526
2568
|
out(`[mem] Maintenance scan:`);
|
|
2569
|
+
// The ops are valid but scan is not scoped by them — say so instead of letting a
|
|
2570
|
+
// scoped-looking invocation imply a scoped report.
|
|
2571
|
+
if (flags.ops !== undefined) {
|
|
2572
|
+
out(` (--ops is an execute-time filter; this scan reports every category)`);
|
|
2573
|
+
}
|
|
2527
2574
|
out(` Total active: ${stats.total}`);
|
|
2528
2575
|
out(` Near-duplicate pairs: ${duplicates.length}`);
|
|
2529
2576
|
out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
|
|
@@ -2567,27 +2614,17 @@ function cmdMaintain(db, args) {
|
|
|
2567
2614
|
}
|
|
2568
2615
|
|
|
2569
2616
|
// 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
2617
|
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2581
2618
|
// (`--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
|
|
2619
|
+
// to the destructive default set and EXECUTED it; route it to the VALID_MAINTAIN_OPS check
|
|
2583
2620
|
// below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
|
|
2584
2621
|
// was the literal `cleanup,decay,boost` when this was written; it now comes from
|
|
2585
2622
|
// DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
|
|
2586
2623
|
const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
|
|
2587
2624
|
const ops = opsStr.split(',').map((s) => s.trim());
|
|
2588
|
-
const invalidOps = ops.filter((op) => !
|
|
2625
|
+
const invalidOps = ops.filter((op) => !VALID_MAINTAIN_OPS.includes(op));
|
|
2589
2626
|
if (invalidOps.length > 0) {
|
|
2590
|
-
fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${
|
|
2627
|
+
fail(`[mem] Unknown operation(s): ${invalidOps.join(', ')}. Valid: ${VALID_MAINTAIN_OPS.join(', ')}`);
|
|
2591
2628
|
return;
|
|
2592
2629
|
}
|
|
2593
2630
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
@@ -3182,6 +3219,8 @@ Commands:
|
|
|
3182
3219
|
--json Machine-readable output (plain doctor run)
|
|
3183
3220
|
|
|
3184
3221
|
fts-check <check|rebuild> FTS5 index check or rebuild
|
|
3222
|
+
Exit 0 when every index is healthy / rebuilt, 1 otherwise —
|
|
3223
|
+
so "fts-check rebuild && <next step>" is safe to chain.
|
|
3185
3224
|
|
|
3186
3225
|
stats Show memory statistics
|
|
3187
3226
|
--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.3.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.3.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.3.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/server.mjs
CHANGED
|
@@ -10,7 +10,13 @@ import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
|
10
10
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR } from './schema.mjs';
|
|
11
11
|
import { reRankWithContext, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
|
|
12
12
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
deepSearch,
|
|
15
|
+
resolveDeepMode,
|
|
16
|
+
shouldEscalateToDeep,
|
|
17
|
+
autoDeepLlmReady,
|
|
18
|
+
deepDisclosureNote,
|
|
19
|
+
} from './deep-search.mjs';
|
|
14
20
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
15
21
|
import {
|
|
16
22
|
resolveAnchorToken,
|
|
@@ -577,6 +583,18 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
|
577
583
|
if (r.reranked && output.content?.[0]?.type === 'text') {
|
|
578
584
|
output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
|
|
579
585
|
}
|
|
586
|
+
// D#3. The stderr line above is for humans and logs; an MCP client reads the tool RESULT,
|
|
587
|
+
// so the escalation fact and the adjacency caveat have to land in the payload or the one
|
|
588
|
+
// caller running deep=auto by default never sees either.
|
|
589
|
+
if (r.isDeep && output.content?.[0]?.type === 'text') {
|
|
590
|
+
const disclosure = deepDisclosureNote({
|
|
591
|
+
escalated: r.escalated,
|
|
592
|
+
escalatedObsCount: r.escalatedObsCount,
|
|
593
|
+
variantCount: r.variants?.length ?? 0,
|
|
594
|
+
rowCount: r.page?.length ?? 0,
|
|
595
|
+
});
|
|
596
|
+
if (disclosure) output.content[0].text += `\n\n${disclosure}`;
|
|
597
|
+
}
|
|
580
598
|
appendDeferredTrailer(output);
|
|
581
599
|
|
|
582
600
|
// Expose structured fields for tests + the MCP content blob.
|
|
@@ -961,7 +979,7 @@ server.registerTool(
|
|
|
961
979
|
},
|
|
962
980
|
safeHandler(async (args) => {
|
|
963
981
|
// Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
|
|
964
|
-
const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
|
|
982
|
+
const { rows, lines: previewLines, missing } = previewDeleteRows(db, args.ids);
|
|
965
983
|
|
|
966
984
|
if (rows.length === 0) {
|
|
967
985
|
return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
|
|
@@ -969,6 +987,8 @@ server.registerTool(
|
|
|
969
987
|
|
|
970
988
|
if (!args.confirm) {
|
|
971
989
|
const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
|
|
990
|
+
if (missing.length > 0)
|
|
991
|
+
lines.push(`\nNote: ID(s) ${missing.join(', ')} not found and will be skipped.`);
|
|
972
992
|
lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
|
|
973
993
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
974
994
|
}
|
|
@@ -978,7 +998,6 @@ server.registerTool(
|
|
|
978
998
|
// with the CLI `delete` path (was inlined + kept in sync by parity comments).
|
|
979
999
|
const result = deleteObservations(db, args.ids);
|
|
980
1000
|
|
|
981
|
-
const missing = args.ids.filter((id) => !rows.some((r) => r.id === id));
|
|
982
1001
|
const msg = [`Deleted ${result.deleted} observation(s).`];
|
|
983
1002
|
if (result.recoveredChildren > 0)
|
|
984
1003
|
msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);
|