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.
@@ -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 { return; }
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().catch(() => {});
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
- main().catch(() => {});
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
- additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
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="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
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, writeFileSync, mkdirSync } from 'fs';
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 read
156
- // inside the staleness window, filter overlaps from PreToolUse output, then
157
- // merge back so the next UPS sees what we emitted too.
158
- function crossHookInjectedFile(project) {
159
- return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
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
- function readCrossHookInjected(project) {
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: union. Outside: replace (fresh session).
182
- if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS) {
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
- writeFileSync(file, JSON.stringify({
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
- writeFileSync(cooldownPath, JSON.stringify(cleaned));
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 COALESCE(o.compressed_into, 0) = 0
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, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
- import { citeFactorClause } from '../scoring-sql.mjs';
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
- const INJECTED_IDS_FILE = join(DB_DIR, 'runtime', `.claude-mem-injected-${inferProject()}`);
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: INJECTED_IDS_FILE count > 0 within DEDUP_STALE_MS window.
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(INJECTED_IDS_FILE, 'utf8');
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
- ${OBS_BM25}
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 COALESCE(o.compressed_into, 0) = 0
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 COALESCE(o.compressed_into, 0) = 0
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 COALESCE(compressed_into, 0) = 0
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, INJECTED_IDS_FILE)) {
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(INJECTED_IDS_FILE, 'utf8'));
683
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS) {
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
- writeFileSync(INJECTED_IDS_FILE, JSON.stringify({
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 { return; }
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, INJECTED_IDS_FILE);
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(INJECTED_IDS_FILE, 'utf8'));
869
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS) prevCount = prev.count || 0;
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
- writeFileSync(INJECTED_IDS_FILE, JSON.stringify({
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 all errors
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().catch(() => {});
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
  }