switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  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 +1318 -693
  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/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderStatusCard } from '../tool-activity-summary.js'
3
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
4
+ import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
5
+
6
+ /**
7
+ * #3668 — the nested (foreground sub-agent) child block must actually LOOK
8
+ * nested on a phone.
9
+ *
10
+ * `NESTED_PREFIX` used to be three ASCII spaces plus `↳ `. Card bodies reach
11
+ * Telegram as GFM markdown parsed SERVER-SIDE, and that parser left-trims a
12
+ * leading whitespace run off a content line, so the indent was dropped on the
13
+ * wire and every child step rendered FLAT against its parent's steps — the
14
+ * whole nesting cue reduced to one `↳` glyph.
15
+ *
16
+ * The replacement is the repo's already-live-verified indent idiom, U+2800
17
+ * BRAILLE PATTERN BLANK: general category So (Symbol, other), not Zs, so no
18
+ * whitespace-trimming rule can classify it as whitespace, yet it renders as
19
+ * blank width. Evidence, in order of strength:
20
+ *
21
+ * 1. LIVE: on 2026-07-26 four candidate indents were sent to a real phone in
22
+ * one message (recorded at `status-no-truncate.ts`, WORKER_STEP_INDENT) —
23
+ * three U+00A0 rendered FLAT, three U+2800 INDENTED CORRECTLY, a leading
24
+ * `↳ ` rendered as visible ink, a leading `· ` was promoted by Telegram
25
+ * into a real list bullet.
26
+ * 2. SHIPPED: `WORKER_STEP_INDENT` (the combined worker card) has used U+2800
27
+ * since that check and renders indented in production.
28
+ * 3. CATEGORY: the property below — U+2800 is not `\s`, not `White_Space`,
29
+ * not `Zs`.
30
+ *
31
+ * Note what is NOT claimed: no character was chosen on inference. #3662 shipped
32
+ * U+00A0 on exactly that kind of inference, was merged green, and was inert on
33
+ * a phone — which is why these tests assert the PROPERTY, not just the bytes.
34
+ */
35
+
36
+ /** Every rule a server-side trimmer could plausibly use. */
37
+ const isTrimmableWhitespace = (ch: string): boolean =>
38
+ /\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
39
+
40
+ function nestedCard(children: string[]): string {
41
+ return renderStatusCard({
42
+ header: { emoji: '🤖', label: 'Agent', elapsedMs: 95_000, toolCount: 12, state: 'running' },
43
+ steps: ['Reading gateway.ts'],
44
+ childSteps: children,
45
+ final: false,
46
+ })!
47
+ }
48
+
49
+ /** The card's pre-join lines, hard-break chrome removed. */
50
+ function linesOf(card: string): string[] {
51
+ return card
52
+ .split('\n')
53
+ .map((l) => l.replace(/[ \t]+$/, '').replace(new RegExp(`${COLLAPSE_SAFE_SEPARATOR}$`), ''))
54
+ }
55
+
56
+ describe('NESTED_PREFIX renders as a real indent (#3668)', () => {
57
+ it('contains no character any whitespace rule would trim', () => {
58
+ // The `↳ ` glyph is visible ink and is kept; the run BEFORE it is the
59
+ // indent, and every char of THAT must survive a left-trim. Pin the width
60
+ // too — a single blank char reads as near-flat on a phone and would undo
61
+ // the fix while keeping the character-class assertion green.
62
+ const indent = [...NESTED_PREFIX.slice(0, NESTED_PREFIX.indexOf('↳'))]
63
+ expect(indent.length).toBe(3)
64
+ for (const ch of indent) {
65
+ const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
66
+ expect(
67
+ isTrimmableWhitespace(ch),
68
+ `NESTED_PREFIX's indent contains ${cp}, which is Unicode whitespace ` +
69
+ `(\\s / White_Space / Zs). Telegram left-trims a leading whitespace run off a ` +
70
+ `content line, so this indent renders FLAT on a phone — the #3662/#3668 failure. ` +
71
+ `Use U+2800 BRAILLE PATTERN BLANK (category So), live-verified 2026-07-26.`,
72
+ ).toBe(false)
73
+ }
74
+ })
75
+
76
+ it('leads with exactly three U+2800 and keeps the ↳ glyph', () => {
77
+ // Byte pin, companion to the property above: that one says "not
78
+ // whitespace", this one says "the specific glyph we live-tested", in the
79
+ // order that matters (blanks LEAD, so the glyph is never what gets trimmed).
80
+ expect(NESTED_PREFIX).toBe('\u2800\u2800\u2800↳ ')
81
+ // Not a GFM block-structure lead-in: `stackCardLines` promotes every
82
+ // inter-line break to a hard break *because* card lines are never block
83
+ // lines, and a live check showed Telegram promotes a leading `·` to a real
84
+ // list bullet.
85
+ expect(/^[-*+>|#·]/.test(NESTED_PREFIX)).toBe(false)
86
+ })
87
+
88
+ it('every rendered child line survives a server-side leading-whitespace trim with its indent intact', () => {
89
+ const card = nestedCard(['Searching memory', 'Running tests'])
90
+ const childLines = linesOf(card).filter((l) => l.includes('↳'))
91
+ expect(childLines.length).toBe(2)
92
+ for (const line of childLines) {
93
+ // Fail-before assertion: with the old ASCII prefix, `line` started with
94
+ // `' '` and this strip removed the whole indent.
95
+ expect(line.startsWith(NESTED_PREFIX)).toBe(true)
96
+ expect(line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX)).toBe(true)
97
+ // And it leads with neither of the two characters already proven flat.
98
+ expect(line.startsWith(' ')).toBe(false)
99
+ expect(line.startsWith('\u00A0')).toBe(false)
100
+ }
101
+ })
102
+
103
+ it('golden line ordering — parent steps stay flush, child steps sit one level in', () => {
104
+ // Set-membership assertions alone would pass a render that emitted all the
105
+ // parents and then all the children. This pins the ORDER, which is the
106
+ // thing "nested" actually means.
107
+ const card = nestedCard(['Searching memory', 'Running tests'])
108
+ const body = linesOf(card).slice(2)
109
+ expect(body).toEqual([
110
+ '~~_✓ Reading gateway.ts_~~',
111
+ `${NESTED_PREFIX}~~_Searching memory_~~`,
112
+ `${NESTED_PREFIX}**→ Running tests**`,
113
+ ])
114
+ })
115
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `createRobustApiCall` must stay deleted (#3863).
3
+ *
4
+ * It was a second `createRetryApiCall` wiring in `shared/bot-runtime.ts`,
5
+ * described as "exactly how gateway.ts constructs its robustApiCall". It had
6
+ * ZERO production callers — and, worse, its flood-breaker hooks were OPTIONAL
7
+ * (`floodStatePath` defaulted to absent), so the default construction was a
8
+ * send path blind to the shared flood window: the exact failure
9
+ * `scripts/check-retry-flood-hooks.mjs` exists to prevent, sitting in the file
10
+ * a future caller would naturally reach for.
11
+ *
12
+ * It was also load-bearing DOCUMENTATION: `flood-circuit-breaker.ts` and
13
+ * `flood-429-ledger.ts` both cited it as a live wiring, so the breaker's
14
+ * "every onFloodWait goes through here" completeness argument was partly
15
+ * anchored to dead code.
16
+ *
17
+ * This is a source-level guard on purpose: the defect is the existence of the
18
+ * identifier, and there is no runtime behaviour to assert.
19
+ *
20
+ * The rule is "the name may appear only inside backticks" — i.e. as PROSE in a
21
+ * docblock explaining why it is gone. Any bare occurrence is code (a
22
+ * declaration, an import, a call) and fails.
23
+ */
24
+ import { describe, it, expect } from 'vitest'
25
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
26
+ import { join, extname } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url))
30
+ const SEARCH_ROOTS = ['telegram-plugin', 'src', 'bin', 'scripts', 'docs']
31
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'vendor', 'coverage'])
32
+ const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md'])
33
+
34
+ function* walk(dir: string): Generator<string> {
35
+ for (const entry of readdirSync(dir)) {
36
+ if (SKIP_DIRS.has(entry)) continue
37
+ const full = join(dir, entry)
38
+ if (statSync(full).isDirectory()) yield* walk(full)
39
+ else if (SEARCH_EXTS.has(extname(entry))) yield full
40
+ }
41
+ }
42
+
43
+ describe('#3863 — the dead createRobustApiCall factory', () => {
44
+ it('appears in no source, script or doc file except as backticked prose', () => {
45
+ const selfPath = fileURLToPath(import.meta.url)
46
+ const offenders: string[] = []
47
+ for (const root of SEARCH_ROOTS) {
48
+ for (const file of walk(join(REPO_ROOT, root))) {
49
+ if (file === selfPath) continue
50
+ // Drop the backticked (prose) occurrences; anything left is code.
51
+ const bare = readFileSync(file, 'utf8').replaceAll('`createRobustApiCall`', '')
52
+ if (bare.includes('createRobustApiCall')) offenders.push(file.slice(REPO_ROOT.length))
53
+ }
54
+ }
55
+ expect(offenders).toEqual([])
56
+ })
57
+
58
+ it('is not exported by shared/bot-runtime.ts', async () => {
59
+ const mod = (await import('../shared/bot-runtime.js')) as Record<string, unknown>
60
+ expect(Object.keys(mod)).not.toContain('createRobustApiCall')
61
+ })
62
+ })
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  loadObligations,
4
4
  persistObligations,
