switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1682 -936
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A reply that was rate-limited and then DELIVERED on retry must not raise a
3
+ * fleet-health delivery-failure alert (#3931).
4
+ *
5
+ * ── The bug ──────────────────────────────────────────────────────────────
6
+ * `installTgPostLogger` is a grammy API transformer, so it runs INSIDE the
7
+ * function the retry policy calls — it observes one POST ATTEMPT, never the
8
+ * logical send's outcome. A 429 that `createRetryApiCall` slept and retried
9
+ * successfully therefore left a `tg-post method=sendRichMessage … status=err`
10
+ * line in the gateway log, and fleet-health's `reply-delivery-failure`
11
+ * signature (`src/fleet-health/detect.ts`) matches per LINE. Result: a
12
+ * severity-3 "the answer never reached the principal" escalation for an answer
13
+ * the operator had already read. Under flood pressure (the class of event that
14
+ * produces 429s in the first place) that is the alarm that fires most.
15
+ *
16
+ * ── The fix these tests pin ──────────────────────────────────────────────
17
+ * The retry policy publishes its attempt context; the transformer labels an
18
+ * attempt the policy is about to repeat `status=retry`. `status=err` now means
19
+ * the logical send is OVER and nothing landed. Nothing is suppressed — the
20
+ * retry lines are still written, they just no longer masquerade as outcomes.
21
+ *
22
+ * These tests drive a REAL grammy `Bot` (a mock-`api` harness sits above the
23
+ * transformer layer, so it would be a false guard) through the REAL retry
24
+ * policy, and feed the REAL emitted lines to the REAL detector.
25
+ */
26
+ import { describe, it, expect, vi, afterEach } from 'vitest'
27
+ import { Bot } from 'grammy'
28
+ import { installTgPostLogger } from '../shared/bot-runtime.js'
29
+ import { createRetryApiCall, willRetryTelegramFailure } from '../retry-api-call.js'
30
+ import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
31
+
32
+ /** A real Bot whose transport answers with `envelopes[i]` for the i-th call. */
33
+ function makeBot(envelopes: Array<Record<string, unknown>>): Bot {
34
+ let i = 0
35
+ const fakeFetch = (async () => {
36
+ const envelope = envelopes[Math.min(i, envelopes.length - 1)]
37
+ i++
38
+ // A Bot API rejection is an HTTP 200 carrying `ok:false` — which is
39
+ // precisely why it RESOLVES the transformer chain (see #3927).
40
+ return { ok: true, status: 200, json: async () => envelope } as unknown as Response
41
+ }) as unknown as typeof fetch
42
+
43
+ const bot = new Bot('123456:TEST_TOKEN', {
44
+ botInfo: {
45
+ id: 123456,
46
+ is_bot: true,
47
+ first_name: 'Test',
48
+ username: 'test_bot',
49
+ can_join_groups: false,
50
+ can_read_all_group_messages: false,
51
+ supports_inline_queries: false,
52
+ can_connect_to_business: false,
53
+ has_main_web_app: false,
54
+ },
55
+ client: { fetch: fakeFetch },
56
+ })
57
+ installTgPostLogger(bot)
58
+ return bot
59
+ }
60
+
61
+ const OK_ENVELOPE = {
62
+ ok: true,
63
+ result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
64
+ }
65
+ const FLOOD_429 = {
66
+ ok: false,
67
+ error_code: 429,
68
+ description: 'Too Many Requests: retry after 3',
69
+ parameters: { retry_after: 3 },
70
+ }
71
+
72
+ /**
73
+ * Send one user-facing reply through the production composition
74
+ * (retry policy → transformer → wire) and return the gateway log lines it
75
+ * produced, shaped exactly as `gateway-supervisor.log` carries them.
76
+ */
77
+ async function gatewayLinesForReply(
78
+ envelopes: Array<Record<string, unknown>>,
79
+ ): Promise<{ lines: string[]; threw: boolean }> {
80
+ const written: string[] = []
81
+ const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
82
+ written.push(String(chunk))
83
+ return true
84
+ }) as unknown as typeof process.stderr.write)
85
+ let threw = false
86
+ try {
87
+ const bot = makeBot(envelopes)
88
+ // The real policy, with sleeping stubbed out so a 3s flood-wait doesn't
89
+ // cost the suite 3 seconds. Everything else is production behaviour.
90
+ const robustApiCall = createRetryApiCall({ sleep: async () => {} })
91
+ await robustApiCall(() =>
92
+ bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
93
+ ).catch(() => {
94
+ threw = true
95
+ })
96
+ } finally {
97
+ spy.mockRestore()
98
+ }
99
+ const lines = written
100
+ .filter((l) => l.startsWith('tg-post '))
101
+ .map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
102
+ return { lines, threw }
103
+ }
104
+
105
+ afterEach(() => {
106
+ vi.restoreAllMocks()
107
+ })
108
+
109
+ describe('#3931 — a retried-and-delivered reply raises no delivery-failure alert', () => {
110
+ it('429-then-ok: fleet-health sees zero reply-delivery-failures and does not escalate', async () => {
111
+ const { lines, threw } = await gatewayLinesForReply([FLOOD_429, OK_ENVELOPE])
112
+
113
+ // Ground truth: the reply WAS delivered. If this ever fails, the fixture
114
+ // stopped modelling the scenario and the assertions below mean nothing.
115
+ expect(threw).toBe(false)
116
+ expect(lines).toHaveLength(2)
117
+ expect(lines[1]).toContain('status=ok')
118
+
119
+ // THE outcome: the detector raises nothing, and the agent is not escalated.
120
+ const { gw_hits, findings } = detectGatewayFindings('alpha', lines.join('\n'))
121
+ expect(gw_hits['reply-delivery-failure']).toBe(0)
122
+ expect(findings.filter((f) => f.signal === 'reply-delivery-failure')).toHaveLength(0)
123
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(false)
124
+
125
+ // The evidence is not hidden — the rate-limit attempt is still on the log,
126
+ // honestly labelled as an attempt rather than an outcome.
127
+ expect(lines[0]).toContain('status=retry')
128
+ expect(lines[0]).toContain('code=429')
129
+ })
130
+
131
+ it('an ok:true reply is untouched — no retry tier leaks onto the happy path', async () => {
132
+ const { lines, threw } = await gatewayLinesForReply([OK_ENVELOPE])
133
+ expect(threw).toBe(false)
134
+ expect(lines).toHaveLength(1)
135
+ expect(lines[0]).toContain('status=ok')
136
+ expect(lines[0]).toContain('err=- code=- desc=-')
137
+ })
138
+ })
139
+
140
+ describe('#3931 — a genuinely undelivered reply still escalates', () => {
141
+ it('a terminal 403 escalates', async () => {
142
+ const { lines, threw } = await gatewayLinesForReply([
143
+ { ok: false, error_code: 403, description: 'Forbidden: bot was blocked by the user' },
144
+ ])
145
+ expect(threw).toBe(true)
146
+ expect(lines).toHaveLength(1)
147
+ expect(lines[0]).toContain('status=err')
148
+
149
+ expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
150
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
151
+ })
152
+
153
+ it('a 429 on EVERY attempt (retries exhausted, nothing landed) escalates exactly once', async () => {
154
+ const { lines, threw } = await gatewayLinesForReply([FLOOD_429])
155
+ expect(threw).toBe(true)
156
+ // Three attempts: two survivable, the last one terminal.
157
+ expect(lines).toHaveLength(3)
158
+ expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
159
+ expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
160
+
161
+ // One alert for one lost answer — not one per attempt.
162
+ expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
163
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
164
+ })
165
+ })
166
+
167
+ describe('willRetryTelegramFailure — the predicate mirrors the retry loop', () => {
168
+ const ctx = { attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 }
169
+
170
+ it('is false with no enclosing retry policy (a raw bot.api.* call is its own outcome)', () => {
171
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, undefined)).toBe(false)
172
+ })
173
+
174
+ it('is false on the last attempt — the loop falls through to give-up', () => {
175
+ expect(
176
+ willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, { ...ctx, attempt: 2 }),
177
+ ).toBe(false)
178
+ })
179
+
180
+ it('is false for a 429 over the in-process sleep ceiling (FLOOD_WAIT_ACTIVE, not retried)', () => {
181
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 15908 }, ctx)).toBe(false)
182
+ })
183
+
184
+ it('is true for a 429 under the ceiling and for a transient transport error', () => {
185
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, ctx)).toBe(true)
186
+ expect(willRetryTelegramFailure({ message: 'ECONNRESET' }, ctx)).toBe(true)
187
+ })
188
+
189
+ it('is false for the error classes the loop rethrows immediately', () => {
190
+ expect(willRetryTelegramFailure({ errorCode: 400, message: 'Bad Request' }, ctx)).toBe(false)
191
+ expect(willRetryTelegramFailure({ errorCode: 403 }, ctx)).toBe(false)
192
+ // Local disk exhaustion must NOT look retryable — retrying it is what
193
+ // trips the flood ban in the first place (#2923).
194
+ expect(willRetryTelegramFailure({ message: 'ENOSPC: no space left on device' }, ctx)).toBe(false)
195
+ })
196
+ })
@@ -14,7 +14,13 @@ import {
14
14
  STEP_TIMER_MIN_MS,
15
15
  type SessionActivityHeader,
16
16
  } from "../tool-activity-summary.js";
