claude-mem-lite 2.73.0 → 2.75.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.73.0",
13
+ "version": "2.75.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.73.0",
3
+ "version": "2.75.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
package/hook-update.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Skips in dev mode (symlinked installs). Silent on network failure.
4
4
 
5
5
  import { execSync, execFileSync } from 'node:child_process';
6
- import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync, renameSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync, renameSync, chmodSync } from 'node:fs';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { tmpdir, homedir } from 'node:os';
9
9
  import { DB_DIR } from './schema.mjs';
@@ -400,10 +400,19 @@ function copyReleaseIntoStaging(sourceDir, stagingDir) {
400
400
  const stagedScripts = join(stagingDir, 'scripts');
401
401
  if (existsSync(stagedScripts)) {
402
402
  for (const sf of readdirSync(stagedScripts).filter(n => n.endsWith('.sh'))) {
403
- try { execFileSync('chmod', ['+x', join(stagedScripts, sf)], { stdio: 'pipe' }); } catch {}
403
+ try { chmodSync(join(stagedScripts, sf), 0o755); } catch (e) { debugCatch(e, 'chmod-script'); }
404
404
  }
405
405
  }
406
406
 
407
+ // cli.mjs is invoked via the ~/.local/bin/claude-mem-lite symlink, which needs
408
+ // the target executable. copyFileSync preserves the source mode and git stores
409
+ // cli.mjs as 100644 — without this chmod, auto-update strips the +x bit set by
410
+ // install.mjs:408 and the next CLI invocation dies with "Permission denied".
411
+ const stagedCli = join(stagingDir, 'cli.mjs');
412
+ if (existsSync(stagedCli)) {
413
+ try { chmodSync(stagedCli, 0o755); } catch (e) { debugCatch(e, 'chmod-cli'); }
414
+ }
415
+
407
416
  debugLog('DEBUG', 'hook-update', `Auto-update staged ${copied} source files`);
408
417
  }
409
418
 
package/hook.mjs CHANGED
@@ -45,7 +45,13 @@ import {
45
45
  } from './hook-shared.mjs';
46
46
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
47
47
  import { scrubRecord } from './lib/scrub-record.mjs';
48
- import { extractCitationsFromTranscript, bumpCitationAccess, computeCiteRecall } from './lib/citation-tracker.mjs';
48
+ import {
49
+ extractCitationsFromTranscript,
50
+ extractInjectedFromPreToolUse,
51
+ bumpCitationAccess,
52
+ computeCiteRecall,
53
+ applyCitationDecay,
54
+ } from './lib/citation-tracker.mjs';
49
55
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
50
56
  import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
51
57
  import { detectMemOverride } from './lib/mem-override.mjs';
