switchroom 0.17.0 → 0.17.1

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.
@@ -13,7 +13,7 @@
13
13
  * add on top of the same client. Tracked in the migration TODO inline.
14
14
  */
15
15
 
16
- import { escapeMarkdown, codeSpanSafe } from '../format.js';
16
+ import { escapeMarkdown, codeSpanSafe, hardenCardBreaks } from '../format.js';
17
17
  import type { Bot, Context } from "grammy";
18
18
  import { richMessage } from "../rich-send.js";
19
19
  import {
@@ -84,7 +84,11 @@ export function registerApprovalsCommands(
84
84
  );
85
85
  })
86
86
  .join("\n");
87
- await ctx.replyWithRichMessage(richMessage(`**Active approvals**\n\n${summary}\n\n${detail}`));
87
+ // hardenCardBreaks: the per-agent `summary` rows and per-decision
88
+ // `detail` rows are single-`\n`-joined field lines that would soft-
89
+ // collapse into one blob under the GFM rich renderer. Harden them into
90
+ // GFM hard breaks (block gaps between the three sections preserved).
91
+ await ctx.replyWithRichMessage(richMessage(hardenCardBreaks(`**Active approvals**\n\n${summary}\n\n${detail}`)));
88
92
  return;
89
93
  }
90
94
 
@@ -233,7 +233,7 @@ const REPLY_TO_TEXT_MAX = 200
233
233
  const SILENT_END_FALLBACK_TEXT =
234
234
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
235
235
  'message may not have been answered. Please try asking again.'
236
- import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
236
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
237
237
  import { richMessage } from '../rich-send.js'
238
238
  import { scrubVoice } from '../text-voice-scrub.js'
239
239
  import {
@@ -10812,7 +10812,10 @@ function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: str
10812
10812
  }
10813
10813
  lines.push('')
10814
10814
  lines.push(`_Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide._`)
10815
- return lines.join('\n')
10815
+ // hardenCardBreaks: labelled field lines (key: / why:) would soft-collapse
10816
+ // into one blob under the GFM rich renderer; this card is sent direct via
10817
+ // richMessage, bypassing the switchroomReply chokepoint.
10818
+ return hardenCardBreaks(lines.join('\n'))
10816
10819
  }
10817
10820
 
