switchroom 0.19.26 โ†’ 0.19.27

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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -23,8 +23,15 @@ import type { Driver, ObservedMessage, ObservedReaction } from "./driver.js";
23
23
  *
24
24
  * Single source of truth; the worker-feed scenario asserts against this,
25
25
  * and recall/reply scenarios exclude it via {@link isWorkerFeedMessage}.
26
+ *
27
+ * `Workers?` is load-bearing, not cosmetic: the COMBINED (2+ worker) card's
28
+ * glance line reads `๐Ÿ›  WORKERS ยท N running ยท โ€ฆ`, and `Worker\b` cannot match
29
+ * inside `WORKERS` (no word boundary between `r` and `S`). Without the optional
30
+ * `s` the whole 2+ worker surface was invisible to this predicate, so a
31
+ * recall/reply scenario running with two live workers could latch onto the
32
+ * combined card as the agent's answer. Found during the #3842 card audit.
26
33
  */
27
- export const WORKER_FEED_RE = /๐Ÿ› [๏ธ]?\s*Worker\b|finished\s*ยท\s*(?:completed|failed)/i;
34
+ export const WORKER_FEED_RE = /๐Ÿ› [๏ธ]?\s*Workers?\b|finished\s*ยท\s*(?:completed|failed)/i;
28
35
 
29
36
  /**
30
37
  * True when `m` is a worker-activity-feed message rather than the agent's
@@ -74,22 +81,26 @@ const ACTIVITY_BODY_LINE_RE = /^[โ†’โœ“โ†ณ]/u;
74
81
  const LIVENESS_HEADER_L1_RE = /^(?:๐Ÿค–|๐Ÿ› [๏ธ]?|โš™[๏ธ]?)\s+\S/u;
75
82
 
76
83
  /**
77
- * Leading chrome a SUBORDINATE (worker) card carries since #3820: the `โ””โ”€ `
78
- * header prefix on line 1 and a `U+2800` indent run on every later line, which
79
- * together make the worker card readable as a child of the ๐Ÿค– agent card.
84
+ * Leading indent chrome a card line can carry: a `U+2800` run, applied by
85
+ * `WORKER_STEP_INDENT` to the step lines under each worker header on the
86
+ * combined (2+ worker) card so one worker's steps are visibly separated from
87
+ * the next worker's.
80
88
  *
81
89
  * Every line-shape matcher below must normalise it away first: Telegram
82
- * delivers both verbatim, and NEITHER is stripped by `String.trim()` โ€” U+2800
83
- * is category So, not whitespace (that property is exactly why it survives
90
+ * delivers it verbatim and `String.trim()` does NOT remove it โ€” U+2800 is
91
+ * category So, not whitespace (that property is exactly why it survives
84
92
  * Telegram's own left-trim; see WORKER_STEP_INDENT). Without this, a worker
85
- * card's header stops matching `LIVENESS_HEADER_L1_RE` and its body lines stop
86
- * matching `ACTIVITY_BODY_LINE_RE`.
93
+ * card's body lines stop matching `ACTIVITY_BODY_LINE_RE`.
94
+ *
95
+ * #3842 removed the whole-card `โ””โ”€ ` header prefix and the whole-card indent
96
+ * that #3820/#3821 put on worker cards, so this no longer has to strip a `โ””โ”€ `.
97
+ * The step indent is the only nesting a card emits now.
87
98
  */
88
- const SUBORDINATE_CARD_PREFIX_RE = /^(?:โ””โ”€\s*|\u2800+)+/u;
99
+ const CARD_INDENT_PREFIX_RE = /^\u2800+/u;
89
100
 
90
- /** A card line with its subordinate-card nesting chrome removed, trimmed. */
101
+ /** A card line with its leading step-indent chrome removed, trimmed. */
91
102
  export function stripCardNesting(line: string): string {
92
- return line.replace(SUBORDINATE_CARD_PREFIX_RE, "").trim();
103
+ return line.replace(CARD_INDENT_PREFIX_RE, "").trim();
93
104
  }
94
105
  const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
