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
|
@@ -157,6 +157,110 @@ def _topic_filter_mode() -> str:
|
|
|
157
157
|
return "soft-preamble"
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
# ── Memory v2 M3 (Surface-A) — directive-injection flip guard ───────────────
|
|
161
|
+
#
|
|
162
|
+
# Once an agent has migrated its standing directives into the CLAUDE.md rules
|
|
163
|
+
# block (Memory v2 M1) AND flipped `injectDirectives` OFF, this hook stops
|
|
164
|
+
# injecting the `<active_directives>` block on every turn: the rules block
|
|
165
|
+
# already carries the same guarantees inline, so re-injecting the directives is
|
|
166
|
+
# ~pure token waste (the whole point of M3 — ~49M directive-tokens/30d saved
|
|
167
|
+
# fleet-wide once the heavy agents follow ziggy).
|
|
168
|
+
#
|
|
169
|
+
# `injectDirectives` defaults TRUE (lib/config.py DEFAULTS); an agent is only
|
|
170
|
+
# flipped when switchroom.yaml sets `memory.inject_directives: false`, which
|
|
171
|
+
# plumbs to `HINDSIGHT_INJECT_DIRECTIVES=false` (lib/config.py ENV_OVERRIDES).
|
|
172
|
+
#
|
|
173
|
+
# The flip is DELIBERATELY guarded on physical evidence, not the flag alone:
|
|
174
|
+
# suppression fires IFF the flag is off AND a non-empty rules block is actually
|
|
175
|
+
# present in CLAUDE.md. If the flag is off but no rules block exists (mis-
|
|
176
|
+
# sequenced flip, or M1 not yet run for this agent), we FAIL SAFE — keep
|
|
177
|
+
# injecting directives and surface a one-line degraded-canary notice — rather
|
|
178
|
+
# than strip every guardrail the agent has. A mis-sequenced flip is therefore
|
|
179
|
+
# loud and non-destructive, never silently memory-less. Suppress-only: this
|
|
180
|
+
# NEVER deletes a directive, it only stops re-injecting it. Format-locked to
|
|
181
|
+
# src/memory/rules-block.ts (a golden-fixture test guards against TS drift).
|
|
182
|
+
# See carve-M3.md §2/§4.
|
|
183
|
+
RULES_BLOCK_BEGIN_MARKER = "<!-- switchroom:rules:begin -->"
|
|
184
|
+
RULES_BLOCK_END_MARKER = "<!-- switchroom:rules:end -->"
|
|
185
|
+
|
|
186
|
+
# A rendered rule line looks like `- **R-01** (source: X, added Y): text`. The
|
|
187
|
+
# literal `(none)` placeholder an empty block renders must NOT count as a rule
|
|
188
|
+
# (that is exactly the empty-block case we fail-safe on). Anchored, no
|
|
189
|
+
# unbounded alternation.
|
|
190
|
+
_RULES_BLOCK_RULE_LINE_RE = re.compile(r"^- \*\*R-", re.MULTILINE)
|
|
191
|
+
|
|
192
|
+
# config.py DEFAULTS key + settings.json field name for the flip flag.
|
|
193
|
+
INJECT_DIRECTIVES_CONFIG_KEY = "injectDirectives"
|
|
194
|
+
|
|
195
|
+
_DIRECTIVE_FLIP_DEGRADED_NOTICE = (
|
|
196
|
+
"⚠️ memory config: injectDirectives is OFF for this agent but no CLAUDE.md "
|
|
197
|
+
"rules block was found — still injecting <active_directives> this turn to "
|
|
198
|
+
"avoid a guardrail-less turn. Run the M1 rules-block migration (or "
|
|
199
|
+
"re-enable injectDirectives) to clear this notice."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _recall_project_dir(hook_input: dict) -> str | None:
|
|
204
|
+
"""Resolve the agent's project directory (where CLAUDE.md lives).
|
|
205
|
+
|
|
206
|
+
Prefers `CLAUDE_PROJECT_DIR` (set by Claude Code for hook subprocesses);
|
|
207
|
+
falls back to the hook payload's `cwd`. Returns None when neither is a
|
|
208
|
+
usable directory, so callers treat "can't locate the project dir" the same
|
|
209
|
+
as "no rules block" — fail-safe, keep injecting.
|
|
210
|
+
"""
|
|
211
|
+
for candidate in (os.environ.get("CLAUDE_PROJECT_DIR"), hook_input.get("cwd")):
|
|
212
|
+
if candidate and os.path.isdir(candidate):
|
|
213
|
+
return candidate
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def rules_block_present(project_dir: str | None) -> bool:
|
|
218
|
+
"""True iff CLAUDE.md in `project_dir` carries a NON-EMPTY rules block.
|
|
219
|
+
|
|
220
|
+
Non-empty means: the `switchroom:rules:begin/end` markers are both present
|
|
221
|
+
AND at least one rendered rule line (`- **R-…`) sits between them. The
|
|
222
|
+
`(none)` placeholder an empty block renders does NOT count. Any IO error →
|
|
223
|
+
False (fail-safe: absence of proof of a rules block keeps injection on).
|
|
224
|
+
"""
|
|
225
|
+
if not project_dir:
|
|
226
|
+
return False
|
|
227
|
+
path = os.path.join(project_dir, "CLAUDE.md")
|
|
228
|
+
try:
|
|
229
|
+
with open(path, encoding="utf-8") as fh:
|
|
230
|
+
text = fh.read()
|
|
231
|
+
except OSError:
|
|
232
|
+
return False
|
|
233
|
+
begin = text.find(RULES_BLOCK_BEGIN_MARKER)
|
|
234
|
+
if begin == -1:
|
|
235
|
+
return False
|
|
236
|
+
end = text.find(RULES_BLOCK_END_MARKER, begin)
|
|
237
|
+
if end == -1:
|
|
238
|
+
return False
|
|
239
|
+
inner = text[begin + len(RULES_BLOCK_BEGIN_MARKER):end]
|
|
240
|
+
return _RULES_BLOCK_RULE_LINE_RE.search(inner) is not None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def directive_injection_decision(config: dict, hook_input: dict) -> tuple[bool, str | None]:
|
|
244
|
+
"""Decide whether to inject the `<active_directives>` block this turn.
|
|
245
|
+
|
|
246
|
+
Returns `(inject, degraded_notice)`:
|
|
247
|
+
* flag ON (default) → (True, None) inject normally
|
|
248
|
+
* flag OFF and rules block present → (False, None) SUPPRESS (M3 flip)
|
|
249
|
+
* flag OFF but NO rules block present → (True, notice) FAIL SAFE: still
|
|
250
|
+
inject AND surface a one-line degraded-canary notice, so a mis-
|
|
251
|
+
sequenced flip is loud instead of silently guardrail-less.
|
|
252
|
+
|
|
253
|
+
Central to all three recall emit surfaces (main path, prefetch fast path,
|
|
254
|
+
task-notification directives-only path) so a flipped agent cannot leak
|
|
255
|
+
directives on a cache hit. Suppress-only — never deletes a directive.
|
|
256
|
+
"""
|
|
257
|
+
if config.get(INJECT_DIRECTIVES_CONFIG_KEY, True):
|
|
258
|
+
return True, None
|
|
259
|
+
if rules_block_present(_recall_project_dir(hook_input)):
|
|
260
|
+
return False, None
|
|
261
|
+
return True, _DIRECTIVE_FLIP_DEGRADED_NOTICE
|
|
262
|
+
|
|
263
|
+
|
|
160
264
|
def _filter_by_active_topic(results: list, active_thread_id: str | None) -> tuple[list, int]:
|
|
161
265
|
"""When hard-filter mode is on AND we know the active thread, drop
|
|
162
266
|
any memory whose stored metadata.thread_id is set to a different
|
|
@@ -471,9 +575,17 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
|
|
|
471
575
|
debug_log(config, f"Prefetch buffer: directive fetch failed: {exc}")
|
|
472
576
|
directives_block = None
|
|
473
577
|
|
|
578
|
+
# Memory v2 M3 (Surface-A) — apply the directive-injection flip decision on
|
|
579
|
+
# the fast path too, so a flipped agent does not leak `<active_directives>`
|
|
580
|
+
# on a prefetch/cache hit. Suppress iff flag off AND rules block present;
|
|
581
|
+
# else keep injecting (and carry the degraded-canary notice, if any).
|
|
582
|
+
_inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
|
|
583
|
+
if not _inject_directives:
|
|
584
|
+
directives_block = None
|
|
585
|
+
|
|
474
586
|
if payload is not None:
|
|
475
587
|
memories_block = payload.get("context") or ""
|
|
476
|
-
parts = [b for b in (directives_block, memories_block) if b]
|
|
588
|
+
parts = [b for b in (_dir_notice, directives_block, memories_block) if b]
|
|
477
589
|
if not parts:
|
|
478
590
|
return False
|
|
479
591
|
_emit_cached_context("\n\n".join(parts))
|
|
@@ -486,13 +598,13 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
|
|
|
486
598
|
last = read_state(LAST_RECALL_STATE) or {}
|
|
487
599
|
stale_memories = last.get("memories_context") or ""
|
|
488
600
|
stale_block = stale_recall_notice(stale_memories)
|
|
489
|
-
parts = [b for b in (directives_block, stale_block) if b]
|
|
601
|
+
parts = [b for b in (_dir_notice, directives_block, stale_block) if b]
|
|
490
602
|
if parts:
|
|
491
603
|
_emit_cached_context("\n\n".join(parts))
|
|
492
604
|
return True
|
|
493
605
|
|
|
494
|
-
if directives_block:
|
|
495
|
-
_emit_cached_context(directives_block)
|
|
606
|
+
if directives_block or _dir_notice:
|
|
607
|
+
_emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
|
|
496
608
|
return True
|
|
497
609
|
|
|
498
610
|
# Nothing fresh, nothing stale, nothing cached — say so explicitly
|
|
@@ -534,8 +646,15 @@ def _emit_directives_only(config: dict, hook_input: dict) -> None:
|
|
|
534
646
|
debug_log(config, f"Task-notification skip: directive fetch failed: {exc}")
|
|
535
647
|
return
|
|
536
648
|
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
# Memory v2 M3 (Surface-A) — apply the flip decision here too, so a flipped
|
|
650
|
+
# agent's directives-only fast path (task-notification junk gate) also
|
|
651
|
+
# suppresses `<active_directives>` once the rules block carries them.
|
|
652
|
+
_inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
|
|
653
|
+
if not _inject_directives:
|
|
654
|
+
directives_block = None
|
|
655
|
+
|
|
656
|
+
if directives_block or _dir_notice:
|
|
657
|
+
_emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
|
|
539
658
|
|
|
540
659
|
|
|
541
660
|
def _is_demoted_memory(memory) -> bool:
|
|
@@ -2829,6 +2948,16 @@ def main():
|
|
|
2829
2948
|
bank_errored = any(bt.get("errored") for bt in bank_timings)
|
|
2830
2949
|
|
|
2831
2950
|
directives_block = format_active_directives_block(directives) if directives else None
|
|
2951
|
+
|
|
2952
|
+
# Memory v2 M3 (Surface-A) — directive-injection flip decision. Suppress the
|
|
2953
|
+
# `<active_directives>` block iff this agent is flipped (injectDirectives
|
|
2954
|
+
# OFF) AND a non-empty CLAUDE.md rules block physically carries the same
|
|
2955
|
+
# guarantees; else keep injecting and carry a degraded-canary notice. This
|
|
2956
|
+
# is the primary (synchronous) surface; the two fast paths above mirror it.
|
|
2957
|
+
_inject_directives, directive_flip_notice = directive_injection_decision(config, hook_input)
|
|
2958
|
+
if not _inject_directives:
|
|
2959
|
+
directives_block = None
|
|
2960
|
+
|
|
2832
2961
|
if directives_block:
|
|
2833
2962
|
debug_log(config, f"Injecting {len(directives)} active directives")
|
|
2834
2963
|
|
|
@@ -3267,12 +3396,13 @@ def main():
|
|
|
3267
3396
|
# is precisely the turn on which the agent must not assume it remembers.
|
|
3268
3397
|
# #3837: so is a set the score floor withheld entirely.
|
|
3269
3398
|
if not directives_block and not memories_block and not transcript_fallback_block:
|
|
3270
|
-
if degraded_block or withheld_block or nudge_block or profile_nudge_block:
|
|
3399
|
+
if degraded_block or withheld_block or nudge_block or profile_nudge_block or directive_flip_notice:
|
|
3271
3400
|
_emit_cached_context(
|
|
3272
3401
|
"\n\n".join(
|
|
3273
3402
|
[
|
|
3274
3403
|
b
|
|
3275
3404
|
for b in (
|
|
3405
|
+
directive_flip_notice,
|
|
3276
3406
|
degraded_block,
|
|
3277
3407
|
withheld_block,
|
|
3278
3408
|
nudge_block,
|
|
@@ -3346,14 +3476,17 @@ def main():
|
|
|
3346
3476
|
"hookSpecificOutput": {
|
|
3347
3477
|
"hookEventName": "UserPromptSubmit",
|
|
3348
3478
|
"additionalContext": _combine_context(
|
|
3479
|
+
directive_flip_notice,
|
|
3349
3480
|
_combine_context(
|
|
3350
3481
|
_combine_context(
|
|
3351
|
-
_combine_context(
|
|
3352
|
-
|
|
3482
|
+
_combine_context(
|
|
3483
|
+
_combine_context(degraded_block, withheld_block),
|
|
3484
|
+
context_message,
|
|
3485
|
+
),
|
|
3486
|
+
nudge_block,
|
|
3353
3487
|
),
|
|
3354
|
-
|
|
3488
|
+
profile_nudge_block,
|
|
3355
3489
|
),
|
|
3356
|
-
profile_nudge_block,
|
|
3357
3490
|
),
|
|
3358
3491
|
}
|
|
3359
3492
|
}
|
|
@@ -73,7 +73,16 @@ def build_hooks(plugin_root: str) -> dict:
|
|
|
73
73
|
"timeout": 5,
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
|
-
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"hooks": [
|
|
79
|
+
{
|
|
80
|
+
"type": "command",
|
|
81
|
+
"command": f'python3 "{plugin_root}/scripts/orientation.py"',
|
|
82
|
+
"timeout": 8,
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
},
|
|
77
86
|
],
|
|
78
87
|
"SessionEnd": [
|
|
79
88
|
{
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- switchroom:rules:begin -->
|
|
2
|
+
Standing rules — sanctioned via the `memory rule` tool. Do not hand-edit;
|
|
3
|
+
edits made outside the tool break the tamper sentinel below.
|
|
4
|
+
|
|
5
|
+
- **R-01** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Always end replies to the user with a one-line summary.
|
|
6
|
+
- **R-02** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Never send email without explicit user approval.
|
|
7
|
+
|
|
8
|
+
<!-- switchroom:rules:sentinel sha256=533c23603bb3b72930c8c79b4f4970481b0ce7caf13c3e2bb63953b652b8a0ae rules=2 -->
|
|
9
|
+
<!-- switchroom:rules:end -->
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Memory v2 M5 — Surface B: orientation-at-boot (hook wiring).
|
|
2
|
+
|
|
3
|
+
End-to-end tests for orientation.py's run() — the SessionStart hook entry.
|
|
4
|
+
Every assertion is an OUTCOME (what reached stdout / whether a refresh was
|
|
5
|
+
requested / that boot was never blocked), not a call-path spy, so reverting the
|
|
6
|
+
fix it guards turns the test RED (carve-M5 §8 tautology-guard discipline).
|
|
7
|
+
|
|
8
|
+
Coverage:
|
|
9
|
+
T2 DARK by default: memoryOrientationEnabled off is a HARD no-op — no
|
|
10
|
+
stdout, no bank resolve, no network call (the red-team kill switch).
|
|
11
|
+
T2 Every failure path (server unreachable, no model, read error, empty
|
|
12
|
+
payload, degraded/undated model) degrades to a VISIBLE cold notice AND
|
|
13
|
+
enqueues a refresh AND never raises — boot is never blocked.
|
|
14
|
+
T3 A stale (1.5x-3x) model is injected WITH a visible prefix; a fresh model
|
|
15
|
+
is injected plainly; a degraded (>3x) model is NOT injected.
|
|
16
|
+
T4 Matcher-less re-fire: a "compact" SessionStart produces the SAME output
|
|
17
|
+
as "startup" (the free post-compaction re-seat — E-88).
|
|
18
|
+
main() always exits 0 (a non-zero SessionStart hook BLOCKS the turn).
|
|
19
|
+
|
|
20
|
+
Stdlib-only.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from contextlib import redirect_stdout
|
|
30
|
+
from datetime import datetime, timedelta, timezone
|
|
31
|
+
from unittest import mock
|
|
32
|
+
|
|
33
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
34
|
+
if SCRIPTS_DIR not in sys.path:
|
|
35
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
36
|
+
|
|
37
|
+
import orientation # noqa: E402
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _iso_hours_ago(hours):
|
|
41
|
+
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _FakeClient:
|
|
45
|
+
"""Stand-in for HindsightClient. Records the OUTCOME the hook depends on:
|
|
46
|
+
which bank was read, and returns scripted list/get responses (or raises)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, models=None, model_full=None, list_raises=None, get_raises=None):
|
|
49
|
+
self._models = models if models is not None else {"items": []}
|
|
50
|
+
self._full = model_full
|
|
51
|
+
self._list_raises = list_raises
|
|
52
|
+
self._get_raises = get_raises
|
|
53
|
+
self.listed_bank = None
|
|
54
|
+
self.got = None
|
|
55
|
+
|
|
56
|
+
def list_mental_models(self, bank_id, timeout=5):
|
|
57
|
+
self.listed_bank = bank_id
|
|
58
|
+
if self._list_raises:
|
|
59
|
+
raise self._list_raises
|
|
60
|
+
return self._models
|
|
61
|
+
|
|
62
|
+
def get_mental_model(self, bank_id, model_id, detail="full", timeout=5):
|
|
63
|
+
self.got = (bank_id, model_id)
|
|
64
|
+
if self._get_raises:
|
|
65
|
+
raise self._get_raises
|
|
66
|
+
return self._full
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run(config, hook_input=None, client=None, api_raises=None):
|
|
70
|
+
"""Drive orientation.run() with patched I/O; return (stdout_str, client)."""
|
|
71
|
+
hook_input = hook_input or {"source": "startup"}
|
|
72
|
+
buf = io.StringIO()
|
|
73
|
+
with mock.patch.object(orientation, "derive_bank_id", return_value="klanker"), \
|
|
74
|
+
mock.patch.object(orientation, "HindsightClient", return_value=client):
|
|
75
|
+
if api_raises is not None:
|
|
76
|
+
api_ctx = mock.patch.object(orientation, "get_api_url", side_effect=api_raises)
|
|
77
|
+
else:
|
|
78
|
+
api_ctx = mock.patch.object(
|
|
79
|
+
orientation, "get_api_url", return_value="http://127.0.0.1:18888"
|
|
80
|
+
)
|
|
81
|
+
with api_ctx, redirect_stdout(buf):
|
|
82
|
+
orientation.run(hook_input, config)
|
|
83
|
+
return buf.getvalue(), client
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _emitted_context(stdout_str):
|
|
87
|
+
"""Parse the additionalContext out of the hook's stdout envelope, or None."""
|
|
88
|
+
stdout_str = stdout_str.strip()
|
|
89
|
+
if not stdout_str:
|
|
90
|
+
return None
|
|
91
|
+
payload = json.loads(stdout_str)
|
|
92
|
+
return payload["hookSpecificOutput"]["additionalContext"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _RefreshSpyMixin:
|
|
96
|
+
"""Point HINDSIGHT_STATE_DIR at a tmpdir so enqueue_refresh's JSONL marker is
|
|
97
|
+
inspectable — the OUTCOME 'a refresh was requested' without a live scheduler."""
|
|
98
|
+
|
|
99
|
+
def setUp(self):
|
|
100
|
+
self._tmp = tempfile.mkdtemp()
|
|
101
|
+
self._env = mock.patch.dict(os.environ, {"HINDSIGHT_STATE_DIR": self._tmp})
|
|
102
|
+
self._env.start()
|
|
103
|
+
|
|
104
|
+
def tearDown(self):
|
|
105
|
+
self._env.stop()
|
|
106
|
+
import shutil
|
|
107
|
+
shutil.rmtree(self._tmp, ignore_errors=True)
|
|
108
|
+
|
|
109
|
+
def _refresh_requested(self):
|
|
110
|
+
marker = os.path.join(self._tmp, "orientation-refresh-pending.jsonl")
|
|
111
|
+
if not os.path.exists(marker):
|
|
112
|
+
return False
|
|
113
|
+
with open(marker) as f:
|
|
114
|
+
return any(line.strip() for line in f)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
ENABLED = {"memoryOrientationEnabled": True, "memoryOrientationCadenceHours": 48}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestDarkByDefault(_RefreshSpyMixin, unittest.TestCase):
|
|
121
|
+
"""T2 — the kill switch. Off (the default) is a hard, silent no-op."""
|
|
122
|
+
|
|
123
|
+
def test_disabled_emits_nothing_and_never_touches_client(self):
|
|
124
|
+
client = _FakeClient()
|
|
125
|
+
# A client whose methods would blow up if called — proves no I/O happens.
|
|
126
|
+
client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
|
|
127
|
+
stdout, _ = _run({"memoryOrientationEnabled": False}, client=client)
|
|
128
|
+
self.assertEqual(stdout.strip(), "")
|
|
129
|
+
self.assertFalse(self._refresh_requested())
|
|
130
|
+
|
|
131
|
+
def test_absent_flag_defaults_to_off(self):
|
|
132
|
+
# A stripped/absent value must fail to OFF (fail-safe).
|
|
133
|
+
client = _FakeClient()
|
|
134
|
+
client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
|
|
135
|
+
stdout, _ = _run({}, client=client)
|
|
136
|
+
self.assertEqual(stdout.strip(), "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestColdPaths(_RefreshSpyMixin, unittest.TestCase):
|
|
140
|
+
"""T2 — every failure degrades to a visible cold notice + refresh, never a block."""
|
|
141
|
+
|
|
142
|
+
def _assert_cold(self, stdout):
|
|
143
|
+
ctx = _emitted_context(stdout)
|
|
144
|
+
self.assertIsNotNone(ctx)
|
|
145
|
+
self.assertIn("not yet built or refreshed", ctx)
|
|
146
|
+
self.assertTrue(self._refresh_requested())
|
|
147
|
+
|
|
148
|
+
def test_server_unreachable(self):
|
|
149
|
+
stdout, _ = _run(ENABLED, client=None, api_raises=RuntimeError("no daemon"))
|
|
150
|
+
self._assert_cold(stdout)
|
|
151
|
+
|
|
152
|
+
def test_no_model_named_orientation(self):
|
|
153
|
+
client = _FakeClient(models={"items": [{"id": "x", "name": "something-else"}]})
|
|
154
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
155
|
+
self._assert_cold(stdout)
|
|
156
|
+
|
|
157
|
+
def test_list_models_raises(self):
|
|
158
|
+
client = _FakeClient(list_raises=TimeoutError("read timed out"))
|
|
159
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
160
|
+
self._assert_cold(stdout)
|
|
161
|
+
|
|
162
|
+
def test_model_read_raises(self):
|
|
163
|
+
client = _FakeClient(
|
|
164
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
165
|
+
get_raises=TimeoutError("read timed out"),
|
|
166
|
+
)
|
|
167
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
168
|
+
self._assert_cold(stdout)
|
|
169
|
+
|
|
170
|
+
def test_empty_content(self):
|
|
171
|
+
client = _FakeClient(
|
|
172
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
173
|
+
model_full={"content": " ", "last_refreshed_at": _iso_hours_ago(1)},
|
|
174
|
+
)
|
|
175
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
176
|
+
self._assert_cold(stdout)
|
|
177
|
+
|
|
178
|
+
def test_degraded_model_not_injected(self):
|
|
179
|
+
# Refreshed 200h ago at cadence 48 -> >3x -> degraded -> cold, never
|
|
180
|
+
# presented stale-as-fresh.
|
|
181
|
+
client = _FakeClient(
|
|
182
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
183
|
+
model_full={"content": "# Real\nbody", "last_refreshed_at": _iso_hours_ago(200)},
|
|
184
|
+
)
|
|
185
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
186
|
+
self._assert_cold(stdout)
|
|
187
|
+
|
|
188
|
+
def test_undated_model_not_injected(self):
|
|
189
|
+
client = _FakeClient(
|
|
190
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
191
|
+
model_full={"content": "# Real\nbody", "last_refreshed_at": None},
|
|
192
|
+
)
|
|
193
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
194
|
+
self._assert_cold(stdout)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestInjectPaths(_RefreshSpyMixin, unittest.TestCase):
|
|
198
|
+
"""T3 — usable models are injected; fresh plainly, stale with a prefix."""
|
|
199
|
+
|
|
200
|
+
def test_fresh_model_injected_plainly(self):
|
|
201
|
+
client = _FakeClient(
|
|
202
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
203
|
+
model_full={"content": "# Brief\nthe orientation body",
|
|
204
|
+
"last_refreshed_at": _iso_hours_ago(2)},
|
|
205
|
+
)
|
|
206
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
207
|
+
ctx = _emitted_context(stdout)
|
|
208
|
+
self.assertIn("the orientation body", ctx)
|
|
209
|
+
self.assertNotIn("may be stale", ctx)
|
|
210
|
+
# A usable inject does NOT request a refresh.
|
|
211
|
+
self.assertFalse(self._refresh_requested())
|
|
212
|
+
# It read the agent's OWN bank.
|
|
213
|
+
self.assertEqual(client.listed_bank, "klanker")
|
|
214
|
+
|
|
215
|
+
def test_stale_model_injected_with_prefix(self):
|
|
216
|
+
# 100h ago at cadence 48 -> 1.5x(72) < 100 < 3x(144) -> stale.
|
|
217
|
+
client = _FakeClient(
|
|
218
|
+
models={"items": [{"id": "m1", "name": "orientation"}]},
|
|
219
|
+
model_full={"content": "# Brief\nthe orientation body",
|
|
220
|
+
"last_refreshed_at": _iso_hours_ago(100)},
|
|
221
|
+
)
|
|
222
|
+
stdout, _ = _run(ENABLED, client=client)
|
|
223
|
+
ctx = _emitted_context(stdout)
|
|
224
|
+
self.assertIn("the orientation body", ctx)
|
|
225
|
+
self.assertIn("may be stale", ctx)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestCompactionReSeat(_RefreshSpyMixin, unittest.TestCase):
|
|
229
|
+
"""T4 — matcher-less: a compaction SessionStart re-seats identically to startup."""
|
|
230
|
+
|
|
231
|
+
def _model(self):
|
|
232
|
+
return {"items": [{"id": "m1", "name": "orientation"}]}, {
|
|
233
|
+
"content": "# Brief\nbody text", "last_refreshed_at": _iso_hours_ago(2)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def test_compact_source_matches_startup_source(self):
|
|
237
|
+
models, full = self._model()
|
|
238
|
+
out_start, _ = _run(ENABLED, hook_input={"source": "startup"},
|
|
239
|
+
client=_FakeClient(models=models, model_full=full))
|
|
240
|
+
out_compact, _ = _run(ENABLED, hook_input={"source": "compact"},
|
|
241
|
+
client=_FakeClient(models=models, model_full=full))
|
|
242
|
+
self.assertEqual(_emitted_context(out_start), _emitted_context(out_compact))
|
|
243
|
+
self.assertIn("body text", _emitted_context(out_compact))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TestNeverBlocksBoot(unittest.TestCase):
|
|
247
|
+
"""The script must exit 0 on every path — a non-zero SessionStart hook BLOCKS
|
|
248
|
+
the turn in Claude Code. Driven as a real subprocess (the actual __main__
|
|
249
|
+
guard), because that guard is exactly what turns an internal raise into a
|
|
250
|
+
clean exit; an in-process patch of sys.exit could never catch a regression
|
|
251
|
+
where the guard itself is removed."""
|
|
252
|
+
|
|
253
|
+
def _run_script(self, env_extra, stdin_text="{}"):
|
|
254
|
+
import subprocess
|
|
255
|
+
env = dict(os.environ)
|
|
256
|
+
env.update(env_extra)
|
|
257
|
+
script = os.path.join(SCRIPTS_DIR, "orientation.py")
|
|
258
|
+
proc = subprocess.run(
|
|
259
|
+
[sys.executable, script],
|
|
260
|
+
input=stdin_text, capture_output=True, text=True, env=env, timeout=20,
|
|
261
|
+
)
|
|
262
|
+
return proc
|
|
263
|
+
|
|
264
|
+
def test_disabled_exits_zero_silent(self):
|
|
265
|
+
proc = self._run_script({"HINDSIGHT_ORIENTATION_ENABLED": "false"})
|
|
266
|
+
self.assertEqual(proc.returncode, 0)
|
|
267
|
+
self.assertEqual(proc.stdout.strip(), "")
|
|
268
|
+
|
|
269
|
+
def test_enabled_but_server_unreachable_exits_zero_with_cold_notice(self):
|
|
270
|
+
# Point at a closed port: the read fails, but boot must NOT block — exit
|
|
271
|
+
# 0 and a visible cold notice on stdout, never a hang or non-zero.
|
|
272
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
273
|
+
proc = self._run_script({
|
|
274
|
+
"HINDSIGHT_ORIENTATION_ENABLED": "true",
|
|
275
|
+
"HINDSIGHT_API_URL": "http://127.0.0.1:9", # discard port, refused
|
|
276
|
+
"HINDSIGHT_STATE_DIR": tmp,
|
|
277
|
+
})
|
|
278
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
279
|
+
self.assertIn("not yet built or refreshed", proc.stdout)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
unittest.main()
|