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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +4 -0
- package/hook-episode.mjs +5 -2
- package/hook-memory.mjs +4 -2
- package/hook-optimize.mjs +4 -0
- package/hook-shared.mjs +13 -4
- package/hook-update.mjs +108 -14
- package/hook.mjs +2 -2
- package/install.mjs +2 -2
- package/lib/recent-core.mjs +56 -0
- package/lib/save-observation.mjs +38 -29
- package/lib/search-core.mjs +17 -3
- package/mem-cli.mjs +32 -18
- package/package.json +6 -3
- package/project-utils.mjs +20 -1
- package/registry.mjs +96 -8
- package/scripts/hook-launcher.mjs +32 -0
- package/scripts/post-tool-use.sh +39 -0
- package/search-engine.mjs +6 -3
- package/search-scoring.mjs +35 -14
- package/server.mjs +122 -26
- package/source-files.mjs +4 -0
- package/tfidf.mjs +42 -6
- package/utils.mjs +16 -18
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
683
|
+
markSwapStart();
|
|
684
|
+
try {
|
|
685
|
+
for (const relPath of switchablePaths) {
|
|
686
|
+
const stagedPath = join(stagingDir, relPath);
|
|
687
|
+
if (!existsSync(stagedPath)) continue;
|
|
599
688
|
|
|
600
|
-
|
|
601
|
-
|
|
689
|
+
const targetPath = join(targetDir, relPath);
|
|
690
|
+
const backupPath = join(backupDir, relPath);
|
|
602
691
|
|
|
603
|
-
|
|
604
|
-
|
|
692
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
693
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
605
694
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
695
|
+
if (existsSync(targetPath)) {
|
|
696
|
+
backedUp.push(relPath);
|
|
697
|
+
journalSwap(backupDir, backedUp, installed);
|
|
698
|
+
renameSync(targetPath, backupPath);
|
|
699
|
+
}
|
|
610
700
|
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/save-observation.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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',
|
package/lib/search-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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).
|
|
372
|
-
//
|
|
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
|
-
|
|
410
|
+
since = Date.now() - d.ms;
|
|
412
411
|
}
|
|
413
|
-
params.push(limit);
|
|
414
412
|
|
|
415
|
-
|
|
416
|
-
|
|
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.
|
|
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.
|
|
172
|
-
"
|
|
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": {
|