@@ -504,6 +510,12 @@ async function handleStop() {
504
510
  // P4: scan transcript for `#NN` observation citations in assistant text
505
511
  // and bump access_count for matched rows. Closes the loop on the "cite #NN"
506
512
  // contract — before P4 this was a one-way obligation with no feedback.
513
+ //
514
+ // CLAUDE_MEM_NO_CITATION_TRACK=1 disables BOTH the P4 access_count bump
515
+ // AND the v32 citation-decay loop nested below — anything that needs the
516
+ // transcript scan lives inside this guard. To disable just the decay
517
+ // loop (keep access_count bumps), use MEM_DISABLE_CITATION_DECAY=1 which
518
+ // applyCitationDecay checks separately.
507
519
  try {
508
520
  if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
509
521
  const ids = extractCitationsFromTranscript(transcriptPath);
@@ -512,6 +524,19 @@ async function handleStop() {
512
524
  debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
513
525
  }
514
526
 
527
+ // v32 citation-decay: tighter feedback loop on top of P4. Re-scan
528
+ // transcript with main-thread filter, extract injected IDs from
529
+ // pre-tool-recall attachments only, then mutate importance/streak per
530
+ // applyCitationDecay's contract. Cheap (file still in OS cache).
531
+ try {
532
+ const injected = extractInjectedFromPreToolUse(transcriptPath);
533
+ if (injected.size > 0) {
534
+ const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
535
+ const r = applyCitationDecay(db, project, injected, citedMain, sessionId);
536
+ debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
537
+ }
538
+ } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
539
+
515
540
  // Persist cite-recall ratio for the next SessionStart to surface as
516
541
  // feedback. We deliberately scan the transcript a second time here
517
542
  // (cheap; the file is already in OS cache) rather than threading the
@@ -22,9 +22,12 @@ const CITATION_RE = /#(\d{1,7})\b/g;
22
22
  * cited inside assistant text blocks.
23
23
  *
24
24
  * @param {string} transcriptPath Path to transcript file (.jsonl)
25
+ * @param {object} [opts] Options
26
+ * @param {boolean} [opts.mainOnly=false] If true, skip transcript records where isSidechain === true
25
27
  * @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
26
28
  */
27
- export function extractCitationsFromTranscript(transcriptPath) {
29
+ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
30
+ const { mainOnly = false } = opts;
28
31
  const ids = new Set();
29
32
  if (!transcriptPath || !existsSync(transcriptPath)) return ids;
30
33
  let raw;
@@ -35,6 +38,11 @@ export function extractCitationsFromTranscript(transcriptPath) {
35
38
  try { entry = JSON.parse(line); } catch { continue; }
36
39
  // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
37
40
  if (entry.type !== 'assistant' || !entry.message) continue;
41
+ // Citation-decay loop scopes citation signal to main-thread text only —
42
+ // subagent dispatches run their own session context the parent can't
43
+ // reasonably be held accountable for. Default off preserves the broader
44
+ // access_count-bump semantics of existing callers (P4 bumpCitationAccess).
45
+ if (mainOnly && entry.isSidechain === true) continue;
38
46
  const content = entry.message.content;
39
47
  if (!Array.isArray(content)) continue;
40
48
  for (const block of content) {
@@ -142,3 +150,137 @@ export function bumpCitationAccess(db, ids, project) {
142
150
  }
143
151
  return n;
144
152
  }
153
+
154
+ // Matches a pre-tool-recall lesson line: ` #NN [type] body...`. Bounded type
155
+ // list mirrors observations.type CHECK + the events table's allowed event_type
156
+ // values pre-tool-recall.js can surface.
157
+ const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
158
+
159
+ /**
160
+ * Extract observation IDs injected by pre-tool-recall hook in this transcript.
161
+ *
162
+ * Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
163
+ * text" — only counts IDs the agent actually saw from us, not user-pasted
164
+ * references or unrelated #NN tokens in tool output.
165
+ *
166
+ * @param {string|null|undefined} transcriptPath
167
+ * @returns {Set<number>} unique injected IDs (empty set on missing path/file)
168
+ */
169
+ export function extractInjectedFromPreToolUse(transcriptPath) {
170
+ const ids = new Set();
171
+ if (!transcriptPath || !existsSync(transcriptPath)) return ids;
172
+ let raw;
173
+ try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
174
+ for (const line of raw.split('\n')) {
175
+ if (!line.trim()) continue;
176
+ let entry;
177
+ try { entry = JSON.parse(line); } catch { continue; }
178
+ if (entry.type !== 'attachment') continue;
179
+ const att = entry.attachment;
180
+ if (!att || att.type !== 'hook_success') continue;
181
+ if (!(att.command || '').includes('pre-tool-recall')) continue;
182
+ const stdout = att.stdout || '';
183
+ if (!stdout) continue;
184
+ // stdout is JSON wrapping additionalContext OR raw text (legacy);
185
+ // try JSON first and fall back to raw.
186
+ let text = stdout;
187
+ try {
188
+ const parsed = JSON.parse(stdout);
189
+ text = parsed?.hookSpecificOutput?.additionalContext || stdout;
190
+ } catch {}
191
+ INJECTED_RE.lastIndex = 0;
192
+ let m;
193
+ while ((m = INJECTED_RE.exec(text))) {
194
+ const id = Number(m[1]);
195
+ if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
196
+ }
197
+ }
198
+ return ids;
199
+ }
200
+
201
+ const IMPORTANCE_CAP = 3;
202
+ const IMPORTANCE_FLOOR = 0;
203
+ const UNCITED_STREAK_THRESHOLD = 3;
204
+
205
+ /**
206
+ * Apply the citation-feedback loop for one session: for each injected obs id,
207
+ * decide cited vs uncited and mutate importance/streak/cited_count per spec.
208
+ *
209
+ * - cited: importance += 1 (cap 3), cited_count += 1, streak = 0.
210
+ * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 0), streak = 0.
211
+ * - per-(session, obs) idempotent via last_decided_session_id; re-running for
212
+ * the same session is a no-op (Stop hook may fire more than once).
213
+ * - cross-project IDs are silently ignored by the WHERE clause.
214
+ * - MEM_DISABLE_CITATION_DECAY=1 disables all writes; returns zeros.
215
+ *
216
+ * @param {import('better-sqlite3').Database} db
217
+ * @param {string} project
218
+ * @param {Set<number>|Iterable<number>} injectedIds
219
+ * @param {Set<number>|Iterable<number>} citedIds
220
+ * @param {string} sessionId — memory_session_id of the session being resolved
221
+ * @returns {{promoted: number, demoted: number, touched: number}}
222
+ */
223
+ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId) {
224
+ const empty = { promoted: 0, demoted: 0, touched: 0 };
225
+ if (process.env.MEM_DISABLE_CITATION_DECAY === '1') return empty;
226
+ if (!db || !project || !sessionId) return empty;
227
+ const injected = injectedIds instanceof Set ? injectedIds : new Set(injectedIds || []);
228
+ if (injected.size === 0) return empty;
229
+ const cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
230
+
231
+ const selectStmt = db.prepare(
232
+ 'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ?'
233
+ );
234
+ // decay_seen_count (v34) bumps on every resolution branch — gives
235
+ // citation-stats a denominator that's same-source as cited_count, so the
236
+ // ratio actually means "cite-rate" instead of mixing decay + UserPromptSubmit.
237
+ const updatePromote = db.prepare(`
238
+ UPDATE observations
239
+ SET importance = MIN(?, importance + 1),
240
+ cited_count = cited_count + 1,
241
+ uncited_streak = 0,
242
+ last_decided_session_id = ?,
243
+ decay_seen_count = decay_seen_count + 1
244
+ WHERE id = ?
245
+ `);
246
+ const updateStreakOnly = db.prepare(`
247
+ UPDATE observations
248
+ SET uncited_streak = uncited_streak + 1,
249
+ last_decided_session_id = ?,
250
+ decay_seen_count = decay_seen_count + 1
251
+ WHERE id = ?
252
+ `);
253
+ const updateDemote = db.prepare(`
254
+ UPDATE observations
255
+ SET importance = MAX(?, importance - 1),
256
+ uncited_streak = 0,
257
+ last_decided_session_id = ?,
258
+ demoted_at = ?,
259
+ decay_seen_count = decay_seen_count + 1
260
+ WHERE id = ?
261
+ `);
262
+
263
+ let promoted = 0, demoted = 0, touched = 0;
264
+ const txn = db.transaction(() => {
265
+ for (const id of injected) {
266
+ const row = selectStmt.get(id, project);
267
+ if (!row) continue; // cross-project or deleted
268
+ if (row.last_decided_session_id === sessionId) continue; // idempotent skip
269
+ touched++;
270
+ if (cited.has(id)) {
271
+ updatePromote.run(IMPORTANCE_CAP, sessionId, id);
272
+ promoted++;
273
+ } else {
274
+ const nextStreak = (row.uncited_streak || 0) + 1;
275
+ if (nextStreak >= UNCITED_STREAK_THRESHOLD) {
276
+ updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
277
+ demoted++;
278
+ } else {
279
+ updateStreakOnly.run(sessionId, id);
280
+ }
281
+ }
282
+ }
283
+ });
284
+ try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
285
+ return { promoted, demoted, touched };
286
+ }
package/mem-cli.mjs CHANGED
@@ -2358,6 +2358,96 @@ function cmdMemdirAudit(args) {
2358
2358
  if (nonCompliant > 0) process.exitCode = 1;
2359
2359
  }
2360
2360
 
2361
+ /**
2362
+ * `citation-stats` — visualize the citation-decay feedback loop:
2363
+ * per-project cite rate + active decay queue + recently promoted.
2364
+ * Read-only over observations.
2365
+ *
2366
+ * Flags:
2367
+ * --json machine-readable output
2368
+ * --days N project cite-rate window (default 7)
2369
+ */
2370
+ function cmdCitationStats(db, args) {
2371
+ const { flags } = parseArgs(args);
2372
+ const json = flags.json === true || flags.json === 'true';
2373
+ const days = parseIntFlag(flags.days, { name: '--days', defaultValue: 7, max: 365 });
2374
+
2375
+ const cutoff = Date.now() - days * 86400 * 1000;
2376
+ const perProject = db.prepare(`
2377
+ SELECT project,
2378
+ COALESCE(SUM(cited_count), 0) AS cited,
2379
+ COALESCE(SUM(decay_seen_count), 0) AS resolved,
2380
+ SUM(CASE WHEN uncited_streak >= 2 THEN 1 ELSE 0 END) AS at_risk
2381
+ FROM observations
2382
+ WHERE created_at_epoch >= ?
2383
+ AND COALESCE(compressed_into, 0) = 0
2384
+ AND superseded_at IS NULL
2385
+ GROUP BY project
2386
+ ORDER BY resolved DESC
2387
+ `).all(cutoff);
2388
+
2389
+ const decayQueue = db.prepare(`
2390
+ SELECT id, project, type, title, importance, uncited_streak, cited_count
2391
+ FROM observations
2392
+ WHERE uncited_streak >= 2
2393
+ AND COALESCE(compressed_into, 0) = 0
2394
+ AND superseded_at IS NULL
2395
+ ORDER BY uncited_streak DESC, importance ASC
2396
+ LIMIT 20
2397
+ `).all();
2398
+
2399
+ const promoted = db.prepare(`
2400
+ SELECT id, project, type, title, importance, cited_count
2401
+ FROM observations
2402
+ WHERE importance >= 3 AND cited_count >= 1
2403
+ AND COALESCE(compressed_into, 0) = 0
2404
+ AND superseded_at IS NULL
2405
+ ORDER BY cited_count DESC
2406
+ LIMIT 10
2407
+ `).all();
2408
+
2409
+ const demoted = db.prepare(`
2410
+ SELECT id, project, type, title, importance, demoted_at
2411
+ FROM observations
2412
+ WHERE demoted_at IS NOT NULL
2413
+ AND demoted_at >= ?
2414
+ AND COALESCE(compressed_into, 0) = 0
2415
+ AND superseded_at IS NULL
2416
+ ORDER BY demoted_at DESC
2417
+ LIMIT 10
2418
+ `).all(cutoff);
2419
+
2420
+ if (json) {
2421
+ out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted }, null, 2));
2422
+ return;
2423
+ }
2424
+
2425
+ out(`Cite rate by project (last ${days}d, cited / decay-resolutions):`);
2426
+ for (const r of perProject) {
2427
+ const rate = r.resolved > 0 ? (r.cited * 100 / r.resolved).toFixed(1) + '%' : '—';
2428
+ out(` ${r.project.padEnd(34)} ${String(rate).padStart(6)} cited:${r.cited}/${r.resolved} at_risk:${r.at_risk}`);
2429
+ }
2430
+ out('');
2431
+ out('Active decay queue (uncited_streak >= 2, next miss → demote):');
2432
+ if (decayQueue.length === 0) out(' (none)');
2433
+ for (const r of decayQueue) {
2434
+ out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} streak=${r.uncited_streak}`);
2435
+ }
2436
+ out('');
2437
+ out('Recently promoted (importance=3, cited_count >= 1):');
2438
+ if (promoted.length === 0) out(' (none)');
2439
+ for (const r of promoted) {
2440
+ out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} cited ${r.cited_count}x`);
2441
+ }
2442
+ out('');
2443
+ out(`Recently demoted (last ${days}d, importance ↓):`);
2444
+ if (demoted.length === 0) out(' (none)');
2445
+ for (const r of demoted) {
2446
+ const ago = Math.round((Date.now() - r.demoted_at) / 86400000);
2447
+ out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} ${ago}d ago`);
2448
+ }
2449
+ }
2450
+
2361
2451
  // ─── Help ────────────────────────────────────────────────────────────────────
2362
2452
 
2363
2453
  function cmdHelp() {
@@ -2498,6 +2588,10 @@ Commands:
2498
2588
  totals:{working,active,archive,grand_total},
2499
2589
  tiers:{working:{count,results:[…]}, …}}
2500
2590
 
2591
+ citation-stats Citation-decay feedback loop telemetry
2592
+ --days N Cite-rate window in days (default 7)
2593
+ --json Output as JSON: {window_days,per_project:[],decay_queue:[],promoted:[]}
2594
+
2501
2595
  registry <action> Manage tool resource registry
2502
2596
  list List resources [--type skill|agent] [--limit N] (default 20)
2503
2597
  stats Registry statistics
@@ -2867,7 +2961,7 @@ export async function run(argv) {
2867
2961
  // text-parsing callers keep working — the note lives in stderr for scripts to
2868
2962
  // detect the gap.
2869
2963
  const JSON_SUPPORTED_CMDS = new Set([
2870
- 'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
2964
+ 'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
2871
2965
  ]);
2872
2966
  // `doctor --benchmark` already emits JSON on its own — don't print the misleading
2873
2967
  // "doctor outputs text" note for that subpath. Without --benchmark, doctor is text
@@ -2896,6 +2990,7 @@ export async function run(argv) {
2896
2990
  case 'stats': await cmdStats(db, cmdArgs); break;
2897
2991
  case 'context': cmdContext(db, cmdArgs); break;
2898
2992
  case 'browse': cmdBrowse(db, cmdArgs); break;
2993
+ case 'citation-stats': cmdCitationStats(db, cmdArgs); break;
2899
2994
  case 'registry': cmdRegistry(db, cmdArgs); break;
2900
2995
  case 'import': await cmdImport(cmdArgs); break;
2901
2996
  case 'import-jsonl': await cmdImportJsonl(db, cmdArgs); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.73.0",
3
+ "version": "2.75.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -45,7 +45,28 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
45
45
  // items rank HIGHER as tech debt accumulates), different lifecycle (mutable
46
46
  // status open→done|dropped vs immutable obs). Closure tied to obs via
47
47
  // closed_by_obs_id FK with ON DELETE SET NULL (audit trail preserved).
48
- export const CURRENT_SCHEMA_VERSION = 31;
48
+ // v32 (v2.73.2): citation-decay columns on observations — uncited_streak,
49
+ // cited_count, last_decided_session_id. Stop hook resolves injected obs as
50
+ // cited|uncited; 3 consecutive uncited → importance -1 (floor 0); 1 cited → +1
51
+ // (cap 3). last_decided_session_id makes Stop idempotent across multi-fire.
52
+ export const CURRENT_SCHEMA_VERSION = 34;
53
+
54
+ // Sentinel column for the LATEST migration set. The fast-path uses this to
55
+ // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
56
+ // back (observed once in dev during v2.74.0). Update both the column AND
57
+ // (if needed) the table when adding a new migration batch.
58
+ const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'decay_seen_count' };
59
+
60
+ function hasLatestMigrationColumn(db) {
61
+ try {
62
+ const row = db.prepare(
63
+ `SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
64
+ ).get(LATEST_MIGRATION_COLUMN.table, LATEST_MIGRATION_COLUMN.column);
65
+ return Boolean(row);
66
+ } catch {
67
+ return false; // table itself missing → caller falls through to CORE_SCHEMA
68
+ }
69
+ }
49
70
 
