claude-mem-lite 6.29.0 → 6.29.2

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.29.0",
12
+ "version": "6.29.2",
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. FTS5 BM25 keyword 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.29.0",
3
+ "version": "6.29.2",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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/commands/adopt.md CHANGED
@@ -7,9 +7,9 @@ description: "Use when: user asks to put the claude-mem-lite steering block into
7
7
 
8
8
  Install the claude-mem-lite **steering block** into the current project's
9
9
  `CLAUDE.md` (the canonical home for project instructions — loaded by Claude Code
10
- at system-prompt authority). This replaces the pre-v3.13 scheme that seeded the
11
- project's memory-dir `MEMORY.md`; that polluted an index meant for the user's own
12
- memories, and `MEMORY.md` carries no more weight than `CLAUDE.md` anyway.
10
+ at system-prompt authority). It does not go in the project's memory-dir
11
+ `MEMORY.md`: that index belongs to the user's own memories, and it carries no
12
+ more weight than `CLAUDE.md`.
13
13
 
14
14
  Auto-adopt does not add the block to `CLAUDE.md`: in a git repository it keeps the same block
15
15
  in `CLAUDE.local.md` at the repository root, excluded from git through `.git/info/exclude`.
package/commands/bug.md CHANGED
@@ -10,8 +10,7 @@ Record a known bug + reproduction steps. Writes a searchable **observation**
10
10
  via `mem_search` and the PreToolUse recall hooks warn when the affected files
11
11
  are edited. Does NOT touch memdir. Useful for bugs you can't fix immediately
12
12
  but want future sessions (or yourself after `/clear`) to know about and avoid
13
- re-investigating. (Redirected v3.39: was the `events` table, which `mem_search`
14
- never read.)
13
+ re-investigating.
15
14
 
16
15
  ## When to use
17
16
 
@@ -9,9 +9,8 @@ Record a lesson / gotcha / workaround. Writes a searchable **observation**
9
9
  (`type=discovery` with the text as `lesson_learned`) so future sessions can
10
10
  find it via `mem_search` and the PreToolUse recall hooks surface it on the
11
11
  relevant files. Does NOT touch memdir — so lessons never end up in the L1
12
- system-prompt memory section and do not conflict with the `WHAT_NOT_TO_SAVE`
13
- semantics sdscc enforces. (Redirected v3.39: was the `events` table, which
14
- `mem_search` never read.)
12
+ system-prompt memory section and do not conflict with Claude Code's auto-memory
13
+ rules on what not to save there.
15
14
 
16
15
  ## When to use
17
16
 
@@ -23,7 +22,7 @@ After you (or the user) solve a tricky bug or discover a non-obvious behavior:
23
22
 
24
23
  Don't use for trivial fixes (typos, renames), for rules that belong in memdir
25
24
  (user preferences, project-wide conventions), or for general project notes
26
- (use `/mem:memory` or `mem_save` instead).
25
+ (use `/claude-mem-lite:memory` or `mem_save` instead).
27
26
 
28
27
  ## Arguments
29
28
 
package/commands/mem.md CHANGED
@@ -30,9 +30,9 @@ When the user invokes `/mem`, parse their intent:
30
30
  - `/mem save <text>` → call `mem_save` MCP tool with the text as content
31
31
  - `/mem stats` → run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" stats` via Bash
32
32
  - `/mem get <ids>` → run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" get <ids>` via Bash
33
- - `/mem cleanup` → run `mem_maintain(action="scan")`, report pending purge count and stale items to user, ask for confirmation, then run `mem_maintain(action="execute", operations=["purge_stale"], confirm=true)` if confirmed. **`confirm=true` is required and is not optional politeness:** without it the call returns a dry-run PREVIEW and deletes nothing, while still succeeding — so you would report a cleanup that never happened.
34
- - `/mem cleanup Nd` (e.g. `60d`) → same as above but add `retain_days=N` to only purge items older than N days. **`retain_days` must be between 7 and 365**; anything outside that range is rejected by the schema, so `/mem cleanup 3d` cannot be honoured — say so rather than silently substituting the default.
35
- - `/mem cleanup keep Nd` (e.g. `keep 14d`) → same as above with `retain_days=N`, same 7–365 range.
33
+ - `/mem cleanup` → run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain scan` via Bash, report pending purge count and stale items to user, ask for confirmation, then run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain execute --ops purge_stale --confirm` via Bash if confirmed. **`--confirm` is required and is not optional politeness:** without it the command prints a dry-run PREVIEW and deletes nothing, while still succeeding — so you would report a cleanup that never happened.
34
+ - `/mem cleanup Nd` (e.g. `60d`) → same as above but add `--retain-days N` to only purge items older than N days. **`--retain-days` must be between 7 and 365**; the CLI rejects anything outside that range, so `/mem cleanup 3d` cannot be honoured — say so rather than silently substituting the default.
35
+ - `/mem cleanup keep Nd` (e.g. `keep 14d`) → same as above with `--retain-days N`, same 7–365 range.
36
36
  - `/mem <query>` (no subcommand) → treat as search, run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" search <query>` via Bash
