claude-mem-lite 3.56.0 → 3.57.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": "3.56.0",
13
+ "version": "3.57.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.56.0",
3
+ "version": "3.57.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-context.mjs CHANGED
@@ -1,4 +1,8 @@
1
1
  // claude-mem-lite CLAUDE.md context injection and token budgeting
2
+ // SHARED ENGINE — the `hook-` prefix is historical, not a scope. buildSessionContextLines
3
+ // is imported by hook.mjs (SessionStart), mem-cli.mjs (`context` command) and
4
+ // hook-precompact.mjs, so it runs outside the hook pipeline too. Do not assume
5
+ // hook-pipeline session lifecycle or single-writer concurrency here.
2
6
  // Handles adaptive time windows, token-budgeted selection, and legacy CLAUDE.md cleanup.
3
7
 
4
8
  import { basename, join } from 'path';
package/hook-episode.mjs CHANGED
@@ -109,7 +109,10 @@ export function writeEpisode(episode) {
109
109
  const target = episodeFile();
110
110
  const tmp = target + `.tmp-${process.pid}`;
111
111
  const { _fileSet, ...serializable } = episode;
112
- writeFileSync(tmp, JSON.stringify(serializable));
112
+ // 0600 on the tmp file, not on `target`: mode applies at creation and rename
113
+ // carries it over, so the buffer is never briefly world-readable. The buffer
114
+ // holds captured file paths + scrubbed activity — owner-only like the DB.
115
+ writeFileSync(tmp, JSON.stringify(serializable), { mode: 0o600 });
113
116
  try {
114
117
  renameSync(tmp, target);
115
118
  } catch (err) {
@@ -196,7 +199,7 @@ export function writePendingEntry(entry, sessionId, project) {
196
199
  const pendingFile = join(RUNTIME_DIR, `pending-${ts}-${rand}.json`);
197
200
  const tmp = pendingFile + '.tmp';
198
201
  try {
199
- writeFileSync(tmp, JSON.stringify({ entry, sessionId, project, ts }));
202
+ writeFileSync(tmp, JSON.stringify({ entry, sessionId, project, ts }), { mode: 0o600 });
200
203
  renameSync(tmp, pendingFile);
201
204
  } catch {
202
205
  try { unlinkSync(tmp); } catch {}
package/hook-memory.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters } from './utils.mjs';
4
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
6
  import { recordMetric } from './lib/metrics.mjs';
7
7
  import { DB_DIR } from './schema.mjs';
@@ -376,7 +376,9 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
376
376
  export function recallForFile(db, filePath, project) {
377
377
  if (!db || !filePath) return [];
378
378
  try {
379
- const basename = filePath.split('/').pop();
379
+ // Both separators: filePath comes from a hook payload written by the
380
+ // CLIENT's OS, so a Windows path can reach a POSIX host (and vice versa).
381
+ const basename = basenameAnySep(filePath);
380
382
  const cutoff = Date.now() - FILE_RECALL_LOOKBACK_MS;
381
383
  // Escape SQL LIKE wildcards in filename to prevent injection
382
384
  const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
package/hook-optimize.mjs CHANGED
@@ -1,4 +1,8 @@
1
1
  // claude-mem-lite: LLM-powered database optimization
2
+ // SHARED ENGINE — the `hook-` prefix is historical, not a scope. All three entry
3
+ // surfaces import this: hook.mjs (handleLLMOptimize), server.mjs and mem-cli.mjs
4
+ // (optimizePreview/optimizeRun), plus a lazy import from lib/save-enrich.mjs. Do not
5
+ // assume hook-pipeline session lifecycle or single-writer concurrency here.
2
6
  // Background worker for intelligent maintenance: re-enrich, normalize, cluster-merge, smart-compress
3
7
  // Triggered from auto-maintain (24h) or manually via mem_optimize MCP tool / CLI
4
8
 
package/hook-shared.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync } from 'fs';
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { ensureDb, DB_DIR } from './schema.mjs';
10
10
  import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
@@ -114,8 +114,17 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
114
114
  return count;
115
115
  }
116
116
 
117
- // Ensure runtime directory exists
118
- try { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); } catch {}
117
+ // Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
118
+ // (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
119
+ // shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
120
+ // (server.mjs) sweeps at MCP-server startup, but hooks routinely run before any server
121
+ // exists, so harden here too: create 0700, and chmod a pre-existing dir a prior version
122
+ // created at the default umask. A 0700 dir blocks traversal to every file inside,
123
+ // current and future, regardless of individual file mode (audit sec P3-2 2026-07-24).
124
+ try {
125
+ if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
126
+ else chmodSync(RUNTIME_DIR, 0o700);
127
+ } catch {}
119
128
 
120
129
  // ─── Session ID Management ───────────────────────────────────────────────────
121
130
 
@@ -136,7 +145,7 @@ export function createSessionId() {
136
145
  const id = `hook-${project}-${randomUUID().slice(0, 8)}`;
137
146
  const file = sessionFile();
138
147
  const tmp = file + `.tmp-${process.pid}`;
139
- writeFileSync(tmp, JSON.stringify({ id, startedAt: Date.now(), project }));
148
+ writeFileSync(tmp, JSON.stringify({ id, startedAt: Date.now(), project }), { mode: 0o600 });
140
149
  renameSync(tmp, file);
141
150
  return id;
142
151
  }
package/hook-update.mjs CHANGED
@@ -480,7 +480,16 @@ async function fetchAssetBuffer(url) {
480
480
  // The CLAUDE_MEM_SKIP_SIG_VERIFY escape hatch still forces a skip. publicKey is a
481
481
  // param (defaulting to the embedded constant) only so tests can exercise both regimes.
482
482
  export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey = RELEASE_PUBLIC_KEY) {
483
- if (process.env.CLAUDE_MEM_SKIP_SIG_VERIFY) return { ok: true, action: 'skipped-env' };
483
+ if (process.env.CLAUDE_MEM_SKIP_SIG_VERIFY) {
484
+ // Loud on stderr, not via debugLog: this disables the strongest control in the
485
+ // update path, and debugLog is gated behind CLAUDE_MEM_DEBUG — the one case
486
+ // where silence is exactly wrong. An operator who set the var sees it; an
487
+ // attacker who set it in someone's environment loses the quiet.
488
+ process.stderr.write(
489
+ '[claude-mem-lite] WARNING: CLAUDE_MEM_SKIP_SIG_VERIFY is set — installing this release WITHOUT signature verification.\n'
490
+ );
491
+ return { ok: true, action: 'skipped-env' };
492
+ }
484
493
  if (!publicKey) return { ok: true, action: 'skipped-no-pubkey' };
485
494
 
486
495
  const list = Array.isArray(assets) ? assets : [];
@@ -518,6 +527,79 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
518
527
  // Undo a (partial or complete) file swap: delete the freshly-installed files, then
519
528
  // rename each backup back into place. Shared by the error path and the MED-5
520
529
  // post-install smoke gate so there is ONE rollback implementation.
530
+ // Swap-window marker. The rename loop is atomic per FILE, not per file SET, so a
531
+ // hook process that starts mid-loop can resolve hook.mjs from vN and one of its
532
+ // imports from vN+1 — the install.lock only excludes concurrent WRITERS, not
533
+ // readers. scripts/hook-launcher.mjs skips a fire while this marker is live;
534
+ // hooks are best-effort, so losing one fire beats importing a mixed module graph.
535
+ // Carries pid + ts because the launcher must never be muted permanently by an
536
+ // updater that was killed mid-swap (it applies the same staleness bound).
537
+ const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress');
538
+ // Intent journal, written INSIDE the backup dir before each rename. On a hard kill
539
+ // the backup dir survives (every normal exit deletes it) and this file says exactly
540
+ // which paths were in flight, so the next entry can finish the rollback at the right
541
+ // granularity — a bare directory walk cannot tell a nested relPath from a directory
542
+ // relPath like `node_modules`.
543
+ const SWAP_JOURNAL = '.swap-journal.json';
544
+
545
+ function markSwapStart() {
546
+ try {
547
+ mkdirSync(dirname(SWAP_MARKER), { recursive: true });
548
+ writeFileSync(SWAP_MARKER, JSON.stringify({ pid: process.pid, ts: Date.now() }));
549
+ } catch (e) { debugCatch(e, 'markSwapStart'); }
550
+ }
551
+
552
+ function clearSwapMarker() {
553
+ try { rmSync(SWAP_MARKER, { force: true }); } catch (e) { debugCatch(e, 'clearSwapMarker'); }
554
+ }
555
+
556
+ // Write-ahead: journal the INTENT before the rename, never after. Journalling after
557
+ // a successful rename leaves a window where the file has already moved into the
558
+ // backup dir but nothing records it — recovery would then delete the backup dir with
559
+ // the only copy of that file inside it. rollbackInstall guards every entry with
560
+ // existsSync/force, so an intent that never happened is a harmless no-op.
561
+ function journalSwap(backupDir, backedUp, installed) {
562
+ try {
563
+ writeFileSync(join(backupDir, SWAP_JOURNAL), JSON.stringify({ backedUp, installed }));
564
+ } catch (e) { debugCatch(e, 'journalSwap'); }
565
+ }
566
+
567
+ /**
568
+ * Finish any swap a previous process was killed in the middle of, then clear its
569
+ * residue. Called on every install entry, under the install lock, BEFORE a new
570
+ * staging/backup pair is created.
571
+ * @returns {number} number of interrupted swaps rolled back
572
+ */
573
+ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
574
+ let entries;
575
+ try { entries = readdirSync(targetDir, { withFileTypes: true }); } catch { return 0; }
576
+
577
+ let recovered = 0;
578
+ for (const entry of entries) {
579
+ if (!entry.isDirectory()) continue;
580
+ const dir = join(targetDir, entry.name);
581
+
582
+ // Staging holds only copies — nothing was switched out of it, so it is residue,
583
+ // not a torn swap.
584
+ if (entry.name.startsWith('.update-staging-')) {
585
+ try { rmSync(dir, { recursive: true, force: true }); } catch (e) { debugCatch(e, 'recover-staging'); }
586
+ continue;
587
+ }
588
+ if (!entry.name.startsWith('.update-backup-')) continue;
589
+
590
+ let journal;
591
+ try { journal = JSON.parse(readFileSync(join(dir, SWAP_JOURNAL), 'utf8')); } catch { journal = null; }
592
+ const backedUp = Array.isArray(journal?.backedUp) ? journal.backedUp : [];
593
+ const installed = Array.isArray(journal?.installed) ? journal.installed : [];
594
+ // Copies: rollbackInstall reverses the arrays in place.
595
+ rollbackInstall([...installed], [...backedUp], dir, targetDir);
596
+ try { rmSync(dir, { recursive: true, force: true }); } catch (e) { debugCatch(e, 'recover-backup'); }
597
+ recovered++;
598
+ debugLog('WARN', 'hook-update', `Recovered an interrupted update swap: restored ${backedUp.length} path(s) from ${entry.name}`);
599
+ }
600
+ return recovered;
601
+ }
602
+
521
603
  function rollbackInstall(installed, backedUp, backupDir, targetDir) {
522
604
  for (const relPath of installed.reverse()) {
523
605
  try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch { /* best-effort */ }
@@ -581,6 +663,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
581
663
  const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
582
664
 
583
665
  try {
666
+ // Finish a prior swap that was hard-killed mid-rename before starting another
667
+ // one — otherwise this install stacks on top of a mixed-version tree and its
668
+ // own backup can no longer restore a coherent state.
669
+ recoverInterruptedSwaps(targetDir);
670
+
584
671
  mkdirSync(stagingDir, { recursive: true });
585
672
  mkdirSync(backupDir, { recursive: true });
586
673
 
@@ -593,23 +680,30 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
593
680
  });
594
681
  }
595
682
 
596
- for (const relPath of switchablePaths) {
597
- const stagedPath = join(stagingDir, relPath);
598
- if (!existsSync(stagedPath)) continue;
683
+ markSwapStart();
684
+ try {
685
+ for (const relPath of switchablePaths) {
686
+ const stagedPath = join(stagingDir, relPath);
687
+ if (!existsSync(stagedPath)) continue;
599
688
 
600
- const targetPath = join(targetDir, relPath);
601
- const backupPath = join(backupDir, relPath);
689
+ const targetPath = join(targetDir, relPath);
690
+ const backupPath = join(backupDir, relPath);
602
691
 
603
- mkdirSync(dirname(targetPath), { recursive: true });
604
- mkdirSync(dirname(backupPath), { recursive: true });
692
+ mkdirSync(dirname(targetPath), { recursive: true });
693
+ mkdirSync(dirname(backupPath), { recursive: true });
605
694
 
606
- if (existsSync(targetPath)) {
607
- renameSync(targetPath, backupPath);
608
- backedUp.push(relPath);
609
- }
695
+ if (existsSync(targetPath)) {
696
+ backedUp.push(relPath);
697
+ journalSwap(backupDir, backedUp, installed);
698
+ renameSync(targetPath, backupPath);
699
+ }
610
700
 
611
- renameSync(stagedPath, targetPath);
612
- installed.push(relPath);
701
+ installed.push(relPath);
702
+ journalSwap(backupDir, backedUp, installed);
703
+ renameSync(stagedPath, targetPath);
704
+ }
705
+ } finally {
706
+ clearSwapMarker();
613
707
  }
614
708
 
615
709
  // MED-5: before discarding the rollback backup, prove the switched code boots.
package/hook.mjs CHANGED
@@ -265,7 +265,7 @@ function flushEpisodeGroup(ep) {
265
265
 
266
266
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
267
267
  try {
268
- writeFileSync(flushFile, JSON.stringify(ep));
268
+ writeFileSync(flushFile, JSON.stringify(ep), { mode: 0o600 }); // captured paths + scrubbed activity — owner-only (sec P3-2)
269
269
  } catch {
270
270
  return 'writefail';
271
271
  }
@@ -523,7 +523,7 @@ async function handleStop() {
523
523
  if (id) sub.savedId = id;
524
524
  } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
525
525
  const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
526
- writeFileSync(flushFile, JSON.stringify(sub));
526
+ writeFileSync(flushFile, JSON.stringify(sub), { mode: 0o600 }); // captured paths + scrubbed activity — owner-only (sec P3-2)
527
527
  spawnBackground('llm-episode', flushFile);
528
528
  }
529
529
  }
package/install.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { execSync, execFileSync } from 'child_process';
5
5
  import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, mkdtempSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
6
- import { join, resolve, dirname, isAbsolute } from 'path';
6
+ import { join, resolve, dirname, isAbsolute, basename } from 'path';
7
7
  import { homedir, tmpdir } from 'os';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { createRequire } from 'node:module';
@@ -433,7 +433,7 @@ if (IS_DEV) {
433
433
  try {
434
434
  const pruned = pruneStaleInstallFiles(DATA_DIR, SOURCE_FILES);
435
435
  if (pruned.length > 0) {
436
- ok(`Pruned ${pruned.length} stale file(s): ${pruned.map(p => p.split('/').pop()).join(', ')}`);
436
+ ok(`Pruned ${pruned.length} stale file(s): ${pruned.map(p => basename(p)).join(', ')}`);
437
437
  }
438
438
  } catch (e) { /* prune is best-effort — never block install */ void e; }
439
439
  }
@@ -0,0 +1,56 @@
1
+ // Shared "most recent live observations" core for cmdRecent (CLI `recent`) and
2
+ // runRecent (MCP mem_recent).
3
+ //
4
+ // `recent` was the last retrieval command still hand-building its query on both
5
+ // surfaces: the live-rows filter (COALESCE(compressed_into,0)=0 AND superseded_at
6
+ // IS NULL), the optional project/type/since predicates, and the newest-first
7
+ // ORDER BY + LIMIT existed twice, kept in sync by nothing. That WHERE-clause class
8
+ // of drift has recurred three times (CHANGELOG v2.91.0 / v2.92.0 / v3.42.0), which
9
+ // is why search / timeline / recall were each extracted to a core
10
+ // (lib/search-core.mjs, lib/timeline-core.mjs, lib/recall-core.mjs). Same shape here:
11
+ // the data contract lives in this file, argument parsing and rendering stay per-surface.
12
+ //
13
+ // Columns are the SUPERSET of what the two renderers read (CLI wants `importance`,
14
+ // MCP wants `project`) — same convention as recall-core, so neither surface needs
15
+ // its own SELECT list.
16
+
17
+ const RECENT_COLS = 'id, type, title, subtitle, importance, project, created_at, created_at_epoch';
18
+
19
+ // Upper bound on rows a single `recent` call may pull, shared so the cap can't
20
+ // drift between surfaces. Pre-extraction this literal lived only in cmdRecent
21
+ // (where the positional [N] path had once skipped it entirely, letting
22
+ // `recent 999999` issue an uncapped full-table dump); mem_recent relied solely on
23
+ // its zod max(100). Clamping here means neither surface can regrow that footgun.
24
+ export const RECENT_MAX = 1000;
25
+
26
+ /**
27
+ * Most recent live observations, newest first.
28
+ *
29
+ * @param {import('better-sqlite3').Database} db
30
+ * @param {object} opts
31
+ * @param {string|null} [opts.project] Exact project key (already resolved by the caller).
32
+ * @param {string|null} [opts.type] Observation type (already validated by the caller).
33
+ * @param {number|null} [opts.since] Epoch-ms lower bound on created_at. Each surface
34
+ * parses its own duration flag (`--since` / `date_since`) because the error dialects
35
+ * differ (CLI fail() vs MCP throw); only the resolved bound crosses into the core.
36
+ * @param {number} [opts.limit=10] Clamped to [1, RECENT_MAX].
37
+ * @returns {object[]} rows carrying RECENT_COLS
38
+ */
39
+ export function fetchRecent(db, { project = null, type = null, since = null, limit = 10 } = {}) {
40
+ const params = [];
41
+ const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
42
+ if (project) { wheres.push('project = ?'); params.push(project); }
43
+ if (type) { wheres.push('type = ?'); params.push(type); }
44
+ if (Number.isFinite(since)) { wheres.push('created_at_epoch >= ?'); params.push(since); }
45
+
46
+ const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, RECENT_MAX) : 10;
47
+ params.push(safeLimit);
48
+
49
+ return db.prepare(`
50
+ SELECT ${RECENT_COLS}
51
+ FROM observations
52
+ WHERE ${wheres.join(' AND ')}
53
+ ORDER BY created_at_epoch DESC
54
+ LIMIT ?
55
+ `).all(...params);
56
+ }
@@ -99,9 +99,16 @@ export function saveObservation(db, params) {
99
99
  const bigramText = cjkBigrams(indexText);
100
100
  const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
101
101
 
102
+ // Requested supersession targets, normalized before the transaction opens.
103
+ // Self-reference is filtered inside, once the new id exists.
104
+ const requestedSupersedes = [...new Set(
105
+ (Array.isArray(params.supersedes) ? params.supersedes : [])
106
+ .map(Number).filter((n) => Number.isInteger(n) && n > 0)
107
+ )];
108
+
102
109
  // Atomic: observation row + observation_files junction + observation_vectors
103
- // (TF-IDF). Vector write is best-effort — vocab may be uninitialized on a
104
- // fresh DB; failure must not roll back the observation.
110
+ // (TF-IDF) + supersession tombstones. Vector write is best-effort — vocab may be
111
+ // uninitialized on a fresh DB; failure must not roll back the observation.
105
112
  const saveTx = db.transaction(() => {
106
113
  // Manual-save shape: narrative=content, concepts/facts/files_read empty, no
107
114
  // subtitle/search_aliases (defaults). Column list single-sourced in lib/observation-write.
@@ -118,34 +125,36 @@ export function saveObservation(db, params) {
118
125
  // same indexText the FTS `text` field is built from.
119
126
  insertObservationVector(db, savedId, indexText);
120
127
 
121
- return savedId;
122
- });
123
- const savedId = saveTx();
124
-
125
- // P4 explicit supersession: tombstone + link prior observations this save
126
- // overturns. Only same-project, currently-live rows are eligible never
127
- // tombstone another project's memory or re-stamp an already-superseded row —
128
- // and never supersede the row we just wrote. superseded_at drops the row out of
129
- // live search (all queries filter superseded_at IS NULL); superseded_by records
130
- // WHICH observation replaced it (the missing link in finding #4). The column
131
- // already exists (schema.mjs), so no migration is required.
132
- let supersededIds = [];
133
- const requested = Array.isArray(params.supersedes) ? params.supersedes : [];
134
- const ids = [...new Set(
135
- requested.map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== savedId)
136
- )];
137
- if (ids.length > 0) {
138
- const ph = ids.map(() => '?').join(',');
139
- const eligible = db.prepare(
140
- `SELECT id FROM observations WHERE id IN (${ph}) AND project = ? AND superseded_at IS NULL`
141
- ).all(...ids, project).map((r) => r.id);
142
- if (eligible.length > 0) {
143
- const ph2 = eligible.map(() => '?').join(',');
144
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = ? WHERE id IN (${ph2})`)
145
- .run(now.getTime(), savedId, ...eligible);
146
- supersededIds = eligible;
128
+ // P4 explicit supersession: tombstone + link prior observations this save
129
+ // overturns. Only same-project, currently-live rows are eligible — never
130
+ // tombstone another project's memory or re-stamp an already-superseded row —
131
+ // and never supersede the row we just wrote. superseded_at drops the row out of
132
+ // live search (all queries filter superseded_at IS NULL); superseded_by records
133
+ // WHICH observation replaced it (the missing link in finding #4). The column
134
+ // already exists (schema.mjs), so no migration is required.
135
+ //
136
+ // Runs INSIDE the transaction: committing the correcting row without its
137
+ // tombstones leaves the contradiction supersession exists to retire both the
138
+ // new row and the ones it overturns stay live behind `superseded_at IS NULL`.
139
+ // Write-the-correction and retire-its-predecessors is one unit or neither.
140
+ const ids = requestedSupersedes.filter((n) => n !== savedId);
141
+ let supersededIds = [];
142
+ if (ids.length > 0) {
143
+ const ph = ids.map(() => '?').join(',');
144
+ const eligible = db.prepare(
145
+ `SELECT id FROM observations WHERE id IN (${ph}) AND project = ? AND superseded_at IS NULL`
146
+ ).all(...ids, project).map((r) => r.id);
147
+ if (eligible.length > 0) {
148
+ const ph2 = eligible.map(() => '?').join(',');
149
+ db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = ? WHERE id IN (${ph2})`)
150
+ .run(now.getTime(), savedId, ...eligible);
151
+ supersededIds = eligible;
152
+ }
147
153
  }
148
- }
154
+
155
+ return { savedId, supersededIds };
156
+ });
157
+ const { savedId, supersededIds } = saveTx();
149
158
 
