claude-mem-lite 6.8.2 → 6.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.8.2",
12
+ "version": "6.8.3",
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.8.2",
3
+ "version": "6.8.3",
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/adopt-cli.mjs CHANGED
@@ -425,6 +425,13 @@ function unadoptAll(args) {
425
425
  log(`[unadopt --all] ${dir} → cleaned partial residue (detail doc/state, no block)`);
426
426
  partial++;
427
427
  }
428
+ // OUTSIDE the branch, because residue is orthogonal to what happened to the block: a
429
+ // project can have its block removed AND still carry an unpaired sentinel. An orphan is
430
+ // the one kind of residue the sweep cannot finish — its block has no end marker, so its
431
+ // extent is unknowable — and the two lines above would otherwise imply the project is
432
+ // clean. Inside the else-branch it was also unreachable for the 'removed' case, which is
433
+ // how the print survived a mutation with the whole suite green (pre-ship review P2-2).
434
+ if (r.residue) log(` ⚠ ${r.residue}`);
428
435
  }
429
436
 
430
437
  // 2. Legacy memory-dir cleanup across every memdir (foreign-content guarded).
@@ -481,4 +488,8 @@ export function cmdUnadopt(args = []) {
481
488
  const mig = migrateLegacyMemoryDir(cwd, PLUGIN_SLUG, { force });
482
489
  const migNote = mig.action === 'removed' ? ' (+cleaned legacy memdir)' : '';
483
490
  log(`[unadopt] ${cwd} → ${r.action}${migNote}`);
491
+ // 'partial' is the outcome that used to print as 'absent': the sidecar files are gone but
492
+ // an unpaired sentinel still holds steering text in the user's CLAUDE.md, and only they can
493
+ // decide where that text ends. Silence here is what let it survive every sweep.
494
+ if (r.residue) log(` ⚠ ${r.residue}`);
484
495
  }
