switchroom 0.19.25 โ†’ 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 (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  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 +2213 -1204
  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 +103 -112
  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/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. 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
@@ -72,6 +79,29 @@ const ACTIVITY_BODY_LINE_RE = /^[โ†’โœ“โ†ณ]/u;
72
79
  * Elapsed is `formatFeedElapsed`: `<N>s` under a minute, else `<M>m<SS>s`.
73
80
  */
74
81
  const LIVENESS_HEADER_L1_RE = /^(?:๐Ÿค–|๐Ÿ› [๏ธ]?|โš™[๏ธ]?)\s+\S/u;
82
+
83
+ /**
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.
88
+ *
89
+ * Every line-shape matcher below must normalise it away first: Telegram
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
92
+ * Telegram's own left-trim; see WORKER_STEP_INDENT). Without this, a worker
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.
98
+ */
99
+ const CARD_INDENT_PREFIX_RE = /^\u2800+/u;
100
+
101
+ /** A card line with its leading step-indent chrome removed, trimmed. */
102
+ export function stripCardNesting(line: string): string {
103
+ return line.replace(CARD_INDENT_PREFIX_RE, "").trim();
104
+ }
75
105
  const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
76
106
  const LIVENESS_HEADER_L2_RE = new RegExp(
77
107
  `^(?:${LIVENESS_ELAPSED}\\s*ยท\\s*\\d+\\s+tools?` +
@@ -94,7 +124,7 @@ const LIVENESS_HEADER_L2_RE = new RegExp(
94
124
  export function isLivenessCardMessage(m: ObservedMessage): boolean {
95
125
  const lines = m.text
96
126
  .split("\n")
97
- .map((l) => l.trim())
127
+ .map((l) => stripCardNesting(l))
98
128
  .filter((l) => l.length > 0);
99
129
  if (lines.length < 2) return false;
100
130
  if (!LIVENESS_HEADER_L1_RE.test(lines[0])) return false;
@@ -124,7 +154,7 @@ export function isLivenessCardMessage(m: ObservedMessage): boolean {
124
154
  export function isActivityFeedMessage(m: ObservedMessage): boolean {
125
155
  const lines = m.text
126
156
  .split("\n")
127
- .map((l) => l.trim())
157
+ .map((l) => stripCardNesting(l))
128
158
  .filter((l) => l.length > 0);
129
159
  if (lines.length === 0) return false;
130
160
  if (lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l))) return true;
@@ -4,6 +4,7 @@ import {
4
4
  isFrameworkFallbackText,
5
5
  isLivenessCardMessage,
6
6
  isWorkerFeedMessage,
7
+ stripCardNesting,
7
8
  WORKER_FEED_RE,
8
9
  } from "./assertions.js";
9
10
 
@@ -50,6 +51,41 @@ describe("isWorkerFeedMessage", () => {
50
51
  it("exposes the regex for scenarios that assert on the feed directly", () => {
51
52
  expect(WORKER_FEED_RE.test("๐Ÿ›  Worker ยท x")).toBe(true);
52
53
  });
54
+
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);
60
+ });
61
+ });
62
+
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);
69
+ });
70
+
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", () => {
83
+ expect(stripCardNesting("โ €โ €โ €โ†’ Searching memory")).toBe("โ†’ Searching memory");
84
+ expect(stripCardNesting("๐Ÿ›  WORKER ยท crawl")).toBe("๐Ÿ›  WORKER ยท crawl");
85
+ expect(stripCardNesting("on it, pulling the logs now")).toBe(
86
+ "on it, pulling the logs now",
87
+ );
88
+ });
53
89
  });
54
90
 
