claude-mem-lite 2.88.0 → 2.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mem-cli.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
24
24
  import { buildSessionContextLines } from './hook-context.mjs';
25
25
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
26
- import { parseIntFlag } from './lib/cli-flags.mjs';
26
+ import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
27
27
  import { auditMemdir, memdirPath } from './memdir.mjs';
28
28
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
29
29
  import { basename, join } from 'path';
@@ -34,6 +34,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
34
34
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
35
35
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
36
36
  import { saveObservation } from './lib/save-observation.mjs';
37
+ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
37
38
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
38
39
  import {
39
40
  insertDeferred, listOpenWithOrdinal, dropDeferred,
@@ -70,16 +71,16 @@ function cmdSearch(db, args) {
70
71
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
71
72
  }
72
73
  const minImportance = flags.importance !== undefined ? parseInt(flags.importance, 10) : null;
73
- if (minImportance !== null && (isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
74
+ // isNumericToken first: "2abc"→2 / "1e2"→1 would pass the range check and silently
75
+ // filter at a value the user never typed. Reject garbage like out-of-range does.
76
+ if (minImportance !== null && (!isNumericToken(flags.importance) || isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
74
77
  fail(`[mem] Invalid --importance "${flags.importance}". Must be 1, 2, or 3.`);
75
78
  return;
76
79
  }
77
80
  const branch = flags.branch || null;
78
- const rawOffset = flags.offset !== undefined ? parseInt(flags.offset, 10) : NaN;
79
- if (flags.offset !== undefined && (!Number.isInteger(rawOffset) || rawOffset < 0)) {
80
- process.stderr.write(`[mem] Invalid --offset "${flags.offset}" (must be a non-negative integer); using 0\n`);
81
- }
82
- const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
81
+ // parseIntFlag (min=0) rejects garbage ("2abc"→2, "1e2"→1) the old isInteger check let
82
+ // through, warns once, and falls back to 0 — same WARN-style contract, now garbage-proof.
83
+ const offset = parseIntFlag(flags.offset, { name: '--offset', defaultValue: 0, min: 0 });
83
84
  const tier = flags.tier || null;
84
85
  if (tier && !['working', 'active', 'archive'].includes(tier)) {
85
86
  fail(`[mem] Invalid --tier "${tier}". Use: working, active, archive`);
@@ -125,8 +126,14 @@ function cmdSearch(db, args) {
125
126
  // so the post-merge sort has room to pick the best from each (paired-path with
126
127
  // server.mjs:377 — without this, obs gets systematically squeezed out by sessions).
127
128
  const isCrossSourceMode = !effectiveSource;
128
- const perSourceLimit = isCrossSourceMode ? Math.max(limit * 3, offset + limit + 10) : limit;
129
- const perSourceOffset = isCrossSourceMode ? 0 : offset;
129
+ // Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
130
+ // ALL modes mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
131
+ // unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
132
+ // rows the SQL OFFSET already skipped, so engine-side paging dropped (or
133
+ // duplicated) rows on the --type/--tier/--importance/--branch path (a page that
134
+ // MCP returned came back empty).
135
+ const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
136
+ const perSourceOffset = 0;
130
137
 
131
138
  const results = [];
132
139
  // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
@@ -304,7 +311,10 @@ function cmdSearch(db, args) {
304
311
  }
305
312
  // else 'relevance' keeps BM25 score order (already sorted)
306
313
 
307
- // Trim to limit with offset
314
+ // Trim to limit with offset. The engine always received perSourceOffset=0 and
315
+ // over-fetched (see above), so the merged+reranked `results` start at row 0 and
316
+ // the offset is applied exactly ONCE here — for every mode. `total` is the full
317
+ // match count (capped at perSourceLimit), enabling the "N of M" display.
308
318
  const total = results.length;
309
319
  const paged = results.slice(offset, offset + limit);
310
320
 
@@ -388,7 +398,9 @@ function cmdRecent(db, args) {
388
398
  const { positional, flags } = parseArgs(args);
389
399
  const rawArg = positional[0];
390
400
  const rawLimit = parseInt(rawArg, 10);
391
- const isValid = Number.isInteger(rawLimit) && rawLimit > 0;
401
+ // isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
402
+ // accepted silently; the positional path must reject garbage like the --limit flag does.
403
+ const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
392
404
  if (rawArg !== undefined && !isValid) {
393
405
  process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
394
406
  }
@@ -691,7 +703,9 @@ function cmdTimeline(db, args) {
691
703
  const parseWindow = (label, raw) => {
692
704
  if (raw === undefined) return 5;
693
705
  const n = parseInt(raw, 10);
694
- if (!Number.isInteger(n) || n < 0) {
706
+ // isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
707
+ // accepted silently; reject garbage tokens like the negative path already does.
708
+ if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
695
709
  process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
696
710
  return 5;
697
711
  }
@@ -924,7 +938,9 @@ function cmdSave(db, args) {
924
938
 
925
939
  // Explicit saves default to importance=2 (notable) — user chose to save
926
940
  const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
927
- if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
941
+ // isNumericToken first: bare parseInt would coerce "2abc"→2 / "1e2"→1 and persist a
942
+ // wrong importance that silently skews ranking/decay. Float literals still truncate (#8277).
943
+ if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
928
944
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
929
945
  return;
930
946
  }
@@ -1040,6 +1056,12 @@ function cmdDeferAdd(db, args) {
1040
1056
  return;
1041
1057
  }
1042
1058
  const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
1059
+ // isNumericToken first: bare parseInt would coerce "3xyz"→3 and silently escalate a
1060
+ // deferred item's urgency. Float literals still truncate (#8277).
1061
+ if (flags.priority !== undefined && !isNumericToken(flags.priority)) {
1062
+ fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1063
+ return;
1064
+ }
1043
1065
  if (![1, 2, 3].includes(priority)) {
1044
1066
  fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1045
1067
  return;
@@ -1254,6 +1276,7 @@ async function cmdStats(db, args) {
1254
1276
  const lowVal = db.prepare(`
1255
1277
  SELECT COUNT(*) as c FROM observations
1256
1278
  WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1279
+ AND COALESCE(compressed_into, 0) = 0
1257
1280
  AND created_at_epoch < ? ${projectFilter}
1258
1281
  `).get(thirtyDaysAgo, ...baseParams);
1259
1282
  const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
@@ -1606,6 +1629,19 @@ function cmdUpdate(db, args) {
1606
1629
  return;
1607
1630
  }
1608
1631
 
1632
+ // A value-less `--flag` (last arg, or immediately followed by another --flag)
1633
+ // parses to boolean `true` (cli/common.mjs parseArgs). For string-valued fields
1634
+ // that boolean would slip past the string-only empty guards below and reach the
1635
+ // SQLite bind, surfacing a raw "TypeError: SQLite3 can only bind ..." stacktrace
1636
+ // — the same accidental shell-strip class the empty-title guard (#8470) catches.
1637
+ // Reject it cleanly for every string-valued update flag.
1638
+ for (const key of ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts']) {
1639
+ if (flags[key] === true) {
1640
+ fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
1641
+ return;
1642
+ }
1643
+ }
1644
+
1609
1645
  const updates = [];
1610
1646
  const params = [];
1611
1647
  if (flags.title !== undefined) {
@@ -1628,7 +1664,9 @@ function cmdUpdate(db, args) {
1628
1664
  }
1629
1665
  if (flags.importance) {
1630
1666
  const imp = parseInt(flags.importance, 10);
1631
- if (isNaN(imp) || imp < 1 || imp > 3) {
1667
+ // isNumericToken first: bare parseInt would coerce "2abc"→2 and UPDATE the row to a
1668
+ // wrong importance. Float literals still truncate (#8277).
1669
+ if (!isNumericToken(flags.importance) || isNaN(imp) || imp < 1 || imp > 3) {
1632
1670
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
1633
1671
  return;
1634
1672
  }
@@ -1730,8 +1768,16 @@ function cmdExport(db, args) {
1730
1768
  return;
1731
1769
  }
1732
1770
 
1771
+ // Full round-trippable column set so `restore` rebuilds observations faithfully —
1772
+ // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1773
+ // Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
1774
+ // are unaffected. id + memory_session_id are informational (restore remaps id and
1775
+ // buckets under a restore session).
1733
1776
  const rows = db.prepare(`
1734
- SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, created_at, created_at_epoch
1777
+ SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1778
+ files_read, files_modified, lesson_learned, importance, branch,
1779
+ access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1780
+ last_accessed_at, created_at, created_at_epoch
1735
1781
  FROM observations WHERE ${wheres.join(' AND ')}
1736
1782
  ORDER BY created_at_epoch DESC LIMIT ?
1737
1783
  `).all(...params, limit);
@@ -1758,6 +1804,83 @@ function cmdExport(db, args) {
1758
1804
  }
1759
1805
  }
1760
1806
 
1807
+ // ─── Restore ───────────────────────────────────────────────────────────────
1808
+ // Inverse of `export` — the backup/restore half README:690 promises. Reuses
1809
+ // lib/save-observation.mjs so FK / FTS / TF-IDF vector / minhash / files-junction
1810
+ // stay consistent with cmdSave, then a targeted UPDATE re-applies the value-signals
1811
+ // (access/cited/uncited/injection/decay), branch, and concepts/facts/files_read that
1812
+ // saveObservation derives or zeros — so a restored backup keeps its citation-decay
1813
+ // history and original timing (created_at via the `now` param). Source ids are
1814
+ // discarded (local AUTOINCREMENT; export omits related_ids); session provenance
1815
+ // collapses to saveObservation's manual-<project> bucket (documented MVP tradeoff).
1816
+ function cmdRestore(db, argv) {
1817
+ const { positional, flags } = parseArgs(argv);
1818
+ const file = positional[0];
1819
+ if (!file) { fail('[mem] Usage: claude-mem-lite restore <file> [--project P] [--dry-run]'); return; }
1820
+ let raw;
1821
+ try { raw = readFileSync(file, 'utf8'); }
1822
+ catch (e) { fail(`[mem] Cannot read "${file}": ${e.message}`); return; }
1823
+ const trimmed = raw.trim();
1824
+ if (!trimmed) { out('[mem] Empty file — nothing to restore.'); return; }
1825
+ let rows;
1826
+ try {
1827
+ rows = trimmed[0] === '['
1828
+ ? JSON.parse(trimmed)
1829
+ : trimmed.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1830
+ } catch (e) { fail(`[mem] "${file}" is not valid export JSON/JSONL: ${e.message}`); return; }
1831
+ if (!Array.isArray(rows) || rows.length === 0) { out('[mem] No observations in file.'); return; }
1832
+
1833
+ const projOverride = flags.project ? resolveProject(db, flags.project) : null;
1834
+ const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true';
1835
+ const num = (v) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0;
1836
+
1837
+ const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1838
+ const signalUpdate = db.prepare(`UPDATE observations SET
1839
+ subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
1840
+ access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1841
+ decay_seen_count = ?, last_accessed_at = ?
1842
+ WHERE id = ?`);
1843
+
1844
+ let restored = 0, skipped = 0, malformed = 0;
1845
+ for (const r of rows) {
1846
+ if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
1847
+ const project = projOverride || r.project || inferProject();
1848
+ const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
1849
+ // Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
1850
+ // re-restore of an old-timestamped backup, so gate on project+title+created_at.
1851
+ if (dupCheck.get(project, r.title, createdEpoch)) { skipped++; continue; }
1852
+ if (dryRun) { restored++; continue; }
1853
+ try {
1854
+ let files = [];
1855
+ try { const fm = JSON.parse(r.files_modified || '[]'); if (Array.isArray(fm)) files = fm; } catch { /* leave [] */ }
1856
+ const imp = num(r.importance);
1857
+ const res = saveObservation(db, {
1858
+ content: r.narrative || r.title,
1859
+ title: r.title,
1860
+ type: r.type,
1861
+ importance: imp >= 1 && imp <= 3 ? imp : 1,
1862
+ project,
1863
+ files,
1864
+ lesson_learned: r.lesson_learned || null,
1865
+ now: new Date(createdEpoch),
1866
+ });
1867
+ if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1868
+ // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1869
+ signalUpdate.run(
1870
+ r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
1871
+ num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1872
+ num(r.decay_seen_count), r.last_accessed_at ?? null,
1873
+ res.id,
1874
+ );
1875
+ restored++;
1876
+ } catch (e) {
1877
+ malformed++;
1878
+ if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`[mem] restore row failed: ${e.message}\n`);
1879
+ }
1880
+ }
1881
+ out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${malformed} malformed/failed from ${rows.length} row(s).`);
1882
+ }
1883
+
1761
1884
  // ─── Compress ────────────────────────────────────────────────────────────────
1762
1885
 
1763
1886
  function cmdCompress(db, args) {
@@ -1845,7 +1968,6 @@ function cmdMaintain(db, args) {
1845
1968
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
1846
1969
  out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
1847
1970
  if (duplicates.length > 0) {
1848
- const AUTO_MERGE_THRESHOLD = 0.85;
1849
1971
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
1850
1972
  const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
1851
1973
 
@@ -1876,7 +1998,11 @@ function cmdMaintain(db, args) {
1876
1998
 
1877
1999
  // Execute
1878
2000
  const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
1879
- const opsStr = flags.ops || 'cleanup,decay,boost';
2001
+ // Distinguish flag-absent (use default op set) from flag-present-but-empty
2002
+ // (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
2003
+ // to the destructive default cleanup,decay,boost and EXECUTED it; route it to the
2004
+ // VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
2005
+ const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
1880
2006
  const ops = opsStr.split(',').map(s => s.trim());
1881
2007
  const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
1882
2008
  if (invalidOps.length > 0) {
@@ -2119,6 +2245,12 @@ function cmdRegistry(_memDb, args) {
2119
2245
  }
2120
2246
 
2121
2247
  if (action === 'import') {
2248
+ // A bare value-less flag parses to boolean `true` (parseArgs); for these string
2249
+ // fields that boolean reaches the SQLite bind in upsertResource and throws a raw
2250
+ // TypeError — same class as the `update` guard above (#8470). Reject up front.
2251
+ for (const key of ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
2252
+ if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
2253
+ }
2122
2254
  const name = flags.name;
2123
2255
  const resourceType = flags['resource-type'];
2124
2256
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
@@ -2140,6 +2272,11 @@ function cmdRegistry(_memDb, args) {
2140
2272
  }
2141
2273
 
2142
2274
  if (action === 'remove') {
2275
+ // Bare value-less --name / --resource-type → boolean true → SQLite-bind crash
2276
+ // on the DELETE below; reject like the import branch and the `update` guard.
2277
+ for (const key of ['name', 'resource-type']) {
2278
+ if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
2279
+ }
2143
2280
  const name = flags.name;
2144
2281
  const resourceType = flags['resource-type'];
2145
2282
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
@@ -2414,6 +2551,7 @@ Commands:
2414
2551
  --concepts T Space-separated concept tags
2415
2552
 
2416
2553
  export Export observations as JSON/JSONL
2554
+ restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
2417
2555
  --project P Filter by project
2418
2556
  --type T Filter by type
2419
2557
  --format F json (default) or jsonl
@@ -2641,6 +2779,13 @@ async function cmdImportJsonl(db, argv) {
2641
2779
  out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2642
2780
  if (totalPrompts > 0 || totalObs > 0) {
2643
2781
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2782
+ } else if (totalSkip > 0 && errorCount === 0) {
2783
+ // Nothing imported but every line was skipped — almost always the wrong file
2784
+ // format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
2785
+ // which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
2786
+ // it at the wrong file looked like success. Make the no-op explicit (stdout, like
2787
+ // the summary lines above).
2788
+ out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
2644
2789
  }
2645
2790
  }
2646
2791
 
@@ -2865,6 +3010,7 @@ export async function run(argv) {
2865
3010
  case 'delete': cmdDelete(db, cmdArgs); break;
2866
3011
  case 'update': cmdUpdate(db, cmdArgs); break;
2867
3012
  case 'export': cmdExport(db, cmdArgs); break;
3013
+ case 'restore': cmdRestore(db, cmdArgs); break;
2868
3014
  case 'compress': cmdCompress(db, cmdArgs); break;
2869
3015
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2870
3016
  case 'optimize': await cmdOptimize(db, cmdArgs); break;
package/nlp.mjs CHANGED
@@ -218,6 +218,12 @@ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS]);
218
218
  export function sanitizeFtsQuery(query) {
219
219
  if (!query) return null;
220
220
  const cleaned = query
221
+ // Strip ASCII control chars / NUL FIRST. A NUL survives tokenization (it's not
222
+ // \s), gets phrase-quoted by expandToken, and then terminates SQLite's C string
223
+ // mid-phrase → FTS5 "unterminated string" throw, breaking the documented
224
+ // "never throws on MATCH" invariant. The metachar class below doesn't cover them.
225
+ // eslint-disable-next-line no-control-regex -- intentional: stripping control chars IS the fix
226
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
221
227
  .replace(/[{}()[\]^~*:"\\]/g, ' ')
222
228
  .replace(/(^|\s)-/g, '$1')
223
229
  .trim();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.88.0",
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. Alternative to claude-mem with 600x lower cost.",
3
+ "version": "2.90.0",
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",
7
7
  "engines": {
@@ -69,6 +69,7 @@
69
69
  "lib/save-observation.mjs",
70
70
  "lib/compress-core.mjs",
71
71
  "lib/maintain-core.mjs",
72
+ "lib/dedup-constants.mjs",
72
73
  "lib/deferred-work.mjs",
73
74
  "lib/upgrade-banner.mjs",
74
75
  "lib/scrub-record.mjs",
package/schema.mjs CHANGED
@@ -8,9 +8,17 @@ import { join } from 'path';
8
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
9
9
  import { OBS_FTS_COLUMNS } from './utils.mjs';
10
10
 
11
+ // DATA location — DB, managed resources, registry DB, runtime/. Honors
12
+ // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
11
13
  export const DB_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
12
14
  export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
15
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
16
+ // CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
17
+ // here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
18
+ // bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
19
+ // CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
20
+ // DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
21
+ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
14
22
 
15
23
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
24
  //
@@ -54,13 +62,22 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
54
62
  // re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
55
63
  // the warm-start fast-path left foreign_keys OFF. LATEST_MIGRATION_COLUMN is
56
64
  // unchanged (no new column) — decay_seen_count still exists at v35.
57
- export const CURRENT_SCHEMA_VERSION = 35;
65
+ // v36 (v2.89.0): no DDL — narrows events_fts_au to `AFTER UPDATE OF title, body`.
66
+ // The events FTS triggers (v2.31) were hand-written inline and inherited the
67
+ // pre-v27 broad `AFTER UPDATE ON events` form, so every importance / accessed_count
68
+ // / citation-decay bump thrashed events_fts (delete+reinsert) and reintroduced the
69
+ // SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
70
+ // bumped to force one migration pass; the conditional drop below replaces the
71
+ // legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
72
+ // v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
73
+ // MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
74
+ export const CURRENT_SCHEMA_VERSION = 37;
58
75
 
59
76
  // Sentinel column for the LATEST migration set. The fast-path uses this to
60
77
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
61
78
  // back (observed once in dev during v2.74.0). Update both the column AND
62
79
  // (if needed) the table when adding a new migration batch.
63
- const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'decay_seen_count' };
80
+ const LATEST_MIGRATION_COLUMN = { table: 'user_prompts', column: 'cc_session_id' };
64
81
 
65
82
  function hasLatestMigrationColumn(db) {
66
83
  try {
@@ -198,6 +215,14 @@ const MIGRATIONS = [
198
215
  // share the unrelated injection_count column. Same-source numerator
199
216
  // (cited_count) + same-source denominator = meaningful ratio.
200
217
  'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
218
+ // v37 (D#26 — parallel-session handoff content scoping): the Claude-Code session
219
+ // UUID per user prompt. handleUserPrompt writes hookData.session_id here so
220
+ // buildAndSaveHandoff can scope working_on to ONE CC session — concurrent (and
221
+ // within-12h-TTL sequential) same-project sessions previously merged each other's
222
+ // prompts because getSessionId() is project-scoped (no CC-UUID component). Nullable:
223
+ // legacy rows + non-CC/no-stdin invocations read back NULL and the handoff falls
224
+ // back to its legacy unfiltered query.
225
+ 'ALTER TABLE user_prompts ADD COLUMN cc_session_id TEXT DEFAULT NULL',
201
226
  ];
202
227
 
203
228
  /**
@@ -348,6 +373,7 @@ export function initSchema(db) {
348
373
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`);
349
374
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`);
350
375
  db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
376
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`);
351
377
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`);
352
378
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
353
379
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
@@ -399,6 +425,20 @@ export function initSchema(db) {
399
425
  }
400
426
  } catch { /* non-critical */ }
401
427
 
428
+ // v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
429
+ // triggers were hand-written inline (below) rather than via ensureFTS, so
430
+ // events_fts_au inherited the broad `AFTER UPDATE ON events` form and fires on
431
+ // every non-indexed bump (importance / accessed_count / citation-decay). Drop
432
+ // the legacy trigger when its stored DDL lacks the scoped `UPDATE OF` clause so
433
+ // the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
434
+ // re-run + fresh-DB: undefined row on a fresh DB is a no-op).
435
+ try {
436
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`).get();
437
+ if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
438
+ db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
439
+ }
440
+ } catch { /* non-critical — recreated below */ }
441
+
402
442
  // ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
403
443
  // Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
404
444
  // observation/decision types. Isolated from observations to avoid polluting
@@ -443,7 +483,9 @@ export function initSchema(db) {
443
483
  VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
444
484
  END;
445
485
 
446
- CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE ON events BEGIN
486
+ -- v36: scoped to title, body (the FTS-indexed columns) so non-indexed bumps
487
+ -- (importance / accessed_count / citation-decay) no longer thrash events_fts.
488
+ CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE OF title, body ON events BEGIN
447
489
  INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
448
490
  VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
449
491
  INSERT INTO events_fts(rowid, title, body, event_type, project)
package/search-engine.mjs CHANGED
@@ -257,11 +257,12 @@ export function searchObservationsHybrid(db, ctx) {
257
257
  project: args.project ?? null,
258
258
  type: args.obs_type ?? null,
259
259
  vocabVersion: vocab.version,
260
+ minCosine: ctx.minCosine, // undefined → MIN_COSINE_SIMILARITY (benchmark sweep override)
260
261
  });
261
262
  if (vecResults.length === 0) return results;
262
263
 
263
264
  if (results.length > 0) {
264
- const rrfRanking = rrfMerge(results, vecResults);
265
+ const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
265
266
  const resultMap = new Map(results.map(r => [r.id, r]));
266
267
  for (const vr of vecResults) {
267
268
  if (!resultMap.has(vr.id)) {
package/server.mjs CHANGED
@@ -36,6 +36,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
36
36
  import { searchResources } from './registry-retriever.mjs';
37
37
  import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
38
38
  import { saveObservation } from './lib/save-observation.mjs';
39
+ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
39
40
  import {
40
41
  insertDeferred, listOpenWithOrdinal, dropDeferred,
41
42
  resolveDeferredIds, closeDeferredItems,
@@ -474,7 +475,12 @@ server.registerTool(
474
475
  `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
475
476
  ).all(...obsIds);
476
477
  const rowMap = new Map(fullRows.map(r => [r.id, r]));
477
- const tierCtx = { now: Date.now(), currentProject: currentProject, currentSessionId: '' };
478
+ // Use the explicitly-requested project for tier classification, not the
479
+ // CWD-inferred one — else computeTier's "obs.project === currentProject"
480
+ // (working/active rules) fails for cross-project searches and the tier=
481
+ // filter silently drops valid rows. mem_stats/mem_browse already resolve
482
+ // args.project first; this restores parity.
483
+ const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
478
484
  const filtered = results.filter(r => {
479
485
  if (r.source !== 'obs') return true;
480
486
  const full = rowMap.get(r.id);
@@ -1107,6 +1113,7 @@ server.registerTool(
1107
1113
  const lowVal = db.prepare(`
1108
1114
  SELECT COUNT(*) as c FROM observations
1109
1115
  WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1116
+ AND COALESCE(compressed_into, 0) = 0
1110
1117
  AND created_at_epoch < ? ${projectFilter}
1111
1118
  `).get(thirtyDaysAgo, ...baseParams);
1112
1119
 
@@ -1250,7 +1257,6 @@ server.registerTool(
1250
1257
  ` Pending purge (idle-marked): ${stats.pendingPurge}`,
1251
1258
  ];
1252
1259
  if (duplicates.length > 0) {
1253
- const AUTO_MERGE_THRESHOLD = 0.85;
1254
1260
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
1255
1261
  const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
1256
1262
 
package/source-files.mjs CHANGED
@@ -86,6 +86,11 @@ export const SOURCE_FILES = [
86
86
  // Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
87
87
  // and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
88
88
  'lib/maintain-core.mjs',
89
+ // P10 dedup/merge threshold constants — single source of truth for the Jaccard
90
+ // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
91
+ // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
92
+ // missing it from the manifest would break those paths on auto-update.
93
+ 'lib/dedup-constants.mjs',
89
94
  // v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
90
95
  // server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
91
96
  'lib/deferred-work.mjs',
package/tfidf.mjs CHANGED
@@ -10,6 +10,10 @@ import { createHash } from 'crypto';
10
10
  export const VOCAB_DIM = 512;
11
11
  export const MIN_COSINE_SIMILARITY = 0.05;
12
12
  export const VECTOR_SCAN_LIMIT = 500;
13
+ // Reciprocal Rank Fusion constant. Higher k flattens the rank-position weighting
14
+ // (BM25 and vector lists contribute more equally); lower k lets the top few ranks
15
+ // dominate. 60 is the de-facto RRF default and balances the two retrievers here.
16
+ export const RRF_K = 60;
13
17
 
14
18
  const VOCAB_STOP_WORDS = new Set([
15
19
  ...BASE_STOP_WORDS,
@@ -192,7 +196,7 @@ export function _resetVocabCache() { _vocabCache = null; }
192
196
  * @param {object} db - better-sqlite3 database
193
197
  * @returns {{ terms: Map<string, {index: number, idf: number}>, version: string, dim: number } | null}
194
198
  */
195
- export function buildVocabulary(db) {
199
+ export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
196
200
  const rows = db.prepare(`
197
201
  SELECT title, narrative, concepts FROM observations
198
202
  WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
@@ -217,7 +221,7 @@ export function buildVocabulary(db) {
217
221
  .filter(([term, freq]) => !isNoiseTerm(term) && freq >= 2)
218
222
  .map(([term, freq]) => ({ term, df: freq, idf: idf(freq), ig: freq * idf(freq) }))
219
223
  .sort((a, b) => b.ig - a.ig)
220
- .slice(0, VOCAB_DIM);
224
+ .slice(0, dim);
221
225
 
222
226
  // Build terms map with index and IDF
223
227
  const terms = new Map();
@@ -229,7 +233,7 @@ export function buildVocabulary(db) {
229
233
  const termList = sortedTerms.map(e => e.term).join(',');
230
234
  const version = createHash('md5').update(termList).digest('hex').slice(0, 12);
231
235
 
232
- const vocab = { terms, version, dim: VOCAB_DIM };
236
+ const vocab = { terms, version, dim };
233
237
  _vocabCache = vocab;
234
238
  return vocab;
235
239
  }
@@ -239,8 +243,8 @@ export function buildVocabulary(db) {
239
243
  * @param {object} db - better-sqlite3 database
240
244
  * @returns {object|null} The new vocabulary
241
245
  */
242
- export function rebuildVocabulary(db) {
243
- const vocab = buildVocabulary(db);
246
+ export function rebuildVocabulary(db, opts) {
247
+ const vocab = buildVocabulary(db, opts);
244
248
  if (!vocab) return null;
245
249
 
246
250
  const insertStmt = db.prepare(
@@ -358,7 +362,7 @@ export function cosineSimilarity(a, b) {
358
362
  const VECTOR_TIME_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
359
363
  const VECTOR_MIN_RESULTS = 50; // fallback to full scan if time-window yields fewer
360
364
 
361
- export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT }) {
365
+ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit = VECTOR_SCAN_LIMIT, minCosine = MIN_COSINE_SIMILARITY }) {
362
366
  if (!queryVec) return [];
363
367
 
364
368
  const now = Date.now();
@@ -403,7 +407,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
403
407
  for (const row of rows) {
404
408
  const vec = new Float32Array(row.vector.buffer.slice(row.vector.byteOffset, row.vector.byteOffset + row.vector.byteLength));
405
409
  const sim = cosineSimilarity(queryVec, vec);
406
- if (sim > MIN_COSINE_SIMILARITY) results.push({ id: row.observation_id, similarity: sim });
410
+ if (sim > minCosine) results.push({ id: row.observation_id, similarity: sim });
407
411
  }
408
412
  results.sort((a, b) => b.similarity - a.similarity);
409
413
  return results.slice(0, 20);
@@ -418,7 +422,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
418
422
  * @param {number} k - RRF constant (default 60)
419
423
  * @returns {{ id: number, rrfScore: number }[]}
420
424
  */
421
- export function rrfMerge(bm25Results, vectorResults, k = 60) {
425
+ export function rrfMerge(bm25Results, vectorResults, k = RRF_K) {
422
426
  const scores = new Map();
423
427
  bm25Results.forEach((r, i) => {
424
428
  scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i + 1));