switchroom 0.19.1 → 0.19.3

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 (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -71,8 +71,28 @@ DEFAULTS = {
71
71
  # per-agent via memory.directive_capture_verify=false →
72
72
  # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
73
73
  "directiveCaptureVerify": True,
74
- "recallContextTurns": 1,
74
+ # Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
75
+ # cache on the recall critical path (see lib/directives.py). The list is
76
+ # re-fetched at most once per TTL window for no-write turns; in-session
77
+ # directive writes invalidate the cache immediately via directive_verify.py.
78
+ # 0 disables the cache (live fetch every turn — the A4 rollback lever).
79
+ "directivesCacheTtlSeconds": 120,
80
+ # Switchroom hindsight-leverage A2 (PR2): default 2 so a bare follow-up
81
+ # user message ("and the port?", "what about staging?") embeds together
82
+ # with its antecedent turn, instead of recalling on the pronoun alone.
83
+ # Bounded by recallMaxQueryChars (truncate_recall_query preserves the
84
+ # latest turn and drops oldest context first) so the composition can never
85
+ # blow the recall query budget; the transcript read is byte-tail-bounded
86
+ # by recallTranscriptTailBytes so the added per-turn read stays O(1).
87
+ "recallContextTurns": 2,
75
88
  "recallMaxQueryChars": 800,
89
+ # Switchroom hindsight-leverage A2 (PR2) — latency bound for the multi-turn
90
+ # composition. With recallContextTurns>1 now the default, EVERY recall reads
91
+ # the transcript to slice the last N human turns. A long session's .jsonl can
92
+ # grow to many MB, so read only the last N bytes (complete trailing lines);
93
+ # the last 2-3 human turns always live at the tail. 0 disables the bound
94
+ # (read the whole file — the pre-A2 behaviour / rollback lever).
95
+ "recallTranscriptTailBytes": 262144,
76
96
  "recallRoles": ["user", "assistant"],
77
97
  # Upstream 962140eef — optional recall tag filters passed through to the
78
98
  # recall API, plus per-additional-bank overrides keyed by bank ID.
@@ -80,6 +100,19 @@ DEFAULTS = {
80
100
  "recallTagsMatch": "any",
81
101
  "recallTagGroups": None,
82
102
  "recallAdditionalBankFilters": {},
103
+ # Switchroom (hindsight-leverage PR5) — per-tag recall score weights. A map
104
+ # of ``{tag: multiplier}`` applied to each result's ``scores.final`` just
105
+ # before the final relevance sort/cap in recall.py. A weight < 1.0 DEMOTES
106
+ # (down-ranks) memories carrying that tag without DROPPING them — distinct
107
+ # from the hard demote-tag drop filter (`_is_demoted_memory`), which removes
108
+ # a memory from recall entirely. This is the "reduced weight" the drop
109
+ # filter cannot express: a demoted memory still surfaces when it is the only
110
+ # relevant hit, just below equal-scoring untagged memories. Default {} =
111
+ # no-op (identity weighting). Switchroom's scaffold seeds
112
+ # ``{"sidechain": 0.8}`` so delegated sub-agent process-memories rank just
113
+ # under first-party session memories. Env: HINDSIGHT_RECALL_TAG_WEIGHTS
114
+ # (JSON object).
115
+ "recallTagWeights": {},
83
116
  "recallPromptPreamble": (
84
117
  "Relevant memories from past conversations (prioritize recent when "
85
118
  "conflicting). Only use memories that are directly useful to continue "
@@ -95,6 +128,56 @@ DEFAULTS = {
95
128
  "retainContext": "claude-code",
96
129
  "retainTags": [],
97
130
  "retainMetadata": {},
131
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
132
+ # tagging at retain time. When on (default), build_retain_payload scans the
133
+ # formatted transcript slice for explicit lesson / anti-pattern markers and
134
+ # attaches the matching tag(s) to the retain. This is the retain-side half of
135
+ # #398: a transcript that captures a failure mode ("anti-pattern:", "what not
136
+ # to do") or a self-recognised lesson ("lesson learned", "note to self:") is
137
+ # tagged so the recall-side score-penalty weight map (recallTagWeights, PR5)
138
+ # can DEMOTE it below clean first-party session memories — without ever hard-
139
+ # dropping it. NON-GOAL (epic-recorded): historical corpus is NOT re-tagged;
140
+ # this fires on NEW retains only. Detection is deterministic substring match
141
+ # (case-insensitive), never model-dependent. Disable via
142
+ # HINDSIGHT_LESSON_TAGGING=false (rollback lever).
143
+ "lessonTagging": True,
144
+ # Marker map {tag: [substrings]}. A slice whose lower-cased text contains ANY
145
+ # of a tag's substrings gets that tag. Deliberately explicit prefixes to keep
146
+ # false positives low — a passing mention of the word "lesson" should not tag
147
+ # a whole transcript; an explicit "lesson learned" / "anti-pattern:" should.
148
+ # Operators can extend/replace via HINDSIGHT_LESSON_TAG_MARKERS (JSON object).
149
+ "lessonTagMarkers": {
150
+ "lesson": [
151
+ "lesson learned",
152
+ "lessons learned",
153
+ "lesson:",
154
+ "note to self:",
155
+ "for next time:",
156
+ "takeaway:",
157
+ "key takeaway",
158
+ ],
159
+ "anti-pattern": [
160
+ "anti-pattern:",
161
+ "anti pattern:",
162
+ "antipattern:",
163
+ "what not to do",
164
+ "do not do this again",
165
+ "don't do this again",
166
+ "failure mode:",
167
+ ],
168
+ },
169
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — recall-side demotion weights
170
+ # for the lesson/anti-pattern tags above. Merged UNDER recallTagWeights at
171
+ # recall time (an explicit recallTagWeights entry for the same tag WINS), so
172
+ # lesson/anti-pattern transcripts are down-ranked out of the box while the
173
+ # PR5 sidechain seed and any operator override still compose cleanly. A raw
174
+ # transcript that merely discusses a failure mode should rank below a clean
175
+ # session memory of equal engine score, yet still surface when it is the only
176
+ # relevant hit (re-rank, never drop). Set HINDSIGHT_LESSON_DEMOTION=false to
177
+ # disable the built-in weights (rollback lever); override individual weights
178
+ # via recallTagWeights / HINDSIGHT_RECALL_TAG_WEIGHTS.
179
+ "lessonDemotion": True,
180
+ "lessonDemotionWeights": {"lesson": 0.85, "anti-pattern": 0.5},
98
181
  # Switchroom #3244 — boot reconciliation of un-committed transcript tails.
99
182
  # On by default; the load-bearing recovery for work an abrupt session death
100
183
  # (SIGKILL/OOM/watchdog) skipped. Disable per-agent via
@@ -102,6 +185,46 @@ DEFAULTS = {
102
185
  # HINDSIGHT_RECONCILE_{LOOKBACK_H,MAX_TURNS,BUDGET_S} bounds (read directly).
103
186
  "reconcileOnStart": True,
104
187
  "recallAdditionalBanks": [],
188
+ # Switchroom hindsight-leverage A3 — parallelise multi-bank recall.
189
+ # When on (default), the directives fetch and every bank recall run
190
+ # concurrently in daemon threads under ONE shared deadline
191
+ # (recallParallelDeadlineSeconds), so total critical-path latency is the
192
+ # SLOWEST slot instead of their SUM. Set false
193
+ # (HINDSIGHT_RECALL_PARALLEL=false) to restore the pre-A3 serial path —
194
+ # the rollback lever if the parallel path ever misbehaves.
195
+ "recallParallel": True,
196
+ # Shared deadline (seconds) for the whole parallel recall section. Sized
197
+ # at the UserPromptSubmit hook ceiling (12s, hooks.json) MINUS 2s headroom
198
+ # for block formatting + cache write + stdout flush, so a straggler bank
199
+ # can never push the hook past its ceiling. Slots still unfinished when the
200
+ # deadline elapses are abandoned (daemon threads) and marked timed_out.
201
+ "recallParallelDeadlineSeconds": 10,
202
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
203
+ # fallback. Boot reconciliation (reconcile_tail.py) closes the crash-loss
204
+ # window at the NEXT SessionStart, but between an abrupt kill and that boot,
205
+ # live recall returns nothing for the lost turns because the fact layer was
206
+ # never told about them. When on (default) AND every bank returned zero
207
+ # results AND no bank/directives slot hit its deadline (deadline_hit False —
208
+ # so a timed-out bank can't masquerade as a genuinely empty fact layer, the
209
+ # #3369 sequencing constraint that depends on A3's shared-deadline telemetry),
210
+ # recall greps the CURRENT session's transcript tail for turns that mention
211
+ # the query's terms and injects them as a clearly-labelled, lower-confidence
212
+ # fallback block. Everything is bounded: the tail read (…MaxBytes), the number
213
+ # of matched turns (…MaxTurns), the emitted characters (…MaxChars), and the
214
+ # grep wall-time (…DeadlineMs). Set false (HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK
215
+ # =false) to disable — the rollback lever.
216
+ "recallTranscriptFallback": True,
217
+ # Tail bytes read from the session transcript for the grep. 256 KiB covers
218
+ # many turns of recent conversation while keeping the read well inside the
219
+ # hook budget; only the LAST max_bytes are read (partial first line dropped).
220
+ "recallTranscriptFallbackMaxBytes": 262144,
221
+ # Hard ceiling on matched turns injected into the fallback block.
222
+ "recallTranscriptFallbackMaxTurns": 6,
223
+ # Hard ceiling on the fallback block's excerpt characters.
224
+ "recallTranscriptFallbackMaxChars": 2000,
225
+ # Wall-clock bound (ms) on the grep itself — abandons scanning older turns
226
+ # once exceeded so the fallback can never eat the recall critical path.
227
+ "recallTranscriptFallbackDeadlineMs": 1500,
105
228
  # Connection
106
229
  "hindsightApiUrl": None,
107
230
  "hindsightApiToken": None,
@@ -140,6 +263,12 @@ ENV_OVERRIDES = {
140
263
  "HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
141
264
  "HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
142
265
  "HINDSIGHT_RETAIN_MODE": ("retainMode", str),
266
+ # Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
267
+ # + recall demotion toggles and overrides.
268
+ "HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
269
+ "HINDSIGHT_LESSON_TAG_MARKERS": ("lessonTagMarkers", dict),
270
+ "HINDSIGHT_LESSON_DEMOTION": ("lessonDemotion", bool),
271
+ "HINDSIGHT_LESSON_DEMOTION_WEIGHTS": ("lessonDemotionWeights", dict),
143
272
  # Switchroom #3244 — boot reconciliation on/off (default on).
144
273
  "HINDSIGHT_RECONCILE_ON_START": ("reconcileOnStart", bool),
145
274
  "HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
@@ -171,13 +300,33 @@ ENV_OVERRIDES = {
171
300
  # agents.<name>.memory.directive_capture_verify only when the operator
172
301
  # overrode it; the switchroom default is on.
173
302
  "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
303
+ # Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
304
+ # 0 disables the cache (rollback lever).
305
+ "HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
306
+ # Switchroom hindsight-leverage A3 — parallel multi-bank recall toggle +
307
+ # shared deadline. HINDSIGHT_RECALL_PARALLEL=false is the serial rollback
308
+ # lever; the deadline is the ceiling-minus-2s hard budget (see DEFAULTS).
309
+ "HINDSIGHT_RECALL_PARALLEL": ("recallParallel", bool),
310
+ "HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS": ("recallParallelDeadlineSeconds", int),
311
+ # Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep fallback
312
+ # toggle + bounds. HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK=false is the rollback
313
+ # lever; the others tune the byte / turn / char / time bounds.
314
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK": ("recallTranscriptFallback", bool),
315
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_BYTES": ("recallTranscriptFallbackMaxBytes", int),
316
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_TURNS": ("recallTranscriptFallbackMaxTurns", int),
317
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_CHARS": ("recallTranscriptFallbackMaxChars", int),
318
+ "HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_DEADLINE_MS": ("recallTranscriptFallbackDeadlineMs", int),
174
319
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
175
320
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
321
+ # Switchroom hindsight-leverage A2 — byte-tail bound for the multi-turn
322
+ # transcript read (0 = read whole file / rollback lever).
323
+ "HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES": ("recallTranscriptTailBytes", int),
176
324
  # Upstream 962140eef — recall tag filters. The tags env var accepts JSON
177
325
  # or a comma-separated list; the others must be JSON.
178
326
  "HINDSIGHT_RECALL_TAGS": ("recallTags", list),
179
327
  "HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
180
328
  "HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
329
+ "HINDSIGHT_RECALL_TAG_WEIGHTS": ("recallTagWeights", dict),
181
330
  "HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
182
331
  "HINDSIGHT_API_PORT": ("apiPort", int),
183
332
  "HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
@@ -33,11 +33,17 @@ def strip_channel_envelope(content: str) -> str:
33
33
  This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes().
34
34
  Extracts the inner text, preserving the actual user message while removing
35
35
  transport metadata that Hindsight doesn't need.
36
+
37
+ A single prompt may carry MORE THAN ONE envelope (e.g. a coalesced
38
+ burst where several inbound messages were concatenated). Since this now
39
+ sits on the live recall-query path (hindsight-leverage PR 1, review
40
+ finding 6), coalesce EVERY envelope's inner text rather than keeping only
41
+ the first and silently dropping everything after the first ``</channel>``.
36
42
  """
37
- # Match <channel ...>content</channel> — extract inner text
38
- match = re.search(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
39
- if match:
40
- return match.group(1).strip()
43
+ # Match every <channel ...>content</channel> — extract & join inner texts.
44
+ matches = re.findall(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
45
+ if matches:
46
+ return "\n".join(m.strip() for m in matches if m.strip()).strip()
41
47
  return content
42
48
 
43
49
 
@@ -79,7 +85,14 @@ def compose_recall_query(
79
85
 
80
86
  <latest query>
81
87
  """
82
- latest = latest_query.strip()
88
+ # Switchroom A1 (hindsight-leverage PR 1) — strip the <channel> envelope
89
+ # from the latest query INSIDE the helper as well, so any caller (present
90
+ # or future) gets an envelope-free composed query. The recall.py caller
91
+ # already strips before calling, but keeping the strip here is defence in
92
+ # depth: it guarantees the trailing latest-query segment appended below
93
+ # (and returned on the turns<=1 short-circuit) never carries the raw
94
+ # chat_id/ts/user XML noise into the embedding or the char cap.
95
+ latest = strip_channel_envelope(latest_query).strip()
83
96
  if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
84
97
  return latest
85
98
 
@@ -236,6 +249,43 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
236
249
  return messages[start_index:]
237
250
 
238
251
 
252
+ # ---------------------------------------------------------------------------
253
+ # Sidechain (sub-agent transcript) detection
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ def transcript_first_line_is_sidechain(path: str) -> bool:
258
+ """True when the first JSON line of ``path`` carries ``isSidechain: true``.
259
+
260
+ Switchroom hindsight-leverage PR5. Claude Code writes sub-agent (Task-tool)
261
+ transcripts as separate ``.jsonl`` files under
262
+ ``<project>/<session>/subagents/agent-<agent_id>.jsonl`` whose every line
263
+ carries ``isSidechain: true``. This shared predicate lets BOTH the
264
+ SubagentStop retain (which resolves + retains these deliberately, tagged
265
+ ``sidechain`` + volume-gated) AND the boot reconciler / any transcript
266
+ sweeper (which must NOT treat a sidechain as a pseudo-session and re-retain
267
+ it untagged, at full recall weight, bypassing the volume gate) recognise a
268
+ sidechain file from its first line alone — a cheap single-line read. Any
269
+ read/parse error is treated as "not a sidechain" (fail-open: a
270
+ genuinely-unreadable file is skipped elsewhere by its empty transcript).
271
+ """
272
+ import json
273
+
274
+ try:
275
+ with open(path, encoding="utf-8") as f:
276
+ for line in f:
277
+ line = line.strip()
278
+ if not line:
279
+ continue
280
+ try:
281
+ return json.loads(line).get("isSidechain") is True
282
+ except json.JSONDecodeError:
283
+ return False
284
+ except OSError:
285
+ return False
286
+ return False
287
+
288
+
239
289
  # ---------------------------------------------------------------------------
240
290
  # Memory formatting (recall results → context string)
241
291
  # ---------------------------------------------------------------------------
@@ -18,8 +18,11 @@ recall path; a directive-fetch failure must not kill the recall block.
18
18
 
19
19
  import re
20
20
  import sys
21
+ import time
21
22
  from typing import Optional
22
23
 
24
+ from .state import list_state_names, read_state, remove_state, write_state
25
+
23
26
  # Sanity cap on how many directives we ever inject into the prompt. Banks
24
27
  # with more active directives than this are pathological; truncate with a
25
28
  # footer so the agent knows there are more.
@@ -29,25 +32,56 @@ MAX_DIRECTIVES = 15
29
32
  # UserPromptSubmit critical path — we cannot block it for long.
30
33
  DIRECTIVES_TIMEOUT_SECONDS = 2
31
34
 
35
+ # --- Directives-list cache (switchroom hindsight-leverage A4) -----------------
36
+ #
37
+ # `list_directives` runs on the recall (UserPromptSubmit) critical path every
38
+ # non-skipped turn — a fresh 2s-timeout HTTP round-trip whose result changes
39
+ # only when a directive is created/updated/deleted (rare). We cache the fetched
40
+ # list in the plugin state dir with a short TTL so the common no-write turn
41
+ # skips the round-trip, while bounding staleness:
42
+ # * In-session writes: directive_verify.py (Stop hook) deletes the cache when
43
+ # the just-ended turn contains a create/update/delete_directive tool_use, so
44
+ # the very next recall re-fetches — the new state is visible in turn N+1.
45
+ # * Cross-process writes (another session, operator CLI) have no invalidation
46
+ # channel and rely on TTL alone → at most TTL seconds stale.
47
+ #
48
+ # Invalidation blind spots (both fall back to TTL, ≤ TTL stale — acceptable):
49
+ # * Sub-agent / sidechain directive writes: the create_directive tool_use is
50
+ # in the SIDECHAIN transcript, not the parent's, so the parent's Stop hook
51
+ # (which reads the parent transcript) never sees it. PR 5 (SubagentStop)
52
+ # is where sidechain awareness lands.
53
+ # * Bash / operator-CLI directive writes (`switchroom` or a curl) produce no
54
+ # tool_use in any transcript, so there is nothing for the Stop hook to
55
+ # detect.
56
+ #
57
+ # Rollback: set the TTL to 0 (HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS=0) to
58
+ # disable the cache entirely — every turn fetches live, as before A4.
59
+ DIRECTIVES_CACHE_TTL_SECONDS = 120
32
60
 
33
- def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
34
- """Fetch active directives for a bank, sorted by priority (highest first).
61
+ # All directive cache files share this basename prefix so they can be
62
+ # enumerated for bulk (bank-agnostic) invalidation.
63
+ _CACHE_PREFIX = "directives_cache."
35
64
 
36
- Args:
37
- client: A HindsightClient instance with a list_directives method.
38
- bank_id: The bank to fetch directives from.
39
- timeout: HTTP timeout in seconds.
40
65
 
41
- Returns:
42
- A list of directive dicts (each with id, name, content, priority,
43
- tags, ...), sorted by priority descending. On any failure returns
44
- an empty list and logs a single warn line to stderr — never raises.
66
+ def _cache_name(bank_id: str) -> str:
67
+ """State-file name for a bank's cached directive list."""
68
+ return f"{_CACHE_PREFIX}{bank_id}.json"
69
+
70
+
71
+ def _fetch_directives_with_status(client, bank_id: str, timeout: int) -> tuple:
72
+ """Fetch + normalize active directives, reporting fetch success.
73
+
74
+ Returns ``(ok, directives)``. ``ok`` is False only on a genuine fetch
75
+ FAILURE (HTTP error, non-dict response) — a bank that simply has no
76
+ directives returns ``(True, [])``. Callers use ``ok`` to avoid caching a
77
+ transient failure's empty result (which would mask real directives for a
78
+ whole TTL window). Never raises; logs a single warn line on failure.
45
79
  """
46
80
  try:
47
81
  response = client.list_directives(bank_id=bank_id, active_only=True, timeout=timeout)
48
82
  except Exception as e:
49
83
  print(f"[Hindsight] list_directives failed for bank '{bank_id}': {e}", file=sys.stderr)
50
- return []
84
+ return False, []
51
85
 
52
86
  if not isinstance(response, dict):
53
87
  print(
@@ -55,20 +89,123 @@ def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIME
55
89
  f"{type(response).__name__}",
56
90
  file=sys.stderr,
57
91
  )
58
- return []
92
+ return False, []
59
93
 
60
94
  items = response.get("items")
61
95
  if not isinstance(items, list):
62
96
  # Empty / malformed response — quiet success, no warn (banks with
63
- # no directives are normal).
64
- return []
97
+ # no directives are normal). Cacheable.
98
+ return True, []
65
99
 
66
100
  # Filter to dicts only, then sort by priority descending. Treat missing
67
101
  # priority as 0 so malformed entries sink to the bottom rather than
68
102
  # crashing.
69
103
  valid = [d for d in items if isinstance(d, dict)]
70
104
  valid.sort(key=lambda d: d.get("priority", 0), reverse=True)
71
- return valid
105
+ return True, valid
106
+
107
+
108
+ def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
109
+ """Fetch active directives for a bank, sorted by priority (highest first).
110
+
111
+ Args:
112
+ client: A HindsightClient instance with a list_directives method.
113
+ bank_id: The bank to fetch directives from.
114
+ timeout: HTTP timeout in seconds.
115
+
116
+ Returns:
117
+ A list of directive dicts (each with id, name, content, priority,
118
+ tags, ...), sorted by priority descending. On any failure returns
119
+ an empty list and logs a single warn line to stderr — never raises.
120
+ """
121
+ _ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
122
+ return directives
123
+
124
+
125
+ def _read_cache(bank_id: str) -> Optional[dict]:
126
+ """Read + validate a bank's cache envelope. Returns None on miss/corruption.
127
+
128
+ A corrupted or wrong-shaped cache (bad JSON handled by read_state; here we
129
+ additionally reject a non-dict envelope, a non-numeric timestamp, or a
130
+ non-list directive payload) is treated as a MISS so the caller falls back to
131
+ a live fetch rather than injecting garbage.
132
+
133
+ Also rejects an envelope whose stored ``bank_id`` does not match the
134
+ requested one: ``_safe_filename`` can collapse two distinct bank ids onto a
135
+ single cache file, and serving bank A's directives for bank B would leak
136
+ rules across banks. The embedded ``bank_id`` is the authoritative key.
137
+ """
138
+ raw = read_state(_cache_name(bank_id), None)
139
+ if not isinstance(raw, dict):
140
+ return None
141
+ if raw.get("bank_id") != bank_id:
142
+ return None
143
+ ts = raw.get("ts")
144
+ directives = raw.get("directives")
145
+ if not isinstance(ts, (int, float)) or isinstance(ts, bool):
146
+ return None
147
+ if not isinstance(directives, list):
148
+ return None
149
+ return raw
150
+
151
+
152
+ def fetch_active_directives_cached(
153
+ client,
154
+ bank_id: str,
155
+ ttl_seconds: int = DIRECTIVES_CACHE_TTL_SECONDS,
156
+ timeout: int = DIRECTIVES_TIMEOUT_SECONDS,
157
+ now: Optional[float] = None,
158
+ ) -> list:
159
+ """Cached wrapper around :func:`fetch_active_directives`.
160
+
161
+ On a fresh cache hit (age < ``ttl_seconds``) returns the cached list WITHOUT
162
+ an HTTP call. On a miss / expiry / corrupted cache, fetches live and, when
163
+ the fetch SUCCEEDED, writes the cache. A failed fetch is never cached, so a
164
+ transient error can't mask real directives for a TTL window.
165
+
166
+ ``ttl_seconds <= 0`` disables the cache (always live-fetch, never write) —
167
+ the A4 rollback lever.
168
+
169
+ Args:
170
+ now: Injectable current epoch seconds, for deterministic tests.
171
+ """
172
+ if now is None:
173
+ now = time.time()
174
+
175
+ caching = isinstance(ttl_seconds, (int, float)) and ttl_seconds > 0
176
+
177
+ if caching:
178
+ cached = _read_cache(bank_id)
179
+ if cached is not None:
180
+ age = now - cached["ts"]
181
+ # A negative age means the stored timestamp is in the FUTURE
182
+ # (wall-clock step-back / a doctored envelope) — treat it as
183
+ # expired rather than "fresh forever", so a clock correction can't
184
+ # pin a stale cache.
185
+ if 0 <= age < ttl_seconds:
186
+ return cached["directives"]
187
+
188
+ ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
189
+
190
+ if caching and ok:
191
+ write_state(_cache_name(bank_id), {"ts": now, "bank_id": bank_id, "directives": directives})
192
+
193
+ return directives
194
+
195
+
196
+ def invalidate_directives_cache(bank_id: Optional[str] = None) -> None:
197
+ """Delete the directives cache so the next recall re-fetches live.
198
+
199
+ With ``bank_id`` set, removes just that bank's cache file. With no argument
200
+ (the Stop-hook invalidation path, which does not resolve the bank), removes
201
+ EVERY directive cache file — directive writes are rare, so a bank-agnostic
202
+ sweep is cheap and robust. Best-effort; never raises.
203
+ """
204
+ if bank_id is not None:
205
+ remove_state(_cache_name(bank_id))
206
+ return
207
+ for name in list_state_names(_CACHE_PREFIX):
208
+ remove_state(name)
72
209
 
73
210
 
74
211
  def format_active_directives_block(directives: list, max_directives: int = MAX_DIRECTIVES) -> Optional[str]:
@@ -0,0 +1,142 @@
1
+ """Deadline-bounded parallel fan-out for the recall critical path.
2
+
3
+ Switchroom hindsight-leverage A3 (parallel multi-bank recall). The recall
4
+ hook (``recall.py``) queries the agent's own bank, every additional bank
5
+ (profile / shared / sender), and the active-directives list. Serially, the
6
+ critical-path latency is the SUM of those round-trips — a heavy agent with two
7
+ extra banks plus directives can serialise four 2-8s calls and breach the 12s
8
+ UserPromptSubmit hook ceiling, which drops recall entirely for that turn.
9
+
10
+ This runs each labelled task in its OWN daemon thread and waits only until a
11
+ single SHARED deadline (the hook ceiling minus a headroom margin). Key
12
+ properties, all enforced by mechanism rather than convention:
13
+
14
+ * **Daemon threads.** Every worker is a daemon, so a thread still blocked on
15
+ a socket read when the deadline elapses can NEVER keep the interpreter (and
16
+ therefore the hook) alive past the ceiling — the process exits and the
17
+ kernel reaps the socket. ``recall.py``'s ``__main__`` additionally calls
18
+ ``os._exit(0)`` after flushing stdout as a belt-and-suspenders against any
19
+ non-daemon thread a client library might spawn.
20
+
21
+ * **One shared deadline.** Total wait is bounded by ``deadline_seconds`` from
22
+ the moment ``run_parallel`` is entered — not per-task — so N slow banks
23
+ cost the deadline ONCE, not N times.
24
+
25
+ * **Completion is observed, not assumed.** Each slot records whether its
26
+ thread finished before we stopped waiting (``completed``), its return value
27
+ or exception, and its wall-clock ``elapsed_ms``. A slot still running at the
28
+ deadline is ``completed=False`` with ``elapsed_ms`` pinned to the deadline —
29
+ the caller maps that to ``timed_out`` for telemetry.
30
+
31
+ Stdlib-only (threading + time); no third-party deps, importable under the
32
+ plugin's ``python3 -m unittest`` harness.
33
+ """
34
+
35
+ import threading
36
+ import time
37
+
38
+
39
+ class SlotResult:
40
+ """Outcome of one labelled task run under the shared deadline.
41
+
42
+ Attributes:
43
+ label: the task's key in the ``tasks`` mapping.
44
+ value: the callable's return value, or None if it raised / did not
45
+ finish before the deadline.
46
+ error: the exception the callable raised, or None. A slot that hit
47
+ the deadline mid-flight has ``error=None`` and
48
+ ``completed=False`` (it never got to raise or return).
49
+ completed: True iff the worker thread finished (returned or raised)
50
+ before ``run_parallel`` stopped waiting on it. False means
51
+ the shared deadline elapsed first.
52
+ elapsed_ms: wall-clock ms the slot took. For a completed slot this is
53
+ its real duration; for a deadline-abandoned slot it is the
54
+ time from fan-out start to the deadline.
55
+ """
56
+
57
+ __slots__ = ("label", "value", "error", "completed", "elapsed_ms")
58
+
59
+ def __init__(self, label):
60
+ self.label = label
61
+ self.value = None
62
+ self.error = None
63
+ self.completed = False
64
+ self.elapsed_ms = None
65
+
66
+
67
+ def _runner(slot, fn, start):
68
+ """Worker body: run the task, capturing value/exception and duration.
69
+
70
+ Catches ``BaseException`` deliberately for slot isolation: a slot runs in
71
+ its own daemon thread, and a task failure of ANY kind — including
72
+ non-``Exception`` subclasses like ``KeyboardInterrupt`` /
73
+ ``SystemExit`` — must be captured into ``slot.error`` rather than escaping
74
+ the thread. An escaped exception would let the slot die silently and the
75
+ deadline join would then see no value AND no recorded error. (``socket.timeout``
76
+ is itself just an ``OSError`` subclass, i.e. an ordinary ``Exception``; the
77
+ broad catch is about never letting any slot failure leak out of the thread.)
78
+ """
79
+ try:
80
+ slot.value = fn()
81
+ except BaseException as e: # noqa: BLE001 - deliberate: isolate the slot
82
+ slot.error = e
83
+ finally:
84
+ slot.elapsed_ms = int((time.monotonic() - start) * 1000)
85
+
86
+
87
+ def run_parallel(tasks, deadline_seconds):
88
+ """Run ``{label: callable}`` concurrently under one shared deadline.
89
+
90
+ Args:
91
+ tasks: mapping of label -> zero-arg callable. Each is invoked once in
92
+ its own daemon thread. Insertion order is preserved in the returned
93
+ mapping (Python dicts are ordered) so the caller can emit per-slot
94
+ telemetry deterministically regardless of completion order.
95
+ deadline_seconds: total wall-clock budget for ALL tasks combined,
96
+ measured from entry. Non-positive values run every task with a
97
+ zero join (each slot is recorded as not-completed unless it was
98
+ already instantaneous) — a degenerate "give up immediately" mode.
99
+
100
+ Returns:
101
+ ``dict[label] -> SlotResult`` in the same order as ``tasks``. Never
102
+ raises for a task failure — a raising task surfaces via
103
+ ``SlotResult.error``.
104
+ """
105
+ start = time.monotonic()
106
+ pairs = [] # (SlotResult, Thread) in task order
107
+ for label, fn in tasks.items():
108
+ slot = SlotResult(label)
109
+ thread = threading.Thread(
110
+ target=_runner,
111
+ args=(slot, fn, start),
112
+ daemon=True,
113
+ name=f"recall-slot-{label}",
114
+ )
115
+ pairs.append((slot, thread))
116
+
117
+ for _slot, thread in pairs:
118
+ thread.start()
119
+
120
+ # Join each thread, but never wait past the SHARED deadline. Because the
121
+ # remaining budget is recomputed from ``start`` on every iteration, the
122
+ # total time spent here is bounded by ``deadline_seconds`` even with many
123
+ # slow slots — a fast early slot leaves more budget for later ones, and
124
+ # once the budget is exhausted every subsequent join is a non-blocking
125
+ # ``is_alive`` check.
126
+ for _slot, thread in pairs:
127
+ remaining = deadline_seconds - (time.monotonic() - start)
128
+ if remaining > 0:
129
+ thread.join(remaining)
130
+
131
+ # Classify each slot as completed (thread finished) or deadline-abandoned.
132
+ now = time.monotonic()
133
+ results = {}
134
+ for slot, thread in pairs:
135
+ if thread.is_alive():
136
+ slot.completed = False
137
+ if slot.elapsed_ms is None:
138
+ slot.elapsed_ms = int((now - start) * 1000)
139
+ else:
140
+ slot.completed = True
141
+ results[slot.label] = slot
142
+ return results
@@ -82,6 +82,37 @@ def write_state(name: str, data):
82
82
  pass
83
83
 
84
84
 
85
+ def remove_state(name: str) -> None:
86
+ """Delete a state file if it exists. Best-effort; never raises.
87
+
88
+ Name is sanitized through the same path-traversal guard as read/write, so
89
+ callers cannot escape the state directory.
90
+ """
91
+ try:
92
+ path = _state_file(name)
93
+ except ValueError:
94
+ return
95
+ try:
96
+ os.remove(path)
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def list_state_names(prefix: str = "") -> list:
102
+ """List state-file basenames in the state dir, optionally by prefix.
103
+
104
+ Returns [] on any error. Used to enumerate a family of cache files (e.g.
105
+ per-bank directive caches) for bulk invalidation.
106
+ """
107
+ try:
108
+ names = os.listdir(_state_dir())
109
+ except OSError:
110
+ return []
111
+ if prefix:
112
+ return [n for n in names if n.startswith(prefix)]
113
+ return names
114
+
115
+
85
116
  def get_turn_count(session_id: str) -> int:
86
117
  """Get the current turn count for a session."""
87
118
  turns = read_state("turns.json", {})