claude-mem-lite 3.62.0 → 3.64.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-memory.mjs +6 -9
- package/hook.mjs +44 -17
- package/install.mjs +21 -0
- package/lib/browse-core.mjs +67 -0
- package/lib/db-backup.mjs +84 -1
- package/lib/delete-core.mjs +18 -1
- package/lib/export-columns.mjs +7 -1
- package/lib/get-core.mjs +33 -0
- package/lib/inject-search-core.mjs +84 -0
- package/lib/injected-ids.mjs +29 -0
- package/lib/observation-write.mjs +38 -2
- package/lib/search-core.mjs +10 -1
- package/mem-cli.mjs +86 -129
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +18 -3
- package/scripts/pre-tool-recall.js +39 -19
- package/scripts/prompt-search-utils.mjs +9 -2
- package/scripts/user-prompt-search.js +59 -31
- package/search-engine.mjs +56 -20
- package/search-scoring.mjs +7 -2
- package/server.mjs +75 -137
- package/source-files.mjs +44 -1
|
@@ -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,24 @@ 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
|
-
|
|
106
|
+
const summary = defang(content.slice(0, 800));
|
|
107
|
+
// D#122 ③: the path interpolates OUTSIDE the wrapper — defang it too (a
|
|
108
|
+
// locally-created managed dir can carry delimiter chars in its name).
|
|
109
|
+
additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${defang(portablePath)}") to load full content.`;
|
|
95
110
|
} else {
|
|
96
|
-
additionalContext = `<skill-bridge name="${
|
|
111
|
+
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
112
|
}
|
|
98
113
|
process.stdout.write(JSON.stringify({
|
|
99
114
|
suppressOutput: true,
|
|
@@ -4,9 +4,12 @@
|
|
|
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';
|
|
11
|
+
import { injectedIdsFileName } from '../lib/injected-ids.mjs';
|
|
12
|
+
import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
|
|
10
13
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
11
14
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
12
15
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
@@ -152,34 +155,46 @@ function entryTimestamp(v) {
|
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
// A3 (v2.83): cross-hook injected-IDs store. UPS writes
|
|
155
|
-
// `runtime/.claude-mem-injected-<project>` with {ids, ts, count}. We
|
|
156
|
-
// inside the staleness window, filter overlaps from PreToolUse output,
|
|
157
|
-
// merge back so the next UPS sees what we emitted too.
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// `runtime/.claude-mem-injected-<project>-<session>` with {ids, ts, count}. We
|
|
159
|
+
// read inside the staleness window, filter overlaps from PreToolUse output,
|
|
160
|
+
// then merge back so the next UPS sees what we emitted too.
|
|
161
|
+
// D#120: the file is keyed per SESSION (payload-only keying let two concurrent
|
|
162
|
+
// windows clobber each other's marker). Derivation shared via lib/injected-ids.mjs
|
|
163
|
+
// (pure, no deps — within the standalone fast-path budget, #8447).
|
|
164
|
+
function crossHookInjectedFile(project, sessionId) {
|
|
165
|
+
return join(RUNTIME_DIR, injectedIdsFileName(project, sessionId));
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
|
|
168
|
+
// M-6 (audit 2026-08-14): the marker file is keyed by PROJECT, so two concurrent
|
|
169
|
+
// CC sessions in the same project shared one suppression state — session A's
|
|
170
|
+
// injections silently deduped session B's, and B inherited A's count cap. Both
|
|
171
|
+
// read and merge now carry the CC session id: a payload written by a DIFFERENT
|
|
172
|
+
// session is ignored (read) / replaced (merge), mirroring the v3.35.2 episode
|
|
173
|
+
// session-key fix. Legacy payloads without `session` keep the old behavior.
|
|
174
|
+
function readCrossHookInjected(project, sessionId) {
|
|
163
175
|
try {
|
|
164
|
-
const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
|
|
165
|
-
const { ids, ts } = JSON.parse(raw);
|
|
176
|
+
const raw = readFileSync(crossHookInjectedFile(project, sessionId), 'utf8');
|
|
177
|
+
const { ids, ts, session } = JSON.parse(raw);
|
|
178
|
+
if (session && sessionId && session !== sessionId) return new Set();
|
|
166
179
|
if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
|
|
167
180
|
if (!Array.isArray(ids)) return new Set();
|
|
168
181
|
return new Set(ids.map(String));
|
|
169
182
|
} catch { return new Set(); }
|
|
170
183
|
}
|
|
171
184
|
|
|
172
|
-
function mergeCrossHookInjected(project, newIds) {
|
|
185
|
+
function mergeCrossHookInjected(project, newIds, sessionId) {
|
|
173
186
|
if (!newIds || newIds.length === 0) return;
|
|
174
187
|
try {
|
|
175
188
|
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
176
|
-
const file = crossHookInjectedFile(project);
|
|
189
|
+
const file = crossHookInjectedFile(project, sessionId);
|
|
177
190
|
let prev = { ids: [], ts: 0, count: 0 };
|
|
178
191
|
try {
|
|
179
192
|
const raw = readFileSync(file, 'utf8');
|
|
180
193
|
const parsed = JSON.parse(raw);
|
|
181
|
-
// Within the staleness window
|
|
182
|
-
|
|
194
|
+
// Within the staleness window AND same session (or legacy): union.
|
|
195
|
+
// Outside / other session: replace.
|
|
196
|
+
if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS
|
|
197
|
+
&& !(parsed.session && sessionId && parsed.session !== sessionId)) {
|
|
183
198
|
prev = parsed;
|
|
184
199
|
}
|
|
185
200
|
} catch { /* fresh file */ }
|
|
@@ -187,10 +202,13 @@ function mergeCrossHookInjected(project, newIds) {
|
|
|
187
202
|
...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
|
|
188
203
|
...newIds.map(String),
|
|
189
204
|
])];
|
|
190
|
-
|
|
205
|
+
// Atomic (tmp+rename, M-6): a plain write torn by a concurrent hook left the
|
|
206
|
+
// shared marker as invalid JSON, silently disabling cross-hook dedup.
|
|
207
|
+
atomicWriteFileSync(file, JSON.stringify({
|
|
191
208
|
ids,
|
|
192
209
|
ts: Date.now(),
|
|
193
210
|
count: (prev.count || 0) + 1,
|
|
211
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
194
212
|
}));
|
|
195
213
|
} catch { /* silent — dedup is best-effort */ }
|
|
196
214
|
}
|
|
@@ -209,7 +227,10 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
|
|
|
209
227
|
if (ts && now - ts < STALE_MS) cleaned[k] = v;
|
|
210
228
|
}
|
|
211
229
|
}
|
|
212
|
-
|
|
230
|
+
// Atomic (tmp+rename, M-6): the cooldown carries lessonIdents that the
|
|
231
|
+
// PostToolUse bind-salience check reads back — a torn write turned that
|
|
232
|
+
// check into a zero-trace no-op.
|
|
233
|
+
atomicWriteFileSync(cooldownPath, JSON.stringify(cleaned));
|
|
213
234
|
} catch { /* silent */ }
|
|
214
235
|
}
|
|
215
236
|
|
|
@@ -412,8 +433,7 @@ try {
|
|
|
412
433
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
413
434
|
WHERE o.project = ?
|
|
414
435
|
AND o.importance >= 2
|
|
415
|
-
AND
|
|
416
|
-
AND o.superseded_at IS NULL
|
|
436
|
+
AND ${liveObsFilterSql('o')}
|
|
417
437
|
AND o.created_at_epoch > ?
|
|
418
438
|
AND ${fileMatch}
|
|
419
439
|
${edgeDecayFilter}
|
|
@@ -474,7 +494,7 @@ try {
|
|
|
474
494
|
// P1 (D#78): tag each row's source table — events share the numeric id
|
|
475
495
|
// space with observations, and the Stop-side edge attribution must never
|
|
476
496
|
// feed an event id into observation_files updates.
|
|
477
|
-
const crossHookSeen = readCrossHookInjected(project);
|
|
497
|
+
const crossHookSeen = readCrossHookInjected(project, sessionId);
|
|
478
498
|
const sourcedRows = [
|
|
479
499
|
...rows.map(r => ({ ...r, src: 'obs' })),
|
|
480
500
|
...eventRows.map(r => ({ ...r, src: 'evt' })),
|
|
@@ -638,7 +658,7 @@ try {
|
|
|
638
658
|
// file so the next UPS prompt skips them too. Always write, even on
|
|
639
659
|
// empty allRows, so the file's ts stays fresh for the no-op case where
|
|
640
660
|
// we'd otherwise drift outside the dedup window.
|
|
641
|
-
mergeCrossHookInjected(project, allRows.map(r => r.id));
|
|
661
|
+
mergeCrossHookInjected(project, allRows.map(r => r.id), sessionId);
|
|
642
662
|
} catch (e) {
|
|
643
663
|
// Silent failure — never block editing, but record for self-observation.
|
|
644
664
|
recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
|
|
@@ -149,18 +149,25 @@ export function extractErrorSignature(text) {
|
|
|
149
149
|
|
|
150
150
|
export const MAX_SESSION_INJECTIONS = 15;
|
|
151
151
|
export const DEDUP_STALE_MS = 300_000; // 5 minutes
|
|
152
|
+
// Marker file-name derivation lives in lib/injected-ids.mjs (D#120) — lib/, not
|
|
153
|
+
// here, so hook.mjs can import it (scripts/ static imports from hook.mjs break
|
|
154
|
+
// under the installExtractedRelease scripts-dir rename).
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
157
|
* Check if injection should be skipped based on deduplication state.
|
|
155
158
|
* @param {number[]} newIds - candidate observation IDs
|
|
156
159
|
* @param {string} injectedFile - path to the dedup state file
|
|
160
|
+
* @param {string} [sessionId] - CC session id; a payload written by a DIFFERENT
|
|
161
|
+
* session never suppresses (M-6, audit 2026-08-14: the file is keyed by project,
|
|
162
|
+
* so session A's injections + count cap silently carried into session B)
|
|
157
163
|
* @returns {boolean} true if injection should be skipped
|
|
158
164
|
*/
|
|
159
|
-
export function shouldSkipByDedup(newIds, injectedFile) {
|
|
165
|
+
export function shouldSkipByDedup(newIds, injectedFile, sessionId) {
|
|
160
166
|
if (!newIds || newIds.length === 0) return true;
|
|
161
167
|
try {
|
|
162
168
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
163
|
-
const { ids: prevIds, ts, count = 0 } = JSON.parse(raw);
|
|
169
|
+
const { ids: prevIds, ts, count = 0, session } = JSON.parse(raw);
|
|
170
|
+
if (session && sessionId && session !== sessionId) return false;
|
|
164
171
|
if (count >= MAX_SESSION_INJECTIONS) return true;
|
|
165
172
|
if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
|
|
166
173
|
if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
|
|
@@ -4,20 +4,31 @@
|
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25,
|
|
8
|
-
import {
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
|
|
8
|
+
import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
|
|
9
9
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
10
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
11
11
|
import { join, sep } from 'path';
|
|
12
12
|
import { pathToFileURL } from 'url';
|
|
13
13
|
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
|
+
import { injectedIdsFileName } from '../lib/injected-ids.mjs';
|
|
15
16
|
import { getDeferredByIds } from '../lib/deferred-work.mjs';
|
|
16
17
|
import { recommendSkill } from '../registry-recommend.mjs';
|
|
18
|
+
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
19
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
17
20
|
|
|
18
21
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
// Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
|
|
24
|
+
// <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
|
|
25
|
+
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
|
|
26
|
+
// D#120: one marker file per CC session — payload-only session keying (M-6) let
|
|
27
|
+
// two concurrent windows full-replace each other's marker, killing dedup between
|
|
28
|
+
// them and resetting `count` on every alternation. Derived per invocation once
|
|
29
|
+
// the session id is parsed from stdin; no session id → legacy project-keyed file.
|
|
30
|
+
const injectedIdsFileFor = (sessionId) =>
|
|
31
|
+
join(DB_DIR, 'runtime', injectedIdsFileName(inferProject(), sessionId));
|
|
21
32
|
// Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
|
|
22
33
|
// scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
|
|
23
34
|
// recall, vs PreToolUse:Read at 94% recall on a tighter file-keyed set.
|
|
@@ -61,7 +72,7 @@ const PROMPT_MIN_LENGTH = 15;
|
|
|
61
72
|
// v2.33.1: follow-up prompts ("前面那个", "继续 X", "再看看 Y") are short by
|
|
62
73
|
// nature but semantically depend on prior turns. Once a session has injected
|
|
63
74
|
// memory at least once, relax gates so short follow-ups still get recall.
|
|
64
|
-
// Detection:
|
|
75
|
+
// Detection: injected-ids marker count > 0 within DEDUP_STALE_MS window.
|
|
65
76
|
const FOLLOWUP_PROMPT_MIN_LENGTH = 8;
|
|
66
77
|
const FOLLOWUP_BM25_MIN_SCORE = Number(process.env.CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP || 5e-6);
|
|
67
78
|
|
|
@@ -168,9 +179,9 @@ export function corpusFloorScale(db) {
|
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
181
|
|
|
171
|
-
function isFollowUpSession() {
|
|
182
|
+
function isFollowUpSession(injectedIdsFile) {
|
|
172
183
|
try {
|
|
173
|
-
const raw = readFileSync(
|
|
184
|
+
const raw = readFileSync(injectedIdsFile, 'utf8');
|
|
174
185
|
const { ts, count = 0 } = JSON.parse(raw);
|
|
175
186
|
if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
|
|
176
187
|
return count > 0;
|
|
@@ -343,12 +354,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
|
|
|
343
354
|
const sql = `
|
|
344
355
|
SELECT o.id, o.type, o.title, o.lesson_learned,
|
|
345
356
|
${OBS_BM25} as bm25_raw,
|
|
346
|
-
${
|
|
347
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
|
|
348
|
-
* ${TYPE_QUALITY_CASE}
|
|
349
|
-
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
350
|
-
* ${noisePenaltyClause('o')}
|
|
351
|
-
* ${citeFactorClause('o')} as relevance
|
|
357
|
+
${injectionRelevanceSql('o')} as relevance
|
|
352
358
|
FROM observations_fts
|
|
353
359
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
354
360
|
WHERE observations_fts MATCH ?
|
|
@@ -356,8 +362,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
|
|
|
356
362
|
AND o.importance >= 1
|
|
357
363
|
AND o.created_at_epoch > ?
|
|
358
364
|
AND (? IS NULL OR o.created_at_epoch <= ?)
|
|
359
|
-
AND
|
|
360
|
-
AND o.superseded_at IS NULL
|
|
365
|
+
AND ${liveObsFilterSql('o')}
|
|
361
366
|
AND ${notLowSignalTitleClause('o')}
|
|
362
367
|
${typeClause}
|
|
363
368
|
ORDER BY relevance
|
|
@@ -403,8 +408,7 @@ function searchByFile(db, files, project, limit) {
|
|
|
403
408
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
404
409
|
WHERE o.project = ?
|
|
405
410
|
AND o.importance >= 1
|
|
406
|
-
AND
|
|
407
|
-
AND o.superseded_at IS NULL
|
|
411
|
+
AND ${liveObsFilterSql('o')}
|
|
408
412
|
AND o.created_at_epoch > ?
|
|
409
413
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
410
414
|
AND ${notLowSignalTitleClause('o')}
|
|
@@ -479,8 +483,7 @@ function searchRecent(db, project, limit) {
|
|
|
479
483
|
FROM observations
|
|
480
484
|
WHERE project = ?
|
|
481
485
|
AND importance >= 1
|
|
482
|
-
AND
|
|
483
|
-
AND superseded_at IS NULL
|
|
486
|
+
AND ${liveObsFilterSql('')}
|
|
484
487
|
AND created_at_epoch > ?
|
|
485
488
|
AND ${notLowSignalTitleClause('')}
|
|
486
489
|
ORDER BY created_at_epoch DESC
|
|
@@ -631,6 +634,9 @@ async function main() {
|
|
|
631
634
|
// would never legitimately be wrapped in <private>).
|
|
632
635
|
if (rawPrompt.startsWith('<task-notification>')) return;
|
|
633
636
|
|
|
637
|
+
// D#120: session-keyed dedup marker — every read/write below uses this path.
|
|
638
|
+
const injectedIdsFile = injectedIdsFileFor(hookData.session_id);
|
|
639
|
+
|
|
634
640
|
// Strip <private>...</private> blocks before length gates and FTS query
|
|
635
641
|
// construction — private content must not pad effective length nor leak
|
|
636
642
|
// into the FTS MATCH query terms. Mirrors hook.mjs handleUserPrompt.
|
|
@@ -660,7 +666,7 @@ async function main() {
|
|
|
660
666
|
// Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
|
|
661
667
|
// convention) so obs ids can't collide in the shared injected-ids file.
|
|
662
668
|
const dedupIds = openRows.map(r => `D${r.id}`);
|
|
663
|
-
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds,
|
|
669
|
+
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, injectedIdsFile, hookData.session_id)) {
|
|
664
670
|
const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
|
|
665
671
|
for (const r of openRows) {
|
|
666
672
|
const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
|
|
@@ -679,16 +685,21 @@ async function main() {
|
|
|
679
685
|
let prevIds = [];
|
|
680
686
|
let prevCount = 0;
|
|
681
687
|
try {
|
|
682
|
-
const prev = JSON.parse(readFileSync(
|
|
683
|
-
|
|
688
|
+
const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
|
|
689
|
+
// M-6: inherit only same-session (or legacy) state — another session's
|
|
690
|
+
// ids/count must not carry over. Atomic write below: a torn concurrent
|
|
691
|
+
// write left the shared marker as invalid JSON (dedup silently off).
|
|
692
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
693
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
684
694
|
prevIds = Array.isArray(prev.ids) ? prev.ids : [];
|
|
685
695
|
prevCount = prev.count || 0;
|
|
686
696
|
}
|
|
687
697
|
} catch {}
|
|
688
|
-
|
|
698
|
+
atomicWriteFileSync(injectedIdsFile, JSON.stringify({
|
|
689
699
|
ids: [...new Set([...prevIds.map(String), ...dedupIds])],
|
|
690
700
|
ts: Date.now(),
|
|
691
701
|
count: prevCount + 1,
|
|
702
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
692
703
|
}));
|
|
693
704
|
} catch {}
|
|
694
705
|
}
|
|
@@ -703,7 +714,7 @@ async function main() {
|
|
|
703
714
|
// "fix bug now") that carry too few content tokens for a meaningful FTS lookup.
|
|
704
715
|
// v2.33.1: follow-up prompts in an already-active session get a lower gate —
|
|
705
716
|
// short continuations ("前面那个?", "does it work?") depend on prior context.
|
|
706
|
-
const followUp = isFollowUpSession();
|
|
717
|
+
const followUp = isFollowUpSession(injectedIdsFile);
|
|
707
718
|
const promptMinLen = followUp ? FOLLOWUP_PROMPT_MIN_LENGTH : PROMPT_MIN_LENGTH;
|
|
708
719
|
if (computeEffectiveLen(promptText.trim()) < promptMinLen) { try { db?.close(); } catch {} return; }
|
|
709
720
|
const bm25Floor = followUp ? FOLLOWUP_BM25_MIN_SCORE : BM25_MIN_SCORE;
|
|
@@ -712,7 +723,13 @@ async function main() {
|
|
|
712
723
|
if (!db) {
|
|
713
724
|
try {
|
|
714
725
|
db = ensureDb();
|
|
715
|
-
} catch {
|
|
726
|
+
} catch (e) {
|
|
727
|
+
// A failed DB open silently kills EVERY prompt-time injection while `stats`
|
|
728
|
+
// reads zero errors (audit 2026-08-14 M-5) — record before the mandatory
|
|
729
|
+
// swallow. Exact blindness class of the 2026-08-13 pre-recall:db-open outage.
|
|
730
|
+
recordHookError('ups:db-open', e, RUNTIME_DIR);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
716
733
|
}
|
|
717
734
|
|
|
718
735
|
try {
|
|
@@ -854,7 +871,7 @@ async function main() {
|
|
|
854
871
|
const candidateIds = rows.length > 0
|
|
855
872
|
? rows.map(r => r.id)
|
|
856
873
|
: promptRows.map(r => `P${r.id}`);
|
|
857
|
-
const dedupSkip = shouldSkipByDedup(candidateIds,
|
|
874
|
+
const dedupSkip = shouldSkipByDedup(candidateIds, injectedIdsFile, hookData.session_id);
|
|
858
875
|
|
|
859
876
|
const output = !dedupSkip
|
|
860
877
|
? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
|
|
@@ -865,13 +882,18 @@ async function main() {
|
|
|
865
882
|
try {
|
|
866
883
|
let prevCount = 0;
|
|
867
884
|
try {
|
|
868
|
-
const prev = JSON.parse(readFileSync(
|
|
869
|
-
|
|
885
|
+
const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
|
|
886
|
+
// M-6: same-session (or legacy) count only; atomic write (torn-write guard).
|
|
887
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
888
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
889
|
+
prevCount = prev.count || 0;
|
|
890
|
+
}
|
|
870
891
|
} catch {}
|
|
871
|
-
|
|
892
|
+
atomicWriteFileSync(injectedIdsFile, JSON.stringify({
|
|
872
893
|
ids: candidateIds,
|
|
873
894
|
ts: Date.now(),
|
|
874
895
|
count: prevCount + 1,
|
|
896
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
875
897
|
}));
|
|
876
898
|
} catch {}
|
|
877
899
|
// v26 P0: bump injection_count for obs-based emits only (prompt-corpus
|
|
@@ -932,8 +954,11 @@ async function main() {
|
|
|
932
954
|
finally { rdb.close(); }
|
|
933
955
|
}
|
|
934
956
|
} catch { /* silent — never block on recommendation failure */ }
|
|
935
|
-
} catch {
|
|
936
|
-
// Hooks must never break Claude Code — swallow
|
|
957
|
+
} catch (e) {
|
|
958
|
+
// Hooks must never break Claude Code — swallow, but RECORD: this catch wraps
|
|
959
|
+
// every FTS query on the surface, so a schema/FTS drift here would zero out
|
|
960
|
+
// prompt-time injection with no trace anywhere (audit 2026-08-14 M-5).
|
|
961
|
+
recordHookError('ups:search', e, RUNTIME_DIR);
|
|
937
962
|
} finally {
|
|
938
963
|
try { db.close(); } catch {}
|
|
939
964
|
}
|
|
@@ -962,5 +987,8 @@ export function isDirectInvocation(metaUrl, argv1) {
|
|
|
962
987
|
return metaUrl.split('?')[0] === pathToFileURL(argv1).href;
|
|
963
988
|
}
|
|
964
989
|
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
965
|
-
main().
|
|
990
|
+
// Last-resort telemetry for anything that escapes main()'s own catches (e.g. a
|
|
991
|
+
// throw between the entry and the guarded body). Recorder never throws; the
|
|
992
|
+
// outer catch keeps the never-non-zero-exit invariant regardless.
|
|
993
|
+
main().catch((e) => { try { recordHookError('ups:main', e, RUNTIME_DIR); } catch { /* never */ } });
|
|
966
994
|
}
|