17
- import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
17
+ import { STATUS_ROLLING_LINES, STATUS_LINE_MAX, NESTED_PREFIX } from "../status-no-truncate.js";
18
+
19
+ // The nested (foreground sub-agent) child-block indent. Asserted through the
20
+ // constant, never as a literal: #3668 replaced its ASCII spaces with U+2800
21
+ // (Telegram left-trims a leading ASCII/Zs run off a content line), and a test
22
+ // that hardcodes the bytes pins the defect instead of the behaviour.
23
+ const NP = NESTED_PREFIX;
18
24
  import { COLLAPSE_SAFE_SEPARATOR } from "../card-format.js";
19
25
 
20
26
  /** The pinned-card collapse separator (#3666) every status-card line now
@@ -352,29 +358,29 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
352
358
  expect(out).toContain("~~_✓ Delegating: review the migration_~~");
353
359
  expect(out).not.toContain("**→ Delegating");
354
360
  // The live → step is the newest nested child line; earlier child = italic.
355
- expect(out).toContain("~~_Reading schema.ts_~~");
356
- expect(out).toContain("**→ Looking for foreign keys**");
361
+ expect(out).toContain(NP + "~~_Reading schema.ts_~~");
362
+ expect(out).toContain(NP + "**→ Looking for foreign keys**");
357
363
  });
358
364
 
359
365
  it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
360
366
  const total = STATUS_ROLLING_LINES + 3;
361
367
  const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
362
368
  const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
363
- expect(out).toContain(` ↳ _+${total - STATUS_ROLLING_LINES} earlier…_`);
369
+ expect(out).toContain(`${NP}_+${total - STATUS_ROLLING_LINES} earlier…_`);
364
370
  // newest nested line is the live → step
365
- expect(out).toContain(` ↳ **→ step ${total}**`);
371
+ expect(out).toContain(`${NP}**→ step ${total}**`);
366
372
  // the oldest (collapsed) lines are not rendered verbatim
367
373
  expect(out).not.toContain("step 1<");
368
374
  });
369
375
 
370
376
  it("renders the child block even when the parent feed is empty", () => {
371
377
  const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
372
- expect(out).toBe("**→ Reading a.ts**");
378
+ expect(out).toBe(NP + "**→ Reading a.ts**");
373
379
  });
374
380
 
375
381
  it("markdown-escapes nested child text (emphasis specials only)", () => {
376
382
  const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
377
- expect(out).toContain("**→ touch a\\_b & 2 \\* 3**");
383
+ expect(out).toContain(NP + "**→ touch a\\_b & 2 \\* 3**");
378
384
  });
379
385
 
380
386
  it("final=true: the nested newest step renders done (✓), not in-progress (→)", () => {
@@ -383,7 +389,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
383
389
  ["Reading schema.ts", "Looking for foreign keys"],
384
390
  true,
385
391
  )!;
386
- expect(out).toContain("~~_Looking for foreign keys_~~"); // newest now struck italic done
392
+ expect(out).toContain(NP + "~~_Looking for foreign keys_~~"); // newest now struck italic done
387
393
  expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
388
394
  });
389
395
 
@@ -400,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
400
406
  false,
401
407
  " · 22s",
402
408
  )!;
403
- expect(out).toContain("**→ Looking for foreign keys · 22s**");
409
+ expect(out).toContain(NP + "**→ Looking for foreign keys · 22s**");
404
410
  expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
405
411
  });
406
412
 
@@ -567,7 +573,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
567
573
  expect(out).not.toContain(`Parent ${i}`);
568
574
  }
569
575
  expect(out).toContain(`_✓ +${totalParent - STATUS_ROLLING_LINES} earlier…_`);
570
- expect(out).toContain("**→ Child step B**");
576
+ expect(out).toContain(NP + "**→ Child step B**");
571
577
  });
572
578
 
573
579
  it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
@@ -582,8 +588,8 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
582
588
  for (let i = 1; i < firstVisible; i++) {
583
589
  expect(out).not.toContain(`Child ${i}`);
584
590
  }
585
- expect(out).toContain(` ↳ _+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
586
- expect(out).toContain(` ↳ **→ Child ${totalChild}**`);
591
+ expect(out).toContain(`${NP}_+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
592
+ expect(out).toContain(`${NP}**→ Child ${totalChild}**`);
587
593
  });
588
594
 
589
595
  it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
@@ -600,7 +606,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
600
606
  const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
601
607
  const out = renderActivityFeedWithNested(parent, child)!;
602
608
  expect(out.length).toBeLessThanOrEqual(4096);
603
- expect(out).toContain(" ↳ ");
609
+ expect(out).toContain(NP);
604
610
  });
605
611
  });
606
612
 
@@ -678,11 +684,11 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
678
684
  expect(out.length).toBeLessThanOrEqual(4000);
679
685
  expect(isValidMarkdown(out)).toBe(true);
680
686
  // Must contain the nested prefix.
681
- expect(out).toContain(" ↳ ");
687
+ expect(out).toContain(NP);
682
688
  // Regression guard: an operator-precedence bug made wrapperOverhead a string
683
689
  // (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
684
- // was silently discarded, leaving only " ↳ **→ **". The empty-wrapper
685
- // output still satisfies the toContain(" ↳ ") check above, so assert that
690
+ // was silently discarded, leaving only `${NESTED_PREFIX}**→ **`. The empty-wrapper
691
+ // output still satisfies the toContain(NP) check above, so assert that
686
692
  // real child content actually survives.
687
693
  expect(out).toContain("Child step");
688
694
  expect(out.length).toBeGreaterThan(100);
@@ -1025,7 +1031,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
1025
1031
  // Parent step is done-styled (child is the live step).
1026
1032
  expect(out).toContain("~~_✓ Delegating: review_~~");
1027
1033
  // Child step is the in-progress step.
1028
- expect(out).toContain("**→ Reading schema.ts**");
1034
+ expect(out).toContain(NP + "**→ Reading schema.ts**");
1029
1035
  });
1030
1036
  });
1031
1037
 
@@ -389,13 +389,14 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
389
389
  * an already-escaped markdown escape is never sliced.
390
390
  */