package/claudemd.mjs CHANGED
@@ -59,8 +59,50 @@ function escapeRe(s) {
59
59
  // are `\r?\n` (not bare `\n`) so a CLAUDE.md re-saved with Windows CRLF endings
60
60
  // still matches — otherwise the block read as "absent" and a fresh LF copy got
61
61
  // appended every SessionStart, growing the file without bound (review C1/H2).
62
+ // The body may not contain ANOTHER sentinel of the same slug. `[\s\S]*?` could, and that is
63
+ // not a tidiness point — it is how a match stopped being one block. Drop the `:end` line by
64
+ // hand (a merge resolution, an editor, another tool) and the next adopt appends a second
65
+ // block below whatever the user has written since; the adopt after THAT matched from the
66
+ // orphaned begin, lazily, to the only `:end` in the file — which now sits past the user's
67
+ // text and past the second begin — so `raw.replace(m[0], section)` deleted all of it.
68
+ // Measured 2026-09-13: a "## Deployment runbook" section appended after adoption was gone
69
+ // after two further adopts, silently, with both runs reporting success.
70
+ //
71
+ // The tempered token below cannot span a sentinel, so the engine backtracks to the
72
+ // WELL-FORMED pair and the orphan is simply left alone — which is the right answer for a
73
+ // file we do not own: where the orphaned body ends is genuinely unknowable, so removeManaged
74
+ // reports it (action 'partial') rather than guessing a span to delete.
75
+ //
76
+ // Safe by construction for legitimate blocks: the shipped body carries the slug twice and
77
+ // never as a sentinel (measured: 1304 bytes — 1296 UTF-16 units, the body has em dashes —
78
+ // with zero `:begin` / `:end` occurrences), and the
79
+ // separators stay `\r?\n` for the CRLF reason below.
62
80
  function blockBody(esc) {
63
- return `<!-- ${esc}:begin (v\\d+) -->\\r?\\n([\\s\\S]*?)\\r?\\n<!-- ${esc}:end -->`;
81
+ const sentinel = `<!-- ${esc}:(?:begin|end)`;
82
+ return `<!-- ${esc}:begin (v\\d+) -->\\r?\\n((?:(?!${sentinel})[\\s\\S])*?)\\r?\\n<!-- ${esc}:end -->`;
83
+ }
84
+
85
+ // Any sentinel LINE of our slug, paired or not. The pair regex above is deliberately blind to
86
+ // an unpaired one; this is what lets residue reporting see what it cannot safely remove.
87
+ function sentinelLineRegexG(slug) {
88
+ return new RegExp(`<!-- ${escapeRe(slug)}:(?:begin|end)\\b[^>]*-->`, 'g');
89
+ }
90
+
91
+ /**
92
+ * Sentinel lines of this slug left in `raw` that no well-formed block accounts for.
93
+ * Zero on a healthy file (every sentinel belongs to a matched pair) and on a clean one.
94
+ * @param {string} raw
95
+ * @param {string} slug
96
+ * @returns {number}
97
+ */
98
+ function orphanSentinelCount(raw, slug) {
99
+ const total = (raw.match(sentinelLineRegexG(slug)) || []).length;
100
+ let paired = 0;
101
+ raw.replace(blockRegexG(slug), (whole) => {
102
+ paired += (whole.match(sentinelLineRegexG(slug)) || []).length;
103
+ return whole;
104
+ });
105
+ return total - paired;
64
106
  }
65
107
  function blockRegex(slug) {
66
108
  return new RegExp(blockBody(escapeRe(slug)));
@@ -137,13 +179,24 @@ export function isAdopted(cwd, slug) {
137
179
  * removed it. removeManaged cleans all three pieces, so sweep on any of them.
138
180
  */
139
181
  export function hasResidue(cwd, slug) {
140
- return (
141
- readBlock(cwd, slug).body !== null ||
142
- existsSync(detailDocPath(cwd, slug)) ||
143
- existsSync(stateFilePath(cwd, slug))
144
- );
182
+ const blk = readBlock(cwd, slug);
183
+ return blk.body !== null || existsSync(detailDocPath(cwd, slug)) || existsSync(stateFilePath(cwd, slug));
145
184
  }
146
185
 
186
+ // An unpaired sentinel is deliberately NOT in the list above (pre-ship review P2-3). A first
187
+ // cut added it, reasoning that the sweep should see what the pair regex cannot. But
188
+ // orphanSentinelCount counts sentinel-shaped TEXT, and a project the plugin never touched can
189
+ // mention the marker in prose — documenting it, pasting half an example, a changelog line.
190
+ // That one mention let `unadopt --all` into a stranger's project, where removeManaged
191
+ // unconditionally deletes the detail doc and state sidecar and rmdir's an empty `.claude/`,
192
+ // then printed "remove those lines by hand" at the user's own paragraph — and never
193
+ // converged, because the mention is still there on the next sweep.
194
+ //
195
+ // The three entries above are all things the PLUGIN WROTE; a sentinel in prose is not. And
196
+ // the sweep gains nothing by entering: removeManaged cannot clean an orphan anyway, by
197
+ // design. The orphan is reported by removeManaged when unadopt genuinely runs — which is the
198
+ // real failure case, where the doc and sidecar are still present and do bring it in.
199
+
147
200
  /**
148
201
  * Whether the installed block/doc has drifted from the shipped content — i.e.
149
202
  * a version bump or a template edit means we should refresh. Returns true when
@@ -232,11 +285,25 @@ export function writeManaged(cwd, { slug, version, block, doc }) {
232
285
  * Remove our managed block from CLAUDE.md (preserving all other content) and
233
286
  * delete the detail doc + state sidecar. Best-effort removes an emptied
234
287
  * .claude/ directory.
235
- * @returns {{action: 'removed'|'absent'}}
288
+ *
289
+ * THREE outcomes, not two — the same rule lib/db-unusable.mjs states about backups: "there
290
+ * is nothing to do" and "I could not finish" must not print in the same voice, because a
291
+ * green-sounding line ends the reader's search. `absent` used to cover both: with one
292
+ * sentinel line missing, the pair regex matched nothing, so this returned 'absent' — while
293
+ * having already deleted the detail doc and the state sidecar and left ~1.3 KB of managed
294
+ * steering text in the user's CLAUDE.md, which it is then loaded from on every session.
295
+ * `partial` is that case, and `residue` names what is left so the caller can say so.
296
+ *
297
+ * Deliberately does NOT delete an orphaned sentinel's body: where it ends is unknowable
298
+ * (that is the defect, not a detail), and guessing a span in a file we do not own is how the
299
+ * adopt side came to delete a user's runbook. Report, do not repair.
300
+ *
301
+ * @returns {{action: 'removed'|'partial'|'absent', residue?: string}}
236
302
  */
237
303
  export function removeManaged(cwd, slug) {
238
304
  const p = claudeMdPath(cwd);
239
305
  let action = 'absent';
306
+ let orphans = 0;
240
307
  if (existsSync(p)) {
241
308
  let raw = readFileSync(p, 'utf8');
242
309
  // H2: loop so ALL same-slug blocks are removed, not just the first (a
@@ -283,8 +350,17 @@ export function removeManaged(cwd, slug) {
283
350
  atomicWrite(p, raw);
284
351
  }
285
352
  }
353
+ // Counted on what is left AFTER the loop, so a healthy file (every sentinel consumed by
354
+ // a matched pair) reports zero and only a genuinely unpaired line survives the count.
355
+ orphans = orphanSentinelCount(raw, slug);
286
356
  }
357
+ // Captured BEFORE the deletions below, because they are what it asks about: is there any
358
+ // evidence the plugin ever wrote in this project? An unpaired sentinel is NOT such
359
+ // evidence — it is text, and a project that merely documents the marker in prose has one
360
+ // (pre-ship review P2-3). Reporting residue there means telling a stranger to delete their
361
+ // own paragraph, on a project this tool has never touched.
287
362
  const dp = detailDocPath(cwd, slug);
363
+ const wasOurs = action === 'removed' || existsSync(dp) || existsSync(stateFilePath(cwd, slug));
288
364
  if (existsSync(dp))
289
365
  try {
290
366
  unlinkSync(dp);
@@ -300,6 +376,18 @@ export function removeManaged(cwd, slug) {
300
376
  } catch {
301
377
  /* best-effort */
302
378
  }
379
+ // `action` answers ONE question — what happened to the block — and `residue` is an
380
+ // independent fact that rides alongside it. A first cut let an orphan override 'removed'
381
+ // too, on the reasoning that both are "unfinished". Pre-ship review P2-1: unadoptAll's
382
+ // else-branch prints "cleaned partial residue (detail doc/state, no block)" and counts
383
+ // `partial++`, so a sweep that DID remove a block reported "no block" and tallied zero
384
+ // removals. Two facts, two fields.
385
+ const residue =
386
+ orphans > 0 && wasOurs
387
+ ? `${orphans} unpaired \`${slug}\` sentinel line(s) remain in ${claudeMdPath(cwd)} — the block they opened has no matching end marker, so its extent cannot be determined safely. Remove those lines and the text they wrap by hand.`
388
+ : null;
389
+ if (action === 'removed') return residue ? { action, residue } : { action };
390
+ if (residue) return { action: 'partial', residue };
303
391
  return { action };
304
392
  }
305
393
 
@@ -67,6 +67,49 @@ export function isFtsCorruptionError(err) {
67
67
  return /SQLITE_CORRUPT_VTAB/i.test(`${err?.code || ''}`);
68
68
  }
69
69
 
70
+ /**
71
+ * What to do about a damaged FTS5 INDEX — the query-time half of isFtsCorruptionError.
72
+ *
73
+ * R10 P3-9 wired the classifier into `ensureDbWithWalRecovery`, which rebuilds and retries.
74
+ * That covers OPEN time. A structure record damaged inside the index opens fine — nothing
75
+ * reads it until the first MATCH — so the fault surfaces at QUERY time, and there the two
76
+ * faces that carry it to a reader (the CLI catch-all, the MCP safeHandler) passed SQLite's
77
+ * own sentence through with no next step. `recent` / `recall` / `browse` / `context` never
78
+ * touch FTS and keep answering, which makes that dead end easy to misread as "search found
79
+ * nothing" rather than "search is broken".
80
+ *
81
+ * ONE STRING FOR BOTH CHANNELS, unlike the file-level family below. The split there exists
82
+ * because that remedy OVERWRITES the database and must not be handed ready-to-run to an
83
+ * agent holding Bash. This one re-derives every index from its own content table: the rows
84
+ * are never read from the index, so a rebuild is lossless and idempotent, and `doctor`
85
+ * already runs it unprompted. Naming "intact" is load-bearing — the reading this line
86
+ * exists to prevent is "my memories are corrupt".
87
+ *
88
+ * SCOPE, measured rather than assumed: a damaged index does not always reach the caller as
89
+ * SQLITE_CORRUPT_VTAB. `observations_fts_data` holds three rows on a one-observation store —
90
+ * id=1 (averages), id=10 (the STRUCTURE record) and one leaf page — and which row the damage
91
+ * lands on decides the code. Over 100 trials each:
92
+ *
93
+ * UPDATE … SET block = randomblob(32) WHERE id > 1 (structure + leaf) 98 VTAB, 2 NOMEM
94
+ * UPDATE … SET block = randomblob(32) WHERE id > 10 (leaf only) 100 VTAB, 0 NOMEM
95
+ * DELETE … WHERE id > 10 (leaf only) 100 VTAB, 0 NOMEM
96
+ *
97
+ * So NOMEM comes from a mangled STRUCTURE record, where SQLite reads a corrupt varint and asks
98
+ * for an absurd allocation — not from leaf damage. (A first draft of this paragraph said
99
+ * "leaf pages", which would send anyone re-measuring `WHERE id > 10` to 0/N and make the
100
+ * boundary below look vacuous. Caught by the pre-ship claims audit.) That case gets no
101
+ * remedy, on purpose:
102
+ * isFtsCorruptionError is what isDbCorruptionError and isDbUnusableError consult, so
103
+ * admitting SQLITE_NOMEM would answer a real out-of-memory with a full FTS rebuild, and
104
+ * would tell a user their index is damaged when it may be their RAM. The code cannot
105
+ * discriminate the two, so this covers the fault it can name.
106
+ * `tests/fts-corruption-query-time-remedy.test.mjs` pins that boundary.
107
+ */
108
+ export const FTS_CORRUPTION_REMEDY =
109
+ 'The FTS5 search index is damaged; the stored observations are intact. ' +
110
+ 'Rebuild it losslessly with `claude-mem-lite fts-check rebuild` ' +
111
+ '(`claude-mem-lite doctor` rebuilds it too, and re-checks everything else).';
112
+
70
113
  /**
71
114
  * True when `err` means "this file exists and SQLite cannot use it as a database".
72
115
  *
package/mem-cli.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import { homedir } from 'os';
6
6
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
7
7
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
8
+ import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
8
9
  import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
9
10
  import { resolveProject } from './project-utils.mjs';
10
11
  // READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
@@ -3847,6 +3848,12 @@ export async function run(argv) {
3847
3848
  // agent runs the CLI, the model's context — got a raw Node stack trace. Print the
3848
3849
  // message, keep the stack behind CLAUDE_MEM_DEBUG for whoever is actually debugging.
3849
3850
  process.stderr.write(`[mem] ${cmd || 'command'} failed: ${(e && e.message) || e}\n`);
3851
+ // A damaged FTS5 index reaches here as SQLITE_CORRUPT_VTAB from the first MATCH.
3852
+ // `fts-check` and `doctor` touch the index too — and both already explain themselves —
3853
+ // so `search` is the one command that DEAD-ENDS on SQLite's sentence, while `recent` /
3854
+ // `recall` / `browse` / `context` / `stats` never read the index and keep working.
3855
+ // The remedy is lossless (see FTS_CORRUPTION_REMEDY); the exit code stays 1.
3856
+ if (isFtsCorruptionError(e)) process.stderr.write(`[mem] ${FTS_CORRUPTION_REMEDY}\n`);
3850
3857
  if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`${(e && e.stack) || ''}\n`);
3851
3858
  process.exitCode = 1;
3852
3859
  } finally {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.8.2",
3
+ "version": "6.8.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.8.2",
9
+ "version": "6.8.3",
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": "6.8.2",
3
+ "version": "6.8.3",
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
@@ -118,6 +118,7 @@ import { saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from '
118
118
  import { applyObsUpdate } from './lib/observation-write.mjs';
119
119
  import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
120
120
  import { recallByFile } from './lib/recall-core.mjs';
121
+ import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
121
122
  import { fetchRecent } from './lib/recent-core.mjs';
122
123
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
123
124
  import {
@@ -374,7 +375,17 @@ function safeHandler(fn, { verbatim = false } = {}) {
374
375
  const result = await fn(args, extra);
375
376
  return verbatim ? result : defangResult(result, { skillBlocks: true });
376
377
  } catch (err) {
377
- return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
378
+ // A damaged FTS5 index arrives here as SQLITE_CORRUPT_VTAB from the first MATCH.
379
+ // Without this the model got SQLite's own sentence and nothing else, on a fault it
380
+ // could have had fixed in one command — and mem_recent / mem_recall / mem_browse
381
+ // keep answering, so the dead end reads as "nothing matched". Both channels carry the
382
+ // same string here, deliberately: unlike the file-level remedy this one is lossless
383
+ // (see FTS_CORRUPTION_REMEDY in lib/db-unusable.mjs).
384
+ const hint = isFtsCorruptionError(err) ? `\n${FTS_CORRUPTION_REMEDY}` : '';
385
+ return defangResult({
386
+ content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
387
+ isError: true,
388
+ });
378
389
  }
379
390
  };
380
391
  }