10818
10821
  /**
@@ -11313,6 +11316,8 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
11313
11316
  pendingVaultRequestAccesses.set(stageId, pending)
11314
11317
  sweepPendingVaultRequestAccesses()
11315
11318
 
11319
+ // renderVaultRequestAccessCard self-hardens its field line breaks (this card
11320
+ // is sent direct, bypassing the switchroomReply chokepoint).
11316
11321
  const text = renderVaultRequestAccessCard(pending)
11317
11322
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
11318
11323
  // Remember the agent's working topic so the grant-outcome inbound resumes in it.
@@ -15717,8 +15722,17 @@ async function switchroomReply(
15717
15722
  }
15718
15723
  // #2669: `options.html` now means "render `text` as GFM markdown via the
15719
15724
  // rich-message path" (legacy field name kept). Plain otherwise.
15725
+ //
15726
+ // Every deterministic slash-command card ships through this html branch as
15727
+ // RAW markdown (no reply-path normalization). Under the Bot API 10.1 GFM
15728
+ // renderer a lone `\n` is a SOFT break, so a card whose builder stacks
15729
+ // labelled fields with single `\n` (e.g. `/usage`, `/model`, `/auth`)
15730
+ // renders as one run-on blob. `hardenCardBreaks` promotes those lone
15731
+ // field breaks to GFM hard breaks (` \n`) while leaving lists / tables /
15732
+ // fenced code / blockquotes / headings on their native single `\n` — the
15733
+ // same treatment the direct-send cards get from `stackCardLines`.
15720
15734
  if (options.html) {
15721
- await ctx.replyWithRichMessage(richMessage(text), replyOpts)
15735
+ await ctx.replyWithRichMessage(richMessage(hardenCardBreaks(text)), replyOpts)
15722
15736
  } else {
15723
15737
  await ctx.reply(text, replyOpts)
15724
15738
  }
@@ -105,6 +105,19 @@ describe("buildMs365CardText", () => {
105
105
  expect(text).toContain("bob@example.com");
106
106
  });
107
107
 
108
+ it("hard-breaks its field lines so GFM doesn't collapse them into a blob", () => {
109
+ // The Agent:/Tool:/Item:/Account: field lines must carry GFM hard breaks
110
+ // (` \n`), not bare `\n` soft breaks. Assert every adjacent pair of
111
+ // non-blank content lines is separated by a hard break or a `\n\n` gap.
112
+ const text = buildMs365CardText(base);
113
+ expect(text).toContain(" \n");
114
+ const nl = text.split("\n");
115
+ for (let i = 0; i < nl.length - 1; i++) {
116
+ if (nl[i].trim() === "" || nl[i + 1].trim() === "") continue; // block gap
117
+ expect(nl[i].endsWith(" ")).toBe(true);
118
+ }
119
+ });
120
+
108
121
  it("omits ID line for new files", () => {
109
122
  const text = buildMs365CardText({ ...base, itemId: "(new)" });
110
123
  expect(text).not.toMatch(/^ID:/m);
@@ -31,6 +31,7 @@
31
31
 
32
32
  import type { IpcClient } from "./ipc-server.js";
33
33
  import type { RequestMs365ApprovalMessage } from "./ipc-protocol.js";
34
+ import { hardenCardBreaks } from "../format.js";
34
35
 
35
36
  // ────────────────────────────────────────────────────────────────────────
36
37
  // Wire shape — validates an inbound preview payload
@@ -188,7 +189,10 @@ export function buildMs365CardText(p: Ms365WritePreview): string {
188
189
  lines.push(
189
190
  "⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
190
191
  );
191
- return lines.join("\n");
192
+ // hardenCardBreaks: labelled field lines (Agent:/Tool:/Item:/Account:/Size:…)
193
+ // would soft-collapse into one blob under the GFM rich renderer; this card is
194
+ // posted direct (not via the switchroomReply chokepoint).
195
+ return hardenCardBreaks(lines.join("\n"));
192
196
  }
193
197
 
194
198
  function truncate(s: string, n: number): string {
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { escapeHtmlForTg } from '../shared/bot-runtime.js'
22
22
  import { codeSpanSafe } from './approval-card.js'
23
+ import { hardenCardBreaks } from '../format.js'
23
24
 
24
25
  /** Minimal shape the card needs — a subset of PendingVaultRequestAccess. */
