switchroom 0.21.16 → 0.21.18

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 (43) hide show
  1. package/dist/agent-scheduler/index.js +5 -0
  2. package/dist/auth-broker/index.js +5 -0
  3. package/dist/cli/notion-write-pretool.mjs +5 -0
  4. package/dist/cli/switchroom.js +1492 -1136
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +5 -0
  7. package/dist/vault/broker/server.js +5 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +9 -4
  11. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  12. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  13. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  14. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  15. package/telegram-plugin/uat/flip/gate.ts +232 -0
  16. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  18. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  19. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  20. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  21. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  22. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  23. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  24. package/telegram-plugin/uat/flip/report.ts +95 -0
  25. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  27. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  28. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  29. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  30. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  31. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  32. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  33. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  34. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
  35. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  36. package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
  37. package/vendor/hindsight-memory/scripts/recall.py +144 -11
  38. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  39. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
@@ -0,0 +1,248 @@
1
+ """Memory v2 M5 — Surface B: orientation-at-boot (pure logic).
2
+
3
+ carve-M5.md §3/§5/§0c. This module holds the network-free, deterministically
4
+ testable half of the orientation SessionStart hook: staleness classification
5
+ (per-tier thresholds, NOT a fixed 36h), rule-aware truncation to the content
6
+ token budget, and the `additionalContext` rendering. The hook entry
7
+ (`orientation.py`) does the I/O — resolve own bank, list mental models, match
8
+ the orientation model by name, GET its content — and calls into here so the
9
+ budget/staleness/render logic can be unit-tested without a live engine.
10
+
11
+ Why the split mirrors the fleet's other deterministic memory surfaces
12
+ (recall.py's nudge regexes, prefetch.py's buffer contract): the value is a
13
+ DETERMINISTIC mechanism, not model discretion, so every branch that decides
14
+ "inject / prefix-as-stale / degrade-to-cold / truncate" must be pinned by a
15
+ test that fails on the bug it guards (carve §8 tautology-guard discipline).
16
+
17
+ TOKEN BUDGET (carve §0c). The epic caps the injected briefing at 2048 tokens,
18
+ but M0 measured real mental models at ~1,766–1,962 tokens of CONTENT alone —
19
+ so a naive "cap at 2048" overflows the moment a stale prefix or framing is
20
+ prepended. The real number to design to is a CONTENT budget below the cap,
21
+ leaving headroom for the prefix + framing. Truncation is rule-aware: whole
22
+ markdown sections are kept, trailing sections dropped, and a visible marker is
23
+ emitted so the loss is never silent.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ from datetime import datetime, timezone
30
+
31
+ #: Hard ceiling on the total injected `additionalContext` (epic line 143).
32
+ ORIENTATION_TOTAL_TOKEN_CAP = 2048
33
+ #: Budget for the model CONTENT alone, below the cap so the stale prefix +
34
+ #: framing tags fit inside 2048 (carve §0c: ~1,800, reserving ~250).
35
+ ORIENTATION_CONTENT_TOKEN_BUDGET = 1800
36
+ #: Chars-per-token estimate M0 used to convert measured char sizes to tokens
37
+ #: (7,063 chars ≈ 1,766 tokens). Deterministic proxy — no tokenizer dependency
38
+ #: on the boot critical path.
39
+ CHARS_PER_TOKEN = 4
40
+
41
+ #: Emitted (once) when trailing sections were dropped to fit the budget, so the
42
+ #: loss is visible to the model rather than a silent mid-sentence byte-cut.
43
+ TRUNCATION_MARKER = "(orientation truncated to fit context budget)"
44
+
45
+ # Framing tags. Kept intentionally tiny — they count against the 2048 cap.
46
+ _OPEN_TAG = "<orientation source=\"memory\">"
47
+ _CLOSE_TAG = "</orientation>"
48
+
49
+
50
+ def estimate_tokens(text: str) -> int:
51
+ """Deterministic token estimate (ceil of chars / CHARS_PER_TOKEN).
52
+
53
+ A proxy, not a real tokenizer — but the SAME proxy M0 measured content
54
+ sizes with, so the budget math is internally consistent, and it needs no
55
+ model/tokenizer on the boot path. Empty string is 0 tokens.
56
+ """
57
+ if not text:
58
+ return 0
59
+ return math.ceil(len(text) / CHARS_PER_TOKEN)
60
+
61
+
62
+ def classify_staleness(
63
+ last_refreshed_at: str | None,
64
+ cadence_hours: int,
65
+ now: datetime | None = None,
66
+ ) -> tuple[str, float | None]:
67
+ """Classify an orientation model's freshness against its agent's TIER.
68
+
69
+ Returns ``(state, hours_ago)`` where state is one of:
70
+ - ``"fresh"`` — refreshed < 1.5× cadence ago: inject plainly.
71
+ - ``"stale"`` — 1.5×–3× cadence ago: inject WITH a visible prefix.
72
+ - ``"degraded"`` — > 3× cadence ago: do NOT inject stale-as-fresh; the
73
+ hook emits the cold notice + enqueues a refresh.
74
+ - ``"unknown"`` — no/unparseable ``last_refreshed_at``: treat as cold
75
+ (fail closed — never present an un-dated model as
76
+ fresh). ``hours_ago`` is None.
77
+
78
+ The thresholds are PER-TIER (carve §5): a fixed 36h would mislabel every
79
+ 48h-cadence agent as stale a full day early. 1.5× / 3× of the resolved
80
+ cadence is the tier-correct boundary — 36h/72h at cadence 24, 72h/144h at
81
+ cadence 48. Keyed on ``last_refreshed_at`` (the freshness watermark the
82
+ persisting refresh advances — m5-0-probe Q3), NOT ``updated_at`` (returned
83
+ null on the single-model read) and NOT the engine's own ``is_stale`` (its
84
+ threshold is engine-defined, not the agent's tier).
85
+ """
86
+ if now is None:
87
+ now = datetime.now(timezone.utc)
88
+ if not last_refreshed_at:
89
+ return ("unknown", None)
90
+ ts = _parse_iso(last_refreshed_at)
91
+ if ts is None:
92
+ return ("unknown", None)
93
+ # A naive timestamp is assumed UTC (the engine stores tz-aware UTC; this is
94
+ # belt-and-braces so an aware/naive mismatch can never raise on subtract).
95
+ if ts.tzinfo is None:
96
+ ts = ts.replace(tzinfo=timezone.utc)
97
+ hours_ago = (now - ts).total_seconds() / 3600.0
98
+ # Guard a clock-skew future timestamp: treat as fresh, never negative-stale.
99
+ if hours_ago < 0:
100
+ hours_ago = 0.0
101
+ stale_at = 1.5 * cadence_hours
102
+ degrade_at = 3.0 * cadence_hours
103
+ if hours_ago < stale_at:
104
+ return ("fresh", hours_ago)
105
+ if hours_ago < degrade_at:
106
+ return ("stale", hours_ago)
107
+ return ("degraded", hours_ago)
108
+
109
+
110
+ def _parse_iso(value: str) -> datetime | None:
111
+ """Parse an engine ISO-8601 timestamp, tolerating a trailing ``Z``."""
112
+ try:
113
+ # datetime.fromisoformat handles "+00:00" and fractional seconds; map a
114
+ # trailing Z (some engine surfaces emit it) to the offset form first.
115
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
116
+ except (ValueError, AttributeError):
117
+ return None
118
+
119
+
120
+ def truncate_to_budget(content: str, token_budget: int) -> tuple[str, bool]:
121
+ """Rule-aware truncation to ``token_budget`` tokens.
122
+
123
+ Returns ``(text, truncated)``. Prefers WHOLE markdown sections (a section
124
+ starts at a line beginning with ``#``): accumulates sections while they fit,
125
+ drops trailing sections that don't, and signals ``truncated=True`` so the
126
+ caller can append :data:`TRUNCATION_MARKER`. If the very first section
127
+ already exceeds the budget, hard-cuts at a whitespace boundary (never
128
+ mid-word) rather than emitting nothing.
129
+
130
+ Never returns more than the budget; an empty/whitespace input returns
131
+ ``("", False)``.
132
+ """
133
+ content = (content or "").strip()
134
+ if not content:
135
+ return ("", False)
136
+ if estimate_tokens(content) <= token_budget:
137
+ return (content, False)
138
+
139
+ sections = _split_sections(content)
140
+ kept: list[str] = []
141
+ used = 0
142
+ for sec in sections:
143
+ sec_tokens = estimate_tokens(sec)
144
+ # +1 token slack for the join newline between kept sections.
145
+ if kept and used + sec_tokens + 1 > token_budget:
146
+ break
147
+ if not kept and sec_tokens > token_budget:
148
+ # First section alone overflows — hard-cut on a word boundary.
149
+ return (_hard_cut(sec, token_budget), True)
150
+ kept.append(sec)
151
+ used += sec_tokens + (1 if len(kept) > 1 else 0)
152
+
153
+ if not kept:
154
+ return (_hard_cut(sections[0], token_budget), True)
155
+ return ("\n".join(kept).strip(), True)
156
+
157
+
158
+ def _split_sections(content: str) -> list[str]:
159
+ """Split markdown into whole sections at lines beginning with ``#``.
160
+
161
+ Leading preamble before the first header is its own section. A document
162
+ with no headers is a single section (the hard-cut path handles overflow).
163
+ """
164
+ lines = content.split("\n")
165
+ sections: list[str] = []
166
+ cur: list[str] = []
167
+ for line in lines:
168
+ if line.startswith("#") and cur:
169
+ sections.append("\n".join(cur))
170
+ cur = [line]
171
+ else:
172
+ cur.append(line)
173
+ if cur:
174
+ sections.append("\n".join(cur))
175
+ return sections
176
+
177
+
178
+ def _hard_cut(text: str, token_budget: int) -> str:
179
+ """Cut ``text`` to fit ``token_budget`` at a whitespace boundary."""
180
+ char_budget = max(0, token_budget * CHARS_PER_TOKEN)
181
+ if len(text) <= char_budget:
182
+ return text.strip()
183
+ cut = text[:char_budget]
184
+ # Back up to the last whitespace so we never slice a word in half.
185
+ ws = cut.rfind(" ")
186
+ nl = cut.rfind("\n")
187
+ boundary = max(ws, nl)
188
+ if boundary > 0:
189
+ cut = cut[:boundary]
190
+ return cut.strip()
191
+
192
+
193
+ def stale_prefix(hours_ago: float | None) -> str:
194
+ """One-line visible staleness prefix (carve §5). Never presents stale as fresh."""
195
+ if hours_ago is None:
196
+ return "(orientation staleness unknown — may be stale)"
197
+ return f"(orientation last refreshed {int(round(hours_ago))}h ago — may be stale)"
198
+
199
+
200
+ def cold_notice() -> str:
201
+ """One-line cold notice (carve §3-4): no usable model, boot un-blocked.
202
+
203
+ Visible by design — a missing/degraded briefing degrades to a NOTICE the
204
+ model can see, never a silently memoryless boot.
205
+ """
206
+ return (
207
+ f"{_OPEN_TAG}\n"
208
+ "orientation model not yet built or refreshed — booting without a "
209
+ "briefing (a background refresh has been requested)\n"
210
+ f"{_CLOSE_TAG}"
211
+ )
212
+
213
+
214
+ def render_orientation(
215
+ content: str,
216
+ staleness: str,
217
+ hours_ago: float | None,
218
+ ) -> str:
219
+ """Render the injected `additionalContext` for a usable model.
220
+
221
+ Applies the stale prefix when warranted, truncates the CONTENT to
222
+ :data:`ORIENTATION_CONTENT_TOKEN_BUDGET` (rule-aware), wraps in the tiny
223
+ framing tags, and guarantees the WHOLE rendered block (prefix + framing +
224
+ content) stays within :data:`ORIENTATION_TOTAL_TOKEN_CAP` — if the prefix
225
+ pushes it over, the content budget is trimmed further so the cap holds.
226
+
227
+ Callers pass only ``fresh`` or ``stale`` here; ``degraded``/``unknown`` go
228
+ to :func:`cold_notice` in the hook (never rendered as fresh).
229
+ """
230
+ prefix = stale_prefix(hours_ago) if staleness == "stale" else ""
231
+ # Reserve budget for the framing tags + prefix so the TOTAL stays under the
232
+ # hard cap, not just the content.
233
+ framing_tokens = estimate_tokens(_OPEN_TAG) + estimate_tokens(_CLOSE_TAG) + 2
234
+ prefix_tokens = estimate_tokens(prefix) + (1 if prefix else 0)
235
+ content_budget = min(
236
+ ORIENTATION_CONTENT_TOKEN_BUDGET,
237
+ ORIENTATION_TOTAL_TOKEN_CAP - framing_tokens - prefix_tokens,
238
+ )
239
+ body, truncated = truncate_to_budget(content, content_budget)
240
+ if truncated:
241
+ body = f"{body}\n\n{TRUNCATION_MARKER}"
242
+
243
+ parts = [_OPEN_TAG]
244
+ if prefix:
245
+ parts.append(prefix)
246
+ parts.append(body)
247
+ parts.append(_CLOSE_TAG)
248
+ return "\n".join(parts)
@@ -210,6 +210,35 @@ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
210
210
  return {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
211
211
 
212
212
 
213
+ def invalidate(session_id: str) -> None:
214
+ """Drop any pending prefetch buffer + sentinel for ``session_id``.
215
+
216
+ Called at MUTATION sites — a rule/directive retire (``directive_verify``)
217
+ or a retain (``prefetch.run_prefetch`` before it re-recalls) — so a buffer
218
+ captured BEFORE the mutation can never be consumed AFTER it. This closes
219
+ the resurrection class the M3 red-team (R2, BLOCKER) flagged at the
220
+ M3/M4 intersection: a rule retired mid-session must not re-inject from a
221
+ buffer prefetched while the rule was still active. It is the read-after-
222
+ write ordering the producer already builds for the happy path, extended
223
+ to the mutation path — NOT a reliance on the buffer's TTL (timing, not
224
+ correctness).
225
+
226
+ The SENTINEL is removed FIRST so a partial invalidation still fails
227
+ closed: ``read_if_fresh`` checks the sentinel before the payload, so once
228
+ the sentinel is gone the reader returns ``None`` regardless of whether the
229
+ payload unlink also succeeded — an orphaned payload is never served as
230
+ fresh. Best-effort and idempotent: a missing file is a no-op, and the
231
+ function NEVER raises (a failed unlink must not break the turn).
232
+ """
233
+ for path in (_sentinel_path(session_id), _buffer_path(session_id)):
234
+ try:
235
+ os.remove(path)
236
+ except FileNotFoundError:
237
+ pass
238
+ except OSError:
239
+ pass
240
+
241
+
213
242
  def poll_for_sentinel(session_id: str, last_consumed_token: Optional[int], cap_ms: int) -> bool:
214
243
  """Clock-bounded poll for a fresh sentinel. Never busy-spins.
