claude-mem-lite 3.5.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/hook.mjs +5 -0
- package/install.mjs +25 -10
- package/lib/atomic-write.mjs +38 -0
- package/lib/citation-tracker.mjs +93 -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 +30 -3
- package/package.json +5 -1
- package/schema.mjs +129 -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/hook.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
bumpCitationAccess,
|
|
55
55
|
computeCiteRecall,
|
|
56
56
|
applyCitationDecay,
|
|
57
|
+
recordCitationFunnel,
|
|
57
58
|
hasMainThreadAssistantText,
|
|
58
59
|
} from './lib/citation-tracker.mjs';
|
|
59
60
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
@@ -572,6 +573,10 @@ async function handleStop() {
|
|
|
572
573
|
for (const id of citeBackIds) citedMain.add(id);
|
|
573
574
|
const r = applyCitationDecay(db, project, injected, citedMain, sessionId);
|
|
574
575
|
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
576
|
+
// R1: persist this session's invocation→cite funnel row. touched =
|
|
577
|
+
// obs resolved this run (denominator), promoted = obs cited this run
|
|
578
|
+
// (numerator). Idempotent (touched is 0 on re-fire) + best-effort.
|
|
579
|
+
recordCitationFunnel(db, project, sessionId, r.touched, r.promoted);
|
|
575
580
|
}
|
|
576
581
|
}
|
|
577
582
|
} catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
|
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/citation-tracker.mjs
CHANGED
|
@@ -544,3 +544,96 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
544
544
|
try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
|
|
545
545
|
return { promoted, demoted, touched };
|
|
546
546
|
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* R1 — persist one accumulating per-session row of the invocation→cite funnel.
|
|
550
|
+
* Fed by applyCitationDecay's return: `injectedDelta` = obs RESOLVED this Stop
|
|
551
|
+
* (touched), `citedDelta` = obs CITED this Stop (promoted). Idempotent against
|
|
552
|
+
* Stop multi-fire by construction — a re-fired Stop re-resolves nothing (touched
|
|
553
|
+
* is 0 for already-decided obs), so the no-op gate below skips it. A later turn
|
|
554
|
+
* that resolves NEW injections accumulates onto the same (project, session) row.
|
|
555
|
+
*
|
|
556
|
+
* Unlike the per-obs cited_count/decay_seen_count counters (lifetime-cumulative,
|
|
557
|
+
* session breakdown lost), this preserves the per-session series that
|
|
558
|
+
* computeCitationFunnelTrend reads back as a trend. Telemetry only — every write
|
|
559
|
+
* is wrapped so a citation_log failure can never break the Stop handler.
|
|
560
|
+
*
|
|
561
|
+
* @param {import('better-sqlite3').Database} db
|
|
562
|
+
* @param {string} project
|
|
563
|
+
* @param {string} sessionId — memory_session_id of the resolved session
|
|
564
|
+
* @param {number} injectedDelta — obs resolved this run (applyCitationDecay.touched)
|
|
565
|
+
* @param {number} citedDelta — obs cited this run (applyCitationDecay.promoted)
|
|
566
|
+
*/
|
|
567
|
+
export function recordCitationFunnel(db, project, sessionId, injectedDelta, citedDelta) {
|
|
568
|
+
if (!db || !project || !sessionId) return;
|
|
569
|
+
const inj = Number(injectedDelta) || 0;
|
|
570
|
+
if (inj <= 0) return; // nothing resolved this run → no row noise
|
|
571
|
+
const cited = Math.max(0, Number(citedDelta) || 0);
|
|
572
|
+
try {
|
|
573
|
+
db.prepare(`
|
|
574
|
+
INSERT INTO citation_log (project, memory_session_id, resolved_at, injected_n, cited_n)
|
|
575
|
+
VALUES (?, ?, ?, ?, ?)
|
|
576
|
+
ON CONFLICT(project, memory_session_id) DO UPDATE SET
|
|
577
|
+
injected_n = injected_n + excluded.injected_n,
|
|
578
|
+
cited_n = cited_n + excluded.cited_n,
|
|
579
|
+
resolved_at = excluded.resolved_at
|
|
580
|
+
`).run(project, sessionId, Date.now(), inj, cited);
|
|
581
|
+
} catch (e) { debugCatch(e, 'recordCitationFunnel'); }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* R1 — read the per-session invocation→cite funnel as a windowed trend.
|
|
586
|
+
* `window` aggregates [now-days, now]; `prior` aggregates [now-2*days, now-days)
|
|
587
|
+
* so `delta_pt` shows whether invocation effectiveness is rising or falling.
|
|
588
|
+
* `sessions` is the most-recent `limit` rows (per-session rate for the table view).
|
|
589
|
+
*
|
|
590
|
+
* @param {import('better-sqlite3').Database} db
|
|
591
|
+
* @param {{days?: number, limit?: number, project?: string|null}} [opts]
|
|
592
|
+
* @returns {{window_days: number, sessions: Array, window: {injected:number,cited:number,rate:number}, prior: {injected:number,cited:number,rate:number}, delta_pt: number|null}}
|
|
593
|
+
*/
|
|
594
|
+
export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project = null } = {}) {
|
|
595
|
+
const rate = (cited, inj) => (inj > 0 ? cited / inj : 0);
|
|
596
|
+
const empty = {
|
|
597
|
+
window_days: days,
|
|
598
|
+
sessions: [],
|
|
599
|
+
window: { injected: 0, cited: 0, rate: 0 },
|
|
600
|
+
prior: { injected: 0, cited: 0, rate: 0 },
|
|
601
|
+
delta_pt: null,
|
|
602
|
+
};
|
|
603
|
+
if (!db) return empty;
|
|
604
|
+
try {
|
|
605
|
+
const now = Date.now();
|
|
606
|
+
const windowStart = now - days * 86400000;
|
|
607
|
+
const priorStart = now - 2 * days * 86400000;
|
|
608
|
+
const projClause = project ? 'AND project = ?' : '';
|
|
609
|
+
|
|
610
|
+
const sessions = db.prepare(`
|
|
611
|
+
SELECT project, memory_session_id, resolved_at, injected_n, cited_n
|
|
612
|
+
FROM citation_log
|
|
613
|
+
WHERE 1=1 ${projClause}
|
|
614
|
+
ORDER BY resolved_at DESC
|
|
615
|
+
LIMIT ?
|
|
616
|
+
`).all(...(project ? [project, limit] : [limit]))
|
|
617
|
+
.map(r => ({ ...r, rate: rate(r.cited_n, r.injected_n) }));
|
|
618
|
+
|
|
619
|
+
const agg = (fromTs, toTs) => {
|
|
620
|
+
const params = toTs === null ? [fromTs] : [fromTs, toTs];
|
|
621
|
+
if (project) params.push(project);
|
|
622
|
+
const upper = toTs === null ? '' : 'AND resolved_at < ?';
|
|
623
|
+
const row = db.prepare(`
|
|
624
|
+
SELECT COALESCE(SUM(injected_n), 0) AS injected, COALESCE(SUM(cited_n), 0) AS cited
|
|
625
|
+
FROM citation_log
|
|
626
|
+
WHERE resolved_at >= ? ${upper} ${projClause}
|
|
627
|
+
`).get(...params);
|
|
628
|
+
return { injected: row.injected, cited: row.cited, rate: rate(row.cited, row.injected) };
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const windowAgg = agg(windowStart, null);
|
|
632
|
+
const priorAgg = agg(priorStart, windowStart);
|
|
633
|
+
const delta_pt = priorAgg.injected > 0
|
|
634
|
+
? Number(((windowAgg.rate - priorAgg.rate) * 100).toFixed(1))
|
|
635
|
+
: null;
|
|
636
|
+
|
|
637
|
+
return { window_days: days, sessions, window: windowAgg, prior: priorAgg, delta_pt };
|
|
638
|
+
} catch (e) { debugCatch(e, 'computeCitationFunnelTrend'); return empty; }
|
|
639
|
+
}
|
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';
|
|
@@ -40,6 +40,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
40
40
|
import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
|
|
41
41
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
42
42
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
43
|
+
import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
|
|
43
44
|
import { aggregateMetrics } from './lib/metrics.mjs';
|
|
44
45
|
import {
|
|
45
46
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
@@ -226,7 +227,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
226
227
|
} else {
|
|
227
228
|
obsResults = searchObservationsHybrid(db, obsCtx);
|
|
228
229
|
if (obsCtx.orFallbackFired) orFallbackFired = true;
|
|
229
|
-
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 })) {
|
|
230
231
|
process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
|
|
231
232
|
obsResults = await runDeep({ auto: true });
|
|
232
233
|
isDeep = true;
|
|
@@ -2313,8 +2314,12 @@ function cmdCitationStats(db, args) {
|
|
|
2313
2314
|
? `${pollutedRows.n} obs have cited_count > decay_seen_count (pre-v34 backfill — invariant holds for new data).`
|
|
2314
2315
|
: null;
|
|
2315
2316
|
|
|
2317
|
+
// R1: per-session invocation→cite funnel trend (citation_log). Same `days` window
|
|
2318
|
+
// as the per-project cite rate above; funnel.prior/delta_pt show the direction.
|
|
2319
|
+
const funnel = computeCitationFunnelTrend(db, { days });
|
|
2320
|
+
|
|
2316
2321
|
if (json) {
|
|
2317
|
-
out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote }, null, 2));
|
|
2322
|
+
out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote, funnel }, null, 2));
|
|
2318
2323
|
return;
|
|
2319
2324
|
}
|
|
2320
2325
|
|
|
@@ -2325,6 +2330,28 @@ function cmdCitationStats(db, args) {
|
|
|
2325
2330
|
out(` ${r.project.padEnd(34)} ${String(rate).padStart(6)} cited:${r.cited}/${r.resolved} at_risk:${r.at_risk}`);
|
|
2326
2331
|
}
|
|
2327
2332
|
out('');
|
|
2333
|
+
|
|
2334
|
+
// R1: invocation→cite funnel — per-session trend + window-vs-prior direction.
|
|
2335
|
+
out(`Invocation→cite funnel (recent sessions, injected→cited; rate window ${days}d):`);
|
|
2336
|
+
if (funnel.sessions.length === 0) {
|
|
2337
|
+
out(' (no resolved sessions in window)');
|
|
2338
|
+
} else {
|
|
2339
|
+
for (const s of funnel.sessions) {
|
|
2340
|
+
const day = s.resolved_at ? new Date(s.resolved_at).toISOString().slice(0, 10) : '—'.repeat(10);
|
|
2341
|
+
const pct = (s.rate * 100).toFixed(1) + '%';
|
|
2342
|
+
out(` ${day} ${(s.project || '').padEnd(28)} inj ${String(s.injected_n).padStart(3)} cited ${String(s.cited_n).padStart(3)} ${pct.padStart(6)}`);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
let trendLine = `window rate ${(funnel.window.rate * 100).toFixed(1)}% cited ${funnel.window.cited}/${funnel.window.injected}`;
|
|
2346
|
+
if (funnel.delta_pt === null) {
|
|
2347
|
+
trendLine += ' (no prior-window data)';
|
|
2348
|
+
} else {
|
|
2349
|
+
const arrow = funnel.delta_pt > 0 ? '↑' : funnel.delta_pt < 0 ? '↓' : '→';
|
|
2350
|
+
const sign = funnel.delta_pt > 0 ? '+' : '';
|
|
2351
|
+
trendLine += ` (prior ${days}d ${(funnel.prior.rate * 100).toFixed(1)}%) ${arrow} ${sign}${funnel.delta_pt}pt`;
|
|
2352
|
+
}
|
|
2353
|
+
out(trendLine);
|
|
2354
|
+
out('');
|
|
2328
2355
|
out('Active decay queue (uncited_streak >= 2, next miss → demote):');
|
|
2329
2356
|
if (decayQueue.length === 0) out(' (none)');
|
|
2330
2357
|
for (const r of decayQueue) {
|
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.
|
|
@@ -71,7 +71,24 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
71
71
|
// legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
|
|
72
72
|
// v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
|
|
73
73
|
// MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
|
|
74
|
-
|
|
74
|
+
// v38 (R1): citation_log table — per-session invocation→cite funnel telemetry. One
|
|
75
|
+
// accumulating row per resolved session (injected_n / cited_n), written by
|
|
76
|
+
// recordCitationFunnel from applyCitationDecay's touched/promoted at Stop. Turns the
|
|
77
|
+
// per-obs cite counters (lifetime-cumulative) into a trendable per-session series so
|
|
78
|
+
// `citation-stats` can answer "is memory invocation effectiveness rising or falling".
|
|
79
|
+
// New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS on the
|
|
80
|
+
// forced migration pass; LATEST_MIGRATION_COLUMN unchanged (no new column) — same
|
|
81
|
+
// pattern as v35/v36.
|
|
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;
|
|
75
92
|
|
|
76
93
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
77
94
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
@@ -168,6 +185,20 @@ const CORE_SCHEMA = `
|
|
|
168
185
|
created_at_epoch INTEGER,
|
|
169
186
|
PRIMARY KEY (project, type, session_id)
|
|
170
187
|
);
|
|
188
|
+
|
|
189
|
+
CREATE TABLE IF NOT EXISTS citation_log (
|
|
190
|
+
project TEXT NOT NULL,
|
|
191
|
+
memory_session_id TEXT NOT NULL,
|
|
192
|
+
resolved_at INTEGER,
|
|
193
|
+
injected_n INTEGER NOT NULL DEFAULT 0,
|
|
194
|
+
cited_n INTEGER NOT NULL DEFAULT 0,
|
|
195
|
+
PRIMARY KEY (project, memory_session_id)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
CREATE TABLE IF NOT EXISTS migration_cleanups (
|
|
199
|
+
name TEXT PRIMARY KEY,
|
|
200
|
+
done_at_epoch INTEGER NOT NULL
|
|
201
|
+
);
|
|
171
202
|
`;
|
|
172
203
|
|
|
173
204
|
// Column migrations (idempotent — only swallow "duplicate column" errors)
|
|
@@ -539,18 +570,8 @@ export function initSchema(db) {
|
|
|
539
570
|
}
|
|
540
571
|
} catch { /* non-critical — migration can retry on next open */ }
|
|
541
572
|
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// fast-path FK fix (initSchema early returns now restore foreign_keys=ON),
|
|
545
|
-
// ensureDb() handles ran with FK OFF, so ON DELETE CASCADE never fired and
|
|
546
|
-
// junction rows leaked (live DB: 6440/9569 = 67% orphans). Idempotent (NOT IN
|
|
547
|
-
// is empty on a clean DB); runs once per version bump via the fast-path gate.
|
|
548
|
-
try {
|
|
549
|
-
db.prepare(`
|
|
550
|
-
DELETE FROM observation_files
|
|
551
|
-
WHERE obs_id NOT IN (SELECT id FROM observations)
|
|
552
|
-
`).run();
|
|
553
|
-
} 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.
|
|
554
575
|
|
|
555
576
|
// Observation vectors table for TF-IDF vector search
|
|
556
577
|
db.exec(`
|
|
@@ -565,16 +586,7 @@ export function initSchema(db) {
|
|
|
565
586
|
|
|
566
587
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_vectors_version ON observation_vectors(vocab_version)`);
|
|
567
588
|
|
|
568
|
-
//
|
|
569
|
-
// Live DBs accumulated 44% orphans even with ON DELETE CASCADE because
|
|
570
|
-
// early migrations ran with `foreign_keys=OFF` and deletes skipped cascade.
|
|
571
|
-
// Idempotent (NOT IN is empty on a clean DB), runs once per ensureDb().
|
|
572
|
-
try {
|
|
573
|
-
db.prepare(`
|
|
574
|
-
DELETE FROM observation_vectors
|
|
575
|
-
WHERE observation_id NOT IN (SELECT id FROM observations)
|
|
576
|
-
`).run();
|
|
577
|
-
} catch { /* non-critical — table-missing path handled by earlier CREATE */ }
|
|
589
|
+
// observation_vectors orphan cleanup moved to runDeferredCleanups() (audit P1-5).
|
|
578
590
|
|
|
579
591
|
// Persisted vocabulary for stable TF-IDF vector indexing
|
|
580
592
|
db.exec(`
|
|
@@ -588,46 +600,9 @@ export function initSchema(db) {
|
|
|
588
600
|
`);
|
|
589
601
|
db.exec('CREATE INDEX IF NOT EXISTS idx_vocab_state_version ON vocab_state(version)');
|
|
590
602
|
|
|
591
|
-
// Project
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
try {
|
|
595
|
-
const shortProjects = db.prepare(`
|
|
596
|
-
SELECT DISTINCT project FROM observations
|
|
597
|
-
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
598
|
-
UNION
|
|
599
|
-
SELECT DISTINCT project FROM sdk_sessions
|
|
600
|
-
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
601
|
-
`).all();
|
|
602
|
-
if (shortProjects.length > 0) {
|
|
603
|
-
const normalize = db.transaction(() => {
|
|
604
|
-
for (const { project: shortName } of shortProjects) {
|
|
605
|
-
// Strategy 1: exact suffix match (e.g., "mem" → "projects--mem")
|
|
606
|
-
let canonical = db.prepare(
|
|
607
|
-
`SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
608
|
-
).get(`%--${shortName}`);
|
|
609
|
-
// Strategy 2: substring match for aliases (e.g., "claude-mem-lite" → match project containing "mem")
|
|
610
|
-
// Extract the most distinctive token from the short name for fuzzy matching
|
|
611
|
-
if (!canonical) {
|
|
612
|
-
const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
|
|
613
|
-
for (const token of tokens) {
|
|
614
|
-
canonical = db.prepare(
|
|
615
|
-
`SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
|
|
616
|
-
GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`
|
|
617
|
-
).get(`%${token}%`);
|
|
618
|
-
if (canonical) break;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (canonical) {
|
|
622
|
-
for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
|
|
623
|
-
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
normalize();
|
|
629
|
-
}
|
|
630
|
-
} 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.
|
|
631
606
|
|
|
632
607
|
// ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
|
|
633
608
|
//
|
|
@@ -787,6 +762,96 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
|
|
|
787
762
|
};
|
|
788
763
|
}
|
|
789
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
|
+
|
|
790
855
|
/**
|
|
791
856
|
* Ensure DB directory, database file, and all tables exist.
|
|
792
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.
|