391
391
  const deepest = Math.max(1, escapedBody.length)
392
+ /** The collapsed parent trail, kept at every shrink level below level 0. */
393
+ const markerSection: CardSection = {
394
+ steps: [],
395
+ window: 1,
396
+ placeholder: parentMarker ?? undefined,
397
+ }
392
398
  return fitCardToBudget((level) => {
393
399
  if (level === 0) return { spec: fullSpec() }
394
- const markerSection: CardSection = {
395
- steps: [],
396
- window: 1,
397
- placeholder: parentMarker ?? undefined,
398
- }
399
400
  if (level < escapedBody.length) {
400
401
  return {
401
402
  spec: {
@@ -427,39 +428,66 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
427
428
  }
428
429
  }, deepest)
429
430
 
431
+ /** The deepest shrink level's spec, for a given already-wrapped body line. */
432
+ function deepestSpec(bodyLine: string): CardSpec {
433
+ return {
434
+ chrome,
435
+ sections: [markerSection, { steps: [], window: 1, placeholder: bodyLine }],
436
+ footer,
437
+ }
438
+ }
439
+
440
+ /** Wrap an ALREADY-ESCAPED newest step in its bullet chrome. */
441
+ function wrapNewest(escaped: string): string {
442
+ return final
443
+ ? `${bodyIndent}_✓ ${escaped}_`
444
+ : `${bodyIndent}**→ ${escaped}${liveSuffix}**`
445
+ }
446
+
430
447
  /**
431
- * The deepest shrink level's single bullet. Charges the fixed header/footer
432
- * (and the parent marker) against `STATUS_CARD_CHAR_BUDGET`, then clips the
433
- * RAW newest text to what is left, re-checking after escaping because
434
- * escaping can expand the string (`&` → `&amp;`).
448
+ * The deepest shrink level's single bullet: clip the RAW newest text to
449
+ * whatever the fixed chrome/footer leaves of `STATUS_CARD_CHAR_BUDGET`,
450
+ * escape, then re-measure escaping can expand the string (`&` → `&amp;`).
451
+ *
452
+ * Everything is measured by RENDERING the level's real spec (#3833). The
453
+ * previous arithmetic estimate charged `[...chrome, ...footer].join('\n')`,
454
+ * but the renderer is `stackCard`, which emits a collapse separator plus a
455
+ * two-space GFM hard break at every line boundary — 4 chars, not 1 — and it
456
+ * also charges the marker line and the section boundaries the estimate only
457
+ * partly counted. That under-count let a card render OVER the wire budget.
458
+ * Measuring with the renderer cannot drift from it by construction.
435
459
  */
436
460
  function truncatedNewestLine(): string {
437
- const fixedCost = [...chrome, ...footer].join('\n').length
438
461
  const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
439
- const wrapperOverhead = final
440
- ? (bodyIndent + '_✓ _').length
441
- : (bodyIndent + '**→ **').length + liveSuffix.length
442
- const headerFooterCost =
443
- fixedCost +
444
- (fixedCost > 0 ? 1 : 0) +
445
- (parentMarker != null ? parentMarker.length + 1 : 0)
446
- const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
447
- let raw = rawNewest.slice(0, Math.max(0, budget))
448
- let newest = escapeMarkdown(raw)
449
- while (
450
- raw.length > 0 &&
451
- wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET
452
- ) {
453
- const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
454
- raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
455
- newest = escapeMarkdown(raw)
462
+ const empty = wrapNewest('')
463
+ const fixedCost = renderCardSpec(deepestSpec(empty)).length
464
+ const budget = STATUS_CARD_CHAR_BUDGET - fixedCost
465
+ // Chrome alone is over budget (a 40k-char worker description, #3682). No
466
+ // amount of body clipping helps; `fitCardToBudget`'s hard-truncate backstop
467
+ // is what keeps the card under the wire limit in that case.
468
+ if (budget <= 0) return empty
469
+ let raw = clipRaw(rawNewest, budget)
470
+ let line = wrapNewest(escapeMarkdown(raw))
471
+ for (;;) {
472
+ const excess = renderCardSpec(deepestSpec(line)).length - STATUS_CARD_CHAR_BUDGET
473
+ if (excess <= 0 || raw.length === 0) return line
474
+ // Dropping k RAW chars drops at least k escaped chars (escaping never
475
+ // shrinks), so this strictly converges.
476
+ raw = clipRaw(raw, Math.max(0, raw.length - excess))
477
+ line = wrapNewest(escapeMarkdown(raw))
456
478
  }
457
- return final
458
- ? `${bodyIndent}_✓ ${newest}_`
459
- : `${bodyIndent}**→ ${newest}${liveSuffix}**`
460
479
  }
461
480
  }
462
481
 
482
+ /**
483
+ * Slice raw (pre-escape) text to `n` chars without leaving a dangling lone
484
+ * high surrogate — an unpaired surrogate is invalid UTF-16 on the wire.
485
+ */
486
+ function clipRaw(text: string, n: number): string {
487
+ const out = text.slice(0, Math.max(0, n))
488
+ return /[\uD800-\uDBFF]$/.test(out) ? out.slice(0, -1) : out
489
+ }
490
+
463
491
  /** Subtle horizontal rule between the running feed and the finished result. */
464
492
  const WORKER_RESULT_RULE = '─────'
465
493
  /** Hard cap on the terminal result paragraph. */
@@ -51,8 +51,9 @@ const FG_DISPATCH_PROMPT =
51
51
  `one-line summary." Wait for the foreground sub-agent to finish, then send ` +
52
52
  `me a brief reply telling me it's done.`;
53
53
 
54
- // The nested foreground block renders each child line prefixed with "↳"
55
- // (NESTED_PREFIX = " "), newest as a bold "→ …" current step. Telegram
54
+ // The nested foreground block renders each child line prefixed with a three
55
+ // U+2800 indent then "↳" (NESTED_PREFIX; #3668 replaced the old ASCII spaces,
56
+ // which Telegram left-trims), newest as a bold "→ …" current step. Telegram
56
57
  // strips the bold but keeps the literal ↳ / → glyphs in message text.
57
58
  const NESTED_RE = /↳/;
58
59
 
@@ -25,9 +25,24 @@ OBSERVATION_SCOPE_STRATEGIES = ("curated", "shared", "combined", "off")
25
25
  #: value that, if left in scope, defeats cross-session dedup on the dominant
26
26
  #: (sidechain) observation path. A tag matching ANY of these is dropped from the
27
27
  #: consolidation scope but LEFT on the source fact.
28
+ #:
29
+ #: ``^source:`` (switchroom provenance tagging) is in the list for a different
30
+ #: reason than the other two, and the name of the constant undersells it: what
31
+ #: this list really means is "tags excluded from the CONSOLIDATION SCOPE, kept
32
+ #: on the source fact". ``source:transcript`` is the opposite of volatile — it
33
+ #: is stamped identically on every auto-retain, forever. That is exactly why it
34
+ #: must not reach the scope: a stable tag flips ``curated`` from the bank-wide
35
+ #: ``"shared"`` scope to an explicit ``[["source:transcript"]]`` partition, so
36
+ #: every observation consolidated after the tag ships would be isolated from
37
+ #: every observation consolidated before it. The tag is a RECALL-side filter
38
+ #: handle (``metadata`` is not filterable, tags are), not a consolidation
39
+ #: partition, so scope computation must stay byte-identical to before it
40
+ #: existed. Pinned against ``RETAIN_PROVENANCE_TAG_SCOPE_PATTERN`` in
41
+ #: ``src/memory/hindsight-retain-provenance.ts``.
28
42
  DEFAULT_VOLATILE_SCOPE_PATTERNS = (
29
43
  r"^parent_session:",
30
44
  r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(-sub-.+)?$",
45
+ r"^source:",
31
46
  )
32
47
 
33
48
  DEFAULTS = {
@@ -0,0 +1,135 @@
1
+ """Provenance on auto-retained transcript content (switchroom fix/retain-provenance).
2
+
3
+ The defect: the Stop-hook auto-retain posts the recent transcript window to
4
+ Hindsight, which extracts facts from it — including from the ASSISTANT's own
5
+ synthesis. Those fabrications land as ordinary memory units and recall back next
6
+ session as though a human had asserted them. Nothing on the stored unit said
7
+ "this came out of a transcript": ``tags`` held only the raw session UUID and the
8
+ only other signal, ``session_id``, lived in ``metadata`` — which Hindsight's
9
+ best-practices page states is NOT filterable, while tags ARE.
10
+
11
+ The fix stamps a stable, semantic ``source:transcript`` tag via switchroom's
12
+ ``retainTags`` default, so recall/reflect can address it (tag filters, tag
13
+ weights). This module asserts the two OUTCOMES that matter at the retain seam:
14
+
15
+ 1. The tag actually reaches the wire payload, alongside the session tag and
16
+ any detected lesson tags (i.e. it composes, it does not clobber).
17
+ 2. It does NOT change the observation consolidation scope. This is the
18
+ non-obvious half: ``observationScopeStrategy: "curated"`` builds the scope
19
+ from a retain's STABLE tags, so a permanently-stamped tag would flip every
20
+ bank from the bank-wide ``"shared"`` scope to a ``[["source:transcript"]]``
21
+ partition, isolating every new observation from every observation
22
+ consolidated before the tag shipped. ``^source:`` is therefore excluded
23
+ from the scope in ``DEFAULT_VOLATILE_SCOPE_PATTERNS`` — the tag is a
24
+ recall-side filter handle, not a consolidation partition.
25
+
26
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
27
+ """
28
+
29
+ import os
30
+ import sys
31
+ import unittest
32
+
33
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
+ if SCRIPTS_DIR not in sys.path:
35
+ sys.path.insert(0, SCRIPTS_DIR)
36
+
37
+ from lib.config import ( # noqa: E402
38
+ DEFAULTS,
39
+ DEFAULT_VOLATILE_SCOPE_PATTERNS,
40
+ compute_observation_scopes,
41
+ )
42
+ from retain import build_retain_payload # noqa: E402
43
+
44
+ #: What switchroom's ``renderHindsightSettingsOverrides`` stamps into every
45
+ #: agent's deployed settings.json (src/memory/hindsight-retain-provenance.ts).
46
+ PROVENANCE_TAG = "source:transcript"
47
+ SESSION_ID = "4c386b32-ddfd-40d1-b557-da8135b294af"
48
+
49
+
50
+ def _cfg(**over):
51
+ c = {
52
+ "retainRoles": ["user", "assistant"],
53
+ "retainToolCalls": True,
54
+ "retainContext": "claude-code",
55
+ "retainMetadata": {},
56
+ "retainTags": ["{session_id}", PROVENANCE_TAG],
57
+ "lessonTagging": DEFAULTS["lessonTagging"],
58
+ "lessonTagMarkers": DEFAULTS["lessonTagMarkers"],
59
+ "observationScopeStrategy": "curated",
60
+ }
61
+ c.update(over)
62
+ return c
63
+
64
+
65
+ def _build(transcript_text="Edited the login handler and ran tests.", **cfg_over):
66
+ msgs = [
67
+ {"role": "user", "content": "what did we change?", "uuid": "u1"},
68
+ {"role": "assistant", "content": transcript_text, "uuid": "a1"},
69
+ ]
70
+ built = build_retain_payload(
71
+ _cfg(**cfg_over),
72
+ SESSION_ID,
73
+ msgs,
74
+ msgs,
75
+ bank_id="bank",
76
+ api_url="http://x",
77
+ api_token=None,
78
+ )
79
+ assert built is not None
80
+ return built["payload"]
81
+
82
+
83
+ class ProvenanceTagReachesTheWire(unittest.TestCase):
84
+ def test_tag_is_on_the_retain_payload(self):
85
+ self.assertIn(PROVENANCE_TAG, _build()["tags"])
86
+
87
+ def test_session_tag_still_templated_and_kept(self):
88
+ tags = _build()["tags"]
89
+ self.assertIn(SESSION_ID, tags)
90
+ self.assertNotIn("{session_id}", tags)
91
+
92
+ def test_composes_with_detected_lesson_tags(self):
93
+ tags = _build("anti-pattern: narrate without executing")["tags"]
94
+ self.assertIn(PROVENANCE_TAG, tags)
95
+ self.assertIn("anti-pattern", tags)
96
+ self.assertIn(SESSION_ID, tags)
97
+
98
+ def test_absent_when_retain_tags_config_omits_it(self):
99
+ # The tag is data, not hardcoded behaviour: retain.py has no knowledge
100
+ # of it beyond honouring the configured retainTags. That keeps the
101
+ # vendored plugin generic and the switchroom stamp the single source.
102
+ tags = _build(retainTags=["{session_id}"])["tags"]
103
+ self.assertNotIn(PROVENANCE_TAG, tags)
104
+
105
+
106
+ class ProvenanceTagIsScopeNeutral(unittest.TestCase):
107
+ """The tag must not silently re-partition observation consolidation."""
108
+
109
+ def test_curated_scope_stays_shared_with_the_provenance_tag(self):
110
+ payload = _build()
111
+ self.assertEqual(payload["observation_scopes"], "shared")
112
+
113
+ def test_scope_is_byte_identical_to_a_bank_without_the_tag(self):
114
+ with_tag = _build()["observation_scopes"]
115
+ without = _build(retainTags=["{session_id}"])["observation_scopes"]
116
+ self.assertEqual(with_tag, without)
117
+
118
+ def test_a_stable_tag_that_is_not_excluded_would_have_changed_the_scope(self):
119
+ # Guards the guard: proves the "shared" result above is caused by the
120
+ # exclusion pattern and not by curated ignoring stable tags generally.
121
+ scope, err = compute_observation_scopes(
122
+ [SESSION_ID, "project:switchroom"], _cfg()
123
+ )
124
+ self.assertIsNone(err)
125
+ self.assertEqual(scope, [["project:switchroom"]])
126
+
127
+ def test_exclusion_pattern_is_present_and_matches_the_tag(self):
128
+ self.assertIn(r"^source:", DEFAULT_VOLATILE_SCOPE_PATTERNS)
129
+ scope, err = compute_observation_scopes([SESSION_ID, PROVENANCE_TAG], _cfg())
130
+ self.assertIsNone(err)
131
+ self.assertEqual(scope, "shared")
132
+
133
+
134
+ if __name__ == "__main__": # pragma: no cover
135
+ unittest.main()
@@ -2,7 +2,7 @@
2
2
  "hindsightApiUrl": "",
3
3
  "bankId": "claude_code",
4
4
  "bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
5
- "retainMission": "Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.\n\nDURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:\n- PREFERENCE — what the user likes, wants, or always does; a standing rule or correction.\n- DECISION — a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision — drop it unless it establishes a standing rule or permanently changes a system.\n- FINDING — a root cause, a measurement, or verified behaviour of a system. Include the number.\n- OUTCOME — a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.\n- RELATIONSHIP — who a person is, what a project or tool is, and how they connect.\nIf a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.\n\nA preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- The act of delegating, dispatching, spawning, launching, steering or merging\n work — including when it succeeded. \"X was dispatched and completed\" is the\n session describing itself. Record only what the work LEARNED or CHANGED.\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nWrite each fact so it stands alone: name the thing, the number, and the date. A\nsentence that only makes sense while reading this transcript is not durable.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.\n",
5
+ "retainMission": "Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.\n\nDURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:\n- PREFERENCE — what the user likes, wants, or always does; a standing rule or correction.\n- DECISION — a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision — drop it unless it establishes a standing rule or permanently changes a system.\n- FINDING — a root cause, a measurement, or verified behaviour of a system. Include the number.\n- OUTCOME — a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.\n- RELATIONSHIP — who a person is, what a project or tool is, and how they connect.\nIf a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.\n\nA preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- The act of delegating, dispatching, spawning, launching, steering or merging\n work — including when it succeeded. \"X was dispatched and completed\" is the\n session describing itself. Record only what the work LEARNED or CHANGED.\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- The assistant's own answers, summaries, recaps, or reflect output. Before\n extracting, ask: does anything in this transcript support this claim OTHER\n than the assistant having asserted it? If not, drop it — a model's own\n synthesis re-extracted as a fact is how a guess becomes permanent.\n Concretely, never produce a fact whose only support is the assistant\n stating a date, a version, an attribution, a total, or a decision that the\n user never confirmed and no tool output shows. An unverified claim recalls\n later as though it had been established, which is worse than not\n remembering it at all.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nWrite each fact so it stands alone: name the thing, the number, and the date. A\nsentence that only makes sense while reading this transcript is not durable.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.\n",
6
6
  "autoRecall": true,
7
7
  "autoRetain": true,
8
8
  "retainMode": "full-session",