switchroom 0.17.0 → 0.17.2
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.
- package/dist/cli/switchroom.js +12 -5
- package/dist/host-control/main.js +371 -18
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +265 -338
- package/telegram-plugin/format.ts +119 -17
- package/telegram-plugin/gateway/approvals-commands.ts +6 -2
- package/telegram-plugin/gateway/gateway.ts +17 -3
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
- package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
- package/telegram-plugin/tests/format-consistency.test.ts +79 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
- package/telegram-plugin/tests/welcome-text.test.ts +64 -0
- package/telegram-plugin/welcome-text.ts +13 -9
- package/vendor/hindsight-memory/CHANGELOG.md +42 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +39 -3
- package/vendor/hindsight-memory/scripts/retain.py +71 -10
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
- package/vendor/hindsight-memory/tests/test_content.py +105 -0
|
@@ -132,8 +132,18 @@ export function repairEscapedWhitespace(text: string): string {
|
|
|
132
132
|
interface MaskedCode {
|
|
133
133
|
masked: string
|
|
134
134
|
restore: (s: string) => string
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* The placeholder prefix injected for FENCED-BLOCK masks only. A masked
|
|
137
|
+
* fenced block occupies a whole line, so `isFenceOpenLine` / `isMarkerLine`
|
|
138
|
+
* treat a line that STARTS with this prefix as a block construct. INLINE
|
|
139
|
+
* code spans get a DISTINCT prefix (see maskCodeRegions) that deliberately
|
|
140
|
+
* does NOT start with this one — so a line that merely opens with an inline
|
|
141
|
+
* span (e.g. `\`key\` = \`value\``) reads as ordinary prose and still gets
|
|
142
|
+
* its line break hardened.
|
|
143
|
+
*/
|
|
136
144
|
placeholder: string
|
|
145
|
+
/** Remove EVERY mask (fenced + inline) — used to measure visible length. */
|
|
146
|
+
stripPlaceholders: (s: string) => string
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/**
|
|
@@ -144,31 +154,40 @@ interface MaskedCode {
|
|
|
144
154
|
* Fenced blocks are extracted FIRST and only when CLOSED (matching ```), so an
|
|
145
155
|
* unclosed fence is left intact rather than misparsed by the inline pass. Inline
|
|
146
156
|
* spans use `[^\`\n]+` — the same definition the chunker treats as code.
|
|
157
|
+
*
|
|
158
|
+
* Fenced and inline masks carry DISTINCT prefixes (`\x00RMF…` vs `\x00RMI…`).
|
|
159
|
+
* This matters because the fenced prefix is what the block-structure predicates
|
|
160
|
+
* (`isFenceOpenLine`, `isMarkerLine`) use to recognise a standalone masked code
|
|
161
|
+
* block. Sharing one prefix (the pre-fix bug) made a line that merely STARTS
|
|
162
|
+
* with an inline code span look like a fenced block, so its lone `\n` was never
|
|
163
|
+
* hardened and the card collapsed into one run-on line (real victim:
|
|
164
|
+
* `/vault get` rendering `\`key\` = \`value\``).
|
|
147
165
|
*/
|
|
148
166
|
function maskCodeRegions(text: string, nonce: string): MaskedCode {
|
|
149
|
-
const
|
|
167
|
+
const FENCE_MASK_PH = `\x00RMF${nonce}_`
|
|
168
|
+
const INLINE_MASK_PH = `\x00RMI${nonce}_`
|
|
150
169
|
const codeMasks: string[] = []
|
|
151
170
|
|
|
152
171
|
const masked = text
|
|
153
172
|
.replace(/```[\s\S]*?```/g, (m) => {
|
|
154
173
|
const idx = codeMasks.length
|
|
155
174
|
codeMasks.push(m)
|
|
156
|
-
return `${
|
|
175
|
+
return `${FENCE_MASK_PH}${idx}\x00`
|
|
157
176
|
})
|
|
158
177
|
.replace(/`[^`\n]+`/g, (m) => {
|
|
159
178
|
const idx = codeMasks.length
|
|
160
179
|
codeMasks.push(m)
|
|
161
|
-
return `${
|
|
180
|
+
return `${INLINE_MASK_PH}${idx}\x00`
|
|
162
181
|
})
|
|
163
182
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
183
|
+
// Restore / strip match EITHER prefix, keyed on the shared index space.
|
|
184
|
+
const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
185
|
+
const anyMaskRe = new RegExp(`\x00RM[FI]${escNonce}_(\\d+)\x00`, 'g')
|
|
168
186
|
const restore = (s: string): string =>
|
|
169
|
-
s.replace(
|
|
187
|
+
s.replace(anyMaskRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
|
|
188
|
+
const stripPlaceholders = (s: string): string => s.replace(anyMaskRe, '')
|
|
170
189
|
|
|
171
|
-
return { masked, restore, placeholder:
|
|
190
|
+
return { masked, restore, placeholder: FENCE_MASK_PH, stripPlaceholders }
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
// ---------------------------------------------------------------------------
|
|
@@ -344,6 +363,92 @@ export function normalizeParagraphBreaks(text: string): string {
|
|
|
344
363
|
return restore(out)
|
|
345
364
|
}
|
|
346
365
|
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Card line-break hardener — for DETERMINISTIC command/card bodies
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Harden the lone `\n` line breaks of a DETERMINISTIC card body into GFM hard
|
|
372
|
+
* breaks (` \n`, two trailing spaces) so every field lands on its own line
|
|
373
|
+
* under Telegram's Bot API 10.1 rich-message (GFM) renderer.
|
|
374
|
+
*
|
|
375
|
+
* Why this exists (the run-on-blob bug): the rich path (#2669) renders a lone
|
|
376
|
+
* `\n` between two non-blank lines as a *soft* break — the two lines collapse
|
|
377
|
+
* onto the same visual line with a space between them. Agent PROSE is repaired
|
|
378
|
+
* on the reply path by `normalizeParagraphBreaks`, but the ~98 slash-command
|
|
379
|
+
* card replies dispatched through `switchroomReply(…, { html: true })` are sent
|
|
380
|
+
* as RAW markdown with no normalization. Their builders stack short labelled
|
|
381
|
+
* fields (`**5h window** …`, `**Model** …`, `Auth: ✓ Max …`) joined by a single
|
|
382
|
+
* `\n`, so the whole card renders as one run-on blob.
|
|
383
|
+
*
|
|
384
|
+
* A deterministic card is NOT free prose — every newline its builder emits is
|
|
385
|
+
* an INTENDED line break. So this hardener promotes UNCONDITIONALLY (no
|
|
386
|
+
* sentence-terminal-punctuation gate, unlike `normalizeParagraphBreaks`) with
|
|
387
|
+
* one exception: a line that participates in a genuine GFM block construct
|
|
388
|
+
* (list / table / blockquote / heading / fenced code) keeps its single `\n` so
|
|
389
|
+
* its native stacking / contiguity survives — a monospace table inside a ```
|
|
390
|
+
* fence is never touched (it is code-masked AND the fence lines are excluded).
|
|
391
|
+
* Real `\n\n` paragraph gaps (a builder's block separators) are preserved.
|
|
392
|
+
*
|
|
393
|
+
* This is the string-level sibling of `stackCardLines` (card-format.ts), which
|
|
394
|
+
* does the same promotion from a pre-split `string[]` of guaranteed
|
|
395
|
+
* single-line, non-block entries. Use `hardenCardBreaks` where the card body is
|
|
396
|
+
* already an assembled string (e.g. the `switchroomReply` chokepoint) and may
|
|
397
|
+
* legitimately contain GFM block constructs.
|
|
398
|
+
*
|
|
399
|
+
* Runs on code-masked text and is idempotent — a break already hardened to
|
|
400
|
+
* ` \n` re-hardens to the same ` \n`.
|
|
401
|
+
*/
|
|
402
|
+
export function hardenCardBreaks(text: string): string {
|
|
403
|
+
if (!text.includes('\n')) return text
|
|
404
|
+
|
|
405
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
406
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
407
|
+
|
|
408
|
+
// Collapse 3+ newline runs to a single clean `\n\n` gap (mirrors
|
|
409
|
+
// normalizeParagraphBreaks step 1) so a stray extra blank line never becomes
|
|
410
|
+
// an oversized gap. A genuine one-blank-line `\n\n` block gap is preserved.
|
|
411
|
+
const out = masked.replace(/\n{3,}/g, '\n\n')
|
|
412
|
+
|
|
413
|
+
// A line participating in a GFM block construct whose single-`\n` contiguity
|
|
414
|
+
// must survive (its interior must NOT get a hard break).
|
|
415
|
+
const isBlockConstructLine = (line: string): boolean =>
|
|
416
|
+
isListItemLine(line) ||
|
|
417
|
+
isTableRowLine(line) ||
|
|
418
|
+
isTableDelimiterLine(line) ||
|
|
419
|
+
isBlockquoteLine(line) ||
|
|
420
|
+
isHeadingLine(line) ||
|
|
421
|
+
isFenceOpenLine(line, placeholder)
|
|
422
|
+
|
|
423
|
+
const lines = out.split('\n')
|
|
424
|
+
const pieces: string[] = []
|
|
425
|
+
for (let i = 0; i < lines.length; i++) {
|
|
426
|
+
let line = lines[i]
|
|
427
|
+
const isLast = i === lines.length - 1
|
|
428
|
+
const next = isLast ? '' : lines[i + 1]
|
|
429
|
+
// Promote only between two non-blank content lines where NEITHER is a GFM
|
|
430
|
+
// block-construct line (so lists / tables / quotes / headings / fences keep
|
|
431
|
+
// their native single-`\n` stacking). A blank current/next line is a `\n\n`
|
|
432
|
+
// paragraph gap — never promote across it.
|
|
433
|
+
const promote =
|
|
434
|
+
!isLast &&
|
|
435
|
+
line.trim() !== '' &&
|
|
436
|
+
next.trim() !== '' &&
|
|
437
|
+
!isBlockConstructLine(line) &&
|
|
438
|
+
!isBlockConstructLine(next)
|
|
439
|
+
if (promote) {
|
|
440
|
+
// Strip trailing whitespace so a re-run emits exactly one ` \n` (never
|
|
441
|
+
// accumulate spaces). Include `\r` for CRLF sources.
|
|
442
|
+
line = line.replace(/[ \t\r]+$/, '')
|
|
443
|
+
}
|
|
444
|
+
pieces.push(line)
|
|
445
|
+
if (isLast) break
|
|
446
|
+
pieces.push(promote ? ' \n' : '\n')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return restore(pieces.join(''))
|
|
450
|
+
}
|
|
451
|
+
|
|
347
452
|
// ---------------------------------------------------------------------------
|
|
348
453
|
// Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
|
|
349
454
|
// ---------------------------------------------------------------------------
|
|
@@ -623,14 +728,11 @@ export function stripExcessBold(text: string): string {
|
|
|
623
728
|
if (!text.includes('**')) return text
|
|
624
729
|
|
|
625
730
|
const nonce = Math.random().toString(36).slice(2)
|
|
626
|
-
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
731
|
+
const { masked, restore, placeholder, stripPlaceholders } = maskCodeRegions(text, nonce)
|
|
627
732
|
|
|
628
|
-
// Non-code character budget: masked text with
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
'g',
|
|
632
|
-
)
|
|
633
|
-
const visible = masked.replace(placeholderRe, '')
|
|
733
|
+
// Non-code character budget: masked text with BOTH fenced + inline masks
|
|
734
|
+
// removed (stripPlaceholders handles the two distinct prefixes).
|
|
735
|
+
const visible = stripPlaceholders(masked)
|
|
634
736
|
if (visible.length < 100) return restore(masked)
|
|
635
737
|
|
|
636
738
|
let boldChars = 0
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
]
|
|
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
|
|
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
|
|
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
|
-
]
|
|
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
|