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.
- package/dist/agent-scheduler/index.js +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
if
|
|
40
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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", {})
|