claude-mem-lite 3.62.0 → 3.63.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 +26 -0
- package/format-utils.mjs +21 -0
- package/hook.mjs +29 -5
- package/install.mjs +21 -0
- package/lib/db-backup.mjs +84 -1
- package/lib/export-columns.mjs +7 -1
- package/mem-cli.mjs +26 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +16 -3
- package/scripts/pre-tool-recall.js +26 -10
- package/scripts/prompt-search-utils.mjs +6 -2
- package/scripts/user-prompt-search.js +38 -11
- package/search-engine.mjs +36 -6
- package/search-scoring.mjs +7 -2
- package/server.mjs +32 -25
- package/source-files.mjs +30 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.63.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.63.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
|
@@ -529,6 +529,32 @@ Notes:
|
|
|
529
529
|
- Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
|
|
530
530
|
- If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
|
|
531
531
|
|
|
532
|
+
#### Trust model per install path
|
|
533
|
+
|
|
534
|
+
The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
|
|
535
|
+
|
|
536
|
+
| Path | Update mechanism | Ed25519 release-signature verification |
|
|
537
|
+
|------|------------------|----------------------------------------|
|
|
538
|
+
| npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
|
|
539
|
+
| `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
|
|
540
|
+
|
|
541
|
+
**Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
# 1. Find the local marketplace clone
|
|
545
|
+
ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
|
|
546
|
+
|
|
547
|
+
# 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
|
|
548
|
+
cd ~/.claude/plugins/marketplaces/sdsrss
|
|
549
|
+
git fetch --tags && git checkout v3.62.0
|
|
550
|
+
|
|
551
|
+
# 3. Reinstall from the pinned clone — inside Claude Code:
|
|
552
|
+
# /plugin install claude-mem-lite@sdsrss
|
|
553
|
+
# 4. To leave the pin later: git checkout main, then the normal update flow.
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
|
|
557
|
+
|
|
532
558
|
### doctor
|
|
533
559
|
|
|
534
560
|
Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
|
package/format-utils.mjs
CHANGED
|
@@ -123,6 +123,27 @@ export function neutralizeSkillDelimiters(s) {
|
|
|
123
123
|
return defangToFixpoint(s, SKILL_BLOCK_RE);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
|
|
127
|
+
// skill body it injects as PreToolUse additionalContext. The body comes from a
|
|
128
|
+
// third-party repo (tools/adopt import) — an untrusted boundary — so a literal
|
|
129
|
+
// `</skill-bridge>` inside it would close the wrapper early and spill the rest of
|
|
130
|
+
// the payload (e.g. a forged <system-reminder>) as undelimited context (audit
|
|
131
|
+
// 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
|
|
132
|
+
// isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
|
|
133
|
+
// call site to the untrusted body only.
|
|
134
|
+
const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
|
|
138
|
+
* to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
|
|
139
|
+
* classes above. Never apply to the wrapper itself.
|
|
140
|
+
* @param {string} s Input string (any type; coerced)
|
|
141
|
+
* @returns {string} Text with skill-bridge delimiters defanged
|
|
142
|
+
*/
|
|
143
|
+
export function neutralizeSkillBridgeDelimiters(s) {
|
|
144
|
+
return defangToFixpoint(s, SKILL_BRIDGE_RE);
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
/**
|
|
127
148
|
* Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
|
|
128
149
|
* The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
|
package/hook.mjs
CHANGED
|
@@ -494,8 +494,12 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
494
494
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
495
495
|
AND o.superseded_at IS NULL
|
|
496
496
|
AND ${notLowSignalTitleClause('o')}
|
|
497
|
+
-- MAX(0, …) clamps recency age to >= 0 (parity with search-engine FULL_SCORE):
|
|
498
|
+
-- a far-future created_at (reachable via restore/import-jsonl, which accept
|
|
499
|
+
-- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
|
|
500
|
+
-- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
|
|
497
501
|
ORDER BY ${OBS_BM25}
|
|
498
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
|
|
502
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / 1209600000.0))
|
|
499
503
|
LIMIT 3
|
|
500
504
|
`).all(ftsQuery, project, nowR);
|
|
501
505
|
|
|
@@ -1017,6 +1021,15 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1017
1021
|
|
|
1018
1022
|
// Auto-dedup (exact): merge identical-title observations within 1h.
|
|
1019
1023
|
// Catches rapid duplicate writes (same hook firing twice, race conditions).
|
|
1024
|
+
// BOTH join sides must be live (audit 2026-08-14 H-1): without the
|
|
1025
|
+
// superseded_at filters (which the fuzzy channel below always had), a row the
|
|
1026
|
+
// fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
|
|
1027
|
+
// the LIVE keeper `b` — both copies gone from every read path. Worse, a user
|
|
1028
|
+
// correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
|
|
1029
|
+
// the same title as A, so the pair (A, B) tombstoned the correction B itself
|
|
1030
|
+
// and the string 'auto-dedup' write clobbered numeric supersession chains that
|
|
1031
|
+
// citation-tracker decay hand-off and timeline re-anchoring both follow. The
|
|
1032
|
+
// UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
|
|
1020
1033
|
const dupPairs = db.prepare(`
|
|
1021
1034
|
SELECT a.id as keep_id, b.id as remove_id
|
|
1022
1035
|
FROM observations a
|
|
@@ -1025,12 +1038,14 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1025
1038
|
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
1026
1039
|
AND COALESCE(a.compressed_into, 0) = 0
|
|
1027
1040
|
AND COALESCE(b.compressed_into, 0) = 0
|
|
1041
|
+
AND a.superseded_at IS NULL
|
|
1042
|
+
AND b.superseded_at IS NULL
|
|
1028
1043
|
LIMIT 20
|
|
1029
1044
|
`).all();
|
|
1030
1045
|
if (dupPairs.length > 0) {
|
|
1031
1046
|
const removeIds = dupPairs.map(p => p.remove_id);
|
|
1032
1047
|
const ph = removeIds.map(() => '?').join(',');
|
|
1033
|
-
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
|
|
1048
|
+
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
|
|
1034
1049
|
debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
|
|
1035
1050
|
}
|
|
1036
1051
|
|
|
@@ -1648,9 +1663,13 @@ async function handleUserPrompt() {
|
|
|
1648
1663
|
try {
|
|
1649
1664
|
const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
|
|
1650
1665
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
1651
|
-
const { ids, ts } = JSON.parse(raw);
|
|
1652
|
-
// Only use if written within last 10 seconds (same prompt cycle)
|
|
1653
|
-
|
|
1666
|
+
const { ids, ts, session } = JSON.parse(raw);
|
|
1667
|
+
// Only use if written within last 10 seconds (same prompt cycle) AND by this
|
|
1668
|
+
// CC session — the file is project-keyed, so a concurrent session's write
|
|
1669
|
+
// would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
|
|
1670
|
+
// Legacy payloads without `session` keep the old time-window-only behavior.
|
|
1671
|
+
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
|
|
1672
|
+
&& !(session && ccSessionId && session !== ccSessionId)) {
|
|
1654
1673
|
for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
|
|
1655
1674
|
}
|
|
1656
1675
|
} catch { /* file may not exist — that's fine */ }
|
|
@@ -1871,6 +1890,11 @@ try {
|
|
|
1871
1890
|
// lib/native-binding-hint.mjs.
|
|
1872
1891
|
const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
|
|
1873
1892
|
if (line) console.error(line);
|
|
1893
|
+
// stderr alone is invisible to `stats` self-observation — only the native-binding
|
|
1894
|
+
// family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
|
|
1895
|
+
// could kill every dispatch-routed surface while the hook-errors log read zero
|
|
1896
|
+
// (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
|
|
1897
|
+
recordHookError(`hook:${event}`, err, RUNTIME_DIR);
|
|
1874
1898
|
}
|
|
1875
1899
|
|
|
1876
1900
|
process.exit(0);
|
package/install.mjs
CHANGED
|
@@ -1561,6 +1561,27 @@ async function doctor() {
|
|
|
1561
1561
|
ok(`Native DB binding: loadable on Node ${process.version}`);
|
|
1562
1562
|
}
|
|
1563
1563
|
|
|
1564
|
+
// Disk footprint (audit 2026-08-14 M-9): a "lite" data dir had grown to 653MB
|
|
1565
|
+
// against a 59MB DB — 360MB of it orphaned per-tag .bak snapshots — with no
|
|
1566
|
+
// check anywhere. Cheap probes only (DB file + .bak aggregate, no tree walk).
|
|
1567
|
+
// The budget itself is enforced by lib/db-backup on every new snapshot; this
|
|
1568
|
+
// check surfaces stores that predate the budget or exceed it between snapshots.
|
|
1569
|
+
try {
|
|
1570
|
+
const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
|
|
1571
|
+
const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
1572
|
+
const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
|
|
1573
|
+
const snaps = listSnapshots(dbFile);
|
|
1574
|
+
const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
|
|
1575
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
1576
|
+
// Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
|
|
1577
|
+
// warning below it promised an eviction enforceBackupBudget would never do.
|
|
1578
|
+
if (backupBytes > backupBudgetBytes()) {
|
|
1579
|
+
dwarn(`Disk footprint: ${snaps.length} backup snapshot(s) hold ${mb(backupBytes)}MB, over the ${mb(backupBudgetBytes())}MB budget (CLAUDE_MEM_BACKUP_BUDGET_MB) — the next maintain/save snapshot evicts oldest snapshots past the 7d undo grace`);
|
|
1580
|
+
} else {
|
|
1581
|
+
ok(`Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`);
|
|
1582
|
+
}
|
|
1583
|
+
} catch { /* footprint check is informational — never block doctor */ }
|
|
1584
|
+
|
|
1564
1585
|
// Plugin/hook lifecycle state
|
|
1565
1586
|
const settings = readSettings();
|
|
1566
1587
|
const hasHooks = hasMemHooksConfigured(settings);
|
package/lib/db-backup.mjs
CHANGED
|
@@ -7,10 +7,92 @@
|
|
|
7
7
|
//
|
|
8
8
|
// MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
|
|
9
9
|
// why the maintenance entry points snapshot before opening their db.transaction().
|
|
10
|
-
import { readdirSync, unlinkSync } from 'fs';
|
|
10
|
+
import { readdirSync, unlinkSync, statSync } from 'fs';
|
|
11
11
|
import { dirname, basename, join } from 'path';
|
|
12
12
|
import { debugLog } from '../utils.mjs';
|
|
13
13
|
|
|
14
|
+
// M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
|
|
15
|
+
// each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
|
|
16
|
+
// siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
|
|
17
|
+
// 360MB against a 59MB live DB. A TOTAL byte budget across ALL tags bounds the
|
|
18
|
+
// footprint of a system whose name promises "lite". Oldest-first eviction, newest
|
|
19
|
+
// snapshot always survives (the most recent safety net must outlive any budget).
|
|
20
|
+
const DEFAULT_BACKUP_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Effective budget (env-tunable). Exported so stats/doctor derive their
|
|
23
|
+
* footprint-warning thresholds from the SAME number eviction acts on —
|
|
24
|
+
* pre-release review 2026-08-16: a hardcoded `3× DB` hint fired ~2.7× below
|
|
25
|
+
* the real budget and promised an eviction that would never happen. */
|
|
26
|
+
export function backupBudgetBytes() {
|
|
27
|
+
const mb = Number(process.env.CLAUDE_MEM_BACKUP_BUDGET_MB);
|
|
28
|
+
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : DEFAULT_BACKUP_BUDGET_BYTES;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Eviction grace: snapshots younger than this are never budget-evicted. This is
|
|
32
|
+
// what protects a fresh `pre-delete` undo pre-image (deleteObservations reports
|
|
33
|
+
// its path as the recovery route) from being unlinked by same-day maintain churn
|
|
34
|
+
// (pre-release review 2026-08-16). Deliberately age-based, NOT newest-per-tag:
|
|
35
|
+
// a per-tag exemption would make every one-shot tag's only snapshot immortal —
|
|
36
|
+
// exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
|
|
37
|
+
// convention (7d) used by stale-observation retention.
|
|
38
|
+
export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
|
|
39
|
+
|
|
40
|
+
// Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
|
|
41
|
+
// snapshotDb writes). Budget eviction deletes ONLY names matching this — a
|
|
42
|
+
// user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
|
|
43
|
+
const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
|
|
44
|
+
|
|
45
|
+
/** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
|
|
46
|
+
export function listSnapshots(dbPath) {
|
|
47
|
+
try {
|
|
48
|
+
const dir = dirname(dbPath);
|
|
49
|
+
const prefix = `${basename(dbPath)}.`;
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const n of readdirSync(dir)) {
|
|
52
|
+
if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
|
|
53
|
+
const full = join(dir, n);
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(full);
|
|
56
|
+
out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
|
|
57
|
+
} catch { /* raced away */ }
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Evict oldest snapshots (across ALL tags) until the total is within the byte
|
|
65
|
+
* budget. Never evicted: the single newest snapshot (even if it alone exceeds
|
|
66
|
+
* the budget), anything younger than BACKUP_EVICTION_GRACE_MS, and any file not
|
|
67
|
+
* matching the canonical snapshot name shape. The total counts every .bak
|
|
68
|
+
* (exempt files included), so a burst of young snapshots can hold the dir above
|
|
69
|
+
* budget for up to the grace window — bounded by write rate, and the price of
|
|
70
|
+
* never deleting an undo pre-image someone may still want.
|
|
71
|
+
* Returns the number of files removed. Best-effort, never throws.
|
|
72
|
+
*/
|
|
73
|
+
export function enforceBackupBudget(dbPath, { budgetBytes = backupBudgetBytes(), nowMs = Date.now() } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const snaps = listSnapshots(dbPath).sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
|
|
76
|
+
let total = snaps.reduce((s, x) => s + x.size, 0);
|
|
77
|
+
let removed = 0;
|
|
78
|
+
const graceCutoff = nowMs - BACKUP_EVICTION_GRACE_MS;
|
|
79
|
+
// Walk oldest-first; stop before touching the newest (index 0).
|
|
80
|
+
for (let i = snaps.length - 1; i >= 1 && total > budgetBytes; i--) {
|
|
81
|
+
if (!SNAPSHOT_STAMP_RE.test(snaps[i].path)) continue; // not ours — never delete
|
|
82
|
+
if (snaps[i].mtimeMs > graceCutoff) continue; // inside undo grace
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(snaps[i].path);
|
|
85
|
+
total -= snaps[i].size;
|
|
86
|
+
removed++;
|
|
87
|
+
} catch { /* per-entry best-effort */ }
|
|
88
|
+
}
|
|
89
|
+
if (removed > 0) {
|
|
90
|
+
try { debugLog('DEBUG', 'db-backup', `budget eviction removed ${removed} snapshot(s)`); } catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
return removed;
|
|
93
|
+
} catch { return 0; }
|
|
94
|
+
}
|
|
95
|
+
|
|
14
96
|
// Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
|
|
15
97
|
// still get unique filenames (VACUUM INTO fails if the target already exists).
|
|
16
98
|
let _seq = 0;
|
|
@@ -31,6 +113,7 @@ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
|
|
|
31
113
|
// defensively since VACUUM INTO takes a string literal, not a bound param.
|
|
32
114
|
db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
|
|
33
115
|
pruneSnapshots(dbPath, tag, retain);
|
|
116
|
+
enforceBackupBudget(dbPath);
|
|
34
117
|
return out;
|
|
35
118
|
} catch (e) {
|
|
36
119
|
try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
|
package/lib/export-columns.mjs
CHANGED
|
@@ -18,10 +18,16 @@
|
|
|
18
18
|
// exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
|
|
19
19
|
// and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
|
|
20
20
|
// it would freeze a stale value into backups).
|
|
21
|
+
// `compressed_into` (audit 2026-08-14 M-8): only ever non-null in an export taken
|
|
22
|
+
// with --include-compressed / include_compressed — the default WHERE excludes those
|
|
23
|
+
// rows. Without the column, a compressed member round-tripped through restore as a
|
|
24
|
+
// LIVE row and turned up in search results next to the weekly-summary keeper that
|
|
25
|
+
// already absorbed it. Restore does not remap it (the keeper's id is meaningless in
|
|
26
|
+
// the target store); it REJECTS marked rows instead — see cmdRestore.
|
|
21
27
|
export const EXPORT_COLUMNS = [
|
|
22
28
|
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
23
29
|
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
24
|
-
'scope',
|
|
30
|
+
'scope', 'compressed_into',
|
|
25
31
|
'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
|
|
26
32
|
'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
|
|
27
33
|
];
|
package/mem-cli.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
hardDeleteCandidateCount,
|
|
23
23
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
24
24
|
} from './lib/maintain-core.mjs';
|
|
25
|
-
import { snapshotDb } from './lib/db-backup.mjs';
|
|
25
|
+
import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
|
|
26
26
|
import { deleteObservations } from './lib/delete-core.mjs';
|
|
27
27
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
28
28
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
@@ -36,7 +36,7 @@ import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
|
|
|
36
36
|
import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
|
|
37
37
|
import { join, sep, dirname } from 'path';
|
|
38
38
|
import { spawnSync } from 'child_process';
|
|
39
|
-
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
39
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
40
40
|
|
|
41
41
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
42
42
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
@@ -1200,6 +1200,15 @@ async function cmdStats(db, args) {
|
|
|
1200
1200
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1201
1201
|
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
|
|
1202
1202
|
|
|
1203
|
+
// M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
|
|
1204
|
+
// pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
|
|
1205
|
+
// only (DB file + .bak aggregate), no recursive tree walk.
|
|
1206
|
+
let dbBytes = 0;
|
|
1207
|
+
try { dbBytes = statSync(join(DB_DIR, 'claude-mem-lite.db')).size; } catch { /* fresh */ }
|
|
1208
|
+
const snaps = listSnapshots(join(DB_DIR, 'claude-mem-lite.db'));
|
|
1209
|
+
const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
|
|
1210
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
1211
|
+
|
|
1203
1212
|
if (jsonOutput) {
|
|
1204
1213
|
out(JSON.stringify({
|
|
1205
1214
|
project,
|
|
@@ -1226,6 +1235,9 @@ async function cmdStats(db, args) {
|
|
|
1226
1235
|
compressed: compressedCount.c,
|
|
1227
1236
|
superseded_only: supersededOnlyCount.c,
|
|
1228
1237
|
hook_errors_24h: hookErrors24h,
|
|
1238
|
+
db_bytes: dbBytes,
|
|
1239
|
+
backup_count: snaps.length,
|
|
1240
|
+
backup_bytes: backupBytes,
|
|
1229
1241
|
},
|
|
1230
1242
|
tier_distribution: {
|
|
1231
1243
|
working: tierMap.working ?? 0,
|
|
@@ -1265,6 +1277,9 @@ async function cmdStats(db, args) {
|
|
|
1265
1277
|
out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
|
|
1266
1278
|
out(` Compressed: ${compressedCount.c}`);
|
|
1267
1279
|
out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
|
|
1280
|
+
// Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
|
|
1281
|
+
// hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
|
|
1282
|
+
out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
|
|
1268
1283
|
// Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
|
|
1269
1284
|
// pre-tool-recall.js via lib/metrics.mjs; CLAUDE_MEM_METRICS=1 to enable).
|
|
1270
1285
|
const featAgg = aggregateMetrics(DB_DIR, 7);
|
|
@@ -1799,9 +1814,15 @@ function cmdRestore(db, argv) {
|
|
|
1799
1814
|
decay_seen_count = ?, last_accessed_at = ?
|
|
1800
1815
|
WHERE id = ?`);
|
|
1801
1816
|
|
|
1802
|
-
let restored = 0, skipped = 0, malformed = 0;
|
|
1817
|
+
let restored = 0, skipped = 0, malformed = 0, tombstoned = 0;
|
|
1803
1818
|
for (const r of rows) {
|
|
1804
1819
|
if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
|
|
1820
|
+
// M-8 (audit 2026-08-14): a row exported with --include-compressed carries its
|
|
1821
|
+
// compressed_into tombstone. Restoring it as a live row resurrects a member its
|
|
1822
|
+
// weekly-summary keeper already absorbed (duplicate search hits, and the marker's
|
|
1823
|
+
// target id is meaningless in this store). Reject rather than remap; the content
|
|
1824
|
+
// lives on in the keeper.
|
|
1825
|
+
if (r.compressed_into) { tombstoned++; continue; }
|
|
1805
1826
|
const project = projOverride || r.project || inferProject();
|
|
1806
1827
|
const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
|
|
1807
1828
|
// Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
|
|
@@ -1868,7 +1889,8 @@ function cmdRestore(db, argv) {
|
|
|
1868
1889
|
// ones that parsed.
|
|
1869
1890
|
const totalMalformed = malformed + parseFailures;
|
|
1870
1891
|
const totalLines = rows.length + parseFailures;
|
|
1871
|
-
|
|
1892
|
+
const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (already absorbed by their summary keeper)` : '';
|
|
1893
|
+
out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
|
|
1872
1894
|
// Name the lossiness where the user meets it. Export omits related_ids and drops
|
|
1873
1895
|
// superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
|
|
1874
1896
|
// cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.63.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync } from 'fs';
|
|
17
17
|
import { basename, join } from 'path';
|
|
18
18
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
19
|
+
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
19
20
|
|
|
20
21
|
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
21
22
|
|
|
@@ -45,7 +46,13 @@ async function main() {
|
|
|
45
46
|
const cdPath = cooldownPathFor(sessionId);
|
|
46
47
|
if (!existsSync(cdPath)) return;
|
|
47
48
|
let entry;
|
|
48
|
-
try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch {
|
|
49
|
+
try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch (e) {
|
|
50
|
+
// A corrupt cooldown file (torn concurrent write — audit 2026-08-14 M-6) turns
|
|
51
|
+
// the bind-salience check into a zero-trace no-op; record it so `stats` can see
|
|
52
|
+
// the surface die instead of silently reading zero errors (M-5).
|
|
53
|
+
recordHookError('post-recall:cooldown-parse', e, RUNTIME_DIR, { file: basename(cdPath) });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
49
56
|
const idents = entry && entry.lessonIdents;
|
|
50
57
|
if (!idents || typeof idents !== 'object') return;
|
|
51
58
|
|
|
@@ -77,4 +84,7 @@ async function main() {
|
|
|
77
84
|
// stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
|
|
78
85
|
// surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
|
|
79
86
|
process.stdout.on('error', () => {});
|
|
80
|
-
main()
|
|
87
|
+
// Record what slips past main()'s early returns before the mandatory swallow —
|
|
88
|
+
// this script had zero telemetry (audit 2026-08-14 M-5). Recorder never throws;
|
|
89
|
+
// the outer catch keeps the exit code 0 regardless.
|
|
90
|
+
main().catch((e) => { try { recordHookError('post-recall:main', e, RUNTIME_DIR); } catch { /* never */ } });
|
|
@@ -15,6 +15,23 @@
|
|
|
15
15
|
const ENABLED = process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on'
|
|
16
16
|
|| process.env.CLAUDE_MEM_SUBAGENT_INJECT === '1';
|
|
17
17
|
|
|
18
|
+
// Telemetry via DYNAMIC import so the default-off fast path stays import-free
|
|
19
|
+
// (the file's stated contract). Only ever reached on the enabled path's failure
|
|
20
|
+
// branches — this script had zero recordHookError coverage, so a dead DB or
|
|
21
|
+
// schema drift silently disabled subagent injection with no trace (audit
|
|
22
|
+
// 2026-08-14 M-5). Swallows everything: telemetry must never break a dispatch.
|
|
23
|
+
async function recordFailure(scope, err, ctx) {
|
|
24
|
+
try {
|
|
25
|
+
const [{ recordHookError }, { resolveDataDir }, { join }] = await Promise.all([
|
|
26
|
+
import('../lib/hook-telemetry.mjs'),
|
|
27
|
+
import('../lib/resolve-data-dir.mjs'),
|
|
28
|
+
import('path'),
|
|
29
|
+
]);
|
|
30
|
+
const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
31
|
+
recordHookError(scope, err, process.env.CLAUDE_MEM_RUNTIME_DIR || join(dataDir, 'runtime'), ctx);
|
|
32
|
+
} catch { /* never */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
function readStdin() {
|
|
19
36
|
return new Promise((resolve) => {
|
|
20
37
|
let data = '';
|
|
@@ -51,7 +68,7 @@ async function main() {
|
|
|
51
68
|
const { buildSubagentInjection } = await import('../hook-memory.mjs');
|
|
52
69
|
|
|
53
70
|
let db;
|
|
54
|
-
try { db = ensureDb(); } catch { return; }
|
|
71
|
+
try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
|
|
55
72
|
try {
|
|
56
73
|
const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
|
|
57
74
|
if (updatedInput) {
|
|
@@ -59,7 +76,7 @@ async function main() {
|
|
|
59
76
|
hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput },
|
|
60
77
|
}));
|
|
61
78
|
}
|
|
62
|
-
} catch { /* never break a dispatch */ } finally {
|
|
79
|
+
} catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
|
|
63
80
|
try { db.close(); } catch { /* */ }
|
|
64
81
|
}
|
|
65
82
|
}
|
|
@@ -69,5 +86,6 @@ async function main() {
|
|
|
69
86
|
// which FLUSHES stdout. The emitted updatedInput echoes the whole prompt back, so the
|
|
70
87
|
// payload can exceed the ~64KB pipe buffer; a forced process.exit() would drop that
|
|
71
88
|
// pending async write and truncate the JSON (the gotcha every sibling hook avoids).
|
|
72
|
-
// Swallow any rejection so the exit code can never go non-zero
|
|
73
|
-
|
|
89
|
+
// Swallow any rejection so the exit code can never go non-zero — but record it
|
|
90
|
+
// first (recordFailure itself swallows everything, including its own failures).
|
|
91
|
+
main().catch((e) => recordFailure('agent-inject:main', e));
|
|
@@ -8,6 +8,9 @@ import { join, resolve, sep } from 'path';
|
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
10
10
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
11
|
+
// format-utils.mjs is import-free — pulling three defang helpers keeps this script
|
|
12
|
+
// inside its "lightweight standalone" budget (no heavy transitive deps).
|
|
13
|
+
import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
|
|
11
14
|
|
|
12
15
|
// CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
|
|
13
16
|
const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
@@ -88,12 +91,22 @@ try {
|
|
|
88
91
|
// console.log() form would render on stock CC but no-op on those variants.
|
|
89
92
|
// Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
|
|
90
93
|
const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
|
|
94
|
+
// Defang the untrusted skill body + name before wrapping (audit 2026-08-14 M-4):
|
|
95
|
+
// registry rows come from third-party repos, and this was the one AUTO injection
|
|
96
|
+
// surface of that data with zero neutralization — a body carrying a literal
|
|
97
|
+
// `</skill-bridge>` + forged <system-reminder> (or a `<skill-loaded>` execute
|
|
98
|
+
// block) escaped the wrapper verbatim. Name additionally drops quote/bracket
|
|
99
|
+
// chars: it lands in an ATTRIBUTE position, where `"` breaks out of the wrapper
|
|
100
|
+
// tag itself. Applied to the truncated summary too (a cut can't be trusted to
|
|
101
|
+
// land mid-tag).
|
|
102
|
+
const defang = (s) => neutralizeSkillBridgeDelimiters(neutralizeSkillDelimiters(neutralizeContextDelimiters(s)));
|
|
103
|
+
const safeName = String(row.name).replace(/["'<>]/g, '');
|
|
91
104
|
let additionalContext;
|
|
92
105
|
if (content.length > 16000) {
|
|
93
|
-
const summary = content.slice(0, 800);
|
|
94
|
-
additionalContext = `<skill-bridge name="${
|
|
106
|
+
const summary = defang(content.slice(0, 800));
|
|
107
|
+
additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
|
|
95
108
|
} else {
|
|
96
|
-
additionalContext = `<skill-bridge name="${
|
|
109
|
+
additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
|
|
97
110
|
}
|
|
98
111
|
process.stdout.write(JSON.stringify({
|
|
99
112
|
suppressOutput: true,
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// and the pure-data lib/low-signal-patterns.mjs (zero runtime deps, ~1ms overhead).
|
|
5
5
|
// Safety: readonly DB, exit 0 always, 3s timeout
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync,
|
|
7
|
+
import { existsSync, readFileSync, mkdirSync } from 'fs';
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
9
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
10
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
10
11
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
11
12
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
12
13
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
@@ -159,17 +160,24 @@ function crossHookInjectedFile(project) {
|
|
|
159
160
|
return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
// M-6 (audit 2026-08-14): the marker file is keyed by PROJECT, so two concurrent
|
|
164
|
+
// CC sessions in the same project shared one suppression state — session A's
|
|
165
|
+
// injections silently deduped session B's, and B inherited A's count cap. Both
|
|
166
|
+
// read and merge now carry the CC session id: a payload written by a DIFFERENT
|
|
167
|
+
// session is ignored (read) / replaced (merge), mirroring the v3.35.2 episode
|
|
168
|
+
// session-key fix. Legacy payloads without `session` keep the old behavior.
|
|
169
|
+
function readCrossHookInjected(project, sessionId) {
|
|
163
170
|
try {
|
|
164
171
|
const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
|
|
165
|
-
const { ids, ts } = JSON.parse(raw);
|
|
172
|
+
const { ids, ts, session } = JSON.parse(raw);
|
|
173
|
+
if (session && sessionId && session !== sessionId) return new Set();
|
|
166
174
|
if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
|
|
167
175
|
if (!Array.isArray(ids)) return new Set();
|
|
168
176
|
return new Set(ids.map(String));
|
|
169
177
|
} catch { return new Set(); }
|
|
170
178
|
}
|
|
171
179
|
|
|
172
|
-
function mergeCrossHookInjected(project, newIds) {
|
|
180
|
+
function mergeCrossHookInjected(project, newIds, sessionId) {
|
|
173
181
|
if (!newIds || newIds.length === 0) return;
|
|
174
182
|
try {
|
|
175
183
|
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
@@ -178,8 +186,10 @@ function mergeCrossHookInjected(project, newIds) {
|
|
|
178
186
|
try {
|
|
179
187
|
const raw = readFileSync(file, 'utf8');
|
|
180
188
|
const parsed = JSON.parse(raw);
|
|
181
|
-
// Within the staleness window
|
|
182
|
-
|
|
189
|
+
// Within the staleness window AND same session (or legacy): union.
|
|
190
|
+
// Outside / other session: replace.
|
|
191
|
+
if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS
|
|
192
|
+
&& !(parsed.session && sessionId && parsed.session !== sessionId)) {
|
|
183
193
|
prev = parsed;
|
|
184
194
|
}
|
|
185
195
|
} catch { /* fresh file */ }
|
|
@@ -187,10 +197,13 @@ function mergeCrossHookInjected(project, newIds) {
|
|
|
187
197
|
...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
|
|
188
198
|
...newIds.map(String),
|
|
189
199
|
])];
|
|
190
|
-
|
|
200
|
+
// Atomic (tmp+rename, M-6): a plain write torn by a concurrent hook left the
|
|
201
|
+
// shared marker as invalid JSON, silently disabling cross-hook dedup.
|
|
202
|
+
atomicWriteFileSync(file, JSON.stringify({
|
|
191
203
|
ids,
|
|
192
204
|
ts: Date.now(),
|
|
193
205
|
count: (prev.count || 0) + 1,
|
|
206
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
194
207
|
}));
|
|
195
208
|
} catch { /* silent — dedup is best-effort */ }
|
|
196
209
|
}
|
|
@@ -209,7 +222,10 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
|
|
|
209
222
|
if (ts && now - ts < STALE_MS) cleaned[k] = v;
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
|
-
|
|
225
|
+
// Atomic (tmp+rename, M-6): the cooldown carries lessonIdents that the
|
|
226
|
+
// PostToolUse bind-salience check reads back — a torn write turned that
|
|
227
|
+
// check into a zero-trace no-op.
|
|
228
|
+
atomicWriteFileSync(cooldownPath, JSON.stringify(cleaned));
|
|
213
229
|
} catch { /* silent */ }
|
|
214
230
|
}
|
|
215
231
|
|
|
@@ -474,7 +490,7 @@ try {
|
|
|
474
490
|
// P1 (D#78): tag each row's source table — events share the numeric id
|
|
475
491
|
// space with observations, and the Stop-side edge attribution must never
|
|
476
492
|
// feed an event id into observation_files updates.
|
|
477
|
-
const crossHookSeen = readCrossHookInjected(project);
|
|
493
|
+
const crossHookSeen = readCrossHookInjected(project, sessionId);
|
|
478
494
|
const sourcedRows = [
|
|
479
495
|
...rows.map(r => ({ ...r, src: 'obs' })),
|
|
480
496
|
...eventRows.map(r => ({ ...r, src: 'evt' })),
|
|
@@ -638,7 +654,7 @@ try {
|
|
|
638
654
|
// file so the next UPS prompt skips them too. Always write, even on
|
|
639
655
|
// empty allRows, so the file's ts stays fresh for the no-op case where
|
|
640
656
|
// we'd otherwise drift outside the dedup window.
|
|
641
|
-
mergeCrossHookInjected(project, allRows.map(r => r.id));
|
|
657
|
+
mergeCrossHookInjected(project, allRows.map(r => r.id), sessionId);
|
|
642
658
|
} catch (e) {
|
|
643
659
|
// Silent failure — never block editing, but record for self-observation.
|
|
644
660
|
recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
|
|
@@ -154,13 +154,17 @@ export const DEDUP_STALE_MS = 300_000; // 5 minutes
|
|
|
154
154
|
* Check if injection should be skipped based on deduplication state.
|
|
155
155
|
* @param {number[]} newIds - candidate observation IDs
|
|
156
156
|
* @param {string} injectedFile - path to the dedup state file
|
|
157
|
+
* @param {string} [sessionId] - CC session id; a payload written by a DIFFERENT
|
|
158
|
+
* session never suppresses (M-6, audit 2026-08-14: the file is keyed by project,
|
|
159
|
+
* so session A's injections + count cap silently carried into session B)
|
|
157
160
|
* @returns {boolean} true if injection should be skipped
|
|
158
161
|
*/
|
|
159
|
-
export function shouldSkipByDedup(newIds, injectedFile) {
|
|
162
|
+
export function shouldSkipByDedup(newIds, injectedFile, sessionId) {
|
|
160
163
|
if (!newIds || newIds.length === 0) return true;
|
|
161
164
|
try {
|
|
162
165
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
163
|
-
const { ids: prevIds, ts, count = 0 } = JSON.parse(raw);
|
|
166
|
+
const { ids: prevIds, ts, count = 0, session } = JSON.parse(raw);
|
|
167
|
+
if (session && sessionId && session !== sessionId) return false;
|
|
164
168
|
if (count >= MAX_SESSION_INJECTIONS) return true;
|
|
165
169
|
if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
|
|
166
170
|
if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
|
|
@@ -14,9 +14,14 @@ import Database from 'better-sqlite3';
|
|
|
14
14
|
import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
|
|
15
15
|
import { getDeferredByIds } from '../lib/deferred-work.mjs';
|
|
16
16
|
import { recommendSkill } from '../registry-recommend.mjs';
|
|
17
|
+
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
18
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
17
19
|
|
|
18
20
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
19
21
|
|
|
22
|
+
// Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
|
|
23
|
+
// <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
|
|
24
|
+
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
|
|
20
25
|
const INJECTED_IDS_FILE = join(DB_DIR, 'runtime', `.claude-mem-injected-${inferProject()}`);
|
|
21
26
|
// Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
|
|
22
27
|
// scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
|
|
@@ -344,7 +349,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
|
|
|
344
349
|
SELECT o.id, o.type, o.title, o.lesson_learned,
|
|
345
350
|
${OBS_BM25} as bm25_raw,
|
|
346
351
|
${OBS_BM25}
|
|
347
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
|
|
352
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
|
|
348
353
|
* ${TYPE_QUALITY_CASE}
|
|
349
354
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
350
355
|
* ${noisePenaltyClause('o')}
|
|
@@ -660,7 +665,7 @@ async function main() {
|
|
|
660
665
|
// Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
|
|
661
666
|
// convention) so obs ids can't collide in the shared injected-ids file.
|
|
662
667
|
const dedupIds = openRows.map(r => `D${r.id}`);
|
|
663
|
-
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE)) {
|
|
668
|
+
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE, hookData.session_id)) {
|
|
664
669
|
const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
|
|
665
670
|
for (const r of openRows) {
|
|
666
671
|
const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
|
|
@@ -680,15 +685,20 @@ async function main() {
|
|
|
680
685
|
let prevCount = 0;
|
|
681
686
|
try {
|
|
682
687
|
const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
|
|
683
|
-
|
|
688
|
+
// M-6: inherit only same-session (or legacy) state — another session's
|
|
689
|
+
// ids/count must not carry over. Atomic write below: a torn concurrent
|
|
690
|
+
// write left the shared marker as invalid JSON (dedup silently off).
|
|
691
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
692
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
684
693
|
prevIds = Array.isArray(prev.ids) ? prev.ids : [];
|
|
685
694
|
prevCount = prev.count || 0;
|
|
686
695
|
}
|
|
687
696
|
} catch {}
|
|
688
|
-
|
|
697
|
+
atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
|
|
689
698
|
ids: [...new Set([...prevIds.map(String), ...dedupIds])],
|
|
690
699
|
ts: Date.now(),
|
|
691
700
|
count: prevCount + 1,
|
|
701
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
692
702
|
}));
|
|
693
703
|
} catch {}
|
|
694
704
|
}
|
|
@@ -712,7 +722,13 @@ async function main() {
|
|
|
712
722
|
if (!db) {
|
|
713
723
|
try {
|
|
714
724
|
db = ensureDb();
|
|
715
|
-
} catch {
|
|
725
|
+
} catch (e) {
|
|
726
|
+
// A failed DB open silently kills EVERY prompt-time injection while `stats`
|
|
727
|
+
// reads zero errors (audit 2026-08-14 M-5) — record before the mandatory
|
|
728
|
+
// swallow. Exact blindness class of the 2026-08-13 pre-recall:db-open outage.
|
|
729
|
+
recordHookError('ups:db-open', e, RUNTIME_DIR);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
716
732
|
}
|
|
717
733
|
|
|
718
734
|
try {
|
|
@@ -854,7 +870,7 @@ async function main() {
|
|
|
854
870
|
const candidateIds = rows.length > 0
|
|
855
871
|
? rows.map(r => r.id)
|
|
856
872
|
: promptRows.map(r => `P${r.id}`);
|
|
857
|
-
const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
|
|
873
|
+
const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE, hookData.session_id);
|
|
858
874
|
|
|
859
875
|
const output = !dedupSkip
|
|
860
876
|
? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
|
|
@@ -866,12 +882,17 @@ async function main() {
|
|
|
866
882
|
let prevCount = 0;
|
|
867
883
|
try {
|
|
868
884
|
const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
|
|
869
|
-
|
|
885
|
+
// M-6: same-session (or legacy) count only; atomic write (torn-write guard).
|
|
886
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
887
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
888
|
+
prevCount = prev.count || 0;
|
|
889
|
+
}
|
|
870
890
|
} catch {}
|
|
871
|
-
|
|
891
|
+
atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
|
|
872
892
|
ids: candidateIds,
|
|
873
893
|
ts: Date.now(),
|
|
874
894
|
count: prevCount + 1,
|
|
895
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
875
896
|
}));
|
|
876
897
|
} catch {}
|
|
877
898
|
// v26 P0: bump injection_count for obs-based emits only (prompt-corpus
|
|
@@ -932,8 +953,11 @@ async function main() {
|
|
|
932
953
|
finally { rdb.close(); }
|
|
933
954
|
}
|
|
934
955
|
} catch { /* silent — never block on recommendation failure */ }
|
|
935
|
-
} catch {
|
|
936
|
-
// Hooks must never break Claude Code — swallow
|
|
956
|
+
} catch (e) {
|
|
957
|
+
// Hooks must never break Claude Code — swallow, but RECORD: this catch wraps
|
|
958
|
+
// every FTS query on the surface, so a schema/FTS drift here would zero out
|
|
959
|
+
// prompt-time injection with no trace anywhere (audit 2026-08-14 M-5).
|
|
960
|
+
recordHookError('ups:search', e, RUNTIME_DIR);
|
|
937
961
|
} finally {
|
|
938
962
|
try { db.close(); } catch {}
|
|
939
963
|
}
|
|
@@ -962,5 +986,8 @@ export function isDirectInvocation(metaUrl, argv1) {
|
|
|
962
986
|
return metaUrl.split('?')[0] === pathToFileURL(argv1).href;
|
|
963
987
|
}
|
|
964
988
|
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
965
|
-
main().
|
|
989
|
+
// Last-resort telemetry for anything that escapes main()'s own catches (e.g. a
|
|
990
|
+
// throw between the entry and the guarded body). Recorder never throws; the
|
|
991
|
+
// outer catch keeps the never-non-zero-exit invariant regardless.
|
|
992
|
+
main().catch((e) => { try { recordHookError('ups:main', e, RUNTIME_DIR); } catch { /* never */ } });
|
|
966
993
|
}
|
package/search-engine.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
DEFAULT_DECAY_HALF_LIFE_MS,
|
|
11
11
|
notLowSignalTitleClause, LOW_SIGNAL_TITLE,
|
|
12
12
|
relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
|
|
13
|
+
noisePenaltyClause,
|
|
13
14
|
} from './utils.mjs';
|
|
15
|
+
import { citeFactorClause } from './scoring-sql.mjs';
|
|
14
16
|
import { getVocabulary, computeVector, vectorSearch, rrfMerge, vectorsEnabled } from './tfidf.mjs';
|
|
15
17
|
import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
16
18
|
|
|
@@ -21,13 +23,26 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
|
21
23
|
// exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
|
|
22
24
|
// #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
|
|
23
25
|
// future row now reads as age 0 = max (finite) recency, not Infinity.
|
|
26
|
+
// M-3 (audit 2026-08-14): cite/noise behavior factors joined FULL_SCORE — they had
|
|
27
|
+
// shipped for months on every AUTO surface (UPS / pre-tool-recall / hook-memory)
|
|
28
|
+
// while the EXPLICIT surfaces (mem_search / CLI search) discarded the accumulated
|
|
29
|
+
// citation + noise signal (the mirror image of "guards wired on the auto face,
|
|
30
|
+
// missing on the explicit face"). Both factors are hard-bounded (cite ∈ [0.4, 3.0],
|
|
31
|
+
// noise ∈ {0.2, 0.5, 1.0}), so they reorder, never dominate. algo-F6 note: the
|
|
32
|
+
// access-count LN bonus below stays — noisePenalty uses access_count only inside a
|
|
33
|
+
// ratio GUARD (never as a bonus) and citeFactor reads cited/uncited columns, so no
|
|
34
|
+
// term is counted twice as a reward. Denoise A/B 2026-08-16: NEUTRAL (suite rows
|
|
35
|
+
// carry zero cite/noise state); the behavioral pin lives in
|
|
36
|
+
// tests/audit-fixes-20260816.test.mjs (M-3).
|
|
24
37
|
const FULL_SCORE = `${OBS_BM25}
|
|
25
38
|
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
26
39
|
* ${TYPE_QUALITY_CASE}
|
|
27
40
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
28
41
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
29
42
|
* (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
|
|
30
|
-
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
43
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
44
|
+
* ${noisePenaltyClause('o')}
|
|
45
|
+
* ${citeFactorClause('o')}`;
|
|
31
46
|
|
|
32
47
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
33
48
|
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
@@ -294,6 +309,10 @@ function expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise =
|
|
|
294
309
|
function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise = false) {
|
|
295
310
|
const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
|
|
296
311
|
if (primaryCount < 3) return;
|
|
312
|
+
// effectiveFtsQuery = the query that actually matched (OR-relaxed when the strict
|
|
313
|
+
// AND missed and the fallback rescued rows — M-2). The strict query here returned
|
|
314
|
+
// zero top docs in that case, silently disabling PRF where it helps most.
|
|
315
|
+
const seedQuery = ctx.effectiveFtsQuery || ftsQuery;
|
|
297
316
|
const topResults = db.prepare(`
|
|
298
317
|
SELECT o.title, o.narrative FROM observations_fts
|
|
299
318
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
@@ -302,7 +321,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
302
321
|
AND (? IS NULL OR o.project = ?)
|
|
303
322
|
ORDER BY ${OBS_BM25}
|
|
304
323
|
LIMIT 8
|
|
305
|
-
`).all(
|
|
324
|
+
`).all(seedQuery, args.project ?? null, args.project ?? null);
|
|
306
325
|
const prfTerms = extractPRFTerms(topResults, ftsQuery);
|
|
307
326
|
if (prfTerms.length === 0) return;
|
|
308
327
|
const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
@@ -428,17 +447,28 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
428
447
|
try {
|
|
429
448
|
const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
|
|
430
449
|
.all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
|
|
431
|
-
if (orRows.length > 0)
|
|
450
|
+
if (orRows.length > 0) {
|
|
451
|
+
ctx.orFallbackFired = true;
|
|
452
|
+
// M-2: PRF's top-doc probe re-queries FTS itself — with the strict-AND
|
|
453
|
+
// query it reads 0 docs in exactly the OR-rescue case, keeping PRF inert.
|
|
454
|
+
// Record the query that actually produced the evidence rows.
|
|
455
|
+
ctx.effectiveFtsQuery = orQuery;
|
|
456
|
+
}
|
|
432
457
|
for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
|
|
433
458
|
} catch (e) { debugCatch(e, 'searchObservationsHybrid-or-fallback'); }
|
|
434
459
|
}
|
|
435
460
|
}
|
|
436
461
|
|
|
437
|
-
// Two-phase query expansion (only when well below limit)
|
|
438
|
-
|
|
462
|
+
// Two-phase query expansion (only when well below limit). Gate on results.length,
|
|
463
|
+
// NOT rows.length (M-2, audit 2026-08-14): `rows` is the strict-AND set only, so
|
|
464
|
+
// when strict-AND missed and the OR fallback rescued a few rows — exactly the
|
|
465
|
+
// vocab-mismatch shape where expansion helps most — the old gate read rows.length
|
|
466
|
+
// === 0 and skipped concept/PRF expansion entirely. PRF likewise seeds from the
|
|
467
|
+
// rescued rows now (they are the only relevance evidence available).
|
|
468
|
+
if (results.length > 0 && results.length < Math.ceil(limit / 2)) {
|
|
439
469
|
const existingIds = new Set(results.map(r => r.id));
|
|
440
470
|
expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise);
|
|
441
|
-
expandObsByPRF(db, ctx, now,
|
|
471
|
+
expandObsByPRF(db, ctx, now, results.length, existingIds, results, includeNoise);
|
|
442
472
|
}
|
|
443
473
|
|
|
444
474
|
// Vector search + RRF hybrid merge
|
package/search-scoring.mjs
CHANGED
|
@@ -165,8 +165,13 @@ export function extractPRFTerms(results, ftsQuery, limit = 3) {
|
|
|
165
165
|
// "cach" returns zero rows, only "caching"/"cache" match). Emitting a bare stem would
|
|
166
166
|
// silently match nothing and kill expansion recall. Track each stem's surface forms with
|
|
167
167
|
// their occurrence counts and emit the most frequent (best-matchable) surface.
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
// Prototype-less: doc text tokenizes to arbitrary identifiers, and a stem that
|
|
169
|
+
// collides with an Object.prototype property ("constructor" — common in code
|
|
170
|
+
// narratives) made `stemSurfaces[stem] ||= new Map()` read the INHERITED function
|
|
171
|
+
// as truthy, skip the assignment, and crash on sm.get (surfaced 2026-08-16 when
|
|
172
|
+
// the M-2 gate fix first ran PRF over OR-rescued rows).
|
|
173
|
+
const stemDocCount = Object.create(null); // stem -> # of top docs it appears in (the >=2 bar)
|
|
174
|
+
const stemSurfaces = Object.create(null); // stem -> Map(surface -> total occurrences)
|
|
170
175
|
const docCount = Math.min(results.length, 8);
|
|
171
176
|
for (let i = 0; i < docCount; i++) {
|
|
172
177
|
const r = results[i];
|
package/server.mjs
CHANGED
|
@@ -1162,32 +1162,22 @@ server.registerTool(
|
|
|
1162
1162
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1163
1163
|
const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
|
|
1164
1164
|
|
|
1165
|
-
// T2-P0-A: purge_stale is the only
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
if (previewRow.candidates > 0) {
|
|
1177
|
-
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1178
|
-
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1179
|
-
}
|
|
1180
|
-
lines.push('');
|
|
1181
|
-
lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
|
|
1182
|
-
lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
|
|
1183
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1184
|
-
}
|
|
1165
|
+
// T2-P0-A: purge_stale is the only op gated on confirm=true — cleanupBroken also
|
|
1166
|
+
// hard-deletes (broken rows only) but always ran unconfirmed on both surfaces, and
|
|
1167
|
+
// the pre-transaction snapshot above covers it. An unconfirmed call gets a dry-run
|
|
1168
|
+
// preview of the purge INSTEAD OF the purge.
|
|
1169
|
+
// M-7 (audit 2026-08-14): preview-instead-of-early-return — the old
|
|
1170
|
+
// `return`-on-unconfirmed skipped EVERY requested op, while the CLI twin ran the
|
|
1171
|
+
// non-destructive ones (cleanup/decay/boost) and previewed only the purge. Same
|
|
1172
|
+
// op list, two different amounts of work done, both reporting success. Aligned
|
|
1173
|
+
// to the CLI semantics (the safer surface changed less: nothing destructive
|
|
1174
|
+
// runs unconfirmed on either surface now or before).
|
|
1175
|
+
const purgeConfirmed = args.confirm === true;
|
|
1185
1176
|
|
|
1186
1177
|
// MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
|
|
1187
1178
|
// only when rows will actually be removed, and OUTSIDE the transaction below
|
|
1188
|
-
// (VACUUM cannot run inside one).
|
|
1189
|
-
|
|
1190
|
-
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
|
|
1179
|
+
// (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
|
|
1180
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') && purgeConfirmed }) > 0) {
|
|
1191
1181
|
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1192
1182
|
}
|
|
1193
1183
|
|
|
@@ -1201,8 +1191,25 @@ server.registerTool(
|
|
|
1201
1191
|
if (ops.includes('purge_stale')) {
|
|
1202
1192
|
const retainDays = args.retain_days ?? 30;
|
|
1203
1193
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1204
|
-
|
|
1205
|
-
|
|
1194
|
+
if (!purgeConfirmed) {
|
|
1195
|
+
// Dry-run preview (parity with CLI `maintain` without --confirm): the other
|
|
1196
|
+
// requested non-destructive ops still run below.
|
|
1197
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1198
|
+
const lines = [
|
|
1199
|
+
'purge_stale preview (confirm=false):',
|
|
1200
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
|
|
1201
|
+
];
|
|
1202
|
+
if (previewRow.candidates > 0) {
|
|
1203
|
+
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1204
|
+
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1205
|
+
}
|
|
1206
|
+
lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
|
|
1207
|
+
lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
|
|
1208
|
+
results.push(lines.join('\n'));
|
|
1209
|
+
} else {
|
|
1210
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1211
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1212
|
+
}
|
|
1206
1213
|
}
|
|
1207
1214
|
|
|
1208
1215
|
if (ops.includes('cleanup')) {
|
package/source-files.mjs
CHANGED
|
@@ -264,9 +264,37 @@ const LAUNCHER_SCRIPT_FILES = [
|
|
|
264
264
|
'binding-probe-cli.mjs',
|
|
265
265
|
];
|
|
266
266
|
|
|
267
|
+
// Plugin/marketplace DECLARATION files (audit 2026-08-14 P-2). Not executable
|
|
268
|
+
// themselves, but they NAME what gets executed: hooks/hooks.json declares the
|
|
269
|
+
// command lines Claude Code runs on every hook fire, .mcp.json declares the MCP
|
|
270
|
+
// server launch command, plugin.json/marketplace.json steer the install source,
|
|
271
|
+
// commands/*.md are model-visible skill bodies, registry/preinstalled.json seeds
|
|
272
|
+
// the resource registry. All ship in the tarball; none were signed — the same
|
|
273
|
+
// shape as the two closed RCE gaps (hook scripts v3.40, launch.mjs v3.42): a
|
|
274
|
+
// release published without the signing key could swap hooks.json to point a
|
|
275
|
+
// hook event at an arbitrary command while every signed hash still matched.
|
|
276
|
+
// Additive: buildReleaseManifest skips entries absent at sign time, and
|
|
277
|
+
// verifyReleaseFiles needs no change to enforce whatever the manifest carries.
|
|
278
|
+
const PLUGIN_DECLARATION_FILES = [
|
|
279
|
+
'hooks/hooks.json',
|
|
280
|
+
'.mcp.json',
|
|
281
|
+
'.claude-plugin/plugin.json',
|
|
282
|
+
'.claude-plugin/marketplace.json',
|
|
283
|
+
'registry/preinstalled.json',
|
|
284
|
+
'commands/mem.md',
|
|
285
|
+
'commands/memory.md',
|
|
286
|
+
'commands/update.md',
|
|
287
|
+
'commands/tools.md',
|
|
288
|
+
'commands/adopt.md',
|
|
289
|
+
'commands/unadopt.md',
|
|
290
|
+
'commands/lesson.md',
|
|
291
|
+
'commands/bug.md',
|
|
292
|
+
];
|
|
293
|
+
|
|
267
294
|
// The complete set of files the release signature MUST cover: every runtime .mjs
|
|
268
295
|
// (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
|
|
269
|
-
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
|
|
296
|
+
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
|
|
297
|
+
// PLUS the plugin declaration files above.
|
|
270
298
|
// HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
|
|
271
299
|
// PUBLISH a release — but without the signing key — could swap a hook script (e.g.
|
|
272
300
|
// post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
|
|
@@ -278,4 +306,5 @@ export const RELEASE_SIGNED_FILES = [
|
|
|
278
306
|
...SOURCE_FILES,
|
|
279
307
|
...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
280
308
|
...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
309
|
+
...PLUGIN_DECLARATION_FILES,
|
|
281
310
|
];
|