55
91
  describe("isActivityFeedMessage", () => {
@@ -11,7 +11,11 @@
11
11
 
12
12
  import { describe, expect, it } from "vitest";
13
13
  import { spinUp } from "../harness.js";
14
- import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
14
+ import {
15
+ isActivityFeedMessage,
16
+ isFrameworkFallbackText,
17
+ stripCardNesting,
18
+ } from "../assertions.js";
15
19
  import type { ObservedMessage } from "../driver.js";
16
20
 
17
21
  const AGENT = "test-harness";
@@ -30,7 +34,10 @@ const NARRATED_WORK_PROMPT =
30
34
  function narratedBodyLines(text: string): string[] {
31
35
  return text
32
36
  .split("\n")
33
- .map((l) => l.trim())
37
+ // strip the #3820 subordinate-card nesting (`โ””โ”€ ` / U+2800 indent) first โ€”
38
+ // `trim()` removes neither, and a worker card's header/body lines would
39
+ // otherwise read as model narration
40
+ .map((l) => stripCardNesting(l))
34
41
  .filter((l) => l.length > 0)
35
42
  .map((l) => l.replace(/^[โ†’โœ“โ†ณ]+\s*/u, "").trim())
36
43
  .filter(
@@ -17,7 +17,11 @@
17
17
 
18
18
  import { describe, expect, it } from "vitest";
19
19
  import { spinUp } from "../harness.js";
20
- import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
20
+ import {
21
+ isActivityFeedMessage,
22
+ isFrameworkFallbackText,
23
+ stripCardNesting,
24
+ } from "../assertions.js";
21
25
  import type { ObservedMessage } from "../driver.js";
22
26
 
23
27
  /**
@@ -30,7 +34,10 @@ import type { ObservedMessage } from "../driver.js";
30
34
  function narratedBodyLines(text: string): string[] {
31
35
  return text
32
36
  .split("\n")
33
- .map((l) => l.trim())
37
+ // strip the #3820 subordinate-card nesting (`โ””โ”€ ` / U+2800 indent) first โ€”
38
+ // `trim()` removes neither, and a worker card's header/body lines would
39
+ // otherwise read as model narration
40
+ .map((l) => stripCardNesting(l))
34
41
  .filter((l) => l.length > 0)
35
42
  // strip glyphs so a `โ†’ Workingโ€ฆ` placeholder compares as `Workingโ€ฆ`
36
43
  .map((l) => l.replace(/^[โ†’โœ“โ†ณ]+\s*/u, "").trim())
@@ -53,9 +53,9 @@ import {
53
53
  cleanWorkerResultParagraph,
54
54
  stripMarkdown,
55
55
  truncate,
56
- COLLAPSE_SAFE_SEPARATOR,
57
56
  } from './card-format.js'
58
57
  import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
+ import { renderCardTitleLine } from './card-layout.js'
59
59
  import {
60
60
  renderStatusCard,
61
61
  formatStepSuffix,
@@ -126,6 +126,49 @@ export interface WorkerActivityView {
126
126
  model?: string
127
127
  }
128
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
+
129
172
  export interface BotApiForWorkerFeed {
130
173
  sendMessage(
131
174
  chatId: string,
@@ -137,6 +180,12 @@ export interface BotApiForWorkerFeed {
137
180
  messageId: number,
138
181
  text: string,
139
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,
140
189
  ): Promise<unknown>
141
190
  }
142
191
 
@@ -164,22 +213,32 @@ export function repeatCountOf(line: string): number {
164
213
 
165
214
  /**
166
215
  * Thin adapter over the unified `renderStatusCard` primitive (emoji ๐Ÿ› , label
167
- * 'Worker'): builds the header, passes raw narrative steps (the primitive runs
168
- * stripMarkdown โ†’ collapse ws โ†’ clip โ†’ escape per line), and on finish passes
169
- * the cleaned result paragraph as the `result` block.
216
+ * 'WORKER'): builds the header, passes raw narrative steps
217
+ * (the primitive runs stripMarkdown โ†’ collapse ws โ†’ clip โ†’ escape per line),
218
+ * and on finish passes the cleaned result paragraph as the `result` block.
170
219
  *
171
220
  * Layout (running):
172
- * ๐Ÿ›  <b>Worker</b> ยท <i>{description}</i>
173
- * <i>{elapsed} ยท {n} tools</i>
174
- * <s><i>โœ“ {earlier step}</i></s>
175
- * <b>โ†’ {newest step}</b>
221
+ * ๐Ÿ›  **WORKER** ยท _{description}_
222
+ * _{elapsed} ยท {n} tools_
223
+ * ~~_โœ“ {earlier step}_~~
224
+ * **โ†’ {newest step}**
176
225
  *
177
226
  * Layout (finished): the feed renders all-done, then a rule + cleaned result:
178
- * ๐Ÿ›  <b>Worker</b> ยท <i>{description}</i>
179
- * <i>done ยท {n} tools ยท {elapsed}</i>
180
- * <s><i>โœ“ {step}</i></s>
227
+ * ๐Ÿ›  **WORKER** ยท _{description}_
228
+ * _done ยท {n} tools ยท {elapsed}_
229
+ * ~~_โœ“ {step}_~~
181
230
  * โ”€โ”€โ”€โ”€โ”€
182
- * โœ… <i>{cleaned result paragraph}</i>
231
+ * โœ… _{cleaned result paragraph}_
232
+ *
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.
183
242
  */
184
243
  export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
185
244
  const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
@@ -201,7 +260,9 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
201
260
 
202
261
  const header: Parameters<typeof renderStatusCard>[0]['header'] = {
203
262
  emoji: '๐Ÿ› ',
204
- label: 'Worker',
263
+ // Caps (#3820, issue option 4): `WORKER` against the agent card's `Agent`
264
+ // is a high-contrast type label, not a same-shaped word one letter apart.
265
+ label: 'WORKER',
205
266
  description: desc,
206
267
  elapsedMs: v.elapsedMs,
207
268
  toolCount: v.toolCount,
@@ -225,9 +286,6 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
225
286
  if (text.length > 0) result = { emoji: v.state === 'done' ? 'โœ…' : 'โš ๏ธ', text }
226
287
  }
227
288
 
228
- // `renderStatusCard` always returns content when a header is supplied. When
229
- // running with no steps it shows just the header โ€” append a "startingโ€ฆ" line
230
- // for parity with the prior behaviour.
231
289
  const card = renderStatusCard({
232
290
  header,
233
291
  steps,
@@ -237,19 +295,18 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
237
295
  // Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
238
296
  // the full recent trail, not the 5-line agent-card default (#3349).
239
297
  historyWindow: workerHistoryDepth(1),
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โ€ฆ_',
240
306
  })
241
307
  if (card == null) {
242
308
  // Unreachable (header always present) โ€” defensive.
243
- return `๐Ÿ›  **Worker** ยท _startingโ€ฆ_`
244
- }
245
- if (!finished && steps.length === 0) {
246
- // Header-only running render โ†’ append the starting placeholder with a GFM
247
- // hard break (` \n`) so it stacks under the header instead of collapsing
248
- // onto the header line in the rich-message renderer (matches stackCardLines).
249
- // The collapse separator is carried here too (#3666) โ€” this card is pinned,
250
- // and a hand-rolled seam would be the one boundary that still mashed in
251
- // Telegram's pinned bar.
252
- return `${card}${COLLAPSE_SAFE_SEPARATOR} \n_startingโ€ฆ_`
309
+ return renderCardTitleLine('๐Ÿ› ', 'WORKER', 'startingโ€ฆ')
253
310
  }
254
311
  return card
255
312
  }
@@ -596,9 +653,21 @@ const COOLDOWN_JITTER_MS = 500
596
653
  * rows and reads like a stuck worker. A single best-effort edit collapses it to
597
654
  * an honest "moved" note โ€” issued once per rotation (โ‰ฅ cap interval), never per
598
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.
599
667
  */
600
- const WORKER_CARD_SUPERSEDED_BODY =
601
- '๐Ÿ›  **Worker** ยท _continued_\n\n_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._'
602
671
 
603
672
  function extractRetryAfterSecs(err: unknown): number | null {
604
673
  if (err == null || typeof err !== 'object') return null
@@ -1176,7 +1245,11 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1176
1245
  }
1177
1246
 
1178
1247
  try {
1179
- 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
+ )
1180
1253
  // Shed honesty (#3084): a cosmetic edit the gate shed resolves the
1181
1254
  // distinguishable SEND_GATE_SHED sentinel (NOT a bare `undefined`, which
1182
1255
  // the gate reserves for a benign no-op drop whose payload IS on screen).
@@ -1465,8 +1538,14 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1465
1538
  // doesn't sit frozen showing live-styled rows (mistakable for a stuck
1466
1539
  // worker). ONE best-effort edit per rotation (โ‰ฅ cap interval), fired
1467
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.
1468
1545
  void opts.bot
1469
- .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
+ )
1470
1549
  .catch(() => {})
1471
1550
  }
1472
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