150
159
  return {
151
160
  kind: 'saved',
@@ -287,7 +287,13 @@ const SINGLE_MATCH_BANDS = [
287
287
  * Mutates `results` in place; callers re-sort afterwards.
288
288
  */
289
289
  export function normalizeCrossSourceScores(results, sourceKey) {
290
- const scored = results.filter((r) => r.score !== null && r.score !== undefined && r.score !== 0);
290
+ // scoreScale:'vector' rows (the obs leg when the vector arm is on RRF-fused
291
+ // ≈1/(60+rank) ≈ 0.02, or raw cosine ≈0.1-1) are NOT on the BM25 scale the lone-hit
292
+ // banding below assumes. Exclude them from globalMaxAbs so the cross-source ratio stays
293
+ // meaningful for BM25 sources, and band a lone vector hit neutrally rather than by an
294
+ // incomparable (near-zero) ratio that would always sink it (audit P2-12 2026-07-24).
295
+ const isVector = (r) => r.scoreScale === 'vector';
296
+ const scored = results.filter((r) => r.score !== null && r.score !== undefined && r.score !== 0 && !isVector(r));
291
297
  const globalMaxAbs = scored.length ? Math.max(...scored.map((r) => Math.abs(r.score))) : 0;
292
298
  for (const src of ['obs', 'session', 'prompt', 'event']) {
293
299
  // score === 0 stays out: for multi-row sources 0/maxAbs would be 0 anyway, and a
@@ -298,10 +304,18 @@ export function normalizeCrossSourceScores(results, sourceKey) {
298
304
  const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined && r.score !== 0);
299
305
  if (srcResults.length === 0) continue;
300
306
  if (srcResults.length === 1) {
301
- const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
302
- srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
307
+ if (isVector(srcResults[0])) {
308
+ // Vector-scaled lone hit: no BM25-comparable magnitude to band by → neutral mid
309
+ // (the MED-5 -0.5), neither sunk by a near-zero ratio nor inflated.
310
+ srcResults[0].score = -0.5;
311
+ } else {
312
+ const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
313
+ srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
314
+ }
303
315
  continue;
304
316
  }
317
+ // Multi-row source (incl. a vector obs leg): within-source max-normalization pins the
318
+ // best to -1 on the source's own scale, so no cross-source magnitude assumption applies.
305
319
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
306
320
  if (maxAbs > 0) {
307
321
  for (const r of srcResults) r.score = r.score / maxAbs;
package/mem-cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
7
  import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
9
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
- import { _resetVocabCache } from './tfidf.mjs';
10
+ import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
11
11
  import { autoBoostIfNeeded, reRankWithContext } from './search-scoring.mjs';
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
@@ -42,9 +42,10 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
42
42
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
43
43
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
44
44
  import { saveObservation } from './lib/save-observation.mjs';
45
- import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
45
+ import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
46
46
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
47
47
  import { recallByFile } from './lib/recall-core.mjs';
48
+ import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
48
49
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
49
50
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
50
51
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
@@ -368,9 +369,8 @@ function cmdRecent(db, args) {
368
369
  // this cap, so `recent 999999` issued an uncapped `LIMIT 999999` full-table dump
369
370
  // while `recent --limit 999999` correctly rejected → default — exactly the
370
371
  // "none capped --limit dumps the whole set" footgun parseIntFlag was extracted
371
- // to close (lib/cli-flags.mjs). Keep the literal in one place so the two paths
372
- // can't drift apart again.
373
- const RECENT_MAX = 1000;
372
+ // to close (lib/cli-flags.mjs). The literal now lives in lib/recent-core.mjs so
373
+ // the MCP surface is capped by the same number.
374
374
  // isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
375
375
  // accepted silently; the positional path must reject garbage like the --limit flag does.
376
376
  const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
@@ -400,25 +400,18 @@ function cmdRecent(db, args) {
400
400
  }
401
401
  }
402
402
 
403
- const params = [];
404
- const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
405
- if (project) { wheres.push('project = ?'); params.push(project); }
406
- if (type) { wheres.push('type = ?'); params.push(type); }
407
403
  // --since: relative lower bound on created_at (e.g. "recent 1000 --since 24h").
404
+ // Parsed here (not in the core) because the two surfaces reject a bad duration
405
+ // in their own dialect — CLI fail(), MCP throw.
406
+ let since = null;
408
407
  if (flags.since !== undefined) {
409
408
  const d = parseDuration(flags.since);
410
409
  if (!d.ok) { fail(`[mem] Invalid --since "${flags.since}". Use <N><unit>, e.g. 7d, 24h, 90m, 2w.`); return; }
411
- wheres.push('created_at_epoch >= ?'); params.push(Date.now() - d.ms);
410
+ since = Date.now() - d.ms;
412
411
  }
413
- params.push(limit);
414
412
 
415
- const rows = db.prepare(`
416
- SELECT id, type, title, subtitle, importance, created_at_epoch, created_at
417
- FROM observations
418
- WHERE ${wheres.join(' AND ')}
419
- ORDER BY created_at_epoch DESC
420
- LIMIT ?
421
- `).all(...params);
413
+ // Shared core with MCP mem_recent: live-rows filter + ordering (lib/recent-core.mjs)
414
+ const rows = fetchRecent(db, { project, type, since, limit });
422
415
 
423
416
  if (jsonOutput) {
424
417
  out(JSON.stringify({
@@ -1704,6 +1697,9 @@ function cmdExport(db, args) {
1704
1697
  if (limitGiven && rows.length >= limit) {
1705
1698
  process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
1706
1699
  }
1700
+ // Fidelity caveat at backup-creation time (mirrors the restore-side note). stderr,
1701
+ // so stdout stays a clean JSON/JSONL stream for `export > backup.json`.
1702
+ process.stderr.write('[mem] Note: export omits related_ids and supersession links (superseded rows are excluded) — content and value-signals round-trip, the relationship graph does not.\n');
1707
1703
  }
1708
1704
 
1709
1705
  // ─── Restore ───────────────────────────────────────────────────────────────
@@ -1751,6 +1747,8 @@ function cmdRestore(db, argv) {
1751
1747
  const num = (v) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0;
1752
1748
 
1753
1749
  const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1750
+ // Final field state of a restored row, for the post-signalUpdate vector rebuild below.
1751
+ const vecRow = db.prepare('SELECT title, narrative, concepts, lesson_learned, search_aliases FROM observations WHERE id = ?');
1754
1752
  const signalUpdate = db.prepare(`UPDATE observations SET
1755
1753
  text = COALESCE(?, text),
1756
1754
  subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
@@ -1808,6 +1806,15 @@ function cmdRestore(db, argv) {
1808
1806
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1809
1807
  res.id,
1810
1808
  );
1809
+ // The FTS `text` column re-syncs through the observations _au trigger, but the
1810
+ // TF-IDF vector has no trigger: saveObservation vectorized title+content+lesson,
1811
+ // so every field signalUpdate just applied (concepts, search_aliases) was missing
1812
+ // from the restored row's vector. Rebuild from the row's FINAL state through the
1813
+ // canonical vecTextForRow — the same text every other (re)build path uses. Reading
1814
+ // the row back (rather than reusing the locals) keeps this identical to
1815
+ // maintain-core's rebuildVectors. Skipped entirely while the vector arm is off,
1816
+ // which is the default (lib/observation-write.mjs).
1817
+ if (vectorsEnabled()) insertObservationVector(db, res.id, vecTextForRow(vecRow.get(res.id)));
1811
1818
  restored++;
1812
1819
  } catch (e) {
1813
1820
  malformed++;
@@ -1820,6 +1827,13 @@ function cmdRestore(db, argv) {
1820
1827
  const totalMalformed = malformed + parseFailures;
1821
1828
  const totalLines = rows.length + parseFailures;
1822
1829
  out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1830
+ // Name the lossiness where the user meets it. Export omits related_ids and drops
1831
+ // superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
1832
+ // cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
1833
+ // ids would be stale after the remap), but "N restored" alone reads as full fidelity.
1834
+ if (restored > 0) {
1835
+ out('[mem] Note: related_ids and supersession links are not carried across export/restore (ids are remapped on restore) — restored rows have no cross-links.');
1836
+ }
1823
1837
  }
1824
1838
 
1825
1839
  // ─── Compress ────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.56.0",
3
+ "version": "3.57.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -87,6 +87,7 @@
87
87
  "lib/recall-core.mjs",
88
88
  "lib/timeline-core.mjs",
89
89
  "lib/search-core.mjs",
90
+ "lib/recent-core.mjs",
90
91
  "lib/rrf.mjs",
91
92
  "lib/compress-core.mjs",
92
93
  "lib/db-backup.mjs",
@@ -168,8 +169,10 @@
168
169
  "zod": "^4.3.6"
169
170
  },
170
171
  "overrides": {
171
- "hono": ">=4.12.26",
172
- "fast-uri": ">=3.1.2",
172
+ "hono": ">=4.12.31",
173
+ "@hono/node-server": ">=2.0.11",
174
+ "body-parser": ">=2.3.0",
175
+ "fast-uri": ">=3.1.4 <4",
173
176
  "ip-address": ">=10.1.1"
174
177
  },
175
178
  "devDependencies": {