50
71
  const CORE_SCHEMA = `
51
72
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -151,6 +172,27 @@ const MIGRATIONS = [
151
172
  // injection_count + low access_count = low-signal, deprioritize.
152
173
  'ALTER TABLE observations ADD COLUMN injection_count INTEGER NOT NULL DEFAULT 0',
153
174
  'ALTER TABLE observations ADD COLUMN last_injected_at INTEGER DEFAULT NULL',
175
+ // v32 (citation-decay): per-obs feedback loop for pre-tool-recall injection
176
+ // pool. Stop hook resolves each session's injected IDs as cited|uncited.
177
+ // 3 consecutive uncited sessions → importance -1 (floor 0). 1 cited session →
178
+ // importance +1 (cap 3). last_decided_session_id makes Stop idempotent across
179
+ // multi-fire scenarios (Claude may fire Stop more than once per session).
180
+ 'ALTER TABLE observations ADD COLUMN uncited_streak INTEGER NOT NULL DEFAULT 0',
181
+ 'ALTER TABLE observations ADD COLUMN cited_count INTEGER NOT NULL DEFAULT 0',
182
+ 'ALTER TABLE observations ADD COLUMN last_decided_session_id TEXT DEFAULT NULL',
183
+ // v33 (citation-decay telemetry): timestamp of the most recent demote event.
184
+ // Powers `claude-mem-lite citation-stats`'s "Recently demoted" section.
185
+ // Set in applyCitationDecay's demote branch when streak hits threshold.
186
+ // Single-shot (only the latest demote is preserved); use a decay_log table
187
+ // if historical trend is ever needed.
188
+ 'ALTER TABLE observations ADD COLUMN demoted_at INTEGER DEFAULT NULL',
189
+ // v34 (citation-decay denominator fix): per-obs counter of decay-loop
190
+ // resolutions (cited + uncited paths). Used by citation-stats as the
191
+ // denominator for "cite rate" — bumped only by applyCitationDecay, so it
192
+ // doesn't get polluted by UserPromptSubmit / hook-memory injections that
193
+ // share the unrelated injection_count column. Same-source numerator
194
+ // (cited_count) + same-source denominator = meaningful ratio.
195
+ 'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
154
196
  ];
155
197
 
156
198
  /**
@@ -167,7 +209,10 @@ export function initSchema(db) {
167
209
  try {
168
210
  const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
169
211
  if (row && typeof row.version === 'number') {
170
- if (row.version === CURRENT_SCHEMA_VERSION) return db;
212
+ // Self-heal: version-row says CURRENT but latest migration column may be
213
+ // absent (rolled back / never applied). Fall through to migration apply
214
+ // when the sentinel is missing — duplicates are caught in the loop.
215
+ if (row.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) return db;
171
216
  if (row.version > CURRENT_SCHEMA_VERSION) {
172
217
  throw new Error(
173
218
  `DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
@@ -190,7 +235,7 @@ export function initSchema(db) {
190
235
  db.exec('BEGIN IMMEDIATE');
191
236
  try {
192
237
  const underlock = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
193
- if (underlock && underlock.version === CURRENT_SCHEMA_VERSION) {
238
+ if (underlock && underlock.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) {
194
239
  db.exec('COMMIT');
195
240
  return db;
196
241
  }