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.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +1492 -1136
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
- package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
- package/telegram-plugin/uat/flip/allowlist.ts +349 -0
- package/telegram-plugin/uat/flip/gate.test.ts +153 -0
- package/telegram-plugin/uat/flip/gate.ts +232 -0
- package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
- package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
- package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
- package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
- package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
- package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
- package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
- package/telegram-plugin/uat/flip/recall-log.ts +178 -0
- package/telegram-plugin/uat/flip/report.ts +95 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
- package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
- package/telegram-plugin/uat/runners/scorer.ts +1 -1
- package/vendor/hindsight-memory/hooks/hooks.json +9 -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 +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- 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_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:
|