95
106
  const LIVENESS_HEADER_L2_RE = new RegExp(
@@ -52,29 +52,36 @@ describe("isWorkerFeedMessage", () => {
52
52
  expect(WORKER_FEED_RE.test("๐Ÿ›  Worker ยท x")).toBe(true);
53
53
  });
54
54
 
55
- it("still matches the #3820 SUBORDINATE worker card as Telegram delivers it", () => {
56
- // Since #3820 the worker card leads with `โ””โ”€ ` and indents every later line
57
- // with a U+2800 run so it reads as a child of the ๐Ÿค– agent card. Telegram
58
- // strips the bold/italic entities but delivers that chrome verbatim, and
59
- // `String.trim()` removes neither (U+2800 is category So, not whitespace) โ€”
60
- // so every matcher here has to survive it or recall/reply scenarios start
61
- // latching onto worker cards as answers.
62
- const nested = "โ””โ”€ ๐Ÿ›  WORKER ยท crawling changelog\nโ €โ €โ €55s ยท 9 tools ยท opus 5";
63
- expect(isWorkerFeedMessage(feed(nested))).toBe(true);
55
+ it("matches the FLUSH worker card as Telegram delivers it (#3842)", () => {
56
+ // #3842 removed the whole-card `โ””โ”€ ` prefix and whole-card indent that
57
+ // #3820/#3821 put on worker cards, so the single-worker card arrives flush.
58
+ const flush = "๐Ÿ›  WORKER ยท crawling changelog\n55s ยท 9 tools ยท opus 5";
59
+ expect(isWorkerFeedMessage(feed(flush))).toBe(true);
64
60
  });
65
61
  });
66
62
 
67
- describe("#3820 subordinate-card nesting is transparent to the matchers", () => {
68
- it("classifies a nested agent-shaped card as the liveness card, not an answer", () => {
69
- const nested =
70
- "โ””โ”€ ๐Ÿ›  WORKER ยท crawl\nโ €โ €โ €12s ยท 3 tools\nโ €โ €โ €โœ“ Reading CLAUDE.md\nโ €โ €โ €โ†’ Searching memory";
71
- expect(isLivenessCardMessage(feed(nested))).toBe(true);
72
- expect(isActivityFeedMessage(feed(nested))).toBe(true);
63
+ describe("#3842 the surviving step indent is transparent to the matchers", () => {
64
+ it("classifies a flush agent-shaped worker card as the liveness card, not an answer", () => {
65
+ const flush =
66
+ "๐Ÿ›  WORKER ยท crawl\n12s ยท 3 tools\nโœ“ Reading CLAUDE.md\nโ†’ Searching memory";
67
+ expect(isLivenessCardMessage(feed(flush))).toBe(true);
68
+ expect(isActivityFeedMessage(feed(flush))).toBe(true);
73
69
  });
74
70
 
75
- it("strips the header prefix and the indent, and leaves ordinary prose alone", () => {
76
- expect(stripCardNesting("โ””โ”€ ๐Ÿ›  WORKER ยท crawl")).toBe("๐Ÿ›  WORKER ยท crawl");
71
+ it("classifies a combined card whose step lines carry WORKER_STEP_INDENT", () => {
72
+ // The one indent that survives #3842: step lines under a worker row header
73
+ // on the 2+ worker card. `String.trim()` does NOT remove U+2800 (category
74
+ // So, not whitespace), so the matchers must normalise it explicitly or a
75
+ // recall/reply scenario latches onto the combined card as an answer.
76
+ const combined =
77
+ "๐Ÿ›  WORKERS ยท 2 running ยท oldest 55s ยท 13 tools\n" +
78
+ "1. crawl ยท 55s ยท 9 tools\nโ €โ €โ €โœ“ Reading CLAUDE.md\nโ €โ €โ €โ†’ Searching memory";
79
+ expect(isWorkerFeedMessage(feed(combined))).toBe(true);
80
+ });
81
+
82
+ it("strips the step indent and leaves ordinary prose alone", () => {
77
83
  expect(stripCardNesting("โ €โ €โ €โ†’ Searching memory")).toBe("โ†’ Searching memory");
84
+ expect(stripCardNesting("๐Ÿ›  WORKER ยท crawl")).toBe("๐Ÿ›  WORKER ยท crawl");
78
85
  expect(stripCardNesting("on it, pulling the logs now")).toBe(
79
86
  "on it, pulling the logs now",
80
87
  );
@@ -53,13 +53,9 @@ import {
53
53
  cleanWorkerResultParagraph,
54
54
  stripMarkdown,
55
55
  truncate,
56
- COLLAPSE_SAFE_SEPARATOR,
57
56
  } from './card-format.js'
58
- import {
59
- WORKER_HISTORY_MAX,
60
- SUBORDINATE_HEADER_PREFIX,
61
- SUBORDINATE_LINE_INDENT,
62
- } from './status-no-truncate.js'
57
+ import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
+ import { renderCardTitleLine } from './card-layout.js'
63
59
  import {
64
60
  renderStatusCard,
65
61
  formatStepSuffix,
@@ -130,6 +126,49 @@ export interface WorkerActivityView {
130
126
  model?: string
131
127
  }
132
128
 
129
+ /**
130
+ * Out-of-band metadata about ONE feed edit, handed to the transport adapter
131
+ * alongside the Bot API arguments (switchroom#3848).
132
+ *
133
+ * Deliberately a SEPARATE parameter rather than a field on `opts`: `opts` is
134
+ * forwarded verbatim to `editMessageText` and therefore onto the wire, and
135
+ * `outbound-class.ts` exists precisely so a priority signal never has to be
136
+ * smuggled into an outbound Bot API payload.
137
+ */
138
+ export interface WorkerFeedEditMeta {
139
+ /**
140
+ * True when this edit paints the card's FINAL state โ€” the last worker's
141
+ * terminal recap, or the "superseded" note on a rotated-out message. False
142
+ * for every intermediate / liveness repaint.
143
+ */
144
+ terminal: boolean
145
+ }
146
+
147
+ /**
148
+ * The send-gate priority class a worker-feed edit must carry.
149
+ *
150
+ * Intermediate repaints are `cosmetic`: dropping one costs nothing, because
151
+ * the next render carries full state, and holding them to the fuse's tight
152
+ * cosmetic ceilings (4/message/60s, 6/chat/60s) is what stops the feed
153
+ * earning a per-chat flood ban (#3847).
154
+ *
155
+ * A TERMINAL frame is `useful` (#3848). It is the last frame the operator
156
+ * ever reads for that card and nothing newer is coming to repaint it, so
157
+ * classing it cosmetic made the one frame carrying the finished state
158
+ * compete in โ€” and get shed from โ€” the same starved budget as a heartbeat.
159
+ * `useful` rather than `critical` matches the activity-summary finalize in
160
+ * `gateway/narrative-lane.ts`: it may be DEFERRED under pressure, but it is
161
+ * not shed, and it does not spend the per-chat reply reservation that keeps
162
+ * the operator's real answer unstarvable.
163
+ *
164
+ * Exported so the mapping is unit-testable; `gateway.ts` only calls it.
165
+ */
166
+ export function workerFeedEditPriorityClass(
167
+ meta?: WorkerFeedEditMeta,
168
+ ): 'useful' | 'cosmetic' {
169
+ return meta?.terminal === true ? 'useful' : 'cosmetic'
170
+ }
171
+
133
172
  export interface BotApiForWorkerFeed {
134
173
  sendMessage(
135
174
  chatId: string,
@@ -141,6 +180,12 @@ export interface BotApiForWorkerFeed {
141
180
  messageId: number,
142
181
  text: string,
143
182
  opts?: Record<string, unknown>,
183
+ /**
184
+ * Out-of-band edit metadata (NOT part of the Bot API payload). The
185
+ * gateway adapter maps `terminal` to the send-gate priority class via
186
+ * {@link workerFeedEditPriorityClass}.
187
+ */
188
+ meta?: WorkerFeedEditMeta,
144
189
  ): Promise<unknown>
145
190
  }
146
191
 
@@ -168,30 +213,32 @@ export function repeatCountOf(line: string): number {
168
213
 
169
214
  /**
170
215
  * Thin adapter over the unified `renderStatusCard` primitive (emoji ๐Ÿ› , label
171
- * 'WORKER', `subordinate: true`): builds the header, passes raw narrative steps
216
+ * 'WORKER'): builds the header, passes raw narrative steps
172
217
  * (the primitive runs stripMarkdown โ†’ collapse ws โ†’ clip โ†’ escape per line),
173
218
  * and on finish passes the cleaned result paragraph as the `result` block.
174
219
  *
175
220
  * Layout (running):
176
- * โ””โ”€ ๐Ÿ›  **WORKER** ยท _{description}_
177
- * _{elapsed} ยท {n} tools_
178
- * ~~_โœ“ {earlier step}_~~
179
- * **โ†’ {newest step}**
221
+ * ๐Ÿ›  **WORKER** ยท _{description}_
222
+ * _{elapsed} ยท {n} tools_
223
+ * ~~_โœ“ {earlier step}_~~
224
+ * **โ†’ {newest step}**
180
225
  *
181
226
  * Layout (finished): the feed renders all-done, then a rule + cleaned result:
182
- * โ””โ”€ ๐Ÿ›  **WORKER** ยท _{description}_
183
- * _done ยท {n} tools ยท {elapsed}_
184
- * ~~_โœ“ {step}_~~
185
- * โ”€โ”€โ”€โ”€โ”€
186
- * โœ… _{cleaned result paragraph}_
227
+ * ๐Ÿ›  **WORKER** ยท _{description}_
228
+ * _done ยท {n} tools ยท {elapsed}_
229
+ * ~~_โœ“ {step}_~~
230
+ * โ”€โ”€โ”€โ”€โ”€
231
+ * โœ… _{cleaned result paragraph}_
187
232
  *
188
- * SUBORDINATION (#3820): the `โ””โ”€ ` prefix, the whole-block indent, and the
189
- * caps `WORKER` label exist so this card cannot be mistaken for the ๐Ÿค– agent
190
- * card at a glance on a phone. Before #3820 the two cards differed only by
191
- * their emoji and one capitalised word while sharing the identical two-line
192
- * header, stat row, and step trail. Do NOT "tidy" any of the three away
193
- * individually: each is a separate one-glance cue, and the pair is frequently
194
- * live in the same chat at the same time with overlapping step text.
233
+ * FLUSH (#3842): this card sits at the left margin. #3820/#3821 indented the
234
+ * whole block and prefixed line 1 with `โ””โ”€ ` to mark it subordinate to the ๐Ÿค–
235
+ * agent card; that was reverted because it burned a level of horizontal phone
236
+ * width and because this card does not always sit below the agent card, so a
237
+ * card-level subordination marker asserts a relationship that is not always
238
+ * true. The remaining type cue is line 1: the ๐Ÿ›  emoji plus the caps `WORKER`
239
+ * label against the agent card's ๐Ÿค– `Agent`. Keep that pair โ€” with identical
240
+ * step text and stats the two cards' BODY lines can now coincide byte for
241
+ * byte, so line 1 is doing all of the distinguishing work.
195
242
  */
196
243
  export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
197
244
  const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
@@ -239,9 +286,6 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
239
286
  if (text.length > 0) result = { emoji: v.state === 'done' ? 'โœ…' : 'โš ๏ธ', text }
240
287
  }
241
288
 
242
- // `renderStatusCard` always returns content when a header is supplied. When
243
- // running with no steps it shows just the header โ€” append a "startingโ€ฆ" line
244
- // for parity with the prior behaviour.
245
289
  const card = renderStatusCard({
246
290
  header,
247
291
  steps,
@@ -251,25 +295,18 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
251
295
  // Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
252
296
  // the full recent trail, not the 5-line agent-card default (#3349).
253
297
  historyWindow: workerHistoryDepth(1),
254
- // Structurally subordinate to the ๐Ÿค– agent card (#3820).
255
- subordinate: true,
298
+ // A just-dispatched worker has no narrative yet. The placeholder is a normal
299
+ // BODY line of the shared spec now (#3846): it used to be concatenated onto
300
+ // this function's return value, which put a user-visible card line outside
301
+ // the primitive that owns hard-break stacking, the collapse-safe separator
302
+ // and the char budget, and made the seam the one boundary that could mash in
303
+ // Telegram's pinned bar. Only while RUNNING โ€” a finished worker with no
304
+ // steps must not read as "startingโ€ฆ".
305
+ emptyPlaceholder: finished ? undefined : '_startingโ€ฆ_',
256
306
  })
257
307
  if (card == null) {
258
308
  // Unreachable (header always present) โ€” defensive.
259
- return `${SUBORDINATE_HEADER_PREFIX}๐Ÿ›  **WORKER** ยท _startingโ€ฆ_`
260
- }
261
- if (!finished && steps.length === 0) {
262
- // Header-only running render โ†’ append the starting placeholder with a GFM
263
- // hard break (` \n`) so it stacks under the header instead of collapsing
264
- // onto the header line in the rich-message renderer (matches stackCardLines).
265
- // The collapse separator is carried here too (#3666) โ€” this card is pinned,
266
- // and a hand-rolled seam would be the one boundary that still mashed in
267
- // Telegram's pinned bar.
268
- // The placeholder is a card BODY line, so it carries the subordinate indent
269
- // like every other non-header line (#3820) โ€” otherwise the one render state
270
- // a user sees first (a just-dispatched worker) would be the one that isn't
271
- // visibly nested.
272
- return `${card}${COLLAPSE_SAFE_SEPARATOR} \n${SUBORDINATE_LINE_INDENT}_startingโ€ฆ_`
309
+ return renderCardTitleLine('๐Ÿ› ', 'WORKER', 'startingโ€ฆ')
273
310
  }
274
311
  return card
275
312
  }
@@ -616,10 +653,21 @@ const COOLDOWN_JITTER_MS = 500
616
653
  * rows and reads like a stuck worker. A single best-effort edit collapses it to
617
654
  * an honest "moved" note โ€” issued once per rotation (โ‰ฅ cap interval), never per
618
655
  * tick, so it adds no edit churn / pin storm. Plain voice, no em dash.
656
+ *
657
+ * Exported so the card-preview harness (`scripts/card-previews.ts`) renders the
658
+ * real string rather than a copy that can drift from it.
659
+ *
660
+ * DELIBERATELY still a constant, not a card render (#3846). It shows no live
661
+ * state โ€” no elapsed, no tool count, no steps โ€” so there is nothing for a
662
+ * renderer to compose and no rolling window or char budget to enforce; routing
663
+ * it through `renderStatusCard` would mean inventing a state to render. What it
664
+ * DOES share with every other card is its type chrome, so line 1 comes from the
665
+ * one title composer (`renderCardTitleLine`): if the ๐Ÿ›  / **WORKER** convention
666
+ * ever changes, this notice moves with it instead of being the one stale card.
619
667
  */
620
- const WORKER_CARD_SUPERSEDED_BODY =
621
- `${SUBORDINATE_HEADER_PREFIX}๐Ÿ›  **WORKER** ยท _continued_\n\n` +
622
- `${SUBORDINATE_LINE_INDENT}_Live progress moved to a fresh card to stay pinned._`
668
+ export const WORKER_CARD_SUPERSEDED_BODY =
669
+ `${renderCardTitleLine('๐Ÿ› ', 'WORKER', 'continued')}\n\n` +
670
+ '_Live progress moved to a fresh card to stay pinned._'
623
671
 
624
672
  function extractRetryAfterSecs(err: unknown): number | null {
625
673
  if (err == null || typeof err !== 'object') return null
@@ -1197,7 +1245,11 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1197
1245
  }
1198
1246
 
1199
1247
  try {
1200
- const res = await opts.bot.editMessageText(g.chatId, g.messageId, body, sendOptsFor(g))
1248
+ // #3848: tell the transport whether this is the card's LAST frame. A
1249
+ // terminal recap must not be shed with the heartbeat repaints.
1250
+ const res = await opts.bot.editMessageText(
1251
+ g.chatId, g.messageId, body, sendOptsFor(g), { terminal: isTerminal },
1252
+ )
1201
1253
  // Shed honesty (#3084): a cosmetic edit the gate shed resolves the
1202
1254
  // distinguishable SEND_GATE_SHED sentinel (NOT a bare `undefined`, which
1203
1255
  // the gate reserves for a benign no-op drop whose payload IS on screen).
@@ -1486,8 +1538,14 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1486
1538
  // doesn't sit frozen showing live-styled rows (mistakable for a stuck
1487
1539
  // worker). ONE best-effort edit per rotation (โ‰ฅ cap interval), fired
1488
1540
  // off-chain and swallowing errors โ€” never per-tick, never a burst.
1541
+ // #3848: terminal โ€” this is the retired message's LAST frame, and it
1542
+ // fires at most once per `groupMessageLifetimeCapMs`, so promoting it
1543
+ // out of the cosmetic budget costs nothing and stops the retired card
1544
+ // being left frozen showing live-styled rows.
1489
1545
  void opts.bot
1490
- .editMessageText(g.chatId, retiredId, WORKER_CARD_SUPERSEDED_BODY, sendOptsFor(g))
1546
+ .editMessageText(
1547
+ g.chatId, retiredId, WORKER_CARD_SUPERSEDED_BODY, sendOptsFor(g), { terminal: true },
1548
+ )
1491
1549
  .catch(() => {})
1492
1550
  }
1493
1551
 
@@ -0,0 +1,45 @@
1
+ # CLAUDE.md โ€” vendor/hindsight-memory
2
+
3
+ **This directory is a VENDORED SNAPSHOT of the hindsight-memory plugin. It is
4
+ not the specification, and it is not what runs.** Treat it as evidence about
5
+ one commit of the implementation, nothing more.
6
+
7
+ The load-bearing copy of this rule is the repo-root `CLAUDE.md`
8
+ ("Third-party docs โ€” the official site is the spec"); nothing auto-loads a
9
+ subdirectory `CLAUDE.md`, so this file only reaches an agent that opens it.
10
+
11
+ ## Where the truth is
12
+
13
+ 1. **Official docs site** โ€” <https://hindsight.vectorize.io/>. Curation and
14
+ the memory-unit endpoints: `/developer/api/memories`. Also
15
+ `/developer/api/retain`, `/developer/api/recall`,
16
+ `/developer/observations`, `/developer/configuration`, index at
17
+ `/api-reference`. (`/developer/` itself has no index page โ€” it 404s.)
18
+ 2. **context7** โ€” prefer `/websites/hindsight_vectorize_io` (the docs site)
19
+ over `/vectorize-io/hindsight` (the OSS repo).
20
+ 3. **This tree** โ€” last, and only for "what does the code here actually do".
21
+
22
+ Not finding something here is not evidence it doesn't exist. Search the docs
23
+ before you tell anyone a knob, endpoint or config path is unsupported.
24
+
25
+ ## Trap: `settings.json` here is NOT what switchroom installs
26
+
27
+ `installHindsightPlugin` (`src/agents/scaffold.ts`) copies this tree into each
28
+ agent's `.claude/plugins/hindsight-memory/` and then **stamps switchroom's own
29
+ overrides over `settings.json`** โ€” additional banks, the retain cadence knobs,
30
+ and the recall types. Concretely, this file has
31
+ `"recallTypes": ["world", "experience"]` while switchroom writes
32
+ `["world", "experience", "observation"]` (`scaffold.ts:3449` as of writing;
33
+ line anchors drift โ€” grep `settings.recallTypes`).
34
+
35
+ So reading the vendored `settings.json` to learn live behaviour is actively
36
+ misleading. Read the override site in `scaffold.ts`, or the deployed
37
+ `~/.switchroom/agents/<name>/.claude/plugins/hindsight-memory/settings.json`.
38
+
39
+ ## Editing
40
+
41
+ Don't hand-edit vendored upstream files without a reason (repo-root
42
+ `CLAUDE.md` ยง Secrets & safety rails). This file and switchroom's local
43
+ patches are the exceptions; changes here ship to every agent on the next
44
+ `apply`/reconcile, and the Python under `scripts/` is gated by the required
45
+ `python-ok` check (`python3 -m unittest discover` in `scripts`).
@@ -46,6 +46,20 @@ DEFAULTS = {
46
46
  # cap of 6 its fleet actually deploys.
47
47
  "recallOwnBankMinSlots": 0,
48
48
  "recallAdditionalBankMinSlots": 0,
49
+ # Switchroom #3837: absolute floor on a result's engine relevance score
50
+ # (`scores.final`) for it to be injected. 0.0 (default) DISABLES the floor
51
+ # โ€” nothing is dropped and the injected set is byte-identical to the
52
+ # pre-#3837 behaviour. `recallMinScoreScope` decides which turns a
53
+ # non-zero floor binds on: "degraded" (default) = only turns where the
54
+ # agent's OWN bank timed out or was unreachable, which is the population
55
+ # where a below-floor score actually predicts noise (98.4% of degraded
56
+ # rows have a best injected score under 0.01, against 28.4% of healthy
57
+ # ones); "all" = every turn, which #3761's replay says empties ~28% of
58
+ # HEALTHY recalls at 0.01 and is not recommended as a fleet default. See
59
+ # the design note above `_filter_by_min_score` in recall.py. Env:
60
+ # HINDSIGHT_RECALL_MIN_SCORE / HINDSIGHT_RECALL_MIN_SCORE_SCOPE.
61
+ "recallMinScore": 0.0,
62
+ "recallMinScoreScope": "degraded",
49
63
  "recallTypes": ["world", "experience"],
50
64
  # Switchroom-local: when True (default; Ken-approved ON) recall biases
51
65
  # toward synthesized `observation`-tier facts. Escape hatch: pin off via
@@ -324,6 +338,12 @@ ENV_OVERRIDES = {
324
338
  # .additional_bank_min_slots (cascading through defaults). 0 = off.
325
339
  "HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS": ("recallOwnBankMinSlots", int),
326
340
  "HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS": ("recallAdditionalBankMinSlots", int),
341
+ # Switchroom #3837: absolute `scores.final` floor + the population it binds
342
+ # on. Set by start.sh from agents.<name>.memory.recall.min_score /
343
+ # .min_score_scope (cascading through defaults), exported only when the
344
+ # operator opted in. 0.0 = off (the default, and the shipped behaviour).
345
+ "HINDSIGHT_RECALL_MIN_SCORE": ("recallMinScore", float),
346
+ "HINDSIGHT_RECALL_MIN_SCORE_SCOPE": ("recallMinScoreScope", str),
327
347
  # Switchroom-local: recall fact types (comma-separated). Set by start.sh
328
348
  # from agents.<name>.memory.recall.types only when the operator overrode
329
349
  # the switchroom default (world,experience,observation) โ€” i.e. the
@@ -333,6 +353,19 @@ ENV_OVERRIDES = {
333
353
  # from agents.<name>.memory.recall.skip_trivial only on override; the
334
354
  # switchroom default is on (recall.py falls back to True).
335
355
  "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
356
+ # Switchroom #3841: the last three recall settings that had a config key but
357
+ # no env channel at all, so switchroom.yaml could not reach them and a
358
+ # hand-edit of the installed plugin did not survive `switchroom apply`. Set
359
+ # by start.sh from agents.<name>.memory.recall.prefer_observations / .roles /
360
+ # .prompt_preamble (cascading through defaults), always exported at their
361
+ # existing effective values, so an operator who sets none of them sees no
362
+ # change. The other #3841 knobs (budget, max_tokens, context_turns,
363
+ # max_query_chars, transcript_tail_bytes, tags, tags_match, tag_groups,
364
+ # tag_weights, additional_bank_filters, transcript_fallback, parallel)
365
+ # already had entries in this table and only needed the yaml surface.
366
+ "HINDSIGHT_RECALL_PREFER_OBSERVATIONS": ("recallPreferObservations", bool),
367
+ "HINDSIGHT_RECALL_ROLES": ("recallRoles", list),
368
+ "HINDSIGHT_RECALL_PROMPT_PREAMBLE": ("recallPromptPreamble", str),
336
369
  # Switchroom #2848 Stage B: directive-capture nudge on/off. Set by
337
370
  # start.sh from agents.<name>.memory.directive_capture_nudge only when
338
371
  # the operator overrode it; the switchroom default is on (settings.json