25
26
  export interface VaultRequestAccessCardInput {
@@ -57,5 +58,8 @@ export function renderVaultRequestAccessCard(
57
58
  lines.push(
58
59
  `_Tap Approve to mint a scoped grant token (same flow as \`switchroom vault grant\`). Tap Deny to refuse — the agent will receive a denial result._`,
59
60
  )
60
- return lines.join('\n')
61
+ // hardenCardBreaks: the labelled field lines (key: / scope:…/ why:) would
62
+ // soft-collapse into one blob under the GFM rich renderer; this card is sent
63
+ // direct via richMessage, bypassing the switchroomReply chokepoint.
64
+ return hardenCardBreaks(lines.join('\n'))
61
65
  }
@@ -16,11 +16,90 @@ import {
16
16
  normalizePunctuation,
17
17
  stripExcessBold,
18
18
  splitMarkdownChunks,
19
+ hardenCardBreaks,
19
20
  PARAGRAPH_SPACER,
20
21
  } from '../format.js'
21
22
 
22
23
  const SP = PARAGRAPH_SPACER // U+00A0
23
24
 
25
+ describe('hardenCardBreaks — deterministic card line-break hardener', () => {
26
+ test('promotes lone field breaks to GFM hard breaks (the blob fix)', () => {
27
+ const out = hardenCardBreaks('Agent: assistant\nAuth: Max\nStatus: running')
28
+ expect(out).toBe('Agent: assistant \nAuth: Max \nStatus: running')
29
+ })
30
+
31
+ test('preserves `\\n\\n` block gaps (not promoted)', () => {
32
+ const out = hardenCardBreaks('**Header**\nfield one\n\n**Next**\nfield two')
33
+ expect(out).toBe('**Header** \nfield one\n\n**Next** \nfield two')
34
+ })
35
+
36
+ test('leaves GFM list items on their native single `\\n`', () => {
37
+ const out = hardenCardBreaks('- one\n- two\n- three')
38
+ expect(out).toBe('- one\n- two\n- three')
39
+ })
40
+
41
+ test('leaves GFM table rows untouched', () => {
42
+ const src = '| a | b |\n| - | - |\n| 1 | 2 |'
43
+ expect(hardenCardBreaks(src)).toBe(src)
44
+ })
45
+
46
+ test('never touches a fenced code block interior', () => {
47
+ const src = '**Accounts**\n```\nalice ok\nbob ok\n```\n**Agents**'
48
+ // The fenced monospace table keeps its single `\n`s; the header lines that
49
+ // face the fence are not hard-broken (fence line is a block construct).
50
+ expect(hardenCardBreaks(src)).toBe(src)
51
+ })
52
+
53
+ test('does not hard-break across a heading or blockquote', () => {
54
+ expect(hardenCardBreaks('# Title\nbody')).toBe('# Title\nbody')
55
+ expect(hardenCardBreaks('> quote\nbody')).toBe('> quote\nbody')
56
+ })
57
+
58
+ test('collapses 3+ newline runs to a single `\\n\\n` gap', () => {
59
+ expect(hardenCardBreaks('a\n\n\n\nb')).toBe('a\n\nb')
60
+ })
61
+
62
+ test('is idempotent', () => {
63
+ const src = 'Agent: x\nAuth: y\n\n**H**\n🟢 Broker running\n🟢 Kernel up'
64
+ const once = hardenCardBreaks(src)
65
+ expect(hardenCardBreaks(once)).toBe(once)
66
+ })
67
+
68
+ test('no-op for single-line text', () => {
69
+ expect(hardenCardBreaks('just one line')).toBe('just one line')
70
+ })
71
+
72
+ // Regression (reviewer nit): a field line that STARTS with an inline code
73
+ // span used to be misclassified as a masked fenced-block open (fenced +
74
+ // inline masks shared one placeholder prefix), so its lone `\n` was never
75
+ // hardened and the card collapsed. Real victim: `/vault get` rendering
76
+ // `` `key` = `value` `` on one line.
77
+ test('hardens a line that STARTS with an inline code span', () => {
78
+ const out = hardenCardBreaks('`key` = `value`\n`k2` = `v2`')
79
+ expect(out).toBe('`key` = `value` \n`k2` = `v2`')
80
+ })
81
+
82
+ test('/vault get shape (`key` =\\n`value`) hard-breaks onto two lines', () => {
83
+ const out = hardenCardBreaks('`sk-key` =\n`hunter2`')
84
+ expect(out).toBe('`sk-key` = \n`hunter2`')
85
+ })
86
+
87
+ test('inline-span-leading fix does NOT disturb a real fenced block', () => {
88
+ // A genuine ``` fence between two inline-span-leading field lines: the
89
+ // field lines harden, the fence interior stays byte-for-byte intact.
90
+ const src = '`a` = 1\n```\nx = 1\ny = 2\n```\n`b` = 2'
91
+ const out = hardenCardBreaks(src)
92
+ expect(out).toContain('```\nx = 1\ny = 2\n```') // fence interior untouched
93
+ expect(out).not.toContain('x = 1 \n') // no hard break injected inside fence
94
+ })
95
+
96
+ test('mid-line inline spans still harden (unchanged behaviour)', () => {
97
+ expect(hardenCardBreaks('Model: `opus`\nAuth: `Max`')).toBe(
98
+ 'Model: `opus` \nAuth: `Max`',
99
+ )
100
+ })
101
+ })
102
+
24
103
  describe('addParagraphSpacers — uniform block spacing', () => {
25
104
  test('still spaces prose→prose (existing behaviour)', () => {
26
105
  const out = addParagraphSpacers('Alpha.\n\nBravo.')
@@ -91,4 +91,21 @@ describe('renderVaultRequestAccessCard', () => {
91
91
  })
92
92
  expect(text).toContain('why: _not provided_')
93
93
  })
94
+
95
+ it('hard-breaks its field lines so GFM does not collapse the card into a blob', () => {
96
+ const text = renderVaultRequestAccessCard({
97
+ agent: 'overlord',
98
+ key: 'openai/OPENAI_API_KEY',
99
+ scope: 'read',
100
+ reason: 'call the completions endpoint',
101
+ ttl_seconds: 7 * 86400,
102
+ })
103
+ // The key: / scope: / why: field lines carry GFM hard breaks (` \n`).
104
+ expect(text).toContain(' \n')
105
+ const nl = text.split('\n')
106
+ for (let i = 0; i < nl.length - 1; i++) {
107
+ if (nl[i].trim() === '' || nl[i + 1].trim() === '') continue // `\n\n` block gap
108
+ expect(nl[i].endsWith(' ')).toBe(true)
109
+ }
110
+ })
94
111
  })
@@ -343,6 +343,70 @@ describe("statusPairedText", () => {
343
343
  });
344
344
  });
