switchroom 0.21.17 → 0.21.19

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.
Files changed (33) hide show
  1. package/dist/cli/switchroom.js +1884 -1512
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/skills/switchroom-release/SKILL.md +6 -1
  5. package/telegram-plugin/connection-drop.ts +83 -0
  6. package/telegram-plugin/dist/bridge/bridge.js +41 -2
  7. package/telegram-plugin/dist/gateway/gateway.js +48 -6
  8. package/telegram-plugin/dist/server.js +46 -3
  9. package/telegram-plugin/llm-error-present.ts +25 -0
  10. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  11. package/telegram-plugin/session-tail.ts +38 -1
  12. package/telegram-plugin/tests/llm-error-present.test.ts +183 -0
  13. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  14. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  15. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  16. package/telegram-plugin/uat/flip/gate.ts +232 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  18. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  19. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  20. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  21. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  22. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  23. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  24. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  25. package/telegram-plugin/uat/flip/report.ts +95 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  27. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  28. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  29. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  30. package/vendor/hindsight-memory/scripts/prefetch.py +33 -5
  31. package/vendor/hindsight-memory/scripts/recall.py +131 -17
  32. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +97 -6
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +70 -0
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * M3 directive-flip UAT — Tier-2 behavioural probe runner.
4
+ *
5
+ * The model-in-the-loop half of the flip gate: drives a real Telegram
6
+ * user-account (the same mtcute `Driver` the rest of `uat/` uses) against a
7
+ * TARGET agent and, per probe, verifies the agent still HONOURS its migrated
8
+ * guardrails in live conversation. Deterministic scoring only — every reply is
9
+ * regex-matched against the probe's `passPattern` (see `probe-scoring.ts`); NO
10
+ * LLM judge.
11
+ *
12
+ * Per probe: DM the benign prompt → `expectMessage` for the agent's answer →
13
+ * score → repeat k times with ≥`spacingMs` between sends (default 30s, which
14
+ * dominates the gateway's coalescing gap and keeps us under the user-account
15
+ * flood cap). Folds k attempts into GREEN (3/3) / AMBER (2/3) / RED (≤1/3), and
16
+ * writes one `flip/results/<agent>.<phase>.json` conforming to
17
+ * `Tier2ProbeResults`. A separate baseline + postflip run produce two files a
18
+ * caller diffs with `detectRegressions` (postflip rate < baseline rate).
19
+ *
20
+ * SAFETY: this runner only ever SENDS benign questions. It never flips an
21
+ * agent, never edits config, and the probe suites are authored to contain no
22
+ * actionable instruction with tool side-effects. Point it only at internal
23
+ * test agents.
24
+ *
25
+ * Targeting mirrors `runners/agent-self-sufficiency.ts`: `--agent name:@bot`
26
+ * (repeatable) or `UAT_FLEET="name:@bot,..."`. Auth env (via repo-root `.env`,
27
+ * loaded by `loadUatEnv`): TELEGRAM_API_ID, TELEGRAM_API_HASH,
28
+ * TELEGRAM_UAT_DRIVER_SESSION.
29
+ *
30
+ * Usage:
31
+ * bun telegram-plugin/uat/flip/tier2-probe-runner.ts \
32
+ * --agent test-harness:@meken_switchroom_test_bot \
33
+ * --phase baseline
34
+ *
35
+ * # smoke: one benign probe, single repeat
36
+ * bun telegram-plugin/uat/flip/tier2-probe-runner.ts \
37
+ * --agent test-harness:@meken_switchroom_test_bot --phase baseline --k 1 --smoke
38
+ */
39
+
40
+ import { mkdirSync, writeFileSync } from "node:fs";
41
+ import path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { Driver, type ObservedMessage } from "../driver.js";
44
+ import { loadUatEnv } from "../load-env.js";
45
+ import { expectMessage, isAnswer } from "../assertions.js";
46
+ import { loadProbeSuite, type ProbeSpec, type ProbeSuite } from "./probe-suite.js";
47
+ import {
48
+ foldPhase,
49
+ foldProbe,
50
+ scoreAttempt,
51
+ } from "./probe-scoring.js";
52
+ import type { Tier2ProbeAttempt, Tier2ProbeOutcome, Tier2ProbeResults, ProbePhase } from "./gate.js";
53
+
54
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
55
+
56
+ // ─── CLI / env parsing ──────────────────────────────────────────────────────
57
+
58
+ interface AgentTarget {
59
+ name: string;
60
+ botUsername: string;
61
+ }
62
+
63
+ interface CliConfig {
64
+ agents: AgentTarget[];
65
+ phase: ProbePhase;
66
+ /** Repeats per probe. Default 3. */
67
+ k: number;
68
+ /** Minimum gap between sends of the same probe, ms. Default 30_000. */
69
+ spacingMs: number;
70
+ /** Per-reply observation deadline, ms. Default 120_000. */
71
+ replyTimeoutMs: number;
72
+ /** Output directory for `<agent>.<phase>.json`. Default `<flip>/results`. */
73
+ outDir: string;
74
+ /** Explicit suite path override (single-agent runs). */
75
+ suitePath?: string;
76
+ /** Smoke mode: run only the FIRST probe of the suite, k forced to its value
77
+ * (typically 1). Proves transport without a full behavioural sweep. */
78
+ smoke: boolean;
79
+ }
80
+
81
+ function fail(msg: string): never {
82
+ process.stderr.write(`[tier2] ${msg}\n`);
83
+ process.exit(2);
84
+ }
85
+
86
+ function parseCli(argv: readonly string[]): CliConfig {
87
+ const agents = new Map<string, AgentTarget>();
88
+ let phase: ProbePhase = (process.env.UAT_FLIP_PHASE as ProbePhase) || "baseline";
89
+ let k = Number.parseInt(process.env.UAT_PROBE_K ?? "3", 10);
90
+ let spacingMs = Number.parseInt(process.env.UAT_PROBE_SPACING_MS ?? "30000", 10);
91
+ let replyTimeoutMs = Number.parseInt(process.env.UAT_PROBE_TIMEOUT_MS ?? "120000", 10);
92
+ let outDir = process.env.UAT_PROBE_OUT_DIR ?? path.join(HERE, "results");
93
+ let suitePath: string | undefined;
94
+ let smoke = false;
95
+
96
+ const envFleet = process.env.UAT_FLEET;
97
+ if (envFleet) {
98
+ for (const tok of envFleet.split(",")) {
99
+ const [name, bot] = tok.split(":").map((s) => s.trim());
100
+ if (name && bot) agents.set(name, { name, botUsername: bot });
101
+ }
102
+ }
103
+
104
+ for (let i = 0; i < argv.length; i++) {
105
+ const tok = argv[i]!;
106
+ const next = (): string => {
107
+ const v = argv[++i];
108
+ if (v === undefined) fail(`${tok}: missing value`);
109
+ return v;
110
+ };
111
+ switch (tok) {
112
+ case "--agent": {
113
+ const v = next();
114
+ const [name, bot] = v.split(":").map((s) => s.trim());
115
+ if (!name || !bot) fail(`--agent expects "<name>:@<bot-username>"; got "${v}"`);
116
+ agents.set(name, { name, botUsername: bot });
117
+ break;
118
+ }
119
+ case "--phase": {
120
+ const v = next();
121
+ if (v !== "baseline" && v !== "postflip") fail(`--phase must be baseline|postflip; got "${v}"`);
122
+ phase = v;
123
+ break;
124
+ }
125
+ case "--k":
126
+ k = Number.parseInt(next(), 10);
127
+ break;
128
+ case "--spacing-ms":
129
+ spacingMs = Number.parseInt(next(), 10);
130
+ break;
131
+ case "--reply-timeout-ms":
132
+ replyTimeoutMs = Number.parseInt(next(), 10);
133
+ break;
134
+ case "--out-dir":
135
+ outDir = next();
136
+ break;
137
+ case "--suite":
138
+ suitePath = next();
139
+ break;
140
+ case "--smoke":
141
+ smoke = true;
142
+ break;
143
+ case "--help":
144
+ case "-h":
145
+ printHelp();
146
+ process.exit(0);
147
+ break;
148
+ default:
149
+ if (tok.startsWith("--")) fail(`unknown flag: ${tok}`);
150
+ }
151
+ }
152
+
153
+ if (agents.size === 0) {
154
+ fail('no agent targeted. Pass --agent <name>:@<bot> or set UAT_FLEET. Tier-2 probes only ever target internal TEST agents.');
155
+ }
156
+ if (!Number.isFinite(k) || k < 1) fail(`--k must be a positive integer; got ${k}`);
157
+ if (suitePath && agents.size > 1) {
158
+ fail("--suite is a single-agent override; pass exactly one --agent with it");
159
+ }
160
+
161
+ return {
162
+ agents: [...agents.values()],
163
+ phase,
164
+ k,
165
+ spacingMs,
166
+ replyTimeoutMs,
167
+ outDir,
168
+ ...(suitePath ? { suitePath } : {}),
169
+ smoke,
170
+ };
171
+ }
172
+
173
+ function printHelp(): void {
174
+ process.stdout.write(`M3 directive-flip Tier-2 behavioural probe runner
175
+
176
+ Required env (or fail loud):
177
+ TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_UAT_DRIVER_SESSION
178
+
179
+ Flags:
180
+ --agent NAME:@BOT Target agent. Repeatable. (INTERNAL TEST AGENTS ONLY.)
181
+ --phase baseline|postflip Which flip phase this run records. Default baseline.
182
+ --k N Repeats per probe. Default 3.
183
+ --spacing-ms N Min gap between sends of a probe. Default 30000.
184
+ --reply-timeout-ms N Per-reply deadline. Default 120000.
185
+ --out-dir DIR Results dir. Default <flip>/results.
186
+ --suite PATH Suite override (single --agent). Default probes/<agent>.probes.json.
187
+ --smoke Run only the first probe (transport check).
188
+
189
+ Env equivalents: UAT_FLEET, UAT_FLIP_PHASE, UAT_PROBE_K, UAT_PROBE_SPACING_MS,
190
+ UAT_PROBE_TIMEOUT_MS, UAT_PROBE_OUT_DIR
191
+ `);
192
+ }
193
+
194
+ // ─── Live probe execution ─────────────────────────────────────────────────────
195
+
196
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, Math.max(0, ms)));
197
+
198
+ /**
199
+ * Send one probe prompt and wait for the agent's answer. Uses the prescribed
200
+ * seam: `sendText` then `expectMessage(driver, botId, matcher, {timeout})`,
201
+ * where the matcher is `isAnswer` so worker-feed / activity-card surfaces and
202
+ * the driver's own echo are excluded. Returns a scored {@link Tier2ProbeAttempt}.
203
+ */
204
+ async function runOneAttempt(
205
+ driver: Driver,
206
+ botUserId: number,
207
+ driverUserId: number,
208
+ spec: ProbeSpec,
209
+ timeoutMs: number,
210
+ ): Promise<Tier2ProbeAttempt> {
211
+ const startedAt = Date.now();
212
+ try {
213
+ await driver.sendText(botUserId, spec.prompt);
214
+ } catch (err) {
215
+ return scoreAttempt(spec, "", Date.now() - startedAt, "error", `send failed: ${(err as Error).message}`);
216
+ }
217
+ try {
218
+ const answer: ObservedMessage = await expectMessage(
219
+ driver,
220
+ botUserId,
221
+ (m) => isAnswer(m, driverUserId),
222
+ { timeout: timeoutMs, senderFilter: { notUserId: driverUserId } },
223
+ );
224
+ return scoreAttempt(spec, answer.text, Date.now() - startedAt, "reply");
225
+ } catch (err) {
226
+ const msg = (err as Error).message;
227
+ const kind = /within \d+ms/.test(msg) ? "timeout" : "error";
228
+ return scoreAttempt(spec, "", Date.now() - startedAt, kind, msg);
229
+ }
230
+ }
231
+
232
+ async function runAgent(
233
+ driver: Driver,
234
+ driverUserId: number,
235
+ target: AgentTarget,
236
+ suite: ProbeSuite,
237
+ suiteLabel: string,
238
+ cli: CliConfig,
239
+ ): Promise<Tier2ProbeResults> {
240
+ process.stdout.write(`\n[tier2] ─── agent: ${target.name} (${target.botUsername}) phase=${cli.phase} ───\n`);
241
+ const botUserId = await driver.resolveBotUserId(target.botUsername);
242
+ process.stdout.write(`[tier2] resolved ${target.botUsername} → bot_user_id=${botUserId}\n`);
243
+
244
+ const probes = cli.smoke ? suite.probes.slice(0, 1) : suite.probes;
245
+ if (cli.smoke) process.stdout.write(`[tier2] SMOKE mode: running only "${probes[0]?.id}"\n`);
246
+
247
+ const outcomes: Tier2ProbeOutcome[] = [];
248
+ for (const spec of probes) {
249
+ const attempts: Tier2ProbeAttempt[] = [];
250
+ for (let rep = 0; rep < cli.k; rep++) {
251
+ const a = await runOneAttempt(driver, botUserId, driverUserId, spec, cli.replyTimeoutMs);
252
+ attempts.push(a);
253
+ const glyph = a.pass ? "✓" : a.outcome === "timeout" ? "·" : "✗";
254
+ process.stdout.write(
255
+ `[tier2] ${glyph} ${spec.id} rep ${rep + 1}/${cli.k} (${a.outcome}, ${a.durationMs}ms)\n`,
256
+ );
257
+ // Space repeats of the SAME probe by ≥ spacingMs (respect coalescing +
258
+ // flood limits). No wait after the final repeat of the final probe.
259
+ const isLast = rep === cli.k - 1 && spec === probes[probes.length - 1];
260
+ if (!isLast) await sleep(cli.spacingMs);
261
+ }
262
+ const folded = foldProbe(spec, attempts);
263
+ process.stdout.write(`[tier2] → ${spec.id}: ${folded.verdict} (${folded.passCount}/${folded.k})\n`);
264
+ outcomes.push(folded);
265
+ }
266
+
267
+ return foldPhase(target.name, cli.phase, suiteLabel, outcomes);
268
+ }
269
+
270
+ // ─── Main ──────────────────────────────────────────────────────────────────
271
+
272
+ async function main(): Promise<void> {
273
+ loadUatEnv();
274
+ const cli = parseCli(process.argv.slice(2));
275
+
276
+ const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
277
+ if (!Number.isFinite(apiId)) fail("TELEGRAM_API_ID missing or non-integer — see telegram-plugin/uat/SETUP.md");
278
+ const apiHash = process.env.TELEGRAM_API_HASH ?? "";
279
+ if (!apiHash) fail("TELEGRAM_API_HASH missing — see SETUP.md");
280
+ const session = process.env.TELEGRAM_UAT_DRIVER_SESSION ?? "";
281
+ if (!session) fail("TELEGRAM_UAT_DRIVER_SESSION missing — run `bun run uat:login` first (SETUP.md §4)");
282
+
283
+ // Resolve + validate every suite BEFORE connecting, so a bad suite fails
284
+ // without spending a live session.
285
+ const plans = cli.agents.map((target) => {
286
+ const suitePath = cli.suitePath ?? path.join(HERE, "probes", `${target.name}.probes.json`);
287
+ const suite = loadProbeSuite(suitePath);
288
+ if (suite.agent !== target.name) {
289
+ fail(`suite ${suitePath} declares agent "${suite.agent}" but target is "${target.name}"`);
290
+ }
291
+ return { target, suite, suiteLabel: path.basename(suitePath) };
292
+ });
293
+
294
+ process.stdout.write(`[tier2] connecting to Telegram as the UAT driver account...\n`);
295
+ const driver = new Driver({ apiId, apiHash, session });
296
+ await driver.connect();
297
+ const driverUserId = await driver.getMyUserId();
298
+ process.stdout.write(`[tier2] driver user_id=${driverUserId}\n`);
299
+
300
+ mkdirSync(cli.outDir, { recursive: true });
301
+ const written: string[] = [];
302
+ try {
303
+ for (const plan of plans) {
304
+ const results = await runAgent(driver, driverUserId, plan.target, plan.suite, plan.suiteLabel, cli);
305
+ const outPath = path.join(cli.outDir, `${plan.target.name}.${cli.phase}.json`);
306
+ writeFileSync(outPath, `${JSON.stringify(results, null, 2)}\n`, "utf-8");
307
+ written.push(outPath);
308
+ process.stdout.write(
309
+ `[tier2] wrote ${outPath} — phase ${results.pass ? "PASS" : "FAIL"} ` +
310
+ `(${(results.probes ?? []).filter((p) => p.verdict === "GREEN").length}/${(results.probes ?? []).length} GREEN)\n`,
311
+ );
312
+ }
313
+ } finally {
314
+ await driver.disconnect();
315
+ }
316
+
317
+ process.stdout.write(`\n[tier2] done. wrote ${written.length} result file(s):\n`);
318
+ for (const w of written) process.stdout.write(` ${w}\n`);
319
+ }
320
+
321
+ // Only run when invoked directly (not when imported by a test).
322
+ if (import.meta.main) {
323
+ main().catch((err) => {
324
+ process.stderr.write(`[tier2] fatal: ${(err as Error).stack ?? err}\n`);
325
+ process.exit(1);
326
+ });
327
+ }
@@ -60,7 +60,7 @@ export function scoreReply(
60
60
  * whitespace. Permissive on purpose — the scorer's regex matches
61
61
  * against words, not formatting.
62
62
  */
63
- function stripMarkdown(s: string): string {
63
+ export function stripMarkdown(s: string): string {
64
64
  return s
65
65
  .replace(/```[\s\S]*?```/g, " ")
66
66
  .replace(/`([^`]+)`/g, "$1")
@@ -75,8 +75,18 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
75
75
  that could take the Stop hook (and thus the turn) down with it."""
76
76
  session_id = hook_input.get("session_id") or "unknown"
77
77
 
78
- prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
79
- if prompt.startswith("<task-notification"):
78
+ # F6 — junk gate derived from the TRANSCRIPT's last human turn, not from a
79
+ # `hook_input["prompt"]` field. A Stop hook's input carries only
80
+ # session_id/transcript_path/stop_hook_active — never `prompt`/`user_prompt`
81
+ # — so the old `hook_input.get("prompt")` gate was permanently empty and
82
+ # NEVER fired, meaning prefetch would run on `<task-notification>` turns once
83
+ # lit. `_last_human_prompt` reads the same last-human turn the speculative
84
+ # query is derived from, so the gate now fires on exactly the synthetic
85
+ # follow-up turns `recall.py`'s synchronous gate skips (honouring the same
86
+ # `recallSkipTaskNotification` switch).
87
+ transcript_path = hook_input.get("transcript_path") or ""
88
+ query = _last_human_prompt(transcript_path)
89
+ if config.get("recallSkipTaskNotification", True) and query.startswith("<task-notification"):
80
90
  debug_log(config, "Prefetch: task-notification turn, skipping")
81
91
  return False
82
92
 
@@ -97,9 +107,8 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
97
107
  except Exception as exc: # pragma: no cover - defensive
98
108
  debug_log(config, f"Prefetch: delta retain failed, continuing without it: {exc}")
99
109
 
100
- # Step 2 — speculative recall.
101
- transcript_path = hook_input.get("transcript_path") or ""
102
- query = _last_human_prompt(transcript_path)
110
+ # Step 2 — speculative recall (query derived above from the transcript's
111
+ # last human turn).
103
112
  if not query:
104
113
  debug_log(config, "Prefetch: no usable query, nothing to prefetch")
105
114
  return False
@@ -123,6 +132,25 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
123
132
  debug_log(config, "Prefetch: no candidates, nothing to buffer")
124
133
  return False
125
134
 
135
+ # F5 — CURATE before buffering, through the SAME pipeline the synchronous
136
+ # recall path enforces (demote-drop, relevance sort, score floor, and the
137
+ # `recallMaxMemories` cap). Calling recall's shared helper — rather than
138
+ # `format_memories(results)` on the raw set — is what stops a demoted or
139
+ # uncapped memory from reaching the buffer and bypassing curation the sync
140
+ # path applies (carve §6.1 sharing requirement). Imported lazily so the
141
+ # flag-off no-op in `main()` never pays recall.py's import cost.
142
+ try:
143
+ import recall # noqa: PLC0415 - lazy, kept off the flag-off no-op path
144
+
145
+ results = recall.curate_recall_results(results, config, bank_id)
146
+ except Exception as exc: # pragma: no cover - defensive
147
+ debug_log(config, f"Prefetch: curation failed, skipping buffer: {exc}")
148
+ return False
149
+
150
+ if not results:
151
+ debug_log(config, "Prefetch: all candidates filtered by curation, nothing to buffer")
152
+ return False
153
+
126
154
  from lib.content import format_memories
127
155
 
128
156
  memories_block = format_memories(results)
@@ -92,6 +92,13 @@ _IMPORT_ELAPSED_SECONDS = time.monotonic() - _IMPORT_START_MONOTONIC
92
92
 
93
93
  LAST_RECALL_STATE = "last_recall.json"
94
94
  RECALL_CACHE_STATE = "recall_cache.json"
95
+ # M4 P-REC F3 — the last prefetch-buffer sentinel token this session has
96
+ # already CONSUMED, keyed by session_id. Persisted across turns so a buffer
97
+ # produced at turn N and consumed at N+1 is not re-served as "fresh" at
98
+ # N+2..N+k when no newer sentinel has landed (the stale-buffer-served-as-fresh
99
+ # class). A dict {session_id: token}; capped like the other per-session state.
100
+ PREFETCH_CONSUMED_STATE = "prefetch_consumed.json"
101
+ _PREFETCH_CONSUMED_MAX_SESSIONS = 10000
95
102
 
96
103
  # Switchroom hindsight-leverage A3 — label for the directives fetch slot in the
97
104
  # parallel fan-out. Distinct from any bank_id (banks can't start with "__") so a
@@ -500,12 +507,6 @@ def _emit_cached_context(context: str) -> None:
500
507
  )
501
508
 
502
509
 
503
- _PREFETCH_DEGRADED_NOTICE = (
504
- "⏳ prefetch not ready and no prior recall is cached for this session — "
505
- "proceeding without injected memory this turn."
506
- )
507
-
508
-
509
510
  def stale_recall_notice(memories_context: str) -> str:
510
511
  """Wrap a PRIOR turn's cached memories-only block in an explicit
511
512
  staleness marker for the M4 prefetch-buffer fallback path.
@@ -528,6 +529,100 @@ def stale_recall_notice(memories_context: str) -> str:
528
529
  )
529
530
 
530
531
 
532
+ def _read_consumed_token(session_id: str) -> "int | None":
533
+ """Return the last prefetch sentinel token this session already consumed,
534
+ or None if none is recorded. Failure-tolerant — any read/parse error
535
+ returns None (treat as "nothing consumed yet", the fail-open direction that
536
+ at worst re-serves once, never suppresses forever)."""
537
+ state = read_state(PREFETCH_CONSUMED_STATE, {}) or {}
538
+ if not isinstance(state, dict):
539
+ return None
540
+ token = state.get(session_id)
541
+ return int(token) if isinstance(token, (int, float)) else None
542
+
543
+
544
+ def _write_consumed_token(session_id: str, token: int) -> None:
545
+ """Persist `token` as the last sentinel this session has consumed. Bounded
546
+ to `_PREFETCH_CONSUMED_MAX_SESSIONS` entries. Best-effort — write_state
547
+ never raises."""
548
+ state = read_state(PREFETCH_CONSUMED_STATE, {}) or {}
549
+ if not isinstance(state, dict):
550
+ state = {}
551
+ state[session_id] = int(token)
552
+ if len(state) > _PREFETCH_CONSUMED_MAX_SESSIONS:
553
+ # Drop the numerically-smallest tokens (oldest producers) first.
554
+ for k in sorted(
555
+ state.keys(),
556
+ key=lambda k: state[k] if isinstance(state[k], (int, float)) else 0,
557
+ )[: len(state) - _PREFETCH_CONSUMED_MAX_SESSIONS]:
558
+ state.pop(k, None)
559
+ write_state(PREFETCH_CONSUMED_STATE, state)
560
+
561
+
562
+ def curate_recall_results(results, config, bank_id):
563
+ """M4 F5 — the SHARED recall curation pipeline, so the async prefetch
564
+ producer (`prefetch.py`) can never drift from the guarantees the
565
+ synchronous recall path enforces.
566
+
567
+ Applies, in order, the exact same module-level primitives the synchronous
568
+ path composes inline: drop demote-tagged memories (`_is_demoted_memory`),
569
+ sort by engine relevance (`_sort_by_final_score`), apply the optional
570
+ absolute score floor when it is configured for ALL turns
571
+ (`_filter_by_min_score`; the `degraded`-scope floor is a synchronous-path
572
+ concept the speculative single-bank prefetch cannot evaluate, so it is
573
+ honoured here only when `recallMinScoreScope` is `all`, matching what the
574
+ sync path does on a healthy own-bank turn), and finally the
575
+ `recallMaxMemories` head cap with per-bank slot reservation
576
+ (`_reserve_bank_slots`).
577
+
578
+ Returns the curated result list. Never mutates the caller's list in a way
579
+ that changes its length before returning (it rebinds internally). Pure —
580
+ no I/O, no network — so it is safe to call from the producer hook.
581
+ """
582
+ if not results:
583
+ return results
584
+
585
+ # 1) demote-from-recall drop (Switchroom #432 4.4) — before any cap so the
586
+ # cap fills with non-demoted hits.
587
+ results = [m for m in results if not _is_demoted_memory(m)]
588
+
589
+ # 2) relevance sort (Phase-1 bank-starvation fix) — in place on our copy.
590
+ _sort_by_final_score(results)
591
+
592
+ # 3) optional absolute score floor (#3837). The sync path binds this on
593
+ # `all` scope always, and on `degraded` scope only when the own-bank
594
+ # read degraded. A prefetch is a healthy speculative read (no degraded
595
+ # signal), so we bind only the `all` scope here — identical to the sync
596
+ # path's decision on a non-degraded turn.
597
+ min_score_floor = config.get("recallMinScore", 0.0)
598
+ if isinstance(min_score_floor, bool) or not isinstance(min_score_floor, (int, float)):
599
+ min_score_floor = 0.0
600
+ min_score_floor = float(min_score_floor)
601
+ min_score_scope = config.get("recallMinScoreScope", "degraded")
602
+ if min_score_floor > 0 and min_score_scope == "all":
603
+ results, _dropped = _filter_by_min_score(results, min_score_floor)
604
+
605
+ # 4) recallMaxMemories head cap + per-bank slot reservation. `_reserve_
606
+ # bank_slots` is a passthrough when cap<=0 or the set already fits, and
607
+ # with the fleet-default 0/0 floors on a single-bank prefetch it reduces
608
+ # to a plain head-slice — the same slice the sync path takes.
609
+ recall_max_memories = config.get("recallMaxMemories", 0)
610
+ if (
611
+ isinstance(recall_max_memories, int)
612
+ and recall_max_memories > 0
613
+ and len(results) > recall_max_memories
614
+ ):
615
+ results, _own, _add = _reserve_bank_slots(
616
+ results,
617
+ recall_max_memories,
618
+ bank_id,
619
+ config.get("recallOwnBankMinSlots", 0),
620
+ config.get("recallAdditionalBankMinSlots", 0),
621
+ )
622
+
623
+ return results
624
+
625
+
531
626
  def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool:
532
627
  """M4 P-REC Fix C (consumer side) — join the Stop-hook producer's
533
628
  prefetch buffer for this session instead of running recall
@@ -535,10 +630,12 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
535
630
 
536
631
  Gated entirely by `config.get("memoryPrefetchEnabled", False)` at the
537
632
  caller; this function assumes the flag is already on. Returns True iff
538
- it emitted an `additionalContext` payload (fresh hit, stale fallback, or
539
- the explicit degraded notice) and the caller should return without
540
- running the synchronous path. Returns False on a clean no-op miss (flag
541
- effectively off / nothing to say) so the caller falls through.
633
+ it emitted an `additionalContext` payload (fresh buffer hit, stale
634
+ fallback, or a directives-only block) and the caller should return
635
+ without running the synchronous path. Returns False on a miss with
636
+ nothing to serve — cold start, no fresh buffer, no stale prior recall,
637
+ no directives — so the caller falls through to SYNCHRONOUS recall (M4
638
+ F4: the first turn of a fresh/post-restart session must still recall).
542
639
 
543
640
  Never raises past this function's own boundary in normal operation —
544
641
  every internal step is wrapped so a bug here degrades to "fall through
@@ -547,6 +644,13 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
547
644
  """
548
645
  session_id = hook_input.get("session_id") or "unknown"
549
646
 
647
+ # M4 F3 — the sentinel token this session has ALREADY consumed on a prior
648
+ # turn. Threaded into both `poll_for_sentinel` and `read_if_fresh` so a
649
+ # buffer produced at turn N and consumed at N+1 is treated as STALE (not
650
+ # re-served as fresh) at N+2..N+k until a strictly-newer sentinel lands.
651
+ # None on a cold session (nothing consumed yet).
652
+ last_consumed = _read_consumed_token(session_id)
653
+
550
654
  # Cold-start short-circuit (red-team MAJOR finding): if this session has
551
655
  # NEVER produced a sentinel, polling the full cap on every single
552
656
  # session-open turn would cost ~the poll cap on every fresh session —
@@ -556,9 +660,9 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
556
660
  debug_log(config, "Prefetch buffer: no sentinel ever written for this session, cold-start skip")
557
661
  else:
558
662
  cap_ms = int(config.get("memoryPrefetchPollCapMs", 400))
559
- recall_buffer.poll_for_sentinel(session_id, last_consumed_token=None, cap_ms=cap_ms)
663
+ recall_buffer.poll_for_sentinel(session_id, last_consumed_token=last_consumed, cap_ms=cap_ms)
560
664
 
561
- payload, _token = recall_buffer.read_if_fresh(session_id, last_consumed_token=None)
665
+ payload, _token = recall_buffer.read_if_fresh(session_id, last_consumed_token=last_consumed)
562
666
 
563
667
  # Directives stay on the synchronous, always-fresh path (M3 rule) even
564
668
  # in the fast path — fetched here directly, never from the buffer.
@@ -584,6 +688,12 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
584
688
  directives_block = None
585
689
 
586
690
  if payload is not None:
691
+ # M4 F3 — record this token as consumed BEFORE emitting, so the next
692
+ # turn's `read_if_fresh` rejects the same buffer as stale unless the
693
+ # producer has since written a strictly-newer sentinel. `_token` is the
694
+ # sentinel token `read_if_fresh` just validated as fresh.
695
+ if _token is not None:
696
+ _write_consumed_token(session_id, _token)
587
697
  memories_block = payload.get("context") or ""
588
698
  parts = [b for b in (_dir_notice, directives_block, memories_block) if b]
589
699
  if not parts:
@@ -607,11 +717,15 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
607
717
  _emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
608
718
  return True
609
719
 
610
- # Nothing fresh, nothing stale, nothing cached — say so explicitly
611
- # rather than silently emitting no context (so a degraded turn is
612
- # legible, matching the #3619 degraded-disclosure precedent).
613
- _emit_cached_context(_PREFETCH_DEGRADED_NOTICE)
614
- return True
720
+ # M4 F4 — nothing fresh, nothing stale, nothing cached (the cold-start /
721
+ # first-turn / no-prior-recall case). Return False so the caller runs the
722
+ # SYNCHRONOUS recall path, which is exactly the path that should run on a
723
+ # turn where no prefetch could possibly exist yet. Emitting a degraded
724
+ # notice and returning True here (the previous behaviour) short-circuited
725
+ # sync recall and handed a fresh/post-restart session ZERO memories plus a
726
+ # degraded banner on its very first turn — the opposite of the docstring's
727
+ # "a miss falls through to sync recall" contract.
728
+ return False
615
729
 
616
730
 
617
731
  def _emit_directives_only(config: dict, hook_input: dict) -> None: