switchroom 0.19.19 → 0.19.23

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +121 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +2995 -1583
  5. package/dist/host-control/main.js +122 -2
  6. package/dist/vault/approvals/kernel-server.js +124 -4
  7. package/dist/vault/broker/server.js +124 -4
  8. package/package.json +7 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +13 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +90 -107
  17. package/skills/switchroom-release/SKILL.md +103 -20
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/card-format.ts +92 -3
  20. package/telegram-plugin/dist/gateway/gateway.js +873 -184
  21. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  22. package/telegram-plugin/format.ts +19 -7
  23. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  24. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  26. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  27. package/telegram-plugin/gateway/gateway.ts +75 -63
  28. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  29. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  30. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  31. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  32. package/telegram-plugin/gateway/stream-render.ts +6 -0
  33. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  34. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  35. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  36. package/telegram-plugin/retry-api-call.ts +15 -2
  37. package/telegram-plugin/send-gate.ts +1 -1
  38. package/telegram-plugin/status-no-truncate.ts +64 -1
  39. package/telegram-plugin/status-pin-driver.ts +50 -27
  40. package/telegram-plugin/status-pin.ts +43 -5
  41. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  42. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  43. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  44. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  45. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  46. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  47. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  48. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  49. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  50. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  51. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  52. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  53. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  54. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  55. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  56. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  57. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  58. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  59. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  60. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  61. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  62. package/telegram-plugin/tool-activity-summary.ts +85 -13
  63. package/telegram-plugin/worker-activity-feed.ts +5 -1
  64. package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
  65. package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
  66. package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
  67. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
  76. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -68,7 +68,8 @@ from typing import Optional
68
68
  # The bound — derived, not chosen
69
69
  # ---------------------------------------------------------------------------
70
70
  #
71
- # max_content_chars = retain_chunk_size × floor(client_deadline / chunk_latency)
71
+ # max_content_chars = retain_chunk_size
72
+ # × floor(client_deadline × deadline_safety / chunk_latency)
72
73
  #
73
74
  # Each input is a measured or read property of the deployment, not a taste
74
75
  # call, and each is env-overridable so the bound tracks the deployment:
@@ -83,7 +84,7 @@ from typing import Optional
83
84
  # completion-token bucket; the runaway bucket is
84
85
  # a separate defect, fixed by capping
85
86
  # HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS).
86
- # client_deadline 280 s — the deadline of the DURABILITY path (the
87
+ # client_deadline 310 s — the deadline of the DURABILITY path (the
87
88
  # out-of-hook backlog drain, #3599), deliberately
88
89
  # not the live Stop hook's 15s. The live path is
89
90
  # allowed to miss its deadline: it enqueues to
@@ -96,20 +97,60 @@ from typing import Optional
96
97
  # literal, and `src/setup/hindsight.ts`
97
98
  # (`HINDSIGHT_RETAIN_CLIENT_DEADLINE_S`, #3611)
98
99
  # mirrors it as the client half of that PR's
99
- # `server per-call timeout < client deadline`
100
- # assertion — which its derived 204s server
101
- # timeout satisfies against 280 and would NOT
102
- # against #3599's original 180s literal.
100
+ # `hindsight per-call timeout < client deadline`
101
+ # assertion. A test in `tests/setup/hindsight.test.ts`
102
+ # enforces that the two stay equal, so the mirror
103
+ # is checked, not merely asserted in prose.
104
+ # WAS 280.0. Raised to 310 when the retain
105
+ # per-call timeout became derived from the litellm
106
+ # routing chain (`local 200 + fallback 90 +
107
+ # margin 10 = 300`) instead of from the token
108
+ # budget alone: #3611's 204s could not cover that
109
+ # chain, so the retain OpenRouter fallback hop had
110
+ # 4s of headroom and could never complete. 310 is
111
+ # that 300 plus the same one margin, so the plugin
112
+ # always outlives hindsight by construction. It is
113
+ # NOT a hand-set number on either side — change
114
+ # `src/litellm/timeout-budget.ts` and both move.
103
115
  #
104
- # floor(280 / 18.4) = 15 chunks → 15 × 3000 = 45,000 chars
116
+ # deadline_safety 0.7 — the FRACTION of the deadline a maximally-sized
117
+ # part is allowed to consume. See below; this
118
+ # input did not exist until #3693 and was
119
+ # effectively 1.0.
105
120
  #
106
- # Sanity check against the same backlog: every entry at or below 60,000 chars
107
- # drained successfully inside the 280s deadline (observed per-entry times
108
- # 0.3s–153.4s at concurrency 3), so 45,000 sits inside demonstrated-good
109
- # territory with margin for a slower model or a busier box.
121
+ # floor(310 × 0.7 / 18.4) = 11 chunks → 11 × 3000 = 33,000 chars
122
+ #
123
+ # WHY THERE IS A SAFETY FRACTION AT ALL (#3693). Without it the bound was
124
+ # `floor(deadline / latency)`, which sizes a maximally-sized part to consume
125
+ # ~100% of the deadline BY CONSTRUCTION: 16 × 18.4 = 294.4s against a 310s
126
+ # deadline is 15.6s — 5% — of headroom for the POST/response, server queueing,
127
+ # and any chunk slower than the n=752 MEAN the latency input is. Half the
128
+ # extraction calls are slower than the mean; a part sized to the mean therefore
129
+ # misses the deadline roughly half the time. `drain_pending._backlog_timeout()`
130
+ # defaults to the SAME deadline, so the drainer inherits the same zero margin,
131
+ # times the entry out, bumps its attempt count, and after MAX_ATTEMPTS ages it
132
+ # to `.dead`. That is not a theory: on this fleet every `.dead` marker measured
133
+ # on 2026-07-26 held content of 16,076–44,568 chars — every one of them UNDER
134
+ # the then-current 45,000 bound. The bound was manufacturing dead memories.
135
+ #
136
+ # 0.7 is not a taste call either: it is the utilisation at which the observed
137
+ # per-chunk latency distribution fits, and it independently matches the 30,000
138
+ # char limit the host-side stopgap converged on empirically before this landed.
139
+ # At 0.7 a maximally-sized part is 11 × 18.4 = 202.4s against 310s, leaving
140
+ # 107.6s — enough for a chunk distribution ~50% worse than its own mean.
141
+ #
142
+ # The trade is more parts per logical memory (33,000 rather than 48,000 chars
143
+ # each). That costs nothing in total LLM work — the chunk count across the
144
+ # whole memory is unchanged, only its grouping — and each part now finishes.
145
+ # A part that finishes is worth strictly more than a larger part that does not.
110
146
  DEFAULT_RETAIN_CHUNK_SIZE = 3000
111
147
  DEFAULT_RETAIN_CHUNK_LATENCY_S = 18.4
112
- DEFAULT_RETAIN_CLIENT_DEADLINE_S = 280.0
148
+ DEFAULT_RETAIN_CLIENT_DEADLINE_S = 310.0
149
+
150
+ # Fraction of the client deadline a maximally-sized part may consume.
151
+ # Clamped to (0, 1]: a value above 1.0 would size parts to overrun the
152
+ # deadline outright, which is the defect this input exists to prevent.
153
+ DEFAULT_RETAIN_DEADLINE_SAFETY = 0.7
113
154
 
114
155
  # Absolute floor: one chunk. A bound below one chunk would split every
115
156
  # transcript into extraction-sized confetti and is never the right answer.
@@ -140,6 +181,18 @@ def retain_client_deadline() -> float:
140
181
  return _env_number("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", DEFAULT_RETAIN_CLIENT_DEADLINE_S)
141
182
 
142
183
 
184
+ def retain_deadline_safety() -> float:
185
+ """Fraction of the client deadline a maximally-sized part may consume.
186
+
187
+ Clamped to ``(0, 1]``. A value above 1.0 is not honoured: it would size
188
+ parts to overrun the deadline by construction, which is exactly the defect
189
+ (#3693) this input exists to prevent, so it is treated as "no margin at
190
+ all" — 1.0 — rather than as a licence to go further.
191
+ """
192
+ value = _env_number("HINDSIGHT_RETAIN_DEADLINE_SAFETY", DEFAULT_RETAIN_DEADLINE_SAFETY)
193
+ return min(1.0, value)
194
+
195
+
143
196
  def retain_content_limit() -> int:
144
197
  """Max chars of retain content that can complete inside the client deadline.
145
198
 
@@ -159,7 +212,12 @@ def retain_content_limit() -> int:
159
212
  latency = _env_number("HINDSIGHT_RETAIN_CHUNK_LATENCY_S", DEFAULT_RETAIN_CHUNK_LATENCY_S)
160
213
  deadline = retain_client_deadline()
161
214
 
162
- chunks = int(deadline // latency)
215
+ # A FRACTION of the deadline, not all of it (#3693). Sizing to the whole
216
+ # deadline leaves a maximally-sized part no headroom for the POST itself,
217
+ # server queueing, or a chunk slower than the MEAN `latency` is measured
218
+ # as — so the part times out, the drain bumps its attempt count, and it
219
+ # ages to `.dead`. See the derivation block at the top of this module.
220
+ chunks = int((deadline * retain_deadline_safety()) // latency)
163
221
  if chunks < 1:
164
222
  chunks = 1
165
223
  return max(MIN_RETAIN_CONTENT_CHARS, chunk_size * chunks)
@@ -1296,6 +1296,56 @@ def _combine_context(base, nudge) -> str:
1296
1296
  return "\n\n".join(parts)
1297
1297
 
1298
1298
 
1299
+ def degraded_recall_notice(bank_id, bank_timings) -> str:
1300
+ """Switchroom #3619 — return the degraded-recall disclosure for this turn,
1301
+ or "" when the agent's own bank answered.
1302
+
1303
+ Until now a recall whose own bank timed out was indistinguishable, from the
1304
+ agent's side, from a bank that genuinely held nothing relevant: both
1305
+ produced an empty block and silence. That ambiguity is what let a measured
1306
+ ~90% own-bank timeout rate run for weeks unnoticed while every agent's
1307
+ CLAUDE.md asserted recall "auto-fires on every inbound message" — the agent
1308
+ had no way to know it was answering from an empty context, so it never said
1309
+ so and the operator never saw it.
1310
+
1311
+ Only the agent's OWN bank warrants the notice: additional banks (a shared
1312
+ profile bank, say) are supplementary, and a side-bank timeout does not mean
1313
+ the agent lost its own memory. Matching is by `bank_id`, never by position
1314
+ in `bank_timings` — the fan-out order is not stable.
1315
+
1316
+ Kept to a single short line on purpose: this fires on an already-degraded
1317
+ turn, and a verbose block would spend the very budget the degradation is
1318
+ starving. The caller must keep it OUT of the cached context (see
1319
+ `_combine_context`) — it is per-turn state and would otherwise replay on a
1320
+ later healthy cache hit.
1321
+ """
1322
+ if not bank_id or not bank_timings:
1323
+ return ""
1324
+ own = next(
1325
+ (
1326
+ bt
1327
+ for bt in bank_timings
1328
+ if isinstance(bt, dict) and bt.get("bank_id") == bank_id
1329
+ ),
1330
+ None,
1331
+ )
1332
+ if not own:
1333
+ return ""
1334
+ if own.get("timed_out"):
1335
+ reason = "timed out"
1336
+ elif own.get("errored"):
1337
+ reason = "was unreachable"
1338
+ else:
1339
+ return ""
1340
+ return (
1341
+ f"[Hindsight] Memory recall was DEGRADED this turn: your own bank "
1342
+ f"('{bank_id}') {reason}, so the memories below (if any) are "
1343
+ f"incomplete and may be missing entirely. Treat an absence of "
1344
+ f"relevant memory as UNKNOWN, not as 'nothing was remembered' — "
1345
+ f"say so rather than asserting there is no prior context."
1346
+ )
1347
+
1348
+
1299
1349
  def main():
1300
1350
  config = load_config()
1301
1351
 
@@ -2129,13 +2179,22 @@ def main():
2129
2179
  "transcript_fallback_truncated": transcript_fallback_telemetry["truncated"],
2130
2180
  })
2131
2181
 
2132
- # If neither block has content, there's nothing to inject — exit
2182
+ # Switchroom #3619 — DEGRADED-RECALL DISCLOSURE. See
2183
+ # `degraded_recall_notice` for why this exists and why only the agent's
2184
+ # OWN bank counts.
2185
+ degraded_block = degraded_recall_notice(bank_id, bank_timings)
2186
+
2187
+ # If no block has content, there's nothing to inject — exit
2133
2188
  # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
2134
2189
  # the directive-capture nudge fired, in which case emit the nudge alone
2135
2190
  # (a correction with no memories/directives still needs the reminder).
2191
+ # #3619: a degraded own-bank read is likewise worth emitting alone — that
2192
+ # is precisely the turn on which the agent must not assume it remembers.
2136
2193
  if not directives_block and not memories_block and not transcript_fallback_block:
2137
- if nudge_block:
2138
- _emit_cached_context(nudge_block)
2194
+ if degraded_block or nudge_block:
2195
+ _emit_cached_context(
2196
+ "\n\n".join([b for b in (degraded_block, nudge_block) if b])
2197
+ )
2139
2198
  return
2140
2199
 
2141
2200
  # Compose final context. Directives block goes ABOVE memories so the
@@ -2143,6 +2202,12 @@ def main():
2143
2202
  # transcript fallback (#3369) goes LAST — it is the lowest-confidence
2144
2203
  # signal (raw transcript, not synthesized fact) and only present when
2145
2204
  # memories_block is empty by construction.
2205
+ #
2206
+ # #3619's degraded notice is deliberately NOT part of context_message: like
2207
+ # the #2848 nudge it is per-turn state, and this string is what gets cached
2208
+ # and written to LAST_RECALL_STATE. Caching it would replay "recall was
2209
+ # DEGRADED" on later healthy cache hits; it is prepended at emit time
2210
+ # instead, so a cache hit re-derives the turn's real condition.
2146
2211
  parts = []
2147
2212
  if directives_block:
2148
2213
  parts.append(directives_block)
@@ -2177,11 +2242,15 @@ def main():
2177
2242
 
2178
2243
  # Output JSON for Claude Code hook system. #2848: append the
2179
2244
  # directive-capture nudge (if it fired) at emit time — it's kept out of
2180
- # the cached / last-recall context above so it can't go stale.
2245
+ # the cached / last-recall context above so it can't go stale. #3619: the
2246
+ # degraded-recall notice is prepended for the same reason, and goes FIRST
2247
+ # because it changes how everything after it should be read.
2181
2248
  output = {
2182
2249
  "hookSpecificOutput": {
2183
2250
  "hookEventName": "UserPromptSubmit",
2184
- "additionalContext": _combine_context(context_message, nudge_block),
2251
+ "additionalContext": _combine_context(
2252
+ _combine_context(degraded_block, context_message), nudge_block
2253
+ ),
2185
2254
  }
2186
2255
  }
2187
2256
  json.dump(output, sys.stdout)