5
+ removeUnmaintainedSnapshot,
5
6
  type ObligationStoreFsSeam,
6
7
  } from "../gateway/obligation-store.js";
7
8
  import type { Obligation } from "../gateway/obligation-ledger.js";
@@ -33,6 +34,10 @@ function memFs(seed: Record<string, string> = {}) {
33
34
  fsyncDirSync: (p) => {
34
35
  calls.push(`fsyncDir:${p}`);
35
36
  },
37
+ unlinkSync: (p) => {
38
+ calls.push(`unlink:${p}`);
39
+ files.delete(p);
40
+ },
36
41
  };
37
42
  return { fs, files, calls };
38
43
  }
@@ -240,4 +245,50 @@ describe("obligation-store — power-cut durability (fsync ordering)", () => {
240
245
  expect(files.has(PATH)).toBe(true);
241
246
  expect(loadObligations(PATH, fs)).toHaveLength(1);
242
247
  });
248
+
249
+ // ── #4146: a snapshot nobody maintains must not read as "nobody waiting" ──
250
+
251
+ it("removeUnmaintainedSnapshot deletes a leftover snapshot and says why", () => {
252
+ // The exact hazard: the agent once ran with the ledger on, so an EMPTY
253
+ // open set is on disk. Persistence is now off, so that emptiness is a
254
+ // leftover — and the W1-d audience classifier would read it as positive
255
+ // proof that nobody is waiting, and suppress a message owed to a human.
256
+ const { fs, files } = memFs({ [PATH]: JSON.stringify({ v: 1, obligations: [] }) });
257
+ const logs: string[] = [];
258
+ expect(removeUnmaintainedSnapshot(PATH, fs, "static=true enabled=false", (l) => logs.push(l))).toBe(true);
259
+ expect(files.has(PATH)).toBe(false);
260
+ // ...and the classifier's precondition now genuinely holds: absent file.
261
+ expect(fs.existsSync(PATH)).toBe(false);
262
+ expect(logs.join("")).toContain("static=true enabled=false");
263
+ expect(logs.join("")).toContain("#4146");
264
+ });
265
+
266
+ it("removeUnmaintainedSnapshot is a no-op when there is nothing to remove", () => {
267
+ const { fs } = memFs();
268
+ const logs: string[] = [];
269
+ expect(removeUnmaintainedSnapshot(PATH, fs, "reason", (l) => logs.push(l))).toBe(false);
270
+ expect(logs).toEqual([]);
271
+ });
272
+
273
+ it("removeUnmaintainedSnapshot never throws, and reports failure rather than claiming success", () => {
274
+ // A failure here must degrade to the reader-side ledger-off guard, not
275
+ // crash gateway boot and not be mistaken for a completed cleanup.
276
+ const { fs } = memFs({ [PATH]: "{}" });
277
+ const logs: string[] = [];
278
+ const failing: ObligationStoreFsSeam = {
279
+ ...fs,
280
+ unlinkSync: () => {
281
+ throw new Error("EACCES: permission denied, unlink");
282
+ },
283
+ };
284
+ expect(removeUnmaintainedSnapshot(PATH, failing, "reason", (l) => logs.push(l))).toBe(false);
285
+ expect(logs.join("")).toContain("could not remove unmaintained snapshot");
286
+ });
287
+
288
+ it("a seam with no unlinkSync degrades to no cleanup rather than throwing", () => {
289
+ const { fs, files } = memFs({ [PATH]: "{}" });
290
+ const { unlinkSync: _dropped, ...legacySeam } = fs;
291
+ expect(removeUnmaintainedSnapshot(PATH, legacySeam, "reason", () => {})).toBe(false);
292
+ expect(files.has(PATH)).toBe(true);
293
+ });
243
294
  });