215
244
 
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook: Memory v2 M5 — orientation-at-boot (Surface B).
3
+
4
+ carve-M5.md §3. Injects the agent's cron-refreshed ``orientation`` mental
5
+ model into context at SessionStart AND re-seats it after every compaction —
6
+ "reflect once, serve from cache" (E-79), deterministically, zero tool call.
7
+
8
+ DARK BY DEFAULT (red-team kill switch). Entirely gated by
9
+ ``memoryOrientationEnabled`` (default False — carve §4, same discipline as
10
+ M4's ``memoryPrefetchEnabled``). When off this hook is a hard no-op: it reads
11
+ config, sees the flag off, and exits BEFORE any bank resolve or network call.
12
+ The flag is per-agent — a stripped/absent value fails to OFF (fail-safe), so
13
+ an un-flipped agent boots exactly as pre-M5.
14
+
15
+ MATCHER-LESS (load-bearing, carve §1/§3). Registered with NO matcher, so it
16
+ fires on every SessionStart source — startup, resume, clear, fork AND compact.
17
+ The compact firing is the free deterministic post-compaction re-seat that
18
+ answers E-88 ("nothing re-seats orientation today"): the hook re-reads the
19
+ model from the engine each time, so a mid-session refresh is picked up at the
20
+ next compaction with no session restart. Do NOT add a matcher.
21
+
22
+ FAIL-SAFE INVARIANTS (all asserted in tests/test_orientation_hook.py):
23
+ - Boot is NEVER blocked on the orientation read. Every failure path
24
+ (server down, no model, read error, read timeout, degraded/undated
25
+ model) degrades to a VISIBLE one-line cold notice + a refresh request,
26
+ and exits 0. Never a silently memoryless boot, never a stale briefing
27
+ presented as fresh.
28
+ - A stale (1.5×–3× cadence) model is injected WITH a visible prefix; a
29
+ degraded (>3×) or undated model is NOT injected — the cold notice is
30
+ emitted instead. Thresholds are per-tier (carve §5), not a fixed 36h.
31
+
32
+ STDOUT CONTRACT. A SessionStart hook's stdout IS added to the model's context
33
+ (unlike PreCompact); we emit ``{"hookSpecificOutput":{"hookEventName":
34
+ "SessionStart","additionalContext": "…"}}``. Exit code is 0 ALWAYS — a
35
+ non-zero exit BLOCKS the turn in Claude Code, and orientation must degrade to
36
+ a notice, never a hard stop (same doctrine as rules-sentinel-hook.sh).
37
+ """
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+
45
+ from lib import orientation as orient
46
+ from lib.bank import derive_bank_id
47
+ from lib.client import HindsightClient
48
+ from lib.config import debug_log, load_config
49
+ from lib.daemon import get_api_url
50
+
51
+ #: Generous read-timeout for the model GET (carve §3). overlord's own-bank read
52
+ #: is ~270ms and klanker's ~4ms (m0/m5-0 probes), but the latency asymmetry is
53
+ #: unexplained and possibly bank-size-correlated, so leave headroom — and on a
54
+ #: timeout degrade to the cold notice, never a hung boot.
55
+ ORIENTATION_READ_TIMEOUT_S = 3
56
+
57
+
58
+ def _emit(additional_context: str) -> None:
59
+ """Print the SessionStart additionalContext envelope to stdout."""
60
+ print(
61
+ json.dumps(
62
+ {
63
+ "hookSpecificOutput": {
64
+ "hookEventName": "SessionStart",
65
+ "additionalContext": additional_context,
66
+ }
67
+ }
68
+ )
69
+ )
70
+
71
+
72
+ def enqueue_refresh(config: dict, bank_id: str, model_name: str) -> None:
73
+ """Signal that ``bank_id``'s orientation model needs a background refresh.
74
+
75
+ The cold/degraded path requests a refresh so a boot that found no usable
76
+ briefing does not stay briefing-less indefinitely (carve §3-4). This is the
77
+ SEAM the M5-C out-of-session tiered scheduler consumes; the scheduler is
78
+ cadence-driven regardless, so this is a best-effort nudge, never a blocking
79
+ dependency. Kept as a thin, override-able function so the cold-path tests
80
+ can assert the OUTCOME ("a refresh was requested") without a live scheduler.
81
+
82
+ Best-effort and total: any failure is swallowed — a boot must never fail
83
+ because a refresh request could not be recorded.
84
+ """
85
+ debug_log(
86
+ config,
87
+ f"orientation cold/degraded on bank '{bank_id}' model "
88
+ f"'{model_name}' — refresh requested",
89
+ )
90
+ try:
91
+ state_dir = os.environ.get("HINDSIGHT_STATE_DIR") or os.path.join(
92
+ os.path.expanduser("~"), ".hindsight"
93
+ )
94
+ os.makedirs(state_dir, exist_ok=True)
95
+ marker = os.path.join(state_dir, "orientation-refresh-pending.jsonl")
96
+ with open(marker, "a") as f:
97
+ f.write(json.dumps({"bank_id": bank_id, "model": model_name}) + "\n")
98
+ except Exception as e: # noqa: BLE001 — best-effort, never breaks boot
99
+ debug_log(config, f"orientation refresh-request write failed (ignored): {e}")
100
+
101
+
102
+ def _match_model(models: dict, name: str) -> dict | None:
103
+ """Find the mental model whose ``name`` matches the configured orientation name."""
104
+ items = models.get("items") if isinstance(models, dict) else None
105
+ if not isinstance(items, list):
106
+ return None
107
+ for item in items:
108
+ if isinstance(item, dict) and item.get("name") == name:
109
+ return item
110
+ return None
111
+
112
+
113
+ def run(hook_input: dict, config: dict) -> None:
114
+ """Core hook logic. Emits at most one additionalContext block; never raises."""
115
+ if not config.get("memoryOrientationEnabled", False):
116
+ debug_log(config, "memoryOrientationEnabled off — orientation no-op")
117
+ return
118
+
119
+ model_name = config.get("memoryOrientationModel", "orientation")
120
+ cadence_hours = int(config.get("memoryOrientationCadenceHours", 48) or 48)
121
+ bank_id = derive_bank_id(hook_input, config)
122
+
123
+ def _dbg(*a):
124
+ debug_log(config, *a)
125
+
126
+ # Resolve the engine REST URL WITHOUT starting the daemon (too slow for
127
+ # SessionStart). If the server is unreachable, degrade to the cold notice —
128
+ # we cannot read the model, and boot must not block.
129
+ try:
130
+ api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
131
+ client = HindsightClient(api_url, config.get("hindsightApiToken"))
132
+ except (RuntimeError, ValueError) as e:
133
+ debug_log(config, f"orientation: Hindsight unreachable ({e}) — cold notice")
134
+ enqueue_refresh(config, bank_id, model_name)
135
+ _emit(orient.cold_notice())
136
+ return
137
+
138
+ # Resolve the orientation model by NAME → id, then read its content +
139
+ # freshness watermark. Any read error/timeout is a cold notice, not a hang.
140
+ try:
141
+ models = client.list_mental_models(bank_id, timeout=ORIENTATION_READ_TIMEOUT_S)
142
+ match = _match_model(models, model_name)
143
+ except Exception as e: # noqa: BLE001 — degrade to cold, never block boot
144
+ debug_log(config, f"orientation: list models failed ({e}) — cold notice")
145
+ enqueue_refresh(config, bank_id, model_name)
146
+ _emit(orient.cold_notice())
147
+ return
148
+
149
+ if not match or not match.get("id"):
150
+ debug_log(config, f"orientation: no model named '{model_name}' — cold notice")
151
+ enqueue_refresh(config, bank_id, model_name)
152
+ _emit(orient.cold_notice())
153
+ return
154
+
155
+ try:
156
+ full = client.get_mental_model(
157
+ bank_id, match["id"], detail="full", timeout=ORIENTATION_READ_TIMEOUT_S
158
+ )
159
+ except Exception as e: # noqa: BLE001 — includes urllib timeout
160
+ debug_log(config, f"orientation: model read failed ({e}) — cold notice")
161
+ enqueue_refresh(config, bank_id, model_name)
162
+ _emit(orient.cold_notice())
163
+ return
164
+
165
+ content = (full or {}).get("content") or ""
166
+ last_refreshed_at = (full or {}).get("last_refreshed_at")
167
+ state, hours_ago = orient.classify_staleness(last_refreshed_at, cadence_hours)
168
+
169
+ if not content.strip() or state in ("degraded", "unknown"):
170
+ # Empty payload, or refreshed > 3× cadence ago / undated: never present
171
+ # stale-as-fresh. Cold notice + refresh request (carve §5).
172
+ debug_log(config, f"orientation: state={state}, empty={not content.strip()} — cold notice")
173
+ enqueue_refresh(config, bank_id, model_name)
174
+ _emit(orient.cold_notice())
175
+ return
176
+
177
+ debug_log(config, f"orientation: injecting (state={state}, {hours_ago}h ago)")
178
+ _emit(orient.render_orientation(content, state, hours_ago))
179
+
180
+
181
+ def main():
182
+ config = load_config()
183
+ try:
184
+ hook_input = json.load(sys.stdin)
185
+ except (json.JSONDecodeError, EOFError):
186
+ hook_input = {}
187
+ run(hook_input, config)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ try:
192
+ main()
193
+ except Exception as e: # noqa: BLE001 — a hook that exits non-zero blocks the turn
194
+ print(f"[Hindsight] Orientation SessionStart error: {e}", file=sys.stderr)
195
+ sys.exit(0)
@@ -80,6 +80,16 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
80
80
  debug_log(config, "Prefetch: task-notification turn, skipping")
81
81
  return False
82
82
 
83
+ # Step 0 — MUTATION INVALIDATION (red-team-M3 R2, BLOCKER). This turn is
84
+ # about to retain (step 1) — a memory mutation. Drop any buffer left from a
85
+ # prior turn BEFORE recalling, so that if this turn's fresh recall fails or
86
+ # returns empty (steps 2-3 below bail without overwriting), the consumer
87
+ # cannot resurrect the pre-mutation snapshot: with no sentinel it falls to
88
+ # the explicitly stale-marked LAST_RECALL_STATE / degraded notice instead of
89
+ # serving a stale buffer as fresh. A successful recall repopulates the buffer
90
+ # with the post-mutation snapshot at step 3. Never raises.
91
+ recall_buffer.invalidate(session_id)
92
+
83
93
  # Step 1 — delta retain (Fix A: content-derived document_id, never the
84
94
  # bare {session_id} document; never truncates).
85
95
  try: