claude-mem-lite 3.61.1 → 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/cli/common.mjs +78 -5
- package/format-utils.mjs +86 -1
- package/hook-handoff.mjs +11 -2
- package/hook-llm.mjs +3 -3
- package/hook-shared.mjs +14 -1
- package/hook.mjs +134 -18
- package/hooks/hooks.json +10 -0
- package/install.mjs +64 -2
- package/lib/db-backup.mjs +84 -1
- package/lib/export-columns.mjs +7 -1
- package/lib/recall-core.mjs +8 -0
- package/mem-cli.mjs +50 -13
- 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 +102 -41
- package/source-files.mjs +30 -1
- package/tool-schemas.mjs +10 -3
package/install.mjs
CHANGED
|
@@ -679,6 +679,24 @@ const memPostToolUse = {
|
|
|
679
679
|
}]
|
|
680
680
|
};
|
|
681
681
|
|
|
682
|
+
// Component 2 of the bind-salience forcing function: after an Edit/Write, flag an
|
|
683
|
+
// identifier the file's own lesson named that the edit just removed (component 1 is the
|
|
684
|
+
// pre-edit directive from scripts/pre-tool-recall.js, which also records the identifiers
|
|
685
|
+
// this one checks). Shipped, signed and tested since it was written, but registered in
|
|
686
|
+
// NEITHER registry — so `CLAUDE_MEM_SALIENCE=bind` delivered half the mechanism and
|
|
687
|
+
// nothing said so (audit B6, 2026-08-14). Matched on the edit tools only, NOT Read: there
|
|
688
|
+
// is no post-edit state to compare after a read. Inert (returns before touching stdin)
|
|
689
|
+
// unless CLAUDE_MEM_SALIENCE=bind, so the default chain pays one short-circuit spawn per
|
|
690
|
+
// edit and emits nothing.
|
|
691
|
+
const memPostToolRecall = {
|
|
692
|
+
matcher: 'Edit|Write|NotebookEdit',
|
|
693
|
+
hooks: [{
|
|
694
|
+
type: 'command',
|
|
695
|
+
command: nodeHook('scripts/post-tool-recall.js'),
|
|
696
|
+
timeout: 3
|
|
697
|
+
}]
|
|
698
|
+
};
|
|
699
|
+
|
|
682
700
|
const memSessionStart = {
|
|
683
701
|
matcher: 'startup|clear|compact',
|
|
684
702
|
hooks: [{
|
|
@@ -697,6 +715,21 @@ const memStop = {
|
|
|
697
715
|
}]
|
|
698
716
|
};
|
|
699
717
|
|
|
718
|
+
// Fires immediately BEFORE auto-compaction, re-emitting <claude-mem-context> so the
|
|
719
|
+
// summarizer that rewrites the transcript still has memory in scope (SessionStart's
|
|
720
|
+
// compact matcher fires AFTER, when the context is already gone). Parity with
|
|
721
|
+
// hooks/hooks.json: omitting it here made every settings.json install lose exactly the
|
|
722
|
+
// block that exists to survive compaction, invisibly — doctor only ever asked "are ANY
|
|
723
|
+
// mem hooks present", never "which events" (audit B3, 2026-08-14).
|
|
724
|
+
const memPreCompact = {
|
|
725
|
+
matcher: '*',
|
|
726
|
+
hooks: [{
|
|
727
|
+
type: 'command',
|
|
728
|
+
command: nodeHook('hook.mjs', 'pre-compact'),
|
|
729
|
+
timeout: 5
|
|
730
|
+
}]
|
|
731
|
+
};
|
|
732
|
+
|
|
700
733
|
const memUserPrompt = {
|
|
701
734
|
matcher: '*',
|
|
702
735
|
hooks: [
|
|
@@ -754,9 +787,14 @@ const memPreAgentInject = {
|
|
|
754
787
|
|
|
755
788
|
// Filter out existing mem hooks, then append fresh ones
|
|
756
789
|
// PreToolUse has three separate matchers, so we register all three
|
|
790
|
+
// Event set MUST stay equal to hooks/hooks.json's (minus scripts/setup.sh, which
|
|
791
|
+
// bootstraps the plugin cache and has no settings.json counterpart) —
|
|
792
|
+
// tests/audit-silent-20260814.test.mjs diffs a real `install --dev` run's
|
|
793
|
+
// settings.json against the shipped manifest and reds on any new divergence.
|
|
757
794
|
const hookConfigs = {
|
|
758
795
|
PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
|
|
759
|
-
PostToolUse: [memPostToolUse],
|
|
796
|
+
PostToolUse: [memPostToolUse, memPostToolRecall],
|
|
797
|
+
PreCompact: [memPreCompact],
|
|
760
798
|
SessionStart: [memSessionStart],
|
|
761
799
|
Stop: [memStop],
|
|
762
800
|
UserPromptSubmit: [memUserPrompt],
|
|
@@ -768,7 +806,10 @@ for (const [event, configs] of Object.entries(hookConfigs)) {
|
|
|
768
806
|
}
|
|
769
807
|
|
|
770
808
|
writeSettings(settings);
|
|
771
|
-
|
|
809
|
+
// Derived from the map, not a parallel literal: the pre-B3 line said five events and
|
|
810
|
+
// kept saying five after the map changed, which is how a missing registration reads as
|
|
811
|
+
// a successful one.
|
|
812
|
+
ok(`Hooks configured (${Object.keys(hookConfigs).join(', ')})`);
|
|
772
813
|
}
|
|
773
814
|
|
|
774
815
|
function backupLegacyClaudeMemData() {
|
|
@@ -1520,6 +1561,27 @@ async function doctor() {
|
|
|
1520
1561
|
ok(`Native DB binding: loadable on Node ${process.version}`);
|
|
1521
1562
|
}
|
|
1522
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
|
+
|
|
1523
1585
|
// Plugin/hook lifecycle state
|
|
1524
1586
|
const settings = readSettings();
|
|
1525
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/lib/recall-core.mjs
CHANGED
|
@@ -13,6 +13,13 @@ import { notLowSignalTitleClause } from '../utils.mjs';
|
|
|
13
13
|
* { filename, rows } where rows carry the column superset both surfaces render.
|
|
14
14
|
* Side effect: bumps access_count / last_accessed_at on every returned row —
|
|
15
15
|
* recall IS engagement, and the tier/decay system feeds on these counters.
|
|
16
|
+
*
|
|
17
|
+
* `superseded_at IS NULL` is load-bearing twice over (audit B2, 2026-08-14): recall was
|
|
18
|
+
* the ONE retrieval path missing it, so a lesson a later save explicitly retracted
|
|
19
|
+
* (`--supersedes N`) was still served to an agent about to edit that very file — and the
|
|
20
|
+
* access-count bump below runs over exactly these rows, so the tombstone was ALSO pushed
|
|
21
|
+
* back up the decay/tier system on every read. `includeNoise` is about LOW_SIGNAL titles
|
|
22
|
+
* and must not reach this clause: nobody asks for retracted content.
|
|
16
23
|
*/
|
|
17
24
|
export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
|
|
18
25
|
const filename = basename(file);
|
|
@@ -25,6 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
25
32
|
FROM observations o
|
|
26
33
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
27
34
|
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
35
|
+
AND o.superseded_at IS NULL
|
|
28
36
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
29
37
|
${noiseClause}
|
|
30
38
|
ORDER BY o.created_at_epoch DESC
|
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,14 +36,14 @@ 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:
|
|
43
43
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
44
44
|
import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
|
|
45
45
|
import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
46
|
-
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
46
|
+
import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
47
47
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
48
48
|
import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
|
|
49
49
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
@@ -533,7 +533,7 @@ function renderObsRows(db, ids, requestedFields) {
|
|
|
533
533
|
const formatted = formatObsFieldValue(f, val);
|
|
534
534
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
535
535
|
const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
|
|
536
|
-
lines.push(`${f}: ${display}`);
|
|
536
|
+
lines.push(`${obsFieldLabel(f)}: ${display}`);
|
|
537
537
|
}
|
|
538
538
|
parts.push(lines.join('\n'));
|
|
539
539
|
}
|
|
@@ -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);
|
|
@@ -1327,7 +1342,13 @@ function cmdContext(db, args) {
|
|
|
1327
1342
|
}
|
|
1328
1343
|
out(JSON.stringify(result, null, 2));
|
|
1329
1344
|
} else {
|
|
1330
|
-
|
|
1345
|
+
// outVerbatim: `context` is the one CLI command that must EMIT a real
|
|
1346
|
+
// <claude-mem-context> wrapper — it prints the same block the SessionStart hook
|
|
1347
|
+
// injects, so `out`'s defang would strip the delimiters this command exists to
|
|
1348
|
+
// produce (the CLI twin of why <skill-loaded> is excluded from CONTEXT_DELIMITER_RE).
|
|
1349
|
+
// The untrusted half is already neutralized one layer up: buildSessionContextLines
|
|
1350
|
+
// defangs every row it renders, so only the trusted wrapper is written raw here.
|
|
1351
|
+
outVerbatim(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
|
|
1331
1352
|
}
|
|
1332
1353
|
}
|
|
1333
1354
|
|
|
@@ -1714,15 +1735,20 @@ function cmdExport(db, args) {
|
|
|
1714
1735
|
// jsonl → 0 lines (valid empty file)
|
|
1715
1736
|
// The friendly note goes to stderr so it doesn't poison stdout for callers
|
|
1716
1737
|
// piping to a parser.
|
|
1717
|
-
if (format === 'json')
|
|
1738
|
+
if (format === 'json') outVerbatim('[]');
|
|
1718
1739
|
process.stderr.write('[mem] No observations found matching criteria\n');
|
|
1719
1740
|
return;
|
|
1720
1741
|
}
|
|
1721
1742
|
|
|
1743
|
+
// outVerbatim, NOT out: `out` neutralizes structural context delimiters (cli/common.mjs)
|
|
1744
|
+
// because CLI stdout is model context — but this stream is a BACKUP that `restore` reads
|
|
1745
|
+
// back, so defanging it would silently rewrite any row whose text legitimately contains
|
|
1746
|
+
// `<system-reminder>`/`</claude-mem-context>` and persist the rewrite on restore. Mirrors
|
|
1747
|
+
// safeHandler(mem_export, { verbatim: true }) on the MCP side (audit 2026-08-14 A1).
|
|
1722
1748
|
if (format === 'jsonl') {
|
|
1723
|
-
for (const r of rows)
|
|
1749
|
+
for (const r of rows) outVerbatim(JSON.stringify(r));
|
|
1724
1750
|
} else {
|
|
1725
|
-
|
|
1751
|
+
outVerbatim(JSON.stringify(rows, null, 2));
|
|
1726
1752
|
}
|
|
1727
1753
|
|
|
1728
1754
|
if (limitGiven && rows.length >= limit) {
|
|
@@ -1788,9 +1814,15 @@ function cmdRestore(db, argv) {
|
|
|
1788
1814
|
decay_seen_count = ?, last_accessed_at = ?
|
|
1789
1815
|
WHERE id = ?`);
|
|
1790
1816
|
|
|
1791
|
-
let restored = 0, skipped = 0, malformed = 0;
|
|
1817
|
+
let restored = 0, skipped = 0, malformed = 0, tombstoned = 0;
|
|
1792
1818
|
for (const r of rows) {
|
|
1793
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; }
|
|
1794
1826
|
const project = projOverride || r.project || inferProject();
|
|
1795
1827
|
const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
|
|
1796
1828
|
// Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
|
|
@@ -1857,7 +1889,8 @@ function cmdRestore(db, argv) {
|
|
|
1857
1889
|
// ones that parsed.
|
|
1858
1890
|
const totalMalformed = malformed + parseFailures;
|
|
1859
1891
|
const totalLines = rows.length + parseFailures;
|
|
1860
|
-
|
|
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).`);
|
|
1861
1894
|
// Name the lossiness where the user meets it. Export omits related_ids and drops
|
|
1862
1895
|
// superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
|
|
1863
1896
|
// cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
|
|
@@ -1971,7 +2004,7 @@ function cmdMaintain(db, args) {
|
|
|
1971
2004
|
out(` Broken (no title/narrative): ${stats.broken}`);
|
|
1972
2005
|
out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
|
|
1973
2006
|
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
|
|
1974
|
-
out(
|
|
2007
|
+
out(formatPendingPurgeLine(stats.pendingPurge));
|
|
1975
2008
|
if (duplicates.length > 0) {
|
|
1976
2009
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
1977
2010
|
const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
|
|
@@ -3119,8 +3152,12 @@ async function cmdOptimize(db, args) {
|
|
|
3119
3152
|
if (project) out(` Project filter: ${project}`);
|
|
3120
3153
|
out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
|
|
3121
3154
|
out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
|
|
3122
|
-
|
|
3123
|
-
|
|
3155
|
+
// "candidates" matches the MCP wording (server.mjs mem_optimize preview) AND the
|
|
3156
|
+
// Re-enrich line just above, which already read that way on both surfaces. The two
|
|
3157
|
+
// surfaces render one optimizePreview() result — tests/audit-findings-20260814.test.mjs
|
|
3158
|
+
// drives both and compares the label lists, so the drift cannot reopen.
|
|
3159
|
+
out(` Cluster-merge candidates: ${preview.clusterMerge} clusters`);
|
|
3160
|
+
out(` Smart-compress candidates: ${preview.smartCompress} clusters`);
|
|
3124
3161
|
out(` Total: ${preview.total} items`);
|
|
3125
3162
|
if (verbose) {
|
|
3126
3163
|
out('');
|
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 });
|