switchroom 0.21.15 → 0.21.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +6 -0
  3. package/dist/auth-broker/index.js +6 -0
  4. package/dist/cli/notion-write-pretool.mjs +6 -0
  5. package/dist/cli/switchroom.js +2086 -871
  6. package/dist/host-control/main.js +7 -1
  7. package/dist/vault/approvals/kernel-server.js +6 -0
  8. package/dist/vault/broker/server.js +6 -0
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +11 -0
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  12. package/skills/mental-model-curator/SKILL.md +187 -56
  13. package/telegram-plugin/dist/gateway/gateway.js +10 -4
  14. package/vendor/hindsight-memory/hooks/hooks.json +19 -0
  15. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  16. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  17. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  18. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  19. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +265 -0
  20. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  21. package/vendor/hindsight-memory/scripts/prefetch.py +166 -0
  22. package/vendor/hindsight-memory/scripts/recall.py +323 -5
  23. package/vendor/hindsight-memory/scripts/retain.py +132 -49
  24. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  25. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -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)
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """M4 P-PRE — async Stop-hook prefetch producer.
3
+
4
+ carve-M4.md: moves recall injection off the synchronous UserPromptSubmit
5
+ path by speculatively retaining + recalling at the END of turn N (this
6
+ script, registered as an async Stop hook), so turn N+1's UserPromptSubmit
7
+ (`recall.py`) can join an already-warm buffer instead of paying the full
8
+ recall latency synchronously.
9
+
10
+ Entirely gated by `memoryPrefetchEnabled` (default OFF/falsy — Fix C, the
11
+ red-team-mandated per-agent kill switch). When off this script is a no-op:
12
+ it reads config, sees the flag off, and exits silently before touching
13
+ `lib.retain`, `lib.recall_buffer`, or the network.
14
+
15
+ Call order (test a-integration in `tests/test_prefetch_pipeline.py`):
16
+ 1. delta retain (`retain.run_retain(hook_input, force=False, delta=True)`)
17
+ — persists this turn's NEW slice using the Fix-A content-derived
18
+ document_id, never truncating the session document.
19
+ 2. speculative recall (best-effort query derived from the transcript's
20
+ last human turn — the strongest available proxy for what turn N+1
21
+ will ask about).
22
+ 3. `recall_buffer.write_buffer` then `recall_buffer.write_sentinel`
23
+ (STRICTLY in that order — the sentinel-ordering contract `lib/
24
+ recall_buffer.py` and `tests/test_recall_buffer.py` prove).
25
+
26
+ Silent on stdout always (an async Stop hook's stdout is not surfaced to the
27
+ transcript); errors go to stderr and NEVER raise past `main()` — a broken
28
+ prefetch must degrade to "recall.py's synchronous path runs as before",
29
+ never break the turn or leave a torn buffer (a crash between steps 2 and 3a
30
+ just means no sentinel is written this turn, which `read_if_fresh` already
31
+ treats as "nothing fresh" — fail-closed by construction).
32
+
33
+ Skips the same junk turns `recall.py` skips (`<task-notification>` prefix)
34
+ — a synthetic follow-up turn is not a real conversation shift worth
35
+ prefetching for.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import sys
42
+
43
+ from lib.bank import derive_bank_id
44
+ from lib.client import HindsightClient
45
+ from lib.config import debug_log, load_config
46
+ from lib.content import _extract_text_content, strip_channel_envelope
47
+ from lib.daemon import get_api_url
48
+ from lib import recall_buffer
49
+ import retain as retain_module
50
+
51
+
52
+ def _last_human_prompt(transcript_path: str) -> str:
53
+ """Best-effort: the most recent human-authored user turn's text, used
54
+ as the speculative query for next turn's prefetch. Never raises —
55
+ returns "" on any read/parse failure (degrades to no query, which the
56
+ caller treats as "nothing to prefetch")."""
57
+ try:
58
+ messages = retain_module.read_transcript(transcript_path)
59
+ except Exception:
60
+ return ""
61
+ for msg in reversed(messages):
62
+ if msg.get("role") != "user":
63
+ continue
64
+ text = _extract_text_content(msg.get("content"), role="user")
65
+ text = strip_channel_envelope(text) if text else text
66
+ if text and text.strip():
67
+ return text.strip()
68
+ return ""
69
+
70
+
71
+ def run_prefetch(hook_input: dict, config: dict) -> bool:
72
+ """Execute one prefetch cycle. Returns True iff a buffer+sentinel pair
73
+ was written. Never raises — every internal step is wrapped so a bug in
74
+ ANY sub-step degrades to "no buffer written this turn", never a crash
75
+ that could take the Stop hook (and thus the turn) down with it."""
76
+ session_id = hook_input.get("session_id") or "unknown"
77
+
78
+ prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
79
+ if prompt.startswith("<task-notification"):
80
+ debug_log(config, "Prefetch: task-notification turn, skipping")
81
+ return False
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
+
93
+ # Step 1 — delta retain (Fix A: content-derived document_id, never the
94
+ # bare {session_id} document; never truncates).
95
+ try:
96
+ retain_module.run_retain(hook_input, force=False, delta=True)
97
+ except Exception as exc: # pragma: no cover - defensive
98
+ debug_log(config, f"Prefetch: delta retain failed, continuing without it: {exc}")
99
+
100
+ # Step 2 — speculative recall.
101
+ transcript_path = hook_input.get("transcript_path") or ""
102
+ query = _last_human_prompt(transcript_path)
103
+ if not query:
104
+ debug_log(config, "Prefetch: no usable query, nothing to prefetch")
105
+ return False
106
+
107
+ try:
108
+ bank_id = derive_bank_id(hook_input, config)
109
+ api_url = get_api_url(config)
110
+ client = HindsightClient(api_url)
111
+ response = client.recall(
112
+ bank_id,
113
+ query,
114
+ types=None,
115
+ timeout=config.get("memoryPrefetchTimeoutSeconds", 5),
116
+ )
117
+ results = (response or {}).get("results") or []
118
+ except Exception as exc: # pragma: no cover - defensive
119
+ debug_log(config, f"Prefetch: recall fetch failed: {exc}")
120
+ return False
121
+
122
+ if not results:
123
+ debug_log(config, "Prefetch: no candidates, nothing to buffer")
124
+ return False
125
+
126
+ from lib.content import format_memories
127
+
128
+ memories_block = format_memories(results)
129
+ if not memories_block:
130
+ return False
131
+
132
+ # Step 3 — write payload THEN sentinel, strictly in that order.
133
+ try:
134
+ recall_buffer.write_buffer(session_id, memories_block, {"result_count": len(results)})
135
+ recall_buffer.write_sentinel(session_id)
136
+ except Exception as exc: # pragma: no cover - defensive
137
+ debug_log(config, f"Prefetch: buffer write failed: {exc}")
138
+ return False
139
+
140
+ return True
141
+
142
+
143
+ def main():
144
+ config = load_config()
145
+ if not config.get("memoryPrefetchEnabled", False):
146
+ # Fix C, producer side: the whole mechanism is dark by default.
147
+ return
148
+
149
+ try:
150
+ hook_input = json.load(sys.stdin)
151
+ except (json.JSONDecodeError, EOFError):
152
+ print("[Hindsight] Prefetch: failed to read hook input", file=sys.stderr)
153
+ return
154
+
155
+ try:
156
+ run_prefetch(hook_input, config)
157
+ except Exception as exc: # pragma: no cover - defensive, must never break the turn
158
+ print(f"[Hindsight] Prefetch: unexpected error: {exc}", file=sys.stderr)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ try:
163
+ main()
164
+ except Exception as e: # pragma: no cover - defensive
165
+ print(f"[Hindsight] Unexpected error in prefetch: {e}", file=sys.stderr)
166
+ sys.exit(0)