345
345
 
346
+ // Regression: the Bot API 10.1 rich-message (GFM) renderer collapses a LONE
347
+ // `\n` between two non-blank lines into a SPACE (soft break), so a card built
348
+ // with `lines.join("\n")` renders as one run-on blob. The deterministic card
349
+ // builders MUST route through `stackCardLines`, which promotes every inter-
350
+ // field break to a GFM hard break (` \n`) and keeps intentional `\n\n` block
351
+ // gaps. These tests pin that so the "/status renders as a giant run-on blob"
352
+ // bug can't silently regress.
353
+ describe("card line-break hardening (GFM soft-break blob fix)", () => {
354
+ const meta: AgentMetadata = {
355
+ ...baseMeta,
356
+ agentName: "assistant",
357
+ model: "sonnet",
358
+ status: "running",
359
+ uptime: "3h",
360
+ auth: { authenticated: true, subscription_type: "Max", expires_in: "29 days", auth_source: "oauth" },
361
+ live: [
362
+ { status: "ok", label: "Broker", detail: "running" },
363
+ { status: "ok", label: "Kernel", detail: "up" },
364
+ ],
365
+ audit: {
366
+ version: "v0.3.0",
367
+ tools: "all",
368
+ skills: "git, vault",
369
+ limits: "idle 30m",
370
+ channel: "switchroom",
371
+ memoryBank: "assistant",
372
+ },
373
+ };
374
+
375
+ // Every adjacent field-pair inside a block is separated by a GFM hard break,
376
+ // and NO two adjacent non-blank content lines are joined by a bare `\n`
377
+ // (which would soft-collapse into a blob). Block separators stay `\n\n`.
378
+ const assertNoSoftJoin = (out: string) => {
379
+ const nl = out.split("\n");
380
+ for (let i = 0; i < nl.length - 1; i++) {
381
+ const cur = nl[i];
382
+ const next = nl[i + 1];
383
+ if (cur.trim() === "" || next.trim() === "") continue; // `\n\n` block gap
384
+ // A hardened line ends in the two-space GFM hard-break marker.
385
+ expect(cur.endsWith(" ")).toBe(true);
386
+ }
387
+ };
388
+
389
+ it("/status: fields are hard-broken, not soft-collapsed into a blob", () => {
390
+ const out = statusPairedText({ user: "@ken", meta });
391
+ // Adjacent fields inside the identity block use a GFM hard break.
392
+ expect(out).toContain("Auth: ✓ Max · expires 29 days \n");
393
+ // Health rows stack (hard break before each 🟢 row).
394
+ expect(out).toContain(" \n🟢 **Kernel**");
395
+ // Audit rows stack.
396
+ expect(out).toContain("**Version** v0.3.0 \n");
397
+ // Blocks stay separated by a real paragraph gap.
398
+ expect(out).toContain("\n\n**Health**");
399
+ assertNoSoftJoin(out);
400
+ });
401
+
402
+ it("/start, /help, /commands, /status-pending all hard-break their lines", () => {
403
+ assertNoSoftJoin(startText("assistant", false));
404
+ assertNoSoftJoin(helpText("assistant"));
405
+ assertNoSoftJoin(switchroomHelpText("assistant"));
406
+ assertNoSoftJoin(statusPendingText("abc-123"));
407
+ });
408
+ });
409
+
346
410
  // Local alias for the audit shape — duplicates the AgentMetadata.audit
