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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +6 -0
- package/dist/auth-broker/index.js +6 -0
- package/dist/cli/notion-write-pretool.mjs +6 -0
- package/dist/cli/switchroom.js +2086 -871
- package/dist/host-control/main.js +7 -1
- package/dist/vault/approvals/kernel-server.js +6 -0
- package/dist/vault/broker/server.js +6 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +10 -4
- package/vendor/hindsight-memory/hooks/hooks.json +19 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +265 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +166 -0
- package/vendor/hindsight-memory/scripts/recall.py +323 -5
- package/vendor/hindsight-memory/scripts/retain.py +132 -49
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- 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)
|