claude-mem-lite 2.69.0 → 2.71.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.
@@ -0,0 +1,63 @@
1
+ // claude-mem-lite: per-table scrub helper. Applies scrubSecrets to the known
2
+ // text fields of a table row. Numeric / JSON-blob / id fields are passed
3
+ // through untouched.
4
+ //
5
+ // Failsafe policy: when the table is unknown, scrub every string field by
6
+ // default. Newly added tables stay safe even before TEXT_FIELDS_BY_TABLE is
7
+ // updated — over-scrubbing is the safe direction; under-scrubbing leaks.
8
+ //
9
+ // JSON-stringified array fields (e.g. session_handoffs.key_files,
10
+ // session_handoffs.match_keywords-when-array) are NOT listed here — running
11
+ // scrubSecrets over the JSON string can rewrite quoted values and break
12
+ // downstream JSON.parse. Pre-scrub each element upstream of the
13
+ // JSON.stringify call instead.
14
+
15
+ import { scrubSecrets } from '../secret-scrub.mjs';
16
+
17
+ export const TEXT_FIELDS_BY_TABLE = {
18
+ observations: [
19
+ 'title', 'subtitle', 'text', 'narrative',
20
+ 'concepts', 'facts', 'lesson_learned', 'search_aliases',
21
+ ],
22
+ session_summaries: [
23
+ 'request', 'investigated', 'learned',
24
+ 'completed', 'next_steps', 'remaining_items', 'notes',
25
+ 'lessons', 'key_decisions',
26
+ ],
27
+ session_handoffs: [
28
+ 'working_on', 'completed', 'unfinished',
29
+ // Excluded:
30
+ // key_files — JSON.stringify(array); pre-scrub elements at call site
31
+ // match_keywords — currently a space-joined plain string; keeping it
32
+ // here would scrub safely, but the value is built from
33
+ // tokenizeHandoff() output (alphanumeric tokens only),
34
+ // so secrets cannot survive the upstream tokenizer.
35
+ // Excluded to avoid double-work + future-proof against
36
+ // a refactor that switches to JSON.stringify.
37
+ // key_decisions is kept: call site uses '\n'.join (plain string), and
38
+ // decision titles can carry secrets verbatim (LLM output).
39
+ 'key_decisions',
40
+ ],
41
+ };
42
+
43
+ /**
44
+ * Scrub the text fields of a record before INSERT.
45
+ * Returns a shallow copy with string text-fields scrubbed; the input object
46
+ * is left untouched. Non-string values (numbers, null, JSON blobs the caller
47
+ * has already stringified) flow through unchanged.
48
+ */
49
+ export function scrubRecord(table, row) {
50
+ if (!row || typeof row !== 'object') return row;
51
+ const fields = TEXT_FIELDS_BY_TABLE[table];
52
+ const out = { ...row };
53
+ if (fields) {
54
+ for (const f of fields) {
55
+ if (typeof out[f] === 'string') out[f] = scrubSecrets(out[f]);
56
+ }
57
+ } else {
58
+ for (const k of Object.keys(out)) {
59
+ if (typeof out[k] === 'string') out[k] = scrubSecrets(out[k]);
60
+ }
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,31 @@
1
+ // One-shot v2.70.0 upgrade banner.
2
+ // Split out of hook.mjs because hook.mjs has module-level side effects
3
+ // (notably `if (!event) process.exit(0)` at top level) that abort vitest
4
+ // workers if imported directly from a test. See test
5
+ // tests/hook-upgrade-banner.test.mjs.
6
+
7
+ import { writeFileSync, existsSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ /**
11
+ * One-shot stderr banner on first SessionStart after v2.70.0 upgrade.
12
+ * Notifies users that the `### Deferred Work` block now reads from the
13
+ * deferred_work table (not high-importance observations as in v2.69.x).
14
+ * Idempotent via a marker file in `runtimeDir`; subsequent calls in the
15
+ * same project are silent.
16
+ *
17
+ * @param {object} args
18
+ * @param {string} args.project Project name (used in banner + marker filename).
19
+ * @param {string} args.runtimeDir RUNTIME_DIR (test override; production passes hook-shared.RUNTIME_DIR).
20
+ */
21
+ export function emitV270UpgradeBanner({ project, runtimeDir }) {
22
+ const marker = join(runtimeDir, `.deferred-block-migrated-${project}`);
23
+ if (existsSync(marker)) return;
24
+ process.stderr.write(
25
+ `[mem] v2.70.0 upgrade notice (project "${project}"): Deferred Work block now ` +
26
+ `backed by deferred_work table. To keep an obs visible there, run ` +
27
+ `\`claude-mem-lite defer add "<title>" --priority 3\`. ` +
28
+ `Pin to 2.69.x to revert.\n`
29
+ );
30
+ try { writeFileSync(marker, String(Date.now())); } catch { /* best-effort marker */ }
31
+ }
package/mem-cli.mjs CHANGED
@@ -14,6 +14,7 @@ import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-i
14
14
  import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
16
16
  import { searchResources } from './registry-retriever.mjs';
17
+ import { scrubRecord } from './lib/scrub-record.mjs';
17
18
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
18
19
  import { buildSessionContextLines } from './hook-context.mjs';
19
20
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -28,6 +29,10 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
28
29
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
29
30
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
30
31
  import { saveObservation } from './lib/save-observation.mjs';
32
+ import {
33
+ insertDeferred, listOpenWithOrdinal, dropDeferred,
34
+ resolveDeferredIds, closeDeferredItems,
35
+ } from './lib/deferred-work.mjs';
31
36
 
32
37
  // ─── Commands ────────────────────────────────────────────────────────────────
33
38
 
@@ -884,7 +889,7 @@ function cmdSave(db, args) {
884
889
  const { positional, flags } = parseArgs(args);
885
890
  const text = positional.join(' ');
886
891
  if (!text) {
887
- fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T]');
892
+ fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42]');
888
893
  return;
889
894
  }
890
895
 
@@ -914,15 +919,60 @@ function cmdSave(db, args) {
914
919
  return;
915
920
  }
916
921
 
917
- const result = saveObservation(db, {
918
- content: text,
919
- title: flags.title,
920
- type,
921
- importance: rawImp,
922
- project,
923
- files: saveFiles,
924
- lesson_learned: rawLesson,
925
- });
922
+ // --closes-deferred parsing: accepts comma-separated mixed tokens
923
+ // ("1,D#42,3") with bare integers treated as ordinals and "D#N" as raw ids.
924
+ // We pre-parse tokens here (cheap, syntax-only) but defer resolveDeferredIds
925
+ // INTO the transaction, AFTER the dedup check. Resolving outside the
926
+ // transaction would throw on the duplicate-replay path: the previously-
927
+ // closed deferred row is no longer 'open', so ordinal/id resolution would
928
+ // crash even though the duplicate short-circuit makes closure a no-op.
929
+ // Resolving inside the dedup-gated branch keeps "save the same content
930
+ // twice" idempotent (mirrors server.mjs:934 dedup-skip-closure intent).
931
+ let closesTokens = null;
932
+ if (flags['closes-deferred'] !== undefined && flags['closes-deferred'] !== false) {
933
+ const raw = String(flags['closes-deferred']);
934
+ closesTokens = raw.split(',').map(t => t.trim()).filter(Boolean).map(t => {
935
+ return /^\d+$/.test(t) ? parseInt(t, 10) : t;
936
+ });
937
+ if (closesTokens.length === 0) {
938
+ fail('[mem] --closes-deferred requires at least one token (integer ordinal or D#N)');
939
+ return;
940
+ }
941
+ }
942
+
943
+ let result;
944
+ let closesIds = null;
945
+ try {
946
+ result = db.transaction(() => {
947
+ const r = saveObservation(db, {
948
+ content: text,
949
+ title: flags.title,
950
+ type,
951
+ importance: rawImp,
952
+ project,
953
+ files: saveFiles,
954
+ lesson_learned: rawLesson,
955
+ });
956
+ // Skip closure on dedup short-circuit — the obs row already exists, so
957
+ // the deferred item should NOT be re-closed by a duplicate save call.
958
+ // Resolving deferred ids only on the non-duplicate path keeps repeated
959
+ // save commands (with the same --closes-deferred) idempotent even after
960
+ // the deferred row has transitioned out of 'open'.
961
+ if (r.kind === 'duplicate') return r;
962
+ if (closesTokens) {
963
+ closesIds = resolveDeferredIds(db, project, closesTokens);
964
+ closeDeferredItems(db, closesIds, r.id);
965
+ }
966
+ return r;
967
+ })();
968
+ } catch (e) {
969
+ if (closesTokens) {
970
+ fail(`[mem] save with --closes-deferred failed: ${e.message}`);
971
+ } else {
972
+ fail(`[mem] save failed: ${e.message}`);
973
+ }
974
+ return;
975
+ }
926
976
 
927
977
  if (result.kind === 'duplicate') {
928
978
  out(`[mem] Skipped: similar to existing #${result.existingId}. Use "claude-mem-lite get ${result.existingId}" to review.`);
@@ -930,7 +980,108 @@ function cmdSave(db, args) {
930
980
  }
931
981
 
932
982
  const lessonNote = result.lessonCaptured ? ' 💡lesson captured' : '';
933
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}`);
983
+ const closedNote = closesIds && closesIds.length > 0
984
+ ? ` Closed: ${closesIds.map(i => `D#${i}`).join(', ')}.`
985
+ : '';
986
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}`);
987
+ }
988
+
989
+ // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
990
+
991
+ function cmdDefer(db, args) {
992
+ const sub = args[0];
993
+ const rest = args.slice(1);
994
+ switch (sub) {
995
+ case 'add': cmdDeferAdd(db, rest); break;
996
+ case 'list': cmdDeferList(db, rest); break;
997
+ case 'drop': cmdDeferDrop(db, rest); break;
998
+ default:
999
+ fail('[mem] Usage: claude-mem-lite defer <add|list|drop> ...');
1000
+ fail('[mem] defer add "<title>" [--priority 1|2|3] [--detail T] [--files f1,f2] [--project P]');
1001
+ fail('[mem] defer list [--project P] [--limit N]');
1002
+ fail('[mem] defer drop <id-or-D#N> --reason "<reason>" [--project P]');
1003
+ }
1004
+ }
1005
+
1006
+ function cmdDeferAdd(db, args) {
1007
+ const { positional, flags } = parseArgs(args);
1008
+ const title = positional.join(' ').trim();
1009
+ if (!title) {
1010
+ fail('[mem] Usage: claude-mem-lite defer add "<title>" [--priority 1|2|3] [--detail T] [--files f1,f2] [--project P]');
1011
+ return;
1012
+ }
1013
+ const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
1014
+ if (![1, 2, 3].includes(priority)) {
1015
+ fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1016
+ return;
1017
+ }
1018
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1019
+ const detail = typeof flags.detail === 'string' ? flags.detail : null;
1020
+ const files = flags.files
1021
+ ? flags.files.split(',').map(f => f.trim()).filter(Boolean)
1022
+ : null;
1023
+
1024
+ let r;
1025
+ try {
1026
+ r = insertDeferred(db, { project, title, priority, detail, files });
1027
+ } catch (e) {
1028
+ fail(`[mem] defer add failed: ${e.message}`);
1029
+ return;
1030
+ }
1031
+ // Compute the freshly-inserted row's ordinal for an immediately-actionable
1032
+ // response ("ok, deferred this as item N"). Mirrors server.mjs:980.
1033
+ const open = listOpenWithOrdinal(db, project, 50);
1034
+ const ord = open.find(o => o.id === r.id)?.ordinal ?? '?';
1035
+ out(`[mem] Deferred as D#${r.id} (item ${ord}) in project "${project}".`);
1036
+ }
1037
+
1038
+ function cmdDeferList(db, args) {
1039
+ const { flags } = parseArgs(args);
1040
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1041
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 100 });
1042
+ const list = listOpenWithOrdinal(db, project, limit);
1043
+ if (list.length === 0) {
1044
+ out(`[mem] No open deferred items in project "${project}".`);
1045
+ return;
1046
+ }
1047
+ out(`[mem] Open deferred items (project "${project}"):`);
1048
+ for (const r of list) {
1049
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
1050
+ out(` ${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
1051
+ }
1052
+ }
1053
+
1054
+ function cmdDeferDrop(db, args) {
1055
+ const { positional, flags } = parseArgs(args);
1056
+ if (positional.length === 0) {
1057
+ fail('[mem] Usage: claude-mem-lite defer drop <id-or-D#N> --reason "<reason>" [--project P]');
1058
+ return;
1059
+ }
1060
+ const reason = flags.reason;
1061
+ if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
1062
+ fail('[mem] defer drop requires --reason "<non-empty string>"');
1063
+ return;
1064
+ }
1065
+ const rawTok = positional[0];
1066
+ // Accept both bare integer (ordinal) and "D#N" string. Mirrors the MCP
1067
+ // mem_defer_drop input contract (server.mjs:1025) by using the same
1068
+ // single-element resolveDeferredIds call.
1069
+ const token = /^\d+$/.test(rawTok) ? parseInt(rawTok, 10) : rawTok;
1070
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1071
+
1072
+ let realId;
1073
+ try {
1074
+ [realId] = resolveDeferredIds(db, project, [token]);
1075
+ } catch (e) {
1076
+ fail(`[mem] defer drop: ${e.message}`);
1077
+ return;
1078
+ }
1079
+ const r = dropDeferred(db, realId, reason);
1080
+ if (r.changed === 0) {
1081
+ out(`[mem] D#${realId} was not in 'open' status — drop is a no-op.`);
1082
+ return;
1083
+ }
1084
+ out(`[mem] Dropped D#${realId} in project "${project}". Reason: ${reason.trim()}`);
934
1085
  }
