switchroom 0.21.15 → 0.21.17

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 (37) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +6 -0
  3. package/dist/auth-broker/index.js +6 -0
  4. package/dist/cli/notion-write-pretool.mjs +6 -0
  5. package/dist/cli/switchroom.js +2086 -871
  6. package/dist/host-control/main.js +7 -1
  7. package/dist/vault/approvals/kernel-server.js +6 -0
  8. package/dist/vault/broker/server.js +6 -0
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +11 -0
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  12. package/skills/mental-model-curator/SKILL.md +187 -56
  13. package/telegram-plugin/dist/gateway/gateway.js +10 -4
  14. package/vendor/hindsight-memory/hooks/hooks.json +19 -0
  15. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  16. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  17. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  18. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  19. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +265 -0
  20. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  21. package/vendor/hindsight-memory/scripts/prefetch.py +166 -0
  22. package/vendor/hindsight-memory/scripts/recall.py +323 -5
  23. package/vendor/hindsight-memory/scripts/retain.py +132 -49
  24. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  25. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -519,3 +519,38 @@ class HindsightClient:
519
519
  if retain_mission:
520
520
  updates["retain_mission"] = retain_mission
521
521
  return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
522
+
523
+ def list_mental_models(self, bank_id: str, timeout: int = 5) -> dict:
524
+ """List the mental models for a bank (Memory v2 M5 — orientation-at-boot).
525
+
526
+ The orientation SessionStart hook knows the orientation model by NAME
527
+ (the configured `memoryOrientationModel`), not by its `mm-…` id, so it
528
+ lists the bank's models and matches on name → id before reading content
529
+ (carve-M5 §0d/§3). Read-only GET on the ungated engine REST surface;
530
+ m5-0-probe measured the own-bank read at ~4ms on klanker.
531
+
532
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models``. Returns the raw
533
+ response dict, expected to carry an ``items`` list where each item has at
534
+ least ``id`` and ``name``.
535
+ """
536
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/mental-models"
537
+ return self._request("GET", path, timeout=timeout)
538
+
539
+ def get_mental_model(
540
+ self, bank_id: str, model_id: str, detail: str = "full", timeout: int = 5
541
+ ) -> dict:
542
+ """Read one mental model's content + freshness watermark (M5).
543
+
544
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models/{model_id}?detail=full``
545
+ (m5-0-probe Q3/Q4 proved this returns ``content`` + the
546
+ ``last_refreshed_at`` watermark the staleness guard keys on, ~4ms warm).
547
+ Read-only; ungated. The caller wraps this in a generous read-timeout and
548
+ degrades to the cold notice on any error — a slow/failed read must never
549
+ block boot (carve §3 fail-safe).
550
+ """
551
+ bank = urllib.parse.quote(bank_id, safe="")
552
+ model = urllib.parse.quote(model_id, safe="")
553
+ path = f"/v1/default/banks/{bank}/mental-models/{model}"
554
+ if detail:
555
+ path = f"{path}?{urllib.parse.urlencode({'detail': detail})}"
556
+ return self._request("GET", path, timeout=timeout)
@@ -130,6 +130,20 @@ DEFAULTS = {
130
130
  # opt out per-agent via memory.profile_capture_nudge=false →
131
131
  # HINDSIGHT_PROFILE_CAPTURE_NUDGE.
132
132
  "profileCaptureNudge": True,
133
+ # Switchroom Memory v2 M3 Surface-A — directive-injection switch. When True
134
+ # (default), recall.py injects the `<active_directives>` block on every
135
+ # UserPromptSubmit at all three emit sites (main + prefetch/cache fast
136
+ # paths). When False (a flipped M3 canary), that injection is SUPPRESSED —
137
+ # the agent's standing rules come from the live rules block instead, which
138
+ # is the change that collapses the always-on directive-token spend (E-41).
139
+ # Suppression is fail-safe and re-checked every turn: it fires ONLY when a
140
+ # non-empty rules block is physically present in CLAUDE.md; a False flag
141
+ # with an empty/absent block keeps injecting AND emits a degraded-canary
142
+ # notice, so a zero-standing-rules turn is unreachable. Operators flip a
143
+ # canary per-agent via memory.inject_directives=false →
144
+ # HINDSIGHT_INJECT_DIRECTIVES; the ordered flip (rules_block live → migrate
145
+ # → this flag off) is enforced by `switchroom memory flip`.
146
+ "injectDirectives": True,
133
147
  # Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
134
148
  # directive_verify.py Stop hook) split out from the advisory nudge. When
135
149
  # True (default) the verifier may block the stop once to re-prompt capture;
@@ -140,6 +154,23 @@ DEFAULTS = {
140
154
  # per-agent via memory.directive_capture_verify=false →
141
155
  # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
142
156
  "directiveCaptureVerify": True,
157
+ # Memory v2 M5 — orientation-at-boot (Surface B). All four fail-safe by
158
+ # default so a stripped/absent key (the Zod-strip footgun, carve §1) boots
159
+ # exactly as pre-M5. `memoryOrientationEnabled` is the per-agent kill switch,
160
+ # default OFF (dark build — the orientation SessionStart hook no-ops before
161
+ # any bank resolve or network call when off). `memoryOrientationModel` is the
162
+ # mental-model NAME the hook resolves to an id (the agent's OWN bank).
163
+ # `memoryOrientationCadenceHours` is the per-agent refresh cadence tier
164
+ # (klanker/overlord 24, everyone else 48) the staleness guard's per-tier
165
+ # thresholds (1.5×/3×) key on. `memoryOrientationReinjectTurns` is the
166
+ # epic's optional per-turn re-inject knob, default 0 = SessionStart+compaction
167
+ # only (the cheap path; N>0 is the ~55M/30d-at-every-turn expensive variant,
168
+ # which is why it defaults off). Delivered per-agent via the scaffold
169
+ # settings.json stamp and overridable via the HINDSIGHT_ORIENTATION_* env.
170
+ "memoryOrientationEnabled": False,
171
+ "memoryOrientationModel": "orientation",
172
+ "memoryOrientationCadenceHours": 48,
173
+ "memoryOrientationReinjectTurns": 0,
143
174
  # Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
144
175
  # cache on the recall critical path (see lib/directives.py). The list is
145
176
  # re-fetched at most once per TTL window for no-write turns; in-session
@@ -510,11 +541,27 @@ ENV_OVERRIDES = {
510
541
  # it; the switchroom default is on (settings.json pins true; recall.py falls
511
542
  # back to True).
512
543
  "HINDSIGHT_PROFILE_CAPTURE_NUDGE": ("profileCaptureNudge", bool),
544
+ # Switchroom Memory v2 M3 Surface-A: directive-injection switch on/off. Set
545
+ # by start.sh from agents.<name>.memory.inject_directives only when the
546
+ # operator overrode it; the switchroom default is on (settings.json pins
547
+ # true; recall.py falls back to True). False SUPPRESSES the
548
+ # <active_directives> injection for a flipped canary, fail-safe on a live
549
+ # rules block (see recall.py directive_injection_decision).
550
+ "HINDSIGHT_INJECT_DIRECTIVES": ("injectDirectives", bool),
513
551
  # Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
514
552
  # the Stage B nudge. Set by start.sh from
515
553
  # agents.<name>.memory.directive_capture_verify only when the operator
516
554
  # overrode it; the switchroom default is on.
517
555
  "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
556
+ # Memory v2 M5 — orientation-at-boot. Env override channel for the four
557
+ # orientation knobs (settings.json carries the per-agent value; env wins for
558
+ # a docker-exec'd hook or an agent `env:` map). `memoryOrientationEnabled`
559
+ # is the per-agent kill switch (default off); the model NAME, cadence tier,
560
+ # and reinject count follow. See DEFAULTS above and carve-M5 §4.
561
+ "HINDSIGHT_ORIENTATION_ENABLED": ("memoryOrientationEnabled", bool),
562
+ "HINDSIGHT_ORIENTATION_MODEL": ("memoryOrientationModel", str),
563
+ "HINDSIGHT_ORIENTATION_CADENCE_HOURS": ("memoryOrientationCadenceHours", int),
564
+ "HINDSIGHT_ORIENTATION_REINJECT_TURNS": ("memoryOrientationReinjectTurns", int),
518
565
  # Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
519
566
  # 0 disables the cache (rollback lever).
520
567
  "HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
@@ -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)
@@ -0,0 +1,265 @@
1
+ """M4 P0 — prefetch buffer + sentinel primitive.
2
+
3
+ The end-of-turn-N Stop hook (``prefetch.py``) writes a prefetched recall
4
+ block into a per-session buffer file, then writes a ``buffer.done`` sentinel
5
+ LAST. The start-of-turn-N+1 UserPromptSubmit hook (``recall.py``) polls for a
6
+ FRESH sentinel (bounded by ``poll_for_sentinel``'s ``cap_ms``) and, on a hit,
7
+ consumes the buffered block instead of running recall synchronously.
8
+
9
+ Read-after-write safety (the whole ballgame — carve §4 note 1):
10
+
11
+ * The payload is written via temp-file + atomic ``os.replace`` (POSIX
12
+ atomic within the same directory).
13
+ * The sentinel is written LAST, also via temp-file + ``os.fsync`` +
14
+ atomic ``os.replace``, and carries a MONOTONIC token (a `time.time_ns()`
15
+ counter, not wall-clock-comparable across machines but perfectly
16
+ ordered within one) — never content or mtime comparison, since two
17
+ writes CAN land in the same filesystem mtime tick.
18
+ * ``read_if_fresh`` treats "sentinel absent" OR "sentinel token not newer
19
+ than ``last_consumed_token``" as a miss (``None``). A torn write
20
+ (payload present, sentinel absent — e.g. the producer crashed between
21
+ the two writes) is INDISTINGUISHABLE from "no fresh buffer yet" by
22
+ construction, so it fails closed to ``None`` rather than serving a
23
+ possibly-incomplete/stale payload.
24
+
25
+ State dir: ``$HOME/.hindsight/prefetch-buffer/`` (override via
26
+ ``HINDSIGHT_PREFETCH_BUFFER_DIR`` for tests), mirroring the
27
+ ``lib/watermark.py`` convention (a distinct dir, not a shared one, so a
28
+ polling reader and a writing producer never contend for the same files as
29
+ unrelated retain-watermark state).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+ from typing import Optional
40
+
41
+ if sys.platform != "win32":
42
+ import fcntl
43
+ else: # pragma: no cover - switchroom agents are Linux only
44
+ fcntl = None
45
+
46
+ SCHEMA = 1
47
+
48
+ # Default poll behaviour (Hermes's 3s join, hand-rolled — P-REC/P-PRE tune
49
+ # the actual cap via config; this is the primitive's own conservative floor
50
+ # if a caller passes a cap smaller than one sleep tick).
51
+ _MIN_SLEEP_S = 0.02
52
+ _MAX_SLEEP_S = 0.1
53
+
54
+
55
+ def buffer_dir() -> str:
56
+ """Return the prefetch-buffer directory.
57
+
58
+ Override with ``HINDSIGHT_PREFETCH_BUFFER_DIR`` (tests). Default:
59
+ ``$HOME/.hindsight/prefetch-buffer/``.
60
+ """
61
+ override = os.environ.get("HINDSIGHT_PREFETCH_BUFFER_DIR")
62
+ if override:
63
+ return override
64
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "prefetch-buffer")
65
+
66
+
67
+ def _ensure_dir() -> str:
68
+ d = buffer_dir()
69
+ if not os.path.isdir(d):
70
+ os.makedirs(d, mode=0o700, exist_ok=True)
71
+ else:
72
+ try:
73
+ mode = os.stat(d).st_mode & 0o777
74
+ if mode != 0o700:
75
+ os.chmod(d, 0o700)
76
+ except OSError:
77
+ pass
78
+ return d
79
+
80
+
81
+ def _safe_session(session_id: str) -> str:
82
+ """Sanitise a session id for use as a filename (no path traversal)."""
83
+ keep = [c if (c.isalnum() or c in "-_.") else "_" for c in (session_id or "unknown")]
84
+ name = "".join(keep)[:200]
85
+ return name.replace("..", "_") or "unknown"
86
+
87
+
88
+ def _buffer_path(session_id: str) -> str:
89
+ return os.path.join(buffer_dir(), f"{_safe_session(session_id)}.buffer.json")
90
+
91
+
92
+ def _sentinel_path(session_id: str) -> str:
93
+ return os.path.join(buffer_dir(), f"{_safe_session(session_id)}.buffer.done")
94
+
95
+
96
+ def write_buffer(session_id: str, context: str, telemetry: Optional[dict] = None) -> None:
97
+ """Write the prefetched recall payload for ``session_id``.
98
+
99
+ Atomic (temp file + ``os.replace`` within the same directory). Does NOT
100
+ write the sentinel — the caller MUST call ``write_sentinel`` after this,
101
+ and only once the payload write has returned, to preserve the
102
+ read-after-write ordering guarantee.
103
+ """
104
+ d = _ensure_dir()
105
+ final = _buffer_path(session_id)
106
+ tmp = final + f".tmp.{os.getpid()}"
107
+ entry = {
108
+ "schema": SCHEMA,
109
+ "session_id": session_id,
110
+ "context": context,
111
+ "telemetry": telemetry or {},
112
+ "written_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
113
+ }
114
+ with open(tmp, "w", encoding="utf-8") as f:
115
+ json.dump(entry, f, ensure_ascii=False)
116
+ f.flush()
117
+ os.fsync(f.fileno())
118
+ os.chmod(tmp, 0o600)
119
+ os.replace(tmp, final)
120
+
121
+
122
+ def write_sentinel(session_id: str) -> int:
123
+ """Write the ``buffer.done`` sentinel for ``session_id``, LAST.
124
+
125
+ Returns the monotonic token written (an ever-increasing integer derived
126
+ from ``time.time_ns()``, disambiguated against the previous token on
127
+ this session so two writes within the same nanosecond still order).
128
+ fsync'd before the atomic rename so a reader that observes the renamed
129
+ file is guaranteed to observe durable content.
130
+ """
131
+ d = _ensure_dir()
132
+ final = _sentinel_path(session_id)
133
+ token = time.time_ns()
134
+ # Guarantee strict monotonicity even under nanosecond-resolution ties or
135
+ # clock weirdness: never emit a token <= the previously written one.
136
+ prev = _read_sentinel_token(session_id)
137
+ if prev is not None and token <= prev:
138
+ token = prev + 1
139
+ tmp = final + f".tmp.{os.getpid()}"
140
+ with open(tmp, "w", encoding="utf-8") as f:
141
+ json.dump({"schema": SCHEMA, "token": token}, f)
142
+ f.flush()
143
+ os.fsync(f.fileno())
144
+ os.chmod(tmp, 0o600)
145
+ os.replace(tmp, final)
146
+ return token
147
+
148
+
149
+ def _read_sentinel_token(session_id: str) -> Optional[int]:
150
+ path = _sentinel_path(session_id)
151
+ if not os.path.isfile(path):
152
+ return None
153
+ try:
154
+ with open(path, encoding="utf-8") as f:
155
+ data = json.load(f)
156
+ token = data.get("token")
157
+ return int(token) if token is not None else None
158
+ except (OSError, json.JSONDecodeError, ValueError, TypeError):
159
+ return None
160
+
161
+
162
+ def _read_buffer_payload(session_id: str) -> Optional[dict]:
163
+ path = _buffer_path(session_id)
164
+ if not os.path.isfile(path):
165
+ return None
166
+ try:
167
+ with open(path, encoding="utf-8") as f:
168
+ return json.load(f)
169
+ except (OSError, json.JSONDecodeError):
170
+ return None
171
+
172
+
173
+ def sentinel_exists(session_id: str) -> bool:
174
+ """Return True iff a ``buffer.done`` sentinel has EVER been written for
175
+ ``session_id`` (regardless of freshness).
176
+
177
+ Cold-start guard (red-team MAJOR finding): a reader must be able to tell
178
+ "no producer has ever run for this session" from "a producer ran but
179
+ hasn't finished this turn's slice yet" WITHOUT paying the full poll cap
180
+ — the former should skip polling entirely (every session-open turn
181
+ would otherwise eat the full poll cap, the opposite of M4's latency
182
+ goal), the latter is exactly what polling is for.
183
+ """
184
+ return os.path.isfile(_sentinel_path(session_id))
185
+
186
+
187
+ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
188
+ """Return ``(payload_dict | None, current_token)``.
189
+
190
+ ``payload_dict`` (when present) is ``{"context": str, "telemetry": dict}``.
191
+
192
+ Returns ``(None, token_or_last_consumed)`` when:
193
+ * no sentinel exists yet (nothing has been produced this session), or
194
+ * the sentinel's token is not strictly newer than ``last_consumed_token``
195
+ (already consumed — stale from the reader's perspective), or
196
+ * the buffer payload itself is missing/corrupt (a torn write, or a
197
+ sentinel written with no payload at all) — fail-closed, never serve
198
+ a partial/absent payload as fresh.
199
+ """
200
+ token = _read_sentinel_token(session_id)
201
+ if token is None:
202
+ return None, last_consumed_token
203
+ if last_consumed_token is not None and token <= last_consumed_token:
204
+ return None, token
205
+ payload = _read_buffer_payload(session_id)
206
+ if payload is None:
207
+ # Torn write: sentinel landed but payload didn't (or is corrupt).
208
+ # Fail-closed — never serve this as fresh.
209
+ return None, token
210
+ return {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
211
+
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
+
242
+ def poll_for_sentinel(session_id: str, last_consumed_token: Optional[int], cap_ms: int) -> bool:
243
+ """Clock-bounded poll for a fresh sentinel. Never busy-spins.
244
+
245
+ Returns True as soon as ``read_if_fresh`` would return a payload; False
246
+ once the cumulative sleep time reaches ``cap_ms``. Short sleeps (20-100ms,
247
+ backing off) sum to at most ``cap_ms`` — never a hard-loop re-stat.
248
+ """
249
+ if cap_ms <= 0:
250
+ ctx, _ = read_if_fresh(session_id, last_consumed_token)
251
+ return ctx is not None
252
+
253
+ deadline = time.monotonic() + (cap_ms / 1000.0)
254
+ sleep_s = _MIN_SLEEP_S
255
+ while True:
256
+ ctx, _ = read_if_fresh(session_id, last_consumed_token)
257
+ if ctx is not None:
258
+ return True
259
+ remaining = deadline - time.monotonic()
260
+ if remaining <= 0:
261
+ return False
262
+ this_sleep = min(sleep_s, remaining, _MAX_SLEEP_S)
263
+ if this_sleep > 0:
264
+ time.sleep(this_sleep)
265
+ sleep_s = min(sleep_s * 1.5, _MAX_SLEEP_S)