37
37
 
38
38
  Use Bash commands first. For detailed data, use `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" get <id>` via Bash.
@@ -9,12 +9,12 @@ Save important content to your long-term memory database.
9
9
 
10
10
  ## Commands
11
11
 
12
- - `/mem:memory <content>` — Save the given content directly to memory
13
- - `/mem:memory` (no args) — Auto-summarize recent session highlights and save key findings
12
+ - `/claude-mem-lite:memory <content>` — Save the given content directly to memory
13
+ - `/claude-mem-lite:memory` (no args) — Auto-summarize recent session highlights and save key findings
14
14
 
15
15
  ## Instructions
16
16
 
17
- When the user invokes `/mem:memory`, determine the intent:
17
+ When the user invokes `/claude-mem-lite:memory`, determine the intent:
18
18
 
19
19
  ### With explicit content
20
20
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: update
3
3
  description: "Use when: search results seem noisy or for periodic memory maintenance"
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Memory Maintenance
@@ -9,23 +10,25 @@ Run intelligent maintenance on the memory database.
9
10
 
10
11
  ## Usage
11
12
 
12
- - `/mem:update` — Run full maintenance cycle
13
+ - `/claude-mem-lite:update` — Run full maintenance cycle
13
14
 
14
15
  ## Instructions
15
16
 
16
- When the user invokes `/mem:update`, perform the following maintenance cycle:
17
+ When the user invokes `/claude-mem-lite:update`, perform the following maintenance cycle:
17
18
 
18
19
  ### Phase 1: Memory Maintenance
19
20
 
20
- 1. Call `mem_maintain(action="scan")` to analyze maintenance candidates
21
+ Run each command below via Bash; the CLI reaches the maintenance operations whether or not their MCP tools are in your tool list.
22
+
23
+ 1. Run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain scan` to analyze maintenance candidates
21
24
  2. Report scan results to the user (duplicates, stale items, broken items, boostable items, **pending purge** items)
22
- 3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
- 4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
24
- 5. Run `mem_compress(preview=false)` for old low-value observations
25
- 6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"], confirm=true)`. **`confirm=true` is required:** without it the call returns a dry-run preview and deletes nothing while still succeeding, so you would report a purge that never happened. User may optionally specify `retain_days` (default 30, allowed range 7–365) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
25
+ 3. **If pending purge items > 0**: first run the purge command below without `--confirm`: it prints how many rows it would delete and deletes nothing. Report that count to the user and ask for confirmation. If confirmed, run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain execute --ops purge_stale --confirm`. **`--confirm` is required:** without it the command prints a dry-run preview and deletes nothing while still succeeding, so you would report a purge that never happened. User may optionally add `--retain-days N` (default 30, allowed range 7–365) to both runs to control how many days of data to keep; the CLI rejects a value outside that range, so tell the user instead of substituting the default. Do NOT purge without explicit user confirmation. The purge comes before the next step on purpose: decay there queues more rows for purge, and those wait for a later run, so what gets deleted is what the user approved.
26
+ 4. Run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain execute` to apply safe automatic changes — with no `--ops` it runs the default set: cleanup, decay, boost, then demote_pinned (left out when `CLAUDE_MEM_SKIP_DEMOTE_PINNED=1`)
27
+ 5. If duplicates were found in scan, review them and run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" maintain execute --ops dedup --merge-ids keepId:removeId,...` — keep the more important/recent observation in each pair. Groups are comma-separated and the first id in each is kept (`10:11,20:21:22`). For pairs similar enough to merge without review the scan prints a ready-made list after `--merge-ids`; pass it to the command above, since the command the scan prints uses a bare `claude-mem-lite`, which may not be installed
28
+ 6. Run `node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" compress --execute` for old low-value observations (without `--execute` it only previews)
26
29
 
