claude-mem-lite 3.56.1 → 3.58.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.1",
13
+ "version": "3.58.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.1",
3
+ "version": "3.58.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/adopt-cli.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './memdir.mjs';
23
23
  import {
24
24
  writeManaged, removeManaged, isAdopted as claudeMdIsAdopted,
25
+ hasResidue as claudeMdHasResidue,
25
26
  needsRefresh, migrateLegacyMemoryDir, hasLegacyMemdirSentinel,
26
27
  claudeMdPath, detailDocPath,
27
28
  } from './claudemd.mjs';
@@ -311,8 +312,8 @@ function statusAll() {
311
312
 
312
313
  const known = listKnownProjectDirs();
313
314
  let adoptedCount = 0;
314
- for (const dir of known) if (claudeMdIsAdopted(dir, PLUGIN_SLUG)) adoptedCount++;
315
- log(`[adopt --status] known projects (~/.claude.json): ${known.length} scanned, ${adoptedCount} with a CLAUDE.md managed block.`);
315
+ for (const dir of known) if (claudeMdHasResidue(dir, PLUGIN_SLUG)) adoptedCount++;
316
+ log(`[adopt --status] known projects (~/.claude.json): ${known.length} scanned, ${adoptedCount} with a CLAUDE.md managed block or partial residue (detail doc/state).`);
316
317
  if (adoptedCount > 0) log('[adopt --status] run `claude-mem-lite unadopt --all` to remove every CLAUDE.md block.');
317
318
 
318
319
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? 'set' : 'unset';
@@ -347,16 +348,20 @@ function unadoptAll(args) {
347
348
 
348
349
  // 1. New scheme: scrub CLAUDE.md managed blocks across known project paths.
349
350
  const projectDirs = listKnownProjectDirs();
350
- let blocks = 0;
351
+ let blocks = 0, partial = 0;
351
352
  for (const dir of projectDirs) {
352
- if (!claudeMdIsAdopted(dir, PLUGIN_SLUG)) continue;
353
+ // hasResidue, not isAdopted: the sweep must also catch PARTIAL residue
354
+ // (block without detail doc, or an orphaned doc/state sidecar) —
355
+ // isAdopted's block-AND-doc gate skipped those projects forever.
356
+ if (!claudeMdHasResidue(dir, PLUGIN_SLUG)) continue;
353
357
  if (dryRun) {
354
- log(`[unadopt --all --dry-run] ${dir} → would-remove CLAUDE.md block + detail doc`);
358
+ log(`[unadopt --all --dry-run] ${dir} → would-remove plugin residue (CLAUDE.md block and/or detail doc/state)`);
355
359
  blocks++;
356
360
  continue;
357
361
  }
358
362
  const r = removeManaged(dir, PLUGIN_SLUG);
359
363
  if (r.action === 'removed') { log(`[unadopt --all] ${dir} → removed`); blocks++; }
364
+ else { log(`[unadopt --all] ${dir} → cleaned partial residue (detail doc/state, no block)`); partial++; }
360
365
  }
361
366
 
362
367
  // 2. Legacy memory-dir cleanup across every memdir (foreign-content guarded).
@@ -372,7 +377,8 @@ function unadoptAll(args) {
372
377
  }
373
378
 
374
379
  log('');
375
- log(`[unadopt --all] ${dryRun ? 'would remove' : 'removed'} ${blocks} CLAUDE.md block(s) across ${projectDirs.length} known project(s); ${legacy} legacy memory-dir sentinel(s) ${dryRun ? 'pending' : 'cleaned'}.`);
380
+ const partialNote = partial > 0 ? ` (+${partial} partial-residue cleanup(s))` : '';
381
+ log(`[unadopt --all] ${dryRun ? 'would remove' : 'removed'} ${blocks} CLAUDE.md block(s)${partialNote} across ${projectDirs.length} known project(s); ${legacy} legacy memory-dir sentinel(s) ${dryRun ? 'pending' : 'cleaned'}.`);
376
382
  if (projectDirs.length === 0) {
377
383
  log('[unadopt --all] no known projects found in ~/.claude.json — if a project was adopted but never opened in Claude Code, run `claude-mem-lite unadopt` from inside it.');
378
384
  }
@@ -389,7 +395,7 @@ export function cmdUnadopt(args = []) {
389
395
 
390
396
  const cwd = detectCwd();
391
397
  if (dryRun) {
392
- const blockState = claudeMdIsAdopted(cwd, PLUGIN_SLUG) ? 'would-remove CLAUDE.md block + detail doc' : 'no CLAUDE.md block';
398
+ const blockState = claudeMdHasResidue(cwd, PLUGIN_SLUG) ? 'would-remove CLAUDE.md block + detail doc' : 'no CLAUDE.md block';
393
399
  const legacy = hasLegacyMemdirSentinel(cwd, PLUGIN_SLUG) ? 'would-clean legacy memory-dir sentinel' : 'no legacy residue';
394
400
  log(`[unadopt --dry-run] ${cwd}`);
395
401
  log(` ${blockState}`);
package/claudemd.mjs CHANGED
@@ -98,6 +98,20 @@ export function isAdopted(cwd, slug) {
98
98
  return blk.body !== null && existsSync(detailDocPath(cwd, slug));
99
99
  }
100
100
 
101
+ /**
102
+ * Any trace of adoption that unadopt should clean: managed block OR detail doc
103
+ * OR state sidecar. Deliberately weaker than isAdopted (whose AND lets a
104
+ * half-written adopt self-heal on the next SessionStart): the unadopt sweep
105
+ * gated on isAdopted skipped partial residue forever — e.g. a user deleted the
106
+ * detail doc but the CLAUDE.md block remained, and `unadopt --all` never
107
+ * removed it. removeManaged cleans all three pieces, so sweep on any of them.
108
+ */
109
+ export function hasResidue(cwd, slug) {
110
+ return readBlock(cwd, slug).body !== null
111
+ || existsSync(detailDocPath(cwd, slug))
112
+ || existsSync(stateFilePath(cwd, slug));
113
+ }
114
+
101
115
  /**
102
116
  * Whether the installed block/doc has drifted from the shipped content — i.e.
103
117
  * a version bump or a template edit means we should refresh. Returns true when
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,9 +4,9 @@
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
- import { ensureDb, DB_DIR } from './schema.mjs';
9
+ import { ensureDbWithWalRecovery, 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';
11
11
  // Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
12
12
  // fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
@@ -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
  }
@@ -145,7 +154,9 @@ export function createSessionId() {
145
154
 
146
155
  export function openDb() {
147
156
  try {
148
- return ensureDb();
157
+ // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
158
+ // silently dead (null DB) on a corrupt WAL until the next MCP server start.
159
+ return ensureDbWithWalRecovery();
149
160
  } catch {
150
161
  return null;
151
162
  }
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 */ }
@@ -553,6 +635,32 @@ function smokeInstalledRelease(targetDir) {
553
635
  const p = join(targetDir, entry);
554
636
  if (existsSync(p)) execSync(`${q(process.execPath)} --check ${q(p)}`, { timeout: 10000, stdio: 'ignore' });
555
637
  }
638
+ // `cli.mjs help` exits without opening the DB, so it cannot see a
639
+ // present-but-unusable better-sqlite3 binding: npm >= 12 blocks
640
+ // install/lifecycle scripts by default, so the staging `npm install`
641
+ // above exits 0 with the native .node never compiled (a Node major bump
642
+ // strands a stale ABI the same way). Direct installs register server.mjs
643
+ // without the launch.mjs probe, so this gate is their only check. Probe
644
+ // in a child process (execSync so the unit-test mock intercepts, and so
645
+ // the running old-version process's require cache can't mask it); on
646
+ // failure rebuild with scripts enabled for just this dep — plain-rebuild
647
+ // fallback for older npm — then re-probe. A still-broken binding throws
648
+ // out of the try, smoke fails, and the caller rolls back to the old
649
+ // (working) install.
650
+ if (existsSync(join(targetDir, 'node_modules', 'better-sqlite3'))) {
651
+ const probeSrc = 'const{createRequire}=require("node:module");const D=createRequire(process.argv[1])("better-sqlite3");new D(":memory:").close();';
652
+ const probeCmd = `${q(process.execPath)} -e ${q(probeSrc)} ${q(join(targetDir, 'package.json'))}`;
653
+ try {
654
+ execSync(probeCmd, { timeout: 20000, stdio: 'ignore' });
655
+ } catch {
656
+ try {
657
+ execSync('npm rebuild better-sqlite3 --dangerously-allow-all-scripts', { cwd: targetDir, timeout: 120000, stdio: 'ignore' });
658
+ } catch {
659
+ execSync('npm rebuild better-sqlite3', { cwd: targetDir, timeout: 120000, stdio: 'ignore' });
660
+ }
661
+ execSync(probeCmd, { timeout: 20000, stdio: 'ignore' });
662
+ }
663
+ }
556
664
  return true;
557
665
  } catch (e) {
558
666
  debugLog('WARN', 'hook-update', `post-install smoke failed (rolling back): ${e.message}`);
@@ -581,6 +689,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
581
689
  const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
582
690
 
583
691
  try {
692
+ // Finish a prior swap that was hard-killed mid-rename before starting another
693
+ // one — otherwise this install stacks on top of a mixed-version tree and its
694
+ // own backup can no longer restore a coherent state.
695
+ recoverInterruptedSwaps(targetDir);
696
+
584
697
  mkdirSync(stagingDir, { recursive: true });
585
698
  mkdirSync(backupDir, { recursive: true });
586
699
 
@@ -593,23 +706,30 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
593
706
  });
594
707
  }
595
708
 
596
- for (const relPath of switchablePaths) {
597
- const stagedPath = join(stagingDir, relPath);
598
- if (!existsSync(stagedPath)) continue;
709
+ markSwapStart();
710
+ try {
711
+ for (const relPath of switchablePaths) {
712
+ const stagedPath = join(stagingDir, relPath);
713
+ if (!existsSync(stagedPath)) continue;
599
714
 
600
- const targetPath = join(targetDir, relPath);
601
- const backupPath = join(backupDir, relPath);
715
+ const targetPath = join(targetDir, relPath);
716
+ const backupPath = join(backupDir, relPath);
602
717
 
603
- mkdirSync(dirname(targetPath), { recursive: true });
604
- mkdirSync(dirname(backupPath), { recursive: true });
718
+ mkdirSync(dirname(targetPath), { recursive: true });
719
+ mkdirSync(dirname(backupPath), { recursive: true });
605
720
 
606
- if (existsSync(targetPath)) {
607
- renameSync(targetPath, backupPath);
608
- backedUp.push(relPath);
609
- }
721
+ if (existsSync(targetPath)) {
722
+ backedUp.push(relPath);
723
+ journalSwap(backupDir, backedUp, installed);
724
+ renameSync(targetPath, backupPath);
725
+ }
610
726
 
611
- renameSync(stagedPath, targetPath);
612
- installed.push(relPath);
727
+ installed.push(relPath);
728
+ journalSwap(backupDir, backedUp, installed);
729
+ renameSync(stagedPath, targetPath);
730
+ }
731
+ } finally {
732
+ clearSwapMarker();
613
733
  }
614
734
 
615
735
  // 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
  }
@@ -466,7 +466,7 @@ if (IS_DEV) {
466
466
  ok(`better-sqlite3: ${verify.action}`);
467
467
  } else {
468
468
  fail(`better-sqlite3 binding unusable after rebuild: ${verify.error}`);
469
- log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --build-from-source');
469
+ log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --dangerously-allow-all-scripts');
470
470
  process.exit(1);
471
471
  }
472
472
  }
@@ -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;