347
411
  // type so the test file doesn't have to re-import it just for one
348
412
  // hostile-input fixture.
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { maskUsername } from "./demo-mask.js";
16
- import { escapeMarkdown } from "./card-format.js";
16
+ import { escapeMarkdown, stackCardLines } from "./card-format.js";
17
17
 
18
18
  export type AuthSummary = {
19
19
  authenticated: boolean;
@@ -148,7 +148,7 @@ export function formatAgentLine(meta: AgentMetadata): string {
148
148
  */
149
149
  export function startText(agentName: string, dmDisabled: boolean): string {
150
150
  if (dmDisabled) return "This bot isn't accepting new connections.";
151
- return [
151
+ return stackCardLines([
152
152
  `**Switchroom** — Telegram on your Claude Pro or Max subscription.`,
153
153
  ``,
154
154
  `This bot is the **${escapeHtml(agentName)}** agent. Pair first, then send messages here and they reach the agent; replies and reactions come back.`,
@@ -158,7 +158,7 @@ export function startText(agentName: string, dmDisabled: boolean): string {
158
158
  `2. In Claude Code: \`/telegram:access pair <code>\``,
159
159
  ``,
160
160
  `After pairing, try \`/status\` or \`/commands\`.`,
161
- ].join("\n");
161
+ ]);
162
162
  }
163
163
 
164
164
  /**
@@ -166,7 +166,7 @@ export function startText(agentName: string, dmDisabled: boolean): string {
166
166
  * Deliberately short because Telegram truncates /help popovers.
167
167
  */
168
168
  export function helpText(agentName: string): string {
169
- return [
169
+ return stackCardLines([
170
170
  `**Switchroom** — your Pro/Max subscription, wired to Telegram.`,
171
171
  ``,
172
172
  `This bot is the **${escapeHtml(agentName)}** agent. Text and photos route through to it; replies, reactions and progress cards come back.`,
@@ -177,7 +177,7 @@ export function helpText(agentName: string): string {
177
177
  `\`/status\` — agent, model, auth`,
178
178
  `\`/vault audit <agent>\` — admin: review agent's vault access + one-tap [🔓 Allow] on recent denials`,
179
179
  `\`/commands\` — full command list`,
180
- ].join("\n");
180
+ ]);
181
181
  }
182
182
 
183
183
  /**
@@ -246,14 +246,18 @@ export function statusPairedText(params: {
246
246
  if (audit.memoryBank) lines.push(`**Memory** ${escapeHtml(audit.memoryBank)}`);
247
247
  }
248
248
 
249
- return lines.join("\n");
249
+ return stackCardLines(lines);
250
250
  }
251
251
 
252
252
  /**
253
253
  * `/status` when the sender isn't paired yet but has a pending code.
254
254
  */
255
255
  export function statusPendingText(code: string): string {
256
- return `Pending pairing — run in Claude Code:\n\n\`/telegram:access pair ${code}\``;
256
+ return stackCardLines([
257
+ `Pending pairing — run in Claude Code:`,
258
+ ``,
259
+ `\`/telegram:access pair ${code}\``,
260
+ ]);
257
261
  }
258
262
 
259
263
  /**
@@ -365,7 +369,7 @@ export const TELEGRAM_BASE_COMMANDS = TELEGRAM_MENU_COMMANDS.slice(0, 3);
365
369
  export const TELEGRAM_SWITCHROOM_COMMANDS = TELEGRAM_MENU_COMMANDS.slice(3);
366
370
 
367
371
  export function switchroomHelpText(agentName: string): string {
368
- return [
372
+ return stackCardLines([
369
373
  `**Switchroom bot** — commands for the **${escapeHtml(agentName)}** agent.`,
370
374
  ``,
371
375
  `**Session & approvals**`,
@@ -415,7 +419,7 @@ export function switchroomHelpText(agentName: string): string {
415
419
  `\`/commands\` — this help`,
416
420
  ``,
417
421
  `_Tip: \`/update\` shows the plan; \`/update apply\` executes it; \`/restart\` bounces a stuck agent; \`/version\` checks what's running._`,
418
- ].join("\n");
422
+ ]);
419
423
  }
420
424
 
421
425
  /**
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Changed (switchroom divergence)
6
+
7
+ - **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
8
+ throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
9
+ applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
10
+ sets this in `scaffold.ts` for every-turn crash durability) chunked mode fell
11
+ through to full-session and re-consolidated the entire accumulated transcript
12
+ on every Stop fire. Window selection is now extracted into a pure
13
+ `select_retain_window()` helper and slices a window of
14
+ `max(retainEveryNTurns, 1) + retainOverlapTurns` turns whenever
15
+ `retainMode == "chunked"`, independent of the throttle. The throttle-skip
16
+ logic (`retain_every_n > 1` firing cadence) is unchanged, so `> 1` behaviour
17
+ and the full-session default are equivalent. This is a deliberate switchroom
18
+ divergence from pristine vendor and is a **candidate to upstream to
19
+ vectorize-io/hindsight** — decoupling *what* to retain from *whether* to fire
20
+ this turn is a general improvement, not switchroom-specific.
21
+
22
+ - **content.py: `slice_last_turns_by_user_boundary()` counts genuine HUMAN
23
+ turns only** (switchroom Phase 6b, adversarial-review fix). Claude Code emits
24
+ tool results as `role="user"` messages whose content is a list of
25
+ `tool_result` blocks. The boundary counter treated every `role="user"`
26
+ message as a turn, so on a tool-heavy turn (≥N sequential tool rounds) a
27
+ fixed-size retain window filled with `tool_result` messages and pushed the
28
+ actual human message OUTSIDE the window — silently dropping the fact from
29
+ that fire and every later fire (whose window starts even further away), so it
30
+ was never retained; on restart the fact was gone. A message whose content is
31
+ entirely `tool_result` blocks is now skipped as a boundary
32
+ (`_is_tool_result_only_user_message`), so "window = N turns" means N *human*
33
+ turns regardless of tool volume. Affects both the retain window-slice and the
34
+ recall context-slice (both want N human turns). **Candidate to upstream** —
35
+ the same silent-loss bug exists in vendor's own `retainEveryNTurns > 1`
36
+ chunked path. NOTE: switchroom never ran chunked before Phase 6b, so this
37
+ changes no previously-exercised switchroom behaviour.
38
+
39
+ - **retain.py: SessionEnd `force=True` widens chunked mode to a full-session
40
+ sweep** (switchroom Phase 6b, belt-and-braces). Per-turn fires still slice
41
+ the window; the single forced retain at SessionEnd
42
+ (`session_end.py` → `run_retain(force=True)`) now retains the whole session
43
+ in chunked mode, guaranteeing a graceful shutdown always flushes everything
44
+ even if per-turn windowing had an edge. Costs one full sweep per session (at
45
+ end), not per turn.
46
+
5
47
  ### Ported from upstream (vectorize-io/hindsight, `hindsight-integrations/claude-code/`)
6
48
 
7
49
  - `c5a61db2b` — raise `_check_health` default timeout 2s→10s in
@@ -167,13 +167,48 @@ def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
167
167
  # ---------------------------------------------------------------------------
168
168
 
169
169
 
170
+ def _is_tool_result_only_user_message(message: dict) -> bool:
171
+ """True when a ``role="user"`` message carries ONLY tool_result blocks.
172
+
173
+ SWITCHROOM DIVERGENCE (candidate to upstream to vectorize-io/hindsight):
174
+ Claude Code emits tool results as ``role="user"`` messages whose content
175
+ is a list of ``{"type": "tool_result", ...}`` blocks — they are NOT
176
+ human turns. A genuine human turn has text (a string, or a content list
177
+ with at least one non-tool_result block, e.g. ``{"type": "text"}`` or an
178
+ image). Treating tool_result messages as turn boundaries lets a tool-heavy
179
+ turn (≥N sequential tool rounds) fill a fixed-size retain window with
180
+ tool_result messages and push the actual human message OUTSIDE the window
181
+ — silently dropping the fact from that fire, and from every later fire
182
+ (whose window starts even further from the human message). On restart the
183
+ fact is gone. This helper lets the boundary counter skip those messages so
184
+ "window = N turns" means N *human* turns regardless of tool volume.
185
+ """
186
+ if message.get("role") != "user":
187
+ return False
188
+ content = message.get("content")
189
+ if isinstance(content, list):
190
+ blocks = [b for b in content if isinstance(b, dict)]
191
+ # A non-empty content list that is ENTIRELY tool_result blocks.
192
+ if blocks and all(b.get("type") == "tool_result" for b in blocks):
193
+ return True
194
+ return False
195
+
196
+
170
197
  def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
171
198
  """Slice messages to the last N turns, where a turn starts at a user message.
172
199
 
173
200
  Port of: sliceLastTurnsByUserBoundary() in index.js
174
201
 
175
- Walks backward counting user messages as turn boundaries. Returns
176
- messages from the Nth user boundary to the end.
202
+ Walks backward counting GENUINE HUMAN user messages as turn boundaries.
203
+ Returns messages from the Nth human boundary to the end.
204
+
205
+ SWITCHROOM DIVERGENCE (candidate to upstream): tool_result messages carry
206
+ ``role="user"`` in the Claude Code transcript but are not human turns; they
207
+ are skipped as boundaries (see ``_is_tool_result_only_user_message``). This
208
+ keeps the fixed-size retain window anchored to human turns so a tool-heavy
209
+ turn can never push the human's fact outside the window (silent memory loss).
210
+ Affects both the retain window-slice and the recall context slice — both
211
+ want "N human turns", not "N transcript user-messages".
177
212
  """
178
213
  if not isinstance(messages, list) or not messages or turns <= 0:
179
214
  return []
@@ -182,7 +217,8 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
182
217
  start_index = -1
183
218
 
184
219
  for i in range(len(messages) - 1, -1, -1):
185
- if messages[i].get("role") == "user":
220
+ msg = messages[i]
221
+ if msg.get("role") == "user" and not _is_tool_result_only_user_message(msg):
186
222
  user_turns_seen += 1
187
223
  if user_turns_seen >= turns:
188
224
  start_index = i
@@ -69,6 +69,60 @@ def read_transcript(transcript_path: str) -> list:
69
69
  return messages
70
70
 
71
71
 
72
+ def select_retain_window(
73
+ retain_mode: str,
74
+ retain_every_n: int,
75
+ overlap_turns: int,
76
+ all_messages: list,
77
+ force: bool = False,
78
+ ) -> tuple:
79
+ """Decide which messages to retain and whether to send as a full window.
80
+
81
+ Returns ``(messages_to_retain, retain_full_window)``.
82
+
83
+ SWITCHROOM DIVERGENCE (Phase 6b — candidate to upstream to
84
+ vectorize-io/hindsight): the chunked sliding-window is decoupled from
85
+ the ``retainEveryNTurns > 1`` throttle. Upstream only sliced a window
86
+ when ``retain_every_n > 1``; with ``retainEveryNTurns=1`` (switchroom's
87
+ every-turn crash-durability setting, applied in scaffold.ts) chunked
88
+ mode fell through to full-session and re-consolidated the ENTIRE
89
+ accumulated transcript on every Stop fire — an unbounded, per-turn cost.
90
+
91
+ Decoupling is safe because window selection and the throttle answer two
92
+ independent questions: the throttle decides *whether* to fire this turn
93
+ (still owned by run_retain, unchanged); this function only decides *what*
94
+ to retain once a fire happens. A chunked window of
95
+ ``max(retain_every_n, 1) + overlap_turns`` turns is correct for any
96
+ ``retain_every_n >= 1``. With ``retain_every_n=1, overlap=2`` the window
97
+ is the 3 most-recent HUMAN turns (tool_result messages don't count as
98
+ turns — see slice_last_turns_by_user_boundary).
99
+
100
+ ``force=True`` (SessionEnd final retain) widens chunked mode to a
101
+ full-session sweep — belt-and-braces so a graceful shutdown always flushes
102
+ the whole session even if per-turn windowing had an edge. This costs a
103
+ full sweep only ONCE per session (at end), not per turn.
104
+
105
+ Durability invariant (jtbd-memory-survives-restart UAT): the window
106
+ always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
107
+ returns ``messages[start:]``), so the turn that just completed — the one
108
+ whose Stop hook is firing — is ALWAYS included. Every turn fires (no
109
+ throttle at n=1), so every turn's content is retained on its own fire.
110
+ Boundaries are counted on human messages only, so a tool-heavy turn can't
111
+ push the human's fact outside the window. No fact can fall outside every
112
+ window.
113
+ """
114
+ if retain_mode == "chunked" and not force:
115
+ # Sliding window: N turns + configured overlap. max(retain_every_n, 1)
116
+ # keeps the window valid at n=1 (the decoupling); for n>1 this equals
117
+ # the previous `retain_every_n + overlap_turns` (behaviour unchanged).
118
+ window_turns = max(retain_every_n, 1) + overlap_turns
119
+ messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
120
+ return messages_to_retain, True
121
+ # Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
122
+ # sweep. Retain all messages, always as a full window.
123
+ return list(all_messages), True
124
+
125
+
72
126
  def run_retain(hook_input: dict, force: bool = False) -> dict:
73
127
  """Run the auto-retain flow.
74
128
 
@@ -104,7 +158,8 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
104
158
 
105
159
  debug_log(config, f"Read {len(all_messages)} messages from transcript")
106
160
 
107
- # Retention mode: full session (default) or chunked (legacy)
161
+ # Retention mode: full session (vendor default) or chunked. Switchroom
162
+ # runs chunked at retainEveryNTurns=1 (see select_retain_window / scaffold.ts).
108
163
  retain_mode = config.get("retainMode", "full-session")
109
164
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
110
165
  retain_full_window = False
@@ -118,19 +173,25 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
118
173
  debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
119
174
  return {"status": "skipped", "reason": "throttled"}
120
175
 
121
- if retain_mode == "chunked" and retain_every_n > 1:
122
- # Sliding window: N turns + configured overlap
123
- overlap_turns = config.get("retainOverlapTurns", 0)
124
- window_turns = retain_every_n + overlap_turns
125
- messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
126
- retain_full_window = True
176
+ # Window selection is decoupled from the throttle above — see
177
+ # select_retain_window() for the switchroom-divergence rationale
178
+ # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
179
+ overlap_turns = config.get("retainOverlapTurns", 0)
180
+ messages_to_retain, retain_full_window = select_retain_window(
181
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force
182
+ )
183
+ if retain_mode == "chunked" and not force:
184
+ window_turns = max(retain_every_n, 1) + overlap_turns
185
+ debug_log(
186
+ config,
187
+ f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
188
+ )
189
+ elif retain_mode == "chunked" and force:
127
190
  debug_log(
128
191
  config,
129
- f"Chunked retain firing (window: {window_turns} turns, {len(messages_to_retain)} messages)",
192
+ f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
130
193
  )
131
194
  else:
132
- # Full session mode: retain all messages, always as full window
133
- retain_full_window = True
134
195
  debug_log(config, f"Full session retain: {len(all_messages)} messages")
135
196
 
136
197
  # Format transcript