27
30
  ### Phase 2: Summary
28
31
 
29
- Summarize all maintenance actions taken in zh-CN:
30
- - Memory: observations cleaned, decayed, boosted, deduplicated, compressed
32
+ Summarize all maintenance actions taken in the user's language:
33
+ - Memory: observations cleaned, decayed, boosted, demoted, deduplicated, compressed
31
34
  - Overall health assessment
package/hook-llm.mjs CHANGED
@@ -1429,8 +1429,8 @@ export async function handleLLMEpisode() {
1429
1429
  // returns 3 for any entry touching schema./migration/prisma/.env/.key paths; via the
1430
1430
  // Math.max below that force-promoted ordinary has-lesson episodes to "critical" imp=3
1431
1431
  // even when Haiku judged them 1-2 (audit: auto imp=3 = 34.8%, e.g. a thin-lesson edit
1432
- // to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
1433
- // path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
1432
+ // to schema.mjs). Only the path heuristic was capped then; D#414 below caps the model's own
1433
+ // score too. The isLessonLowSignal branch still floors no-lesson
1434
1434
  // non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
1435
1435
  // D#69: `!lessonLearned` is the old `isLessonLowSignal && !retryRecovered` (the
1436
1436
  // two are equal with grounding off). A lesson the grounding check dropped caps
@@ -1440,10 +1440,16 @@ export async function handleLLMEpisode() {
1440
1440
  // carries text whoever controls that output wrote — reproduced on real Haiku,
1441
1441
  // 3 of 6 hostile windows stored the directive at importance 2. The row and
1442
1442
  // lesson stay searchable; only the automatic injection faces lose them.
1443
+ // D#414: the model's own score is capped at 2 as well, as re-enrich's is (D#376). The window
1444
+ // carries tool output, text whoever controls a file or a command's output wrote, and 3 is
1445
+ // Key Context's top tier; benign episodes did not use it: 72 real sub-episodes from this
1446
+ // machine's transcripts through this prompt (claude -p haiku, 2026-10-17) scored 1 x42,
1447
+ // 2 x30, 3 x0. (A rule-based row, buildImmediateObservation's, saved before the call or when
1448
+ // it fails, can still be 3 after a failing test or build command: that is no model output.)
1443
1449
  importance:
1444
1450
  (!lessonLearned && (groundingDropped || parsed.type !== 'decision')) || quotesToolOutput
1445
1451
  ? Math.min(ruleImportance, 1)
1446
- : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
1452
+ : Math.min(Math.max(ruleImportance, clampImportance(parsed.importance)), 2),
1447
1453
  lessonLearned,
1448
1454
  searchAliases,
1449
1455
  // P3 (D#78): lesson applicability scope — whitelist-validated, invalid → null.
package/hook.mjs CHANGED
@@ -78,7 +78,7 @@ import {
78
78
  } from './hook-episode.mjs';
79
79
  // CODE_DIR, not DB_DIR: the schema-skew notice asks which CODE homes exist, and those are
80
80
  // always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
81
- import { DB_DIR, DB_PATH, CODE_DIR, refreshPlannerStats } from './schema.mjs';
81
+ import { DB_DIR, DB_PATH, CODE_DIR, refreshPlannerStats, recheckObservationIndexTerms } from './schema.mjs';
82
82
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
83
83
  import { entry as preCompactEntry } from './hook-precompact.mjs';
84
84
  import {
@@ -2335,6 +2335,19 @@ function runSessionStartAutoMaintain(db, project) {
2335
2335
  debugCatch(e, 'auto-maintain-handoff-gc');
2336
2336
  }
2337
2337
 
2338
+ // The CJK index terms an older build still writing this store left out (D#413).
2339
+ try {
2340
+ const { appended } = recheckObservationIndexTerms(db);
2341
+ if (appended > 0)
2342
+ debugLog(
2343
+ 'DEBUG',
2344
+ 'auto-maintain',
2345
+ `appended index terms to ${appended} observations an older build wrote`,
2346
+ );
2347
+ } catch (e) {
2348
+ debugCatch(e, 'auto-maintain-recheck-index-terms');
2349
+ }
2350
+
2338
2351
  // Planner statistics, the "once a day" half of SQLite's recommendation (#41). Last, so
2339
2352
  // the purge/dedup above have already moved the row counts it compares. Never throws.
2340
2353
  refreshPlannerStats(db);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.29.0",
3
+ "version": "6.29.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.29.0",
9
+ "version": "6.29.2",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "@modelcontextprotocol/sdk": "^1.30.0",
17
17
  "better-sqlite3": "^13.0.3",
18
- "zod": "^4.6.5"
18
+ "zod": "^4.6.7"
19
19
  },
20
20
  "bin": {
21
21
  "claude-mem-lite": "cli.mjs"
@@ -23,12 +23,12 @@
23
23
  "devDependencies": {
24
24
  "@eslint/js": "^10.0.1",
25
25
  "@vitest/coverage-v8": "^5.0.3",
26
- "acorn": "8.18.0",
27
- "eslint": "^10.11.0",
26
+ "acorn": "8.19.0",
27
+ "eslint": "^10.12.0",
28
28
  "fast-check": "^4.10.2",
29
- "knip": "^6.38.0",
29
+ "knip": "^6.41.0",
30
30
  "picomatch": "^4.0.4",
31
- "prettier": "3.9.9",
31
+ "prettier": "3.9.10",
32
32
  "vitest": "^5.0.3"
33
33
  },
34
34
  "engines": {
@@ -1197,9 +1197,9 @@
1197
1197
  "license": "ISC"
1198
1198
  },
1199
1199
  "node_modules/zod": {
1200
- "version": "4.6.5",
1201
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
1202
- "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
1200
+ "version": "4.6.7",
1201
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
1202
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
1203
1203
  "license": "MIT",
1204
1204
  "funding": {
1205
1205
  "url": "https://github.com/sponsors/colinhacks"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.29.0",
3
+ "version": "6.29.2",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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",
@@ -237,7 +237,7 @@
237
237
  "dependencies": {
238
238
  "@modelcontextprotocol/sdk": "^1.30.0",
239
239
  "better-sqlite3": "^13.0.3",
240
- "zod": "^4.6.5"
240
+ "zod": "^4.6.7"
241
241
  },
242
242
  "overrides": {
243
243
  "hono": ">=4.12.31",
@@ -250,12 +250,12 @@
250
250
  "devDependencies": {
251
251
  "@eslint/js": "^10.0.1",
252
252
  "@vitest/coverage-v8": "^5.0.3",
253
- "acorn": "8.18.0",
254
- "eslint": "^10.11.0",
253
+ "acorn": "8.19.0",
254
+ "eslint": "^10.12.0",
255
255
  "fast-check": "^4.10.2",
256
- "knip": "^6.38.0",
256
+ "knip": "^6.41.0",
257
257
  "picomatch": "^4.0.4",
258
- "prettier": "3.9.9",
258
+ "prettier": "3.9.10",
259
259
  "vitest": "^5.0.3"
260
260
  }
261
261
  }
package/schema.mjs CHANGED
@@ -1303,12 +1303,13 @@ const OBS_DOC_SQL = OBS_FTS_COLUMNS.map((c) => `COALESCE(${c}, '')`).join(` || '
1303
1303
  * appended to and whether the walk is finished.
1304
1304
  *
1305
1305
  * @param {import('better-sqlite3').Database} db
1306
- * @param {{ budgetMs?: number, batchSize?: number }} [opts]
1306
+ * @param {{ budgetMs?: number, batchSize?: number, cursor?: string }} [opts] `cursor`: the name of
1307
+ * the walk's row in migration_cursors (recheckObservationIndexTerms walks on its own)
1307
1308
  * @returns {{ appended: number, done: boolean }}
1308
1309
  */
1309
1310
  export function backfillObservationIndexTerms(
1310
1311
  db,
1311
- { budgetMs = OBS_TERMS_BUDGET_MS, batchSize = OBS_TERMS_BATCH } = {},
1312
+ { budgetMs = OBS_TERMS_BUDGET_MS, batchSize = OBS_TERMS_BATCH, cursor = OBS_TERMS_CURSOR } = {},
1312
1313
  ) {
1313
1314
  const start = performance.now();
1314
1315
  const prior = db.pragma('busy_timeout', { simple: true });
@@ -1318,8 +1319,7 @@ export function backfillObservationIndexTerms(
1318
1319
  const table = db
1319
1320
  .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'migration_cursors'`)
1320
1321
  .get();
1321
- const row =
1322
- table && db.prepare(`SELECT below_id FROM migration_cursors WHERE name = ?`).get(OBS_TERMS_CURSOR);
1322
+ const row = table && db.prepare(`SELECT below_id FROM migration_cursors WHERE name = ?`).get(cursor);
1323
1323
  return row ? row.below_id : Number.MAX_SAFE_INTEGER;
1324
1324
  };
1325
1325
  const pick = db.prepare(
@@ -1356,7 +1356,7 @@ export function backfillObservationIndexTerms(
1356
1356
  db.prepare(
1357
1357
  `INSERT INTO migration_cursors (name, below_id) VALUES (?, ?)
1358
1358
  ON CONFLICT(name) DO UPDATE SET below_id = excluded.below_id`,
1359
- ).run(OBS_TERMS_CURSOR, below);
1359
+ ).run(cursor, below);
1360
1360
  return n;
1361
1361
  });
1362
1362
  let appended = 0;
@@ -1385,6 +1385,54 @@ export function backfillObservationIndexTerms(
1385
1385
  }
1386
1386
  }
1387
1387
 
1388
+ /** How long recheckObservationIndexTerms keeps walking after its first run (D#413). */
1389
+ const OBS_TERMS_RECHECK_WINDOW_MS = 7 * 24 * 3600 * 1000;
1390
+ /** Its budget per run: the maintain worker has no host deadline, and its lock goes stale at 10 min. */
1391
+ const OBS_TERMS_RECHECK_BUDGET_MS = 30_000;
1392
+ /** Its cursor and done marker; the window's start is stored as `<name>-opened`. */
1393
+ const OBS_TERMS_RECHECK = 'recheck-cjk-index-terms';
1394
+
1395
+ /**
1396
+ * Walk the observations again for the CJK index terms an older build left out (D#413), once a day
1397
+ * from the auto-maintain worker, for OBS_TERMS_RECHECK_WINDOW_MS from this function's first run on
1398
+ * the store, then mark itself done. The open-time pass is marked done once, but an older build can
1399
+ * go on writing the store for as long as a session started before the update lives: it inserts
1400
+ * rows without these terms (live store 2026-10-17: 8 of the 36 rows its 6.27.0 code wrote after
1401
+ * the fill) and rewrites a filled row's text without them (6.28.x mem_update), and no column says
1402
+ * which rows a writer touched. The window starts at the first run, not at the open-time pass's
1403
+ * marker, so a store whose pass finished long before this build arrived still gets a whole walk.
1404
+ * Waits for the open-time pass to be done, so the two never walk at once. The walk is
1405
+ * backfillObservationIndexTerms on a cursor of its own: a finished walk drops the cursor, so the
1406
+ * next run starts from the top again; one cut short by its budget or another writer's lock keeps
1407
+ * it, and the next run continues below it. Returns how many rows it appended to and whether it
1408
+ * walked.
1409
+ *
1410
+ * @param {import('better-sqlite3').Database} db
1411
+ * @param {{ now?: number, budgetMs?: number }} [opts]
1412
+ * @returns {{ appended: number, ran: boolean }}
1413
+ */
1414
+ export function recheckObservationIndexTerms(
1415
+ db,
1416
+ { now = Date.now(), budgetMs = OBS_TERMS_RECHECK_BUDGET_MS } = {},
1417
+ ) {
1418
+ const doneAt = db.prepare(`SELECT done_at_epoch FROM migration_cleanups WHERE name = ?`).pluck();
1419
+ const mark = db.prepare(`INSERT OR IGNORE INTO migration_cleanups (name, done_at_epoch) VALUES (?, ?)`);
1420
+ // 'backfill-cjk-index-terms' is the open-time pass's marker (DEFERRED_CLEANUPS).
1421
+ if (doneAt.get('backfill-cjk-index-terms') === undefined || doneAt.get(OBS_TERMS_RECHECK) !== undefined)
1422
+ return { appended: 0, ran: false };
1423
+ mark.run(`${OBS_TERMS_RECHECK}-opened`, now);
1424
+ if (now - doneAt.get(`${OBS_TERMS_RECHECK}-opened`) >= OBS_TERMS_RECHECK_WINDOW_MS) {
1425
+ mark.run(OBS_TERMS_RECHECK, now);
1426
+ return { appended: 0, ran: false };
1427
+ }
1428
+ const { appended, done } = backfillObservationIndexTerms(db, { budgetMs, cursor: OBS_TERMS_RECHECK });
1429
+ const cursors = db
1430
+ .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'migration_cursors'`)
1431
+ .get();
1432
+ if (done && cursors) db.prepare(`DELETE FROM migration_cursors WHERE name = ?`).run(OBS_TERMS_RECHECK);
1433
+ return { appended, ran: true };
1434
+ }
1435
+
1388
1436
  /**
1389
1437
  * How long backfillEventSearchTerms runs per open, checked after each row it segments (its reads,
1390
1438
  * segmentation and the writes before; the last batch's write comes on top):
@@ -1737,9 +1785,11 @@ export function refreshPlannerStats(db, { busyTimeoutMs } = {}) {
1737
1785
 
1738
1786
  /**
1739
1787
  * Whether an open/init error carries a genuine corruption signature. WAL-delete
1740
- * recovery is ONLY safe for these: on a transient error (SQLITE_BUSY) or the
1788
+ * recovery runs ONLY for these: on a transient error (SQLITE_BUSY) or the
1741
1789
  * forward-version guard throw, deleting the WAL would discard committed-but-
1742
- * uncheckpointed transactions — silent data loss.
1790
+ * uncheckpointed transactions — silent data loss. A corruption signature does not
1791
+ * make the delete safe either while another connection has the file open (it loses
1792
+ * that connection's commits the same way), so ensureDbWithWalRecovery retries first.
1743
1793
  */
1744
1794
  export function isDbCorruptionError(err) {
1745
1795
  // R10 P3-9: SQLITE_CORRUPT_VTAB is EXCLUDED. It means a damaged FTS5 index over an
@@ -1764,7 +1814,8 @@ export { isFtsCorruptionError };
1764
1814
  * serves all three openers.
1765
1815
  *
1766
1816
  * Non-corruption failure → rethrows the original error, WAL/SHM left intact.
1767
- * Corruption → deletes -wal/-shm, retries once; a still-failing retry rethrows
1817
+ * Corruption → retries once with the files untouched; corruption again → deletes
1818
+ * -wal/-shm, retries once more; a still-failing retry rethrows
1768
1819
  * with `err.walRecoveryAttempted = true` so callers can word their fatal hint
1769
1820
  * accurately (recovery already tried vs WAL deliberately preserved).
1770
1821
  *
@@ -1783,6 +1834,9 @@ export function ensureDbWithWalRecovery({ warn, info } = {}) {
1783
1834
  let raw = null;
1784
1835
  try {
1785
1836
  raw = new Database(DB_PATH);
1837
+ // D#415: otherwise better-sqlite3's 5000 ms per statement, four rebuilds, in a hook
1838
+ // the host kills at 2 s or 5 s.
1839
+ if (busyDeadline !== null) capBusyWaits(raw);
1786
1840
  const { errors } = rebuildFTS(raw);
1787
1841
  if (errors.length) warn?.(`FTS rebuild reported: ${errors.join('; ')}`);
1788
1842
  } catch (rebuildErr) {
@@ -1808,7 +1862,23 @@ export function ensureDbWithWalRecovery({ warn, info } = {}) {
1808
1862
  }
1809
1863
  }
1810
1864
  if (!isDbCorruptionError(firstErr)) throw firstErr;
1811
- warn?.(`DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
1865
+ // D#427: retry once with the files untouched before deleting anything. A corruption error
1866
+ // that does not come from the file exists (3 of 43 first opens by 6.28.1 of fresh copies of one
1867
+ // intact store threw one on one host, D#424; the other 40 did not), and for it the delete below
1868
+ // is the only thing that loses data: with another connection open (the MCP server keeps one all
1869
+ // session) it can discard what that connection committed since its last checkpoint, and until
1870
+ // it closes the two connections overwrite each other's pages.
1871
+ warn?.(`DB corruption reported, retrying the open with the WAL kept: ${firstErr.message}`);
1872
+ let walErr;
1873
+ try {
1874
+ const db = ensureDb();
1875
+ info?.('DB opened on the retry, WAL kept');
1876
+ return db;
1877
+ } catch (retryErr) {
1878
+ if (!isDbCorruptionError(retryErr)) throw retryErr;
1879
+ walErr = retryErr;
1880
+ }
1881
+ warn?.(`DB corruption detected, attempting WAL recovery: ${walErr.message}`);
1812
1882
  try {
1813
1883
  rmSync(DB_PATH + '-wal', { force: true });
1814
1884
  } catch {