claude-mem-lite 3.6.0 → 3.7.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/README.md +20 -12
- package/deep-search.mjs +26 -4
- package/hook-update.mjs +17 -1
- package/install.mjs +25 -10
- package/lib/atomic-write.mjs +38 -0
- package/lib/err-sampler.mjs +7 -3
- package/lib/lesson-idents.mjs +32 -0
- package/lib/proc-lock.mjs +112 -0
- package/mem-cli.mjs +2 -2
- package/package.json +5 -1
- package/schema.mjs +112 -64
- package/scoring-sql.mjs +25 -0
- package/scripts/post-tool-recall.js +71 -0
- package/scripts/pre-tool-recall.js +27 -2
- package/server.mjs +14 -3
- package/source-files.mjs +10 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.7.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.7.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/README.md
CHANGED
|
@@ -144,7 +144,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
|
|
|
144
144
|
|
|
145
145
|
## Requirements
|
|
146
146
|
|
|
147
|
-
- **Node.js** >=
|
|
147
|
+
- **Node.js** >= 20
|
|
148
148
|
- **Claude Code** CLI installed and configured (`claude` command available)
|
|
149
149
|
- **SQLite3** support (provided by `better-sqlite3`, compiled on install)
|
|
150
150
|
- **Platform**: Linux or macOS (see [Platform Support](#platform-support))
|
|
@@ -632,17 +632,25 @@ claude-mem-lite/
|
|
|
632
632
|
|
|
633
633
|
## Search Quality
|
|
634
634
|
|
|
635
|
-
Benchmarked on 200 observations across 30 queries (standard + hard-negative categories)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
|
641
|
-
|
|
642
|
-
|
|
|
643
|
-
|
|
|
644
|
-
|
|
645
|
-
|
|
635
|
+
Benchmarked on 200 observations across 30 queries (standard + hard-negative categories),
|
|
636
|
+
measuring the **production-hybrid** retriever (FTS5 BM25 + TF-IDF vector + RRF) — the path
|
|
637
|
+
`mem_search` / `recall` actually use. The CI gate (`npm run benchmark:gate`) runs this same
|
|
638
|
+
path and fails on regression.
|
|
639
|
+
|
|
640
|
+
| Metric | Score (production-hybrid) |
|
|
641
|
+
|--------|---------------------------|
|
|
642
|
+
| Recall@10 | 0.90 |
|
|
643
|
+
| Precision@10 | 0.79 |
|
|
644
|
+
| nDCG@10 | 0.97 |
|
|
645
|
+
| MRR@10 | 0.97 |
|
|
646
|
+
| P95 search latency | ~3ms |
|
|
647
|
+
|
|
648
|
+
> **Note on the path measured.** Earlier versions of this table reported the *lexical*
|
|
649
|
+
> FTS-only path (Precision@10 0.96, P95 0.15ms). The hybrid vector arm trades raw
|
|
650
|
+
> precision@10 for higher recall / nDCG / MRR by surfacing semantically-related candidates
|
|
651
|
+
> beyond exact lexical matches; the gate now measures the hybrid path so these numbers
|
|
652
|
+
> reflect real `mem_search` behavior. For field-comparable recall, see the LongMemEval
|
|
653
|
+
> section below.
|
|
646
654
|
|
|
647
655
|
### Recall on LongMemEval (standard benchmark)
|
|
648
656
|
|
package/deep-search.mjs
CHANGED
|
@@ -103,7 +103,9 @@ export function autoDeepLlmReady(env = process.env, injectedLlm) {
|
|
|
103
103
|
* an LLM, so the decision itself is free — only a positive verdict costs a
|
|
104
104
|
* Haiku call (the escalation).
|
|
105
105
|
*
|
|
106
|
-
* Weak when: too few results (count below minResults floor)
|
|
106
|
+
* Weak when: too few results (count below minResults floor) AND the corpus is
|
|
107
|
+
* large enough that deep search could plausibly find more (see corpus guard
|
|
108
|
+
* below).
|
|
107
109
|
*
|
|
108
110
|
* NOTE: ctx.orFallbackFired was intentionally removed as an escalation trigger.
|
|
109
111
|
* orFallbackFired fires on SUCCESSFUL AND→OR recovery — when the fallback
|
|
@@ -114,17 +116,37 @@ export function autoDeepLlmReady(env = process.env, injectedLlm) {
|
|
|
114
116
|
* fails, OR also fails) is still caught: if OR recovers nothing, count is 0-2
|
|
115
117
|
* → escalates on count alone.
|
|
116
118
|
*
|
|
119
|
+
* Corpus guard (folded in): the count-based trigger above is correct for a real
|
|
120
|
+
* corpus, but on a near-empty / brand-new / benchmark project EVERY 0-hit query
|
|
121
|
+
* looks "weak", so a caller that only checks the count would auto-escalate (and
|
|
122
|
+
* fire a Haiku rewrite) on a store HyDE/multi-query can't possibly rescue — the
|
|
123
|
+
* "[mem] auto-escalated … 0 hits" spam. hasEscalatableCorpus used to be a
|
|
124
|
+
* SEPARATE function each caller had to remember to AND in; folding it in here
|
|
125
|
+
* means passing `db` self-suppresses escalation when the corpus is too small,
|
|
126
|
+
* without changing the (correct) count trigger for real corpora. Backward-
|
|
127
|
+
* compatible: callers that omit `db` keep the pure count behaviour (and may
|
|
128
|
+
* still AND hasEscalatableCorpus themselves — double-gating with the same
|
|
129
|
+
* predicate is idempotent, never a regression).
|
|
130
|
+
*
|
|
117
131
|
* @param {Array} results normal-search rows
|
|
118
132
|
* @param {object} ctx the hybrid ctx the engine mutated (unused; kept for
|
|
119
133
|
* backward-compat with callers that pass it)
|
|
120
134
|
* @param {object} [opts]
|
|
121
135
|
* @param {number} [opts.minResults=AUTO_DEEP_MIN_RESULTS]
|
|
136
|
+
* @param {Database} [opts.db] open handle — when given, the corpus-size guard is
|
|
137
|
+
* evaluated here so escalation is suppressed on a
|
|
138
|
+
* too-small store. Omit to keep pure count behaviour.
|
|
139
|
+
* @param {string} [opts.project] project scope for the corpus count (when db given)
|
|
140
|
+
* @param {number} [opts.minCorpus=AUTO_DEEP_MIN_CORPUS] corpus-size floor (when db given)
|
|
122
141
|
* @returns {boolean}
|
|
123
142
|
*/
|
|
124
|
-
export function shouldEscalateToDeep(results, _ctx, { minResults = AUTO_DEEP_MIN_RESULTS } = {}) {
|
|
143
|
+
export function shouldEscalateToDeep(results, _ctx, { minResults = AUTO_DEEP_MIN_RESULTS, db, project = null, minCorpus = AUTO_DEEP_MIN_CORPUS } = {}) {
|
|
125
144
|
const n = Array.isArray(results) ? results.length : 0;
|
|
126
|
-
if (n
|
|
127
|
-
|
|
145
|
+
if (n >= minResults) return false;
|
|
146
|
+
// Count is weak. If a db was supplied, also require an escalatable corpus —
|
|
147
|
+
// this is the fold-in that stops 0-hit escalation on a near-empty store.
|
|
148
|
+
if (db && !hasEscalatableCorpus(db, project, minCorpus)) return false;
|
|
149
|
+
return true;
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/**
|
package/hook-update.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { debugCatch, debugLog } from './utils.mjs';
|
|
|
13
13
|
// extracted tarball's own source-files.mjs inside installExtractedRelease.
|
|
14
14
|
// See loadReleaseManifest below.
|
|
15
15
|
import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
16
|
+
import { acquireLock } from './lib/proc-lock.mjs';
|
|
17
|
+
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
16
18
|
|
|
17
19
|
// ── Configuration ──────────────────────────────────────────
|
|
18
20
|
const GITHUB_REPO = 'sdsrss/claude-mem-lite';
|
|
@@ -379,6 +381,16 @@ export function validateExtractedTarball(sourceDir, expectedVersion, expectedNam
|
|
|
379
381
|
// the target's node_modules untouched. Dependency bumps still flow through the
|
|
380
382
|
// GitHub-tarball path (downloadAndInstall), which keeps skipNpmInstall=false.
|
|
381
383
|
export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR, opts = {}) {
|
|
384
|
+
// Cross-process lock: concurrent SessionStart self-heals / auto-updates must
|
|
385
|
+
// not interleave the rename loop below (→ mixed-version install). A live peer
|
|
386
|
+
// holding the lock means an install is already in flight — skip rather than
|
|
387
|
+
// race. Shared path with install.mjs so direct install + repair + auto-update
|
|
388
|
+
// are mutually exclusive.
|
|
389
|
+
const release = acquireLock(join(STATE_DIR, 'runtime', 'install.lock'));
|
|
390
|
+
if (!release) {
|
|
391
|
+
debugLog('DEBUG', 'hook-update', 'installExtractedRelease: another install/update is in progress — skipping');
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
382
394
|
const ts = `${Date.now()}-${process.pid}`;
|
|
383
395
|
const stagingDir = join(targetDir, `.update-staging-${ts}`);
|
|
384
396
|
const backupDir = join(targetDir, `.update-backup-${ts}`);
|
|
@@ -439,7 +451,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
439
451
|
debugLog('DEBUG', 'hook-update', `Post-update: removed stale global MCP "${k}"`);
|
|
440
452
|
}
|
|
441
453
|
}
|
|
442
|
-
|
|
454
|
+
// Atomic + one-time backup: ~/.claude.json is the user's ENTIRE Claude
|
|
455
|
+
// Code config; a torn write here breaks them outside our control.
|
|
456
|
+
if (changed) atomicWriteFileSync(claudeJsonPath, JSON.stringify(cfg, null, 2) + '\n', { backup: true });
|
|
443
457
|
}
|
|
444
458
|
} catch (e) { debugCatch(e, 'post-update-mcp-dedup'); }
|
|
445
459
|
|
|
@@ -477,6 +491,8 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
477
491
|
try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
|
|
478
492
|
try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
|
|
479
493
|
return false;
|
|
494
|
+
} finally {
|
|
495
|
+
release();
|
|
480
496
|
}
|
|
481
497
|
}
|
|
482
498
|
|
package/install.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// claude-mem-lite Installer — Smart install/uninstall/status/doctor
|
|
3
3
|
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, mkdtempSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
6
6
|
import { join, resolve, dirname, isAbsolute } from 'path';
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
@@ -41,6 +41,8 @@ import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
|
41
41
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
42
42
|
import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
|
|
43
43
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
44
|
+
import { acquireLock } from './lib/proc-lock.mjs';
|
|
45
|
+
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
44
46
|
|
|
45
47
|
// Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
|
|
46
48
|
// and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
|
|
@@ -1807,11 +1809,11 @@ function readSettings() {
|
|
|
1807
1809
|
}
|
|
1808
1810
|
|
|
1809
1811
|
function writeSettings(settings) {
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1812
|
+
// Atomic (pid-unique temp + rename) with a one-time .bak: settings.json is the
|
|
1813
|
+
// user's Claude Code config. The old fixed ".tmp" name let concurrent installs
|
|
1814
|
+
// clobber each other's temp mid-write, and there was no recovery artifact if a
|
|
1815
|
+
// hook-merge bug dropped user config. atomicWriteFileSync handles dir creation.
|
|
1816
|
+
atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', { backup: true });
|
|
1815
1817
|
}
|
|
1816
1818
|
|
|
1817
1819
|
// ─── Cleanup Stale Files ─────────────────────────────────────────────────────
|
|
@@ -1919,8 +1921,7 @@ async function manualUpdate() {
|
|
|
1919
1921
|
// buggy on disk.
|
|
1920
1922
|
async function repair() {
|
|
1921
1923
|
console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
|
|
1922
|
-
const stagingDir = join(tmpdir(),
|
|
1923
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
1924
|
+
const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
|
|
1924
1925
|
try {
|
|
1925
1926
|
const tarballUrl = 'https://api.github.com/repos/sdsrss/claude-mem-lite/tarball';
|
|
1926
1927
|
const tarballPath = join(stagingDir, 'release.tgz');
|
|
@@ -2027,13 +2028,27 @@ function regenerateLockfile() {
|
|
|
2027
2028
|
|
|
2028
2029
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
2029
2030
|
|
|
2031
|
+
// Cross-process gate around the install write phase. repair() is intentionally
|
|
2032
|
+
// NOT locked here: it spawns `install.mjs install` as a child, which takes this
|
|
2033
|
+
// lock — locking the parent too would deadlock. A live peer (another session's
|
|
2034
|
+
// install/self-heal) holds it → skip rather than race into a torn install. Lock
|
|
2035
|
+
// path is shared with hook-update.installExtractedRelease (both env-aware).
|
|
2036
|
+
async function runLockedInstall() {
|
|
2037
|
+
const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
|
|
2038
|
+
if (!release) {
|
|
2039
|
+
console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
try { await install(); } finally { release(); }
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2030
2045
|
export async function main(argv = process.argv.slice(2)) {
|
|
2031
2046
|
cmd = argv[0];
|
|
2032
2047
|
flags = new Set(argv.slice(1));
|
|
2033
2048
|
|
|
2034
2049
|
switch (cmd) {
|
|
2035
2050
|
case 'install':
|
|
2036
|
-
await
|
|
2051
|
+
await runLockedInstall();
|
|
2037
2052
|
break;
|
|
2038
2053
|
case 'uninstall':
|
|
2039
2054
|
await uninstall();
|
|
@@ -2064,7 +2079,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2064
2079
|
default:
|
|
2065
2080
|
if (IS_NPX) {
|
|
2066
2081
|
// npx claude-mem-lite (no args) → auto install
|
|
2067
|
-
await
|
|
2082
|
+
await runLockedInstall();
|
|
2068
2083
|
} else {
|
|
2069
2084
|
// Name the unknown token before the usage block. Pre-fix `install frobnicate`
|
|
2070
2085
|
// dumped usage silently, which read like the user had typed nothing — they had
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// lib/atomic-write.mjs — crash-safe file writes with optional one-time backup.
|
|
2
|
+
//
|
|
3
|
+
// Why: several write paths mutate user-global config that, if torn or clobbered,
|
|
4
|
+
// breaks the user outside the plugin's control — most acutely ~/.claude.json
|
|
5
|
+
// (the WHOLE Claude Code config) in hook-update's post-update MCP dedup, and
|
|
6
|
+
// ~/.claude/settings.json in install. A plain writeFileSync can leave a
|
|
7
|
+
// half-written file on crash, and a fixed ".tmp" name races concurrent writers.
|
|
8
|
+
// This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
|
|
9
|
+
// a one-time ".bak" so a logic bug in the caller's merge is recoverable.
|
|
10
|
+
|
|
11
|
+
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Atomically write `data` to `filePath` (temp + rename). Optionally back up the
|
|
16
|
+
* existing file once to `<filePath>.bak` before the first overwrite.
|
|
17
|
+
* @param {string} filePath
|
|
18
|
+
* @param {string} data
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {boolean} [opts.backup=false] Create <filePath>.bak if absent and the
|
|
21
|
+
* target exists, before writing. Only the first call creates it, so the backup
|
|
22
|
+
* preserves the last-known-good rather than being overwritten each run.
|
|
23
|
+
*/
|
|
24
|
+
export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
|
|
25
|
+
const dir = dirname(filePath);
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
if (backup && existsSync(filePath) && !existsSync(filePath + '.bak')) {
|
|
29
|
+
try { copyFileSync(filePath, filePath + '.bak'); } catch { /* best-effort backup */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
|
|
33
|
+
// each other's temp mid-write. Same-dir temp keeps the rename atomic (no
|
|
34
|
+
// cross-device move).
|
|
35
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
36
|
+
writeFileSync(tmp, data);
|
|
37
|
+
renameSync(tmp, filePath);
|
|
38
|
+
}
|
package/lib/err-sampler.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
19
19
|
import { join } from 'path';
|
|
20
|
+
import { scrubSecrets } from '../secret-scrub.mjs';
|
|
20
21
|
|
|
21
22
|
const DAY_MS = 86400000;
|
|
22
23
|
|
|
@@ -47,11 +48,14 @@ export function maybeSampleError(e, ctx, dbDir) {
|
|
|
47
48
|
const errDir = join(dbDir, 'errors');
|
|
48
49
|
if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true, mode: 0o700 });
|
|
49
50
|
|
|
51
|
+
// Scrub BEFORE truncating: a connection string / Authorization header / 401
|
|
52
|
+
// body can ride along in an error message or stack frame. Scrub the full
|
|
53
|
+
// string first so a secret straddling the slice boundary is still caught.
|
|
50
54
|
const line = JSON.stringify({
|
|
51
55
|
ts: new Date().toISOString(),
|
|
52
|
-
ctx: String(ctx || '').slice(0, 120),
|
|
53
|
-
msg: String(e?.message ?? e ?? '').slice(0, 500),
|
|
54
|
-
stack: typeof e?.stack === 'string' ? e.stack.split('\n').slice(0, 6).join('\n') : undefined,
|
|
56
|
+
ctx: scrubSecrets(String(ctx || '')).slice(0, 120),
|
|
57
|
+
msg: scrubSecrets(String(e?.message ?? e ?? '')).slice(0, 500),
|
|
58
|
+
stack: typeof e?.stack === 'string' ? scrubSecrets(e.stack.split('\n').slice(0, 6).join('\n')) : undefined,
|
|
55
59
|
}) + '\n';
|
|
56
60
|
|
|
57
61
|
appendFileSync(join(errDir, `${today()}.jsonl`), line, { mode: 0o600 });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// lib/lesson-idents.mjs — pure, zero-dependency extractor of code identifiers a
|
|
2
|
+
// lesson names, for the bind-salience PostToolUse "dropped a required reference"
|
|
3
|
+
// check (scripts/post-tool-recall.js). Imported by hot standalone hooks → NO
|
|
4
|
+
// heavy imports (lesson #8447): regex over a string only.
|
|
5
|
+
//
|
|
6
|
+
// Identifier shapes: backtick-quoted, camelCase, snake_case, length >= MIN_LEN.
|
|
7
|
+
// These name functions/columns a lesson tells you to keep (recoverChildrenOf,
|
|
8
|
+
// compressed_into). Plain prose ("recover", "delete") is intentionally excluded.
|
|
9
|
+
|
|
10
|
+
const MIN_LEN = 5;
|
|
11
|
+
const BACKTICK = /`([A-Za-z_][A-Za-z0-9_]*)`/g;
|
|
12
|
+
const CAMEL = /\b([a-z][a-z0-9]*[A-Z][A-Za-z0-9]*)\b/g;
|
|
13
|
+
const SNAKE = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/g;
|
|
14
|
+
|
|
15
|
+
export function extractIdents(text) {
|
|
16
|
+
const s = text || '';
|
|
17
|
+
if (!s) return [];
|
|
18
|
+
const out = new Set();
|
|
19
|
+
for (const re of [BACKTICK, CAMEL, SNAKE]) {
|
|
20
|
+
for (const m of s.matchAll(re)) if (m[1].length >= MIN_LEN) out.add(m[1]);
|
|
21
|
+
}
|
|
22
|
+
return [...out];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Of the identifiers a lesson names, keep only those literally present in the
|
|
26
|
+
// pre-edit file — so the PostToolUse check flags "you removed X" and never
|
|
27
|
+
// "you didn't add X that was never here" (the false positive). '' content → [].
|
|
28
|
+
export function presentIdents(lessonText, content) {
|
|
29
|
+
const c = content || '';
|
|
30
|
+
if (!c) return [];
|
|
31
|
+
return extractIdents(lessonText).filter((id) => c.includes(id));
|
|
32
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// lib/proc-lock.mjs — best-effort inter-process advisory lock (O_EXCL file).
|
|
2
|
+
//
|
|
3
|
+
// Why: multiple Claude Code sessions can fire SessionStart hooks (and their
|
|
4
|
+
// self-heal / auto-update write paths) at the same instant. install(),
|
|
5
|
+
// install.mjs repair, and hook-update.installExtractedRelease all rename source
|
|
6
|
+
// files into the live install dir; two of them interleaving produces a torn /
|
|
7
|
+
// mixed-version install (server vN + hook vN+1). The launcher's 6h cooldown
|
|
8
|
+
// only RATE-LIMITS re-spawns — it is not mutual exclusion (two processes can
|
|
9
|
+
// both observe "no recent attempt" and both spawn). This gives the write paths
|
|
10
|
+
// a real cross-process gate.
|
|
11
|
+
//
|
|
12
|
+
// Semantics: acquireLock() atomically creates the lock file with O_EXCL. If it
|
|
13
|
+
// already exists it is stolen only when STALE (holder's timestamp older than
|
|
14
|
+
// staleMs, or the recorded pid is provably dead on this host). A live holder →
|
|
15
|
+
// acquire returns null and the caller no-ops (someone else is already doing the
|
|
16
|
+
// write). Release unlinks the file. Crash-safe: a crashed holder's lock ages
|
|
17
|
+
// out via staleMs so the next session reclaims it.
|
|
18
|
+
|
|
19
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { dirname } from 'node:path';
|
|
21
|
+
|
|
22
|
+
// 5 min: comfortably longer than any install/update write phase (npm install in
|
|
23
|
+
// staging is timeout-capped at 60s) but short enough that a crashed holder does
|
|
24
|
+
// not block self-heal for long.
|
|
25
|
+
const DEFAULT_STALE_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
function pidAlive(pid) {
|
|
28
|
+
if (typeof pid !== 'number' || pid <= 0) return false;
|
|
29
|
+
try {
|
|
30
|
+
// Signal 0 = existence check, no signal delivered. EPERM means the process
|
|
31
|
+
// exists but is owned by another user (still "alive" for our purposes).
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return e.code === 'EPERM';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isStale(lockPath, staleMs, now) {
|
|
40
|
+
try {
|
|
41
|
+
const { pid, ts } = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
42
|
+
if (typeof ts === 'number' && now() - ts > staleMs) return true;
|
|
43
|
+
// Same-host fast reclaim: holder pid is gone. Cross-host (shared homedir)
|
|
44
|
+
// the pid is meaningless, but ts-staleness above still reclaims it.
|
|
45
|
+
if (typeof pid === 'number' && !pidAlive(pid)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
} catch {
|
|
48
|
+
return true; // unparseable / unreadable lock → treat as stale and reclaim
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRelease(lockPath) {
|
|
53
|
+
let released = false;
|
|
54
|
+
return function release() {
|
|
55
|
+
if (released) return;
|
|
56
|
+
released = true;
|
|
57
|
+
try { unlinkSync(lockPath); } catch { /* already gone — fine */ }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Try to acquire an advisory lock. Non-blocking.
|
|
63
|
+
* @param {string} lockPath Absolute path to the lock file.
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {number} [opts.staleMs] Age after which a held lock is stolen.
|
|
66
|
+
* @param {() => number} [opts.now] Clock injection seam (tests).
|
|
67
|
+
* @returns {(() => void)|null} A release() fn, or null if a live peer holds it.
|
|
68
|
+
*/
|
|
69
|
+
export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
|
|
70
|
+
try { mkdirSync(dirname(lockPath), { recursive: true }); } catch { /* best-effort */ }
|
|
71
|
+
const payload = JSON.stringify({ pid: process.pid, ts: now() });
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(lockPath, payload, { flag: 'wx' }); // O_EXCL — atomic create
|
|
74
|
+
return makeRelease(lockPath);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.code !== 'EEXIST') return null; // permission / fs error → fail closed
|
|
77
|
+
if (!isStale(lockPath, staleMs, now)) return null; // live peer holds it
|
|
78
|
+
// Stale: steal it. unlink + re-create exclusively; lose the race → null.
|
|
79
|
+
try { unlinkSync(lockPath); } catch { /* raced */ }
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(lockPath, payload, { flag: 'wx' });
|
|
82
|
+
return makeRelease(lockPath);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run `fn` while holding the lock; release in a finally. No-op if not acquired.
|
|
91
|
+
* @returns {{acquired: boolean, result?: any}}
|
|
92
|
+
*/
|
|
93
|
+
export function withLock(lockPath, fn, opts) {
|
|
94
|
+
const release = acquireLock(lockPath, opts);
|
|
95
|
+
if (!release) return { acquired: false };
|
|
96
|
+
try {
|
|
97
|
+
return { acquired: true, result: fn() };
|
|
98
|
+
} finally {
|
|
99
|
+
release();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Async variant of withLock — awaits `fn`. */
|
|
104
|
+
export async function withLockAsync(lockPath, fn, opts) {
|
|
105
|
+
const release = acquireLock(lockPath, opts);
|
|
106
|
+
if (!release) return { acquired: false };
|
|
107
|
+
try {
|
|
108
|
+
return { acquired: true, result: await fn() };
|
|
109
|
+
} finally {
|
|
110
|
+
release();
|
|
111
|
+
}
|
|
112
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
|
10
10
|
import { _resetVocabCache } from './tfidf.mjs';
|
|
11
11
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
12
12
|
import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
|
|
13
|
-
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady
|
|
13
|
+
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
14
14
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
15
15
|
import { searchResources } from './registry-retriever.mjs';
|
|
16
16
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -227,7 +227,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
227
227
|
} else {
|
|
228
228
|
obsResults = searchObservationsHybrid(db, obsCtx);
|
|
229
229
|
if (obsCtx.orFallbackFired) orFallbackFired = true;
|
|
230
|
-
if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx
|
|
230
|
+
if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx, { db, project: project || null })) {
|
|
231
231
|
process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
|
|
232
232
|
obsResults = await runDeep({ auto: true });
|
|
233
233
|
isDeep = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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",
|
|
@@ -69,7 +69,10 @@
|
|
|
69
69
|
"lib/file-intel.mjs",
|
|
70
70
|
"lib/reread-guard.mjs",
|
|
71
71
|
"lib/metrics.mjs",
|
|
72
|
+
"lib/lesson-idents.mjs",
|
|
72
73
|
"lib/binding-probe.mjs",
|
|
74
|
+
"lib/proc-lock.mjs",
|
|
75
|
+
"lib/atomic-write.mjs",
|
|
73
76
|
"lib/mem-override.mjs",
|
|
74
77
|
"lib/save-observation.mjs",
|
|
75
78
|
"lib/observation-write.mjs",
|
|
@@ -131,6 +134,7 @@
|
|
|
131
134
|
"scripts/post-tool-use.sh",
|
|
132
135
|
"scripts/user-prompt-search.js",
|
|
133
136
|
"scripts/pre-tool-recall.js",
|
|
137
|
+
"scripts/post-tool-recall.js",
|
|
134
138
|
"scripts/pre-skill-bridge.js",
|
|
135
139
|
"scripts/prompt-search-utils.mjs",
|
|
136
140
|
"scripts/hook-launcher.mjs",
|
package/schema.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
|
|
|
6
6
|
import { homedir } from 'os';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
|
|
9
|
-
import { OBS_FTS_COLUMNS } from './utils.mjs';
|
|
9
|
+
import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
|
|
10
10
|
|
|
11
11
|
// DATA location — DB, managed resources, registry DB, runtime/. Honors
|
|
12
12
|
// CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
|
|
@@ -79,7 +79,16 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
79
79
|
// New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS on the
|
|
80
80
|
// forced migration pass; LATEST_MIGRATION_COLUMN unchanged (no new column) — same
|
|
81
81
|
// pattern as v35/v36.
|
|
82
|
-
|
|
82
|
+
// v39 (audit P1-5): migration_cleanups table — a sentinel registry that makes the
|
|
83
|
+
// one-shot DATA cleanups (orphan deletes, project-name normalization) RETRYABLE.
|
|
84
|
+
// They previously ran inside the version-gated migration body and were swallowed
|
|
85
|
+
// on failure AFTER the version stamp committed, so a failed cleanup could never
|
|
86
|
+
// re-run (the fast-path then skipped the whole body). They now run via
|
|
87
|
+
// runDeferredCleanups() on every ensureDb, each gated by a done-marker row: a
|
|
88
|
+
// failure leaves the marker unset and retries on the next open. New TABLE via
|
|
89
|
+
// CORE_SCHEMA on the forced pass; LATEST_MIGRATION_COLUMN unchanged (no new
|
|
90
|
+
// column) — same pattern as v35/v36/v38.
|
|
91
|
+
export const CURRENT_SCHEMA_VERSION = 39;
|
|
83
92
|
|
|
84
93
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
85
94
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
@@ -185,6 +194,11 @@ const CORE_SCHEMA = `
|
|
|
185
194
|
cited_n INTEGER NOT NULL DEFAULT 0,
|
|
186
195
|
PRIMARY KEY (project, memory_session_id)
|
|
187
196
|
);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE IF NOT EXISTS migration_cleanups (
|
|
199
|
+
name TEXT PRIMARY KEY,
|
|
200
|
+
done_at_epoch INTEGER NOT NULL
|
|
201
|
+
);
|
|
188
202
|
`;
|
|
189
203
|
|
|
190
204
|
// Column migrations (idempotent — only swallow "duplicate column" errors)
|
|
@@ -556,18 +570,8 @@ export function initSchema(db) {
|
|
|
556
570
|
}
|
|
557
571
|
} catch { /* non-critical — migration can retry on next open */ }
|
|
558
572
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// fast-path FK fix (initSchema early returns now restore foreign_keys=ON),
|
|
562
|
-
// ensureDb() handles ran with FK OFF, so ON DELETE CASCADE never fired and
|
|
563
|
-
// junction rows leaked (live DB: 6440/9569 = 67% orphans). Idempotent (NOT IN
|
|
564
|
-
// is empty on a clean DB); runs once per version bump via the fast-path gate.
|
|
565
|
-
try {
|
|
566
|
-
db.prepare(`
|
|
567
|
-
DELETE FROM observation_files
|
|
568
|
-
WHERE obs_id NOT IN (SELECT id FROM observations)
|
|
569
|
-
`).run();
|
|
570
|
-
} catch { /* non-critical — table-missing path handled by earlier CREATE */ }
|
|
573
|
+
// observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
|
|
574
|
+
// it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
|
|
571
575
|
|
|
572
576
|
// Observation vectors table for TF-IDF vector search
|
|
573
577
|
db.exec(`
|
|
@@ -582,16 +586,7 @@ export function initSchema(db) {
|
|
|
582
586
|
|
|
583
587
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_vectors_version ON observation_vectors(vocab_version)`);
|
|
584
588
|
|
|
585
|
-
//
|
|
586
|
-
// Live DBs accumulated 44% orphans even with ON DELETE CASCADE because
|
|
587
|
-
// early migrations ran with `foreign_keys=OFF` and deletes skipped cascade.
|
|
588
|
-
// Idempotent (NOT IN is empty on a clean DB), runs once per ensureDb().
|
|
589
|
-
try {
|
|
590
|
-
db.prepare(`
|
|
591
|
-
DELETE FROM observation_vectors
|
|
592
|
-
WHERE observation_id NOT IN (SELECT id FROM observations)
|
|
593
|
-
`).run();
|
|
594
|
-
} catch { /* non-critical — table-missing path handled by earlier CREATE */ }
|
|
589
|
+
// observation_vectors orphan cleanup moved to runDeferredCleanups() (audit P1-5).
|
|
595
590
|
|
|
596
591
|
// Persisted vocabulary for stable TF-IDF vector indexing
|
|
597
592
|
db.exec(`
|
|
@@ -605,46 +600,9 @@ export function initSchema(db) {
|
|
|
605
600
|
`);
|
|
606
601
|
db.exec('CREATE INDEX IF NOT EXISTS idx_vocab_state_version ON vocab_state(version)');
|
|
607
602
|
|
|
608
|
-
// Project
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
try {
|
|
612
|
-
const shortProjects = db.prepare(`
|
|
613
|
-
SELECT DISTINCT project FROM observations
|
|
614
|
-
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
615
|
-
UNION
|
|
616
|
-
SELECT DISTINCT project FROM sdk_sessions
|
|
617
|
-
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
618
|
-
`).all();
|
|
619
|
-
if (shortProjects.length > 0) {
|
|
620
|
-
const normalize = db.transaction(() => {
|
|
621
|
-
for (const { project: shortName } of shortProjects) {
|
|
622
|
-
// Strategy 1: exact suffix match (e.g., "mem" → "projects--mem")
|
|
623
|
-
let canonical = db.prepare(
|
|
624
|
-
`SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
625
|
-
).get(`%--${shortName}`);
|
|
626
|
-
// Strategy 2: substring match for aliases (e.g., "claude-mem-lite" → match project containing "mem")
|
|
627
|
-
// Extract the most distinctive token from the short name for fuzzy matching
|
|
628
|
-
if (!canonical) {
|
|
629
|
-
const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
|
|
630
|
-
for (const token of tokens) {
|
|
631
|
-
canonical = db.prepare(
|
|
632
|
-
`SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
|
|
633
|
-
GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
634
|
-
).get(`%${token}%`);
|
|
635
|
-
if (canonical) break;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
if (canonical) {
|
|
639
|
-
for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
|
|
640
|
-
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
});
|
|
645
|
-
normalize();
|
|
646
|
-
}
|
|
647
|
-
} catch { /* non-critical — normalization can retry on next open */ }
|
|
603
|
+
// Project-name normalization moved to runDeferredCleanups() (audit P1-5) — it
|
|
604
|
+
// now retries on a later open if it fails, instead of being lost behind the
|
|
605
|
+
// version fast-path. See DEFERRED_CLEANUPS.
|
|
648
606
|
|
|
649
607
|
// ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
|
|
650
608
|
//
|
|
@@ -804,6 +762,96 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
|
|
|
804
762
|
};
|
|
805
763
|
}
|
|
806
764
|
|
|
765
|
+
// ─── Deferred one-shot cleanups (retryable) ─────────────────────────────────
|
|
766
|
+
// Idempotent DATA cleanups that must survive a transient failure. They run on
|
|
767
|
+
// EVERY ensureDb (after initSchema), each gated by a row in migration_cleanups:
|
|
768
|
+
// a cleanup that throws leaves its marker unset and retries on the next open —
|
|
769
|
+
// unlike the old version-gated body, where a swallowed failure committed
|
|
770
|
+
// alongside the version stamp and the fast-path then skipped it forever (P1-5).
|
|
771
|
+
const DEFERRED_CLEANUPS = [
|
|
772
|
+
{
|
|
773
|
+
// v35 (v2.87.0): orphaned observation_files. ON DELETE CASCADE didn't fire
|
|
774
|
+
// while early warm-start handles ran with foreign_keys OFF, so junction rows
|
|
775
|
+
// leaked. Idempotent (NOT IN is empty on a clean DB).
|
|
776
|
+
name: 'orphan-observation-files',
|
|
777
|
+
run: (db) => db.prepare(
|
|
778
|
+
`DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`
|
|
779
|
+
).run(),
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
// v28 (v2.47) P0-1: orphaned observation_vectors — same FK-OFF root cause.
|
|
783
|
+
name: 'orphan-observation-vectors',
|
|
784
|
+
run: (db) => db.prepare(
|
|
785
|
+
`DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`
|
|
786
|
+
).run(),
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
// Project-name normalization: migrate short names ("mem") to canonical
|
|
790
|
+
// ("projects--mem"). Exact suffix match first, then distinctive-token
|
|
791
|
+
// substring. Idempotent: only acts on remaining short-name records.
|
|
792
|
+
name: 'normalize-project-names',
|
|
793
|
+
run: (db) => {
|
|
794
|
+
const shortProjects = db.prepare(`
|
|
795
|
+
SELECT DISTINCT project FROM observations
|
|
796
|
+
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
797
|
+
UNION
|
|
798
|
+
SELECT DISTINCT project FROM sdk_sessions
|
|
799
|
+
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
800
|
+
`).all();
|
|
801
|
+
if (shortProjects.length === 0) return;
|
|
802
|
+
const normalize = db.transaction(() => {
|
|
803
|
+
for (const { project: shortName } of shortProjects) {
|
|
804
|
+
let canonical = db.prepare(
|
|
805
|
+
`SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
806
|
+
).get(`%--${shortName}`);
|
|
807
|
+
if (!canonical) {
|
|
808
|
+
const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
|
|
809
|
+
for (const token of tokens) {
|
|
810
|
+
canonical = db.prepare(
|
|
811
|
+
`SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
|
|
812
|
+
GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
813
|
+
).get(`%${token}%`);
|
|
814
|
+
if (canonical) break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (canonical) {
|
|
818
|
+
for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
|
|
819
|
+
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
normalize();
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
];
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Run registered one-shot data cleanups that haven't completed yet. Each is
|
|
831
|
+
* gated by a row in migration_cleanups, so a transient failure retries on the
|
|
832
|
+
* next open instead of being silently lost behind the schema-version fast-path
|
|
833
|
+
* (audit P1-5). Best-effort: never throws — callers open the DB regardless.
|
|
834
|
+
*/
|
|
835
|
+
export function runDeferredCleanups(db) {
|
|
836
|
+
let done;
|
|
837
|
+
try {
|
|
838
|
+
done = new Set(db.prepare('SELECT name FROM migration_cleanups').all().map(r => r.name));
|
|
839
|
+
} catch {
|
|
840
|
+
return; // table not present yet (pre-migration open) — nothing to do
|
|
841
|
+
}
|
|
842
|
+
const mark = db.prepare('INSERT OR IGNORE INTO migration_cleanups (name, done_at_epoch) VALUES (?, ?)');
|
|
843
|
+
for (const { name, run } of DEFERRED_CLEANUPS) {
|
|
844
|
+
if (done.has(name)) continue;
|
|
845
|
+
try {
|
|
846
|
+
run(db);
|
|
847
|
+
mark.run(name, Date.now());
|
|
848
|
+
} catch (e) {
|
|
849
|
+
// Leave the marker unset → retried next open. Surface for observability.
|
|
850
|
+
debugCatch(e, `deferred-cleanup:${name}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
807
855
|
/**
|
|
808
856
|
* Ensure DB directory, database file, and all tables exist.
|
|
809
857
|
* Safe to call from any process (hook or server). Idempotent.
|
package/scoring-sql.mjs
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
|
|
4
4
|
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
5
5
|
|
|
6
|
+
// ─── Why these multipliers exist (read before "simplifying" them) ────────────
|
|
7
|
+
//
|
|
8
|
+
// The recency-decay, type-quality, project-boost, importance, cite, and noise
|
|
9
|
+
// multipliers below encode PRODUCT PRIORS: recent / same-project / important /
|
|
10
|
+
// high-signal-type / frequently-cited memories are more relevant to the CURRENT
|
|
11
|
+
// dev session. A periodic audit tends to flag them as "0-lift dead weight" —
|
|
12
|
+
// resist that on benchmark evidence alone. Measured (audit ②, obs #8773):
|
|
13
|
+
// * benchmark.mjs --matrix (micro-fixture, now models the full FULL_SCORE
|
|
14
|
+
// chain): type-quality is the TOP contributor (drop-type ΔnDCG=0.0082,
|
|
15
|
+
// ΔMRR=0.0166), decay +0.0043 nDCG, importance +0.0012; the chain lifts
|
|
16
|
+
// hybrid over bm25_only by +0.0093 nDCG / +0.0166 MRR (net 0 queries hurt).
|
|
17
|
+
// project, access and lesson read exactly 0 — but that is STRUCTURAL: the
|
|
18
|
+
// fixture is single-project, access_count=0, and has 0 lesson_learned rows,
|
|
19
|
+
// so it cannot vary those three axes.
|
|
20
|
+
// * longmemeval.mjs --temporal (n=500, real dates): bit-identical to uniform —
|
|
21
|
+
// LongMemEval-S windows (mean 27.9d, 74% <30d) are far shorter than these
|
|
22
|
+
// half-lives, so decay moves no rank there either.
|
|
23
|
+
// Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
|
|
24
|
+
// can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
|
|
25
|
+
// delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
|
|
26
|
+
// (benchmark/ci-gate.mjs) covers the full modelled chain
|
|
27
|
+
// (decay/type/project/importance/access/lesson); cite + noise live on the
|
|
28
|
+
// injection path (hook-memory.mjs) with no recall-benchmark coverage. Genuine
|
|
29
|
+
// validation of the prior-encoding axes needs a labeled real-dev-memory eval.
|
|
30
|
+
|
|
6
31
|
// ─── Type-Differentiated Recency Decay ──────────────────────────────────────
|
|
7
32
|
|
|
8
33
|
/** Recency half-life per observation type (in milliseconds) */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/post-tool-recall.js — PostToolUse companion to pre-tool-recall.js for
|
|
3
|
+
// the bind-salience forcing-function (component 2). After an Edit/Write, if a
|
|
4
|
+
// lesson surfaced for this file named an identifier that was present BEFORE the
|
|
5
|
+
// edit (recorded in the cooldown by pre-tool-recall.js) and is now GONE, emit a
|
|
6
|
+
// one-line non-blocking nudge. Only active under CLAUDE_MEM_SALIENCE=bind.
|
|
7
|
+
//
|
|
8
|
+
// Catches "you removed a required reference" lessons. It does NOT catch "you
|
|
9
|
+
// failed to ADD a call" (the identifier was never in the pre-edit file →
|
|
10
|
+
// presentIdents excluded it); that class is carried by the pre-edit
|
|
11
|
+
// BIND_DIRECTIVE, not here. See the spec's component-2 limits.
|
|
12
|
+
//
|
|
13
|
+
// Safety: readonly, no DB, exit 0 always. cooldownPathFor mirrors
|
|
14
|
+
// pre-tool-recall.js (inlined per the #8447 fast-path convention).
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from 'fs';
|
|
17
|
+
import { basename, join } from 'path';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
|
|
20
|
+
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
21
|
+
|
|
22
|
+
const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
|
|
23
|
+
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
|
|
24
|
+
const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
|
|
25
|
+
|
|
26
|
+
function cooldownPathFor(sessionId) {
|
|
27
|
+
if (!sessionId) return LEGACY_COOLDOWN_PATH;
|
|
28
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
29
|
+
return join(RUNTIME_DIR, `pre-recall-cooldown-${safe}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
if (!SALIENCE_BIND) return;
|
|
34
|
+
if (process.env.CLAUDE_MEM_HOOK_RUNNING) return;
|
|
35
|
+
let input = '';
|
|
36
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
37
|
+
let filePath, sessionId;
|
|
38
|
+
try {
|
|
39
|
+
const e = JSON.parse(input);
|
|
40
|
+
filePath = e.tool_input?.file_path;
|
|
41
|
+
sessionId = e.session_id || null;
|
|
42
|
+
} catch { return; }
|
|
43
|
+
if (!filePath) return;
|
|
44
|
+
|
|
45
|
+
const cdPath = cooldownPathFor(sessionId);
|
|
46
|
+
if (!existsSync(cdPath)) return;
|
|
47
|
+
let entry;
|
|
48
|
+
try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch { return; }
|
|
49
|
+
const idents = entry && entry.lessonIdents;
|
|
50
|
+
if (!idents || typeof idents !== 'object') return;
|
|
51
|
+
|
|
52
|
+
let post;
|
|
53
|
+
try { post = readFileSync(filePath, 'utf8'); } catch { return; }
|
|
54
|
+
|
|
55
|
+
const dropped = [];
|
|
56
|
+
for (const [obsId, tokens] of Object.entries(idents)) {
|
|
57
|
+
for (const t of tokens) if (!post.includes(t)) dropped.push({ obsId, token: t });
|
|
58
|
+
}
|
|
59
|
+
if (!dropped.length) return;
|
|
60
|
+
|
|
61
|
+
const lines = ['[mem] PostToolUse recall — system-injected context, continue your planned action:'];
|
|
62
|
+
for (const d of dropped.slice(0, 3)) {
|
|
63
|
+
lines.push(`[mem] ⚠ your edit to ${basename(filePath)} dropped \`${d.token}\` flagged by #${d.obsId} — if intentional say so, else re-check before moving on.`);
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write(JSON.stringify({
|
|
66
|
+
suppressOutput: true,
|
|
67
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: lines.join('\n') },
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
@@ -13,6 +13,7 @@ import { citeFactorClause } from '../scoring-sql.mjs';
|
|
|
13
13
|
import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
14
14
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
15
15
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
16
|
+
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
16
17
|
|
|
17
18
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
18
19
|
// whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
|
|
@@ -40,7 +41,14 @@ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
|
|
|
40
41
|
// restores the pre-v2.98 passive behavior.
|
|
41
42
|
const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
|
|
42
43
|
|| process.env.CLAUDE_MEM_SALIENCE === '0';
|
|
44
|
+
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
43
45
|
const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
|
|
46
|
+
// v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
|
|
47
|
+
// '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
|
|
48
|
+
// line it's editing and quote the satisfying edit line. Selected by
|
|
49
|
+
// CLAUDE_MEM_SALIENCE=bind; default stays ACK_DIRECTIVE.
|
|
50
|
+
const BIND_DIRECTIVE = "For each lesson: state the one concrete check it forces on the line(s) you're editing, quote the edit line that satisfies it, then report '#NN: <check> — pass' or '#NN: n/a — <why this edit can't reach it>'.";
|
|
51
|
+
const ACTIVE_DIRECTIVE = SALIENCE_BIND ? BIND_DIRECTIVE : ACK_DIRECTIVE;
|
|
44
52
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
|
|
45
53
|
// Feature ① (file intelligence): on the first Read of a file each session, inject
|
|
46
54
|
// its approximate token size + a one-line summary so the agent can decide to read
|
|
@@ -238,7 +246,7 @@ try {
|
|
|
238
246
|
hookEventName: 'PreToolUse',
|
|
239
247
|
additionalContext: [
|
|
240
248
|
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
241
|
-
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${
|
|
249
|
+
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
|
|
242
250
|
].join('\n'),
|
|
243
251
|
},
|
|
244
252
|
}));
|
|
@@ -434,7 +442,7 @@ try {
|
|
|
434
442
|
// Read keeps the quiet form; its forcing-function fires at the later Edit
|
|
435
443
|
// via the Read→Edit ack nudge above.
|
|
436
444
|
if (!isRead && !SALIENCE_LEGACY) {
|
|
437
|
-
lines.push(`[mem] ⚠ Before this edit: ${
|
|
445
|
+
lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
|
|
438
446
|
}
|
|
439
447
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
440
448
|
// R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
|
|
@@ -474,10 +482,27 @@ try {
|
|
|
474
482
|
// full re-read of the unchanged file can be flagged. Read-only, session-scoped;
|
|
475
483
|
// one stat + bounded read, first-read only.
|
|
476
484
|
const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
|
|
485
|
+
// bind salience (component 2): record the identifiers each lesson NAMES that
|
|
486
|
+
// ALSO appear in the current (pre-edit) file, so post-tool-recall.js can flag
|
|
487
|
+
// an edit that drops one. Only under =bind with lessons — keeps the default
|
|
488
|
+
// path free of the extra file read. Bounded read; never throws.
|
|
489
|
+
let lessonIdents;
|
|
490
|
+
if (SALIENCE_BIND && allRows.length > 0) {
|
|
491
|
+
try {
|
|
492
|
+
const pre = readFileSync(filePath, 'utf8').slice(0, 256 * 1024);
|
|
493
|
+
const acc = {};
|
|
494
|
+
for (const r of allRows) {
|
|
495
|
+
const present = presentIdents(`${r.lesson_learned || ''} ${r.title || ''}`, pre);
|
|
496
|
+
if (present.length) acc[r.id] = present;
|
|
497
|
+
}
|
|
498
|
+
if (Object.keys(acc).length) lessonIdents = acc;
|
|
499
|
+
} catch { /* unreadable pre-edit file — skip the diff check */ }
|
|
500
|
+
}
|
|
477
501
|
cooldown[filePath] = {
|
|
478
502
|
ts: now,
|
|
479
503
|
lessonIds: allRows.map(r => r.id),
|
|
480
504
|
mode: isRead ? 'read' : 'edit',
|
|
505
|
+
...(lessonIdents ? { lessonIdents } : {}),
|
|
481
506
|
...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
|
|
482
507
|
};
|
|
483
508
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
package/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
|
10
10
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
11
11
|
import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
|
|
12
12
|
import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
|
|
13
|
-
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady
|
|
13
|
+
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
14
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
15
15
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
16
16
|
import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
|
|
@@ -61,8 +61,19 @@ let db;
|
|
|
61
61
|
try {
|
|
62
62
|
db = ensureDb();
|
|
63
63
|
} catch (firstErr) {
|
|
64
|
+
// WAL-delete recovery is ONLY safe for genuine corruption. On a transient
|
|
65
|
+
// error (SQLITE_BUSY) or the forward-version guard throw, deleting the WAL
|
|
66
|
+
// would discard committed-but-uncheckpointed transactions — silent data loss.
|
|
67
|
+
// Restrict the rm to corruption signatures; otherwise fail fast, WAL intact.
|
|
68
|
+
const sig = `${firstErr.code || ''} ${firstErr.message || ''}`;
|
|
69
|
+
const isCorruption = /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(sig);
|
|
70
|
+
if (!isCorruption) {
|
|
71
|
+
console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${firstErr.message}`);
|
|
72
|
+
console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
64
75
|
// Recovery: remove WAL/SHM files (corrupt WAL is the most common cause) and retry
|
|
65
|
-
debugLog('WARN', 'server', `DB
|
|
76
|
+
debugLog('WARN', 'server', `DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
|
|
66
77
|
try { rmSync(DB_PATH + '-wal', { force: true }); } catch {}
|
|
67
78
|
try { rmSync(DB_PATH + '-shm', { force: true }); } catch {}
|
|
68
79
|
try {
|
|
@@ -412,7 +423,7 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
|
412
423
|
// results is already obs-only here (sessions/prompts pushed below), but the
|
|
413
424
|
// filter makes the invariant explicit and robust to future reordering.
|
|
414
425
|
const obsCountBeforeEscalation = results.length;
|
|
415
|
-
if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx
|
|
426
|
+
if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx, { db, project: args.project || null })) {
|
|
416
427
|
await runDeepInto({ auto: true });
|
|
417
428
|
isDeep = true;
|
|
418
429
|
escalated = true;
|
package/source-files.mjs
CHANGED
|
@@ -59,11 +59,20 @@ export const SOURCE_FILES = [
|
|
|
59
59
|
'lib/file-intel.mjs',
|
|
60
60
|
'lib/reread-guard.mjs',
|
|
61
61
|
'lib/metrics.mjs',
|
|
62
|
+
// v3.6.x: bind-salience producer — extracts identifiers a lesson names that
|
|
63
|
+
// are present in the pre-edit file (component 2). Imported ONLY by
|
|
64
|
+
// scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
|
|
65
|
+
'lib/lesson-idents.mjs',
|
|
62
66
|
// v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
|
|
63
67
|
// (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch
|
|
64
68
|
// self-heal after Node ABI changes). Missing from manifest → auto-update
|
|
65
69
|
// ships a stale install that FATALs on first DB open after Node upgrade.
|
|
66
70
|
'lib/binding-probe.mjs',
|
|
71
|
+
// audit P0/P1: inter-process install lock + atomic config writes — imported by
|
|
72
|
+
// install.mjs (settings.json + install lock) and hook-update.mjs (.claude.json
|
|
73
|
+
// + auto-update lock). Must ship or a partial install/update skips them.
|
|
74
|
+
'lib/proc-lock.mjs',
|
|
75
|
+
'lib/atomic-write.mjs',
|
|
67
76
|
// v2.41 god-module split — mem-cli.mjs router + per-cmd handlers under cli/
|
|
68
77
|
'cli/common.mjs',
|
|
69
78
|
'cli/fts-check.mjs',
|
|
@@ -149,6 +158,7 @@ export const HOOK_SCRIPT_FILES = [
|
|
|
149
158
|
'user-prompt-search.js',
|
|
150
159
|
'prompt-search-utils.mjs',
|
|
151
160
|
'pre-tool-recall.js',
|
|
161
|
+
'post-tool-recall.js',
|
|
152
162
|
'pre-skill-bridge.js',
|
|
153
163
|
// v2.84: self-heal wrapper that detects ERR_MODULE_NOT_FOUND under the
|
|
154
164
|
// install dir and runs install.mjs repair before retrying the entry.
|