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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/adopt-cli.mjs +13 -7
- package/claudemd.mjs +14 -0
- 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 +17 -6
- package/hook-update.mjs +134 -14
- package/hook.mjs +2 -2
- package/install.mjs +3 -3
- 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 +37 -21
- package/package.json +6 -3
- package/project-utils.mjs +20 -1
- package/registry.mjs +96 -8
- package/schema.mjs +43 -0
- package/scripts/hook-launcher.mjs +32 -0
- package/scripts/launch.mjs +29 -3
- package/scripts/post-tool-use.sh +39 -0
- package/scripts/setup.sh +89 -7
- package/search-engine.mjs +6 -3
- package/search-scoring.mjs +35 -14
- package/server.mjs +136 -51
- 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.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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
709
|
+
markSwapStart();
|
|
710
|
+
try {
|
|
711
|
+
for (const relPath of switchablePaths) {
|
|
712
|
+
const stagedPath = join(stagingDir, relPath);
|
|
713
|
+
if (!existsSync(stagedPath)) continue;
|
|
599
714
|
|
|
600
|
-
|
|
601
|
-
|
|
715
|
+
const targetPath = join(targetDir, relPath);
|
|
716
|
+
const backupPath = join(backupDir, relPath);
|
|
602
717
|
|
|
603
|
-
|
|
604
|
-
|
|
718
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
719
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
605
720
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
721
|
+
if (existsSync(targetPath)) {
|
|
722
|
+
backedUp.push(relPath);
|
|
723
|
+
journalSwap(backupDir, backedUp, installed);
|
|
724
|
+
renameSync(targetPath, backupPath);
|
|
725
|
+
}
|
|
610
726
|
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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 --
|
|
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
|
+
}
|
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;
|