935
1086
 
936
1087
  // N-1: Quality-focused stats for R-2 A/B baseline.
@@ -1615,8 +1766,11 @@ function cmdCompress(db, args) {
1615
1766
  VALUES (?, ?, ?, ?, ?, 'active')
1616
1767
  `).run(sessionId, sessionId, proj, now.toISOString(), now.getTime());
1617
1768
 
1769
+ // Defense-in-depth: source rows already scrubbed at original ingest, but
1770
+ // the new compressed narrative is constructed here and re-persisted.
1771
+ const safe = scrubRecord('observations', { text: narrative, title, narrative });
1618
1772
  const summaryResult = insertSummary.run(
1619
- sessionId, proj, narrative, dominantType, title, narrative,
1773
+ sessionId, proj, safe.text, dominantType, safe.title, safe.narrative,
1620
1774
  medianDate.toISOString(), medianEpoch
1621
1775
  );
1622
1776
  const summaryId = Number(summaryResult.lastInsertRowid);
@@ -2178,6 +2332,19 @@ Commands:
2178
2332
  --project P Project name
2179
2333
  --files f1,f2 Comma-separated file paths
2180
2334
  --lesson T Lesson learned (≤500 chars; alias: --lesson-learned)
2335
+ --closes-deferred 1,D#42 Close deferred items in same transaction
2336
+
2337
+ defer <action> First-class deferred work (v2.70+)
2338
+ add "<title>" Mark deferred work for next session
2339
+ --priority N 1-3 (default 2)
2340
+ --detail T Constraint + why deferred
2341
+ --files f1,f2 Comma-separated file paths
2342
+ --project P Project name
2343
+ list List open deferred items
2344
+ --limit N Max results (default 10)
2345
+ --project P Filter by project
2346
+ drop <D#N|ordinal> Drop a deferred item (no fix needed)
2347
+ --reason "..." Required audit trail
2181
2348
 
2182
2349
  delete <id1,id2,...> Delete observations by ID
2183
2350
  --confirm Execute deletion (preview by default)
@@ -2251,6 +2418,9 @@ Commands:
2251
2418
  remove Remove resource --name N --resource-type T
2252
2419
  reindex Rebuild FTS5 index
2253
2420
 
2421
+ import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
2422
+ --project P Project name (default: inferred from cwd)
2423
+
2254
2424
  activity <action> Non-memdir event log (v2.31) — bugfix/lesson/bug/discovery/etc.
2255
2425
  save --type T "<title>" [--body "<text>"] [--files f1,f2] [--file path] [--importance 1-3] [--project P]
2256
2426
  search "<query>" Search events [--type T] [--limit N] [--project P]
@@ -2336,6 +2506,57 @@ async function cmdImport(argv) {
2336
2506
  }
2337
2507
  }
2338
2508
 
2509
+ // ─── Import (Claude Code JSONL transcript — cold-start backfill) ─────────────
2510
+
2511
+ async function cmdImportJsonl(db, argv) {
2512
+ const { positional, flags } = parseArgs(argv);
2513
+ const target = positional[0];
2514
+ if (!target) {
2515
+ fail('[mem] Usage: claude-mem-lite import-jsonl <file-or-dir> [--project <name>]');
2516
+ return;
2517
+ }
2518
+
2519
+ const project = flags.project || inferProject();
2520
+ const fs = await import('fs');
2521
+ const { join: pjoin, resolve } = await import('path');
2522
+ const abs = resolve(target);
2523
+
2524
+ let files = [];
2525
+ let st;
2526
+ try { st = fs.statSync(abs); }
2527
+ catch (e) { fail(`[mem] Cannot stat ${abs}: ${e.message}`); return; }
2528
+
2529
+ if (st.isDirectory()) {
2530
+ const walk = (dir) => {
2531
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
2532
+ const p = pjoin(dir, e.name);
2533
+ if (e.isDirectory()) walk(p);
2534
+ else if (e.isFile() && p.endsWith('.jsonl')) files.push(p);
2535
+ }
2536
+ };
2537
+ walk(abs);
2538
+ } else {
2539
+ files = [abs];
2540
+ }
2541
+
2542
+ if (files.length === 0) { out('[mem] No .jsonl files found.'); return; }
2543
+
2544
+ const { importJsonl } = await import('./lib/import-jsonl.mjs');
2545
+ let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0;
2546
+ for (const f of files) {
2547
+ const r = await importJsonl(db, f, { project });
2548
+ totalPrompts += r.prompts;
2549
+ totalObs += r.observations;
2550
+ totalSkip += r.skipped;
2551
+ totalOrphans += r.orphans || 0;
2552
+ out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2553
+ }
2554
+ out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s).`);
2555
+ if (totalPrompts > 0 || totalObs > 0) {
2556
+ out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2557
+ }
2558
+ }
2559
+
2339
2560
  // ─── Enrich ─────────────────────────────────────────────────────────────────
2340
2561
 
2341
2562
  async function cmdEnrich(argv) {
@@ -2506,6 +2727,7 @@ export async function run(argv) {
2506
2727
  case 'get': cmdGet(db, cmdArgs); break;
2507
2728
  case 'timeline': cmdTimeline(db, cmdArgs); break;
2508
2729
  case 'save': cmdSave(db, cmdArgs); break;
2730
+ case 'defer': cmdDefer(db, cmdArgs); break;
2509
2731
  case 'delete': cmdDelete(db, cmdArgs); break;
2510
2732
  case 'update': cmdUpdate(db, cmdArgs); break;
2511
2733
  case 'export': cmdExport(db, cmdArgs); break;
@@ -2518,6 +2740,7 @@ export async function run(argv) {
2518
2740
  case 'browse': cmdBrowse(db, cmdArgs); break;
2519
2741
  case 'registry': cmdRegistry(db, cmdArgs); break;
2520
2742
  case 'import': await cmdImport(cmdArgs); break;
2743
+ case 'import-jsonl': await cmdImportJsonl(db, cmdArgs); break;
2521
2744
  case 'enrich': await cmdEnrich(cmdArgs); break;
2522
2745
  case 'doctor': await cmdDoctor(db, cmdArgs); break;
2523
2746
  case 'activity': await cmdActivity(db, cmdArgs); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.69.0",
3
+ "version": "2.71.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -39,6 +39,7 @@
39
39
  "hook-handoff.mjs",
40
40
  "hook-update.mjs",
41
41
  "hook-optimize.mjs",
42
+ "hook-precompact.mjs",
42
43
  "plugin-cache-guard.mjs",
43
44
  "memdir.mjs",
44
45
  "adopt-content.mjs",
@@ -62,6 +63,10 @@
62
63
  "lib/metrics.mjs",
63
64
  "lib/mem-override.mjs",
64
65
  "lib/save-observation.mjs",
66
+ "lib/deferred-work.mjs",
67
+ "lib/upgrade-banner.mjs",
68
+ "lib/scrub-record.mjs",
69
+ "lib/import-jsonl.mjs",
65
70
  "cli/common.mjs",
66
71
  "cli/fts-check.mjs",
67
72
  "cli/doctor.mjs",
package/schema.mjs CHANGED
@@ -40,7 +40,12 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
40
40
  // DROP+CREATE so DBs that picked up the strict v29 trigger get the UUID-
41
41
  // gated body. Required because `CREATE TRIGGER IF NOT EXISTS` is a no-op
42
42
  // when the trigger already exists, even with a different body.
43
- export const CURRENT_SCHEMA_VERSION = 30;
43
+ // v31 (v2.70.0): deferred_work table — first-class carry-forward surface.
44
+ // Decoupled from observations: different decay semantics (no time decay; older
45
+ // items rank HIGHER as tech debt accumulates), different lifecycle (mutable
46
+ // status open→done|dropped vs immutable obs). Closure tied to obs via
47
+ // closed_by_obs_id FK with ON DELETE SET NULL (audit trail preserved).
48
+ export const CURRENT_SCHEMA_VERSION = 31;
44
49
 
45
50
  const CORE_SCHEMA = `
46
51
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -541,6 +546,33 @@ export function initSchema(db) {
541
546
  )
542
547
  `);
543
548
 
549
+ // ─── v31 (v2.70.0): deferred_work — carry-forward TODOs ─────────────────────
550
+ // Independent table because decay semantics are inverted (older = higher
551
+ // priority signal) and lifecycle is mutable (status flips). Project-scoped
552
+ // queries; no FTS5 (per-project N expected ≪ 100). Idempotent migration.
553
+ db.exec(`
554
+ CREATE TABLE IF NOT EXISTS deferred_work (
555
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
556
+ project TEXT NOT NULL,
557
+ title TEXT NOT NULL,
558
+ detail TEXT,
559
+ priority INTEGER NOT NULL DEFAULT 2,
560
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','done','dropped')),
561
+ created_at_epoch INTEGER NOT NULL,
562
+ closed_at_epoch INTEGER,
563
+ closed_by_obs_id INTEGER REFERENCES observations(id) ON DELETE SET NULL,
564
+ drop_reason TEXT,
565
+ source_session_id TEXT,
566
+ source_prompt_id INTEGER REFERENCES user_prompts(id) ON DELETE SET NULL,
567
+ files TEXT
568
+ );
569
+ CREATE INDEX IF NOT EXISTS idx_deferred_open
570
+ ON deferred_work(project, priority DESC, created_at_epoch ASC)
571
+ WHERE status = 'open';
572
+ CREATE INDEX IF NOT EXISTS idx_deferred_closed_by
573
+ ON deferred_work(closed_by_obs_id) WHERE closed_by_obs_id IS NOT NULL;
574
+ `);
575
+
544
576
  // Record schema version for fast-path on subsequent calls
545
577
  db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
546
578
  db.transaction(() => {