switchroom 0.16.23 → 0.16.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { basename } from "node:path";
|
|
21
|
+
import { escapeMarkdown } from "./card-format.js";
|
|
21
22
|
import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
|
|
22
23
|
import { redact } from "./secret-detect/redact.js";
|
|
23
24
|
|
|
@@ -62,6 +63,9 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
|
62
63
|
"mcp__hostd__agent_exec": "Run a read-only inspection inside another agent",
|
|
63
64
|
"mcp__hostd__update_check": "Check what a fleet-wide update would do",
|
|
64
65
|
"mcp__hostd__update_apply": "Apply a fleet-wide update (pull + recreate)",
|
|
66
|
+
"mcp__hostd__rollout": "Roll the fleet to a pinned version",
|
|
67
|
+
"mcp__hostd__config_propose_edit": "Propose an edit to switchroom.yaml",
|
|
68
|
+
"mcp__hostd__get_status": "Read the last fleet-update status",
|
|
65
69
|
// hindsight — memory
|
|
66
70
|
"mcp__hindsight__recall": "Recall relevant memories",
|
|
67
71
|
"mcp__hindsight__retain": "Retain a memory",
|
|
@@ -96,10 +100,10 @@ const HOSTD_AGENT_TARGET_VERBS = new Set([
|
|
|
96
100
|
/**
|
|
97
101
|
* Build the multi-line card body for an approval prompt.
|
|
98
102
|
*
|
|
99
|
-
* 🔐
|
|
100
|
-
* why:
|
|
103
|
+
* 🔐 **Gymbro** wants to edit: supplement-log.md
|
|
104
|
+
* why: _logging today's lifts_
|
|
101
105
|
*
|
|
102
|
-
* Output is
|
|
106
|
+
* Output is GFM markdown for the rich-message path (#2669). The agent name is
|
|
103
107
|
* capitalized for the sentence; dropped (with "wants to") when null —
|
|
104
108
|
* the bridge client can be anonymous during early-boot edge cases.
|
|
105
109
|
*
|
|
@@ -127,10 +131,10 @@ export function formatPermissionCardBody(opts: {
|
|
|
127
131
|
|
|
128
132
|
if (opts.agentName && opts.agentName.length > 0) {
|
|
129
133
|
lines.push(
|
|
130
|
-
`🔐
|
|
134
|
+
`🔐 **${escapeTgHtml(capFirst(opts.agentName))}** wants to ${escapeActionMarkdown(action)}`,
|
|
131
135
|
);
|
|
132
136
|
} else {
|
|
133
|
-
lines.push(`🔐 ${
|
|
137
|
+
lines.push(`🔐 ${escapeActionMarkdown(capFirst(action))}`);
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
// why: the caller-supplied rationale (`reason`/`why` arg), never the
|
|
@@ -143,8 +147,8 @@ export function formatPermissionCardBody(opts: {
|
|
|
143
147
|
: rawWhy;
|
|
144
148
|
lines.push(
|
|
145
149
|
truncatedWhy.length > 0
|
|
146
|
-
? `why:
|
|
147
|
-
: `why:
|
|
150
|
+
? `why: _${escapeTgHtml(truncatedWhy)}_`
|
|
151
|
+
: `why: _not provided_`,
|
|
148
152
|
);
|
|
149
153
|
|
|
150
154
|
// Third line (REST-wrapper MCP writes only): a redaction-safe summary of
|
|
@@ -152,7 +156,7 @@ export function formatPermissionCardBody(opts: {
|
|
|
152
156
|
// endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
|
|
153
157
|
const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
|
|
154
158
|
if (argSummary) {
|
|
155
|
-
lines.push(`↳
|
|
159
|
+
lines.push(`↳ _${escapeTgHtml(argSummary)}_`);
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
return lines.join("\n");
|
|
@@ -424,10 +428,10 @@ export function describeGrant(
|
|
|
424
428
|
* the operator scrolls past — so this is the legible signal that the tap
|
|
425
429
|
* landed and names the work being (re)started.
|
|
426
430
|
*
|
|
427
|
-
* Mirrors `formatPermissionCardBody`'s style ("🔐
|
|
428
|
-
* edit: log.md" → "▶️
|
|
431
|
+
* Mirrors `formatPermissionCardBody`'s style ("🔐 **Gymbro** wants to
|
|
432
|
+
* edit: log.md" → "▶️ **Gymbro** — got it, continuing: edit: log.md").
|
|
429
433
|
* `action` is a phrase from {@link naturalAction} (already operator-facing,
|
|
430
|
-
* no tool ids). Output is
|
|
434
|
+
* no tool ids). Output is GFM markdown for the rich-message path (#2669).
|
|
431
435
|
*
|
|
432
436
|
* `timeoutMinutes` marks the TTL auto-deny variant (no operator tapped —
|
|
433
437
|
* the request aged out) so the wording reflects "no answer" rather than a
|
|
@@ -441,25 +445,25 @@ export function formatPermissionResumeMessage(opts: {
|
|
|
441
445
|
}): string {
|
|
442
446
|
const who =
|
|
443
447
|
opts.agentName && opts.agentName.length > 0
|
|
444
|
-
?
|
|
445
|
-
:
|
|
448
|
+
? `**${escapeTgHtml(capFirst(opts.agentName))}**`
|
|
449
|
+
: `**Agent**`;
|
|
446
450
|
const act = (opts.action ?? "").trim();
|
|
447
451
|
const hasAction = act.length > 0;
|
|
448
452
|
|
|
449
453
|
if (opts.behavior === "allow") {
|
|
450
454
|
return hasAction
|
|
451
|
-
? `▶️ ${who} — got it, continuing:
|
|
455
|
+
? `▶️ ${who} — got it, continuing: _${escapeActionMarkdown(act)}_`
|
|
452
456
|
: `▶️ ${who} — got it, back to work.`;
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
// deny
|
|
456
460
|
if (opts.timeoutMinutes != null) {
|
|
457
461
|
return hasAction
|
|
458
|
-
? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (
|
|
462
|
+
? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (_${escapeActionMarkdown(act)}_).`
|
|
459
463
|
: `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it.`;
|
|
460
464
|
}
|
|
461
465
|
return hasAction
|
|
462
|
-
? `🚫 ${who} — noted, I won't ${
|
|
466
|
+
? `🚫 ${who} — noted, I won't ${escapeActionMarkdown(lowerFirst(act))}. Continuing without it.`
|
|
463
467
|
: `🚫 ${who} — noted, continuing without it.`;
|
|
464
468
|
}
|
|
465
469
|
|
|
@@ -520,12 +524,27 @@ function capFirst(text: string): string {
|
|
|
520
524
|
return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
|
521
525
|
}
|
|
522
526
|
|
|
523
|
-
/**
|
|
527
|
+
/** Markdown escape for the rich-message path (#2669). */
|
|
524
528
|
function escapeTgHtml(text: string): string {
|
|
529
|
+
return escapeMarkdown(text);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Escape a phrase that may itself contain DELIBERATE `` `code spans` ``
|
|
534
|
+
* (e.g. the hostd fleet-verb phrases name the target agent in a code span:
|
|
535
|
+
* "restart agent `carrie` in the fleet"). The structural backticks must NOT
|
|
536
|
+
* be escaped — that would render literal `` \`carrie\` `` to the operator
|
|
537
|
+
* (#2669 regression). Everything OUTSIDE a backtick span is still escaped so
|
|
538
|
+
* a hostile Bash command / filename can't inject emphasis. Content INSIDE a
|
|
539
|
+
* span is left verbatim — code spans render literally, so there is no
|
|
540
|
+
* injection risk there.
|
|
541
|
+
*/
|
|
542
|
+
function escapeActionMarkdown(text: string): string {
|
|
543
|
+
// Split on balanced single-backtick spans, keeping the delimiters.
|
|
525
544
|
return text
|
|
526
|
-
.
|
|
527
|
-
.
|
|
528
|
-
.
|
|
545
|
+
.split(/(`[^`]*`)/g)
|
|
546
|
+
.map((seg) => (seg.startsWith("`") && seg.endsWith("`") && seg.length >= 2 ? seg : escapeMarkdown(seg)))
|
|
547
|
+
.join("");
|
|
529
548
|
}
|
|
530
549
|
|
|
531
550
|
function parseInput(raw: string | undefined): Record<string, unknown> | null {
|
|
@@ -48,13 +48,6 @@ export interface PtyHandlerState {
|
|
|
48
48
|
pendingPtyPartial: { text: string } | null
|
|
49
49
|
/** Active streams, keyed by `chat_id:thread_id`. */
|
|
50
50
|
activeDraftStreams: Map<string, DraftStreamHandle>
|
|
51
|
-
/**
|
|
52
|
-
* Parallel to activeDraftStreams: parseMode baked into each controller.
|
|
53
|
-
* Lets `handleStreamReply` detect and rotate streams whose parseMode no
|
|
54
|
-
* longer matches the caller's resolved format (bug 1). Optional for
|
|
55
|
-
* backwards compatibility.
|
|
56
|
-
*/
|
|
57
|
-
activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
|
|
58
51
|
/**
|
|
59
52
|
* Chats whose PTY preview is claimed by an in-flight reply handler.
|
|
60
53
|
* Partials for these chats are dropped to avoid duplicates.
|
|
@@ -67,8 +60,6 @@ export interface PtyHandlerState {
|
|
|
67
60
|
export interface PtyHandlerDeps {
|
|
68
61
|
bot: { api: StreamBotApi }
|
|
69
62
|
retry?: RetryPolicy
|
|
70
|
-
/** Markdown → HTML renderer applied to the text before stream.update. */
|
|
71
|
-
renderText: (text: string) => string
|
|
72
63
|
/** Optional structured event hook, called once per invocation. */
|
|
73
64
|
logEvent?: (ev: {
|
|
74
65
|
kind: 'pty_partial_received'
|
|
@@ -154,7 +145,10 @@ export function handlePtyPartialPure(
|
|
|
154
145
|
bot: deps.bot,
|
|
155
146
|
chatId,
|
|
156
147
|
threadId,
|
|
157
|
-
|
|
148
|
+
// PTY-tail previews are raw terminal/TUI output, not authored
|
|
149
|
+
// markdown — send them literally so control glyphs and box-drawing
|
|
150
|
+
// characters aren't misinterpreted as markdown syntax (#2669).
|
|
151
|
+
literalText: true,
|
|
158
152
|
disableLinkPreview: true,
|
|
159
153
|
throttleMs: 600,
|
|
160
154
|
retry: deps.retry,
|
|
@@ -177,11 +171,9 @@ export function handlePtyPartialPure(
|
|
|
177
171
|
: {}),
|
|
178
172
|
})
|
|
179
173
|
state.activeDraftStreams.set(sKey, stream)
|
|
180
|
-
state.activeDraftParseModes?.set(sKey, 'HTML')
|
|
181
174
|
}
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
void stream.update(rendered).catch(() => { /* swallow — logged elsewhere */ })
|
|
176
|
+
void stream.update(text).catch(() => { /* swallow — logged elsewhere */ })
|
|
185
177
|
|
|
186
178
|
return created ? 'update-new' : 'update-existing'
|
|
187
179
|
}
|
|
@@ -250,21 +250,21 @@ export function formatResetRelative(target: Date | null, now: Date = new Date())
|
|
|
250
250
|
*/
|
|
251
251
|
export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): string {
|
|
252
252
|
const lines: string[] = [];
|
|
253
|
-
lines.push("
|
|
253
|
+
lines.push("**Claude plan quota**");
|
|
254
254
|
lines.push("");
|
|
255
255
|
lines.push(
|
|
256
|
-
|
|
256
|
+
`**5h window** ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
|
|
257
257
|
);
|
|
258
258
|
lines.push(
|
|
259
|
-
|
|
259
|
+
`**7d window** ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
|
|
260
260
|
);
|
|
261
261
|
if (q.representativeClaim) {
|
|
262
262
|
lines.push("");
|
|
263
|
-
lines.push(
|
|
263
|
+
lines.push(`_Binding window: ${q.representativeClaim.replace(/_/g, " ")}_`);
|
|
264
264
|
}
|
|
265
265
|
if (q.overageStatus && q.overageStatus !== "allowed") {
|
|
266
266
|
const reason = q.overageDisabledReason ? ` (${q.overageDisabledReason})` : "";
|
|
267
|
-
lines.push(
|
|
267
|
+
lines.push(`_Overage: ${q.overageStatus}${reason}_`);
|
|
268
268
|
}
|
|
269
269
|
return lines.join("\n");
|
|
270
270
|
}
|
|
@@ -498,12 +498,12 @@ function buildAllExhaustedMessage(
|
|
|
498
498
|
? `Earliest reset: ${formatRelative(new Date(earliest), new Date(now))}.`
|
|
499
499
|
: `Reset time unknown (no window data).`;
|
|
500
500
|
return [
|
|
501
|
-
`🔴
|
|
501
|
+
`🔴 **All accounts exhausted**`,
|
|
502
502
|
``,
|
|
503
503
|
`Every Anthropic account (${accounts.length}) is quota-walled — there is no healthy account to fail over to.`,
|
|
504
504
|
resetLine,
|
|
505
505
|
``,
|
|
506
|
-
|
|
506
|
+
`_This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with \`/auth add\` if this recurs._`,
|
|
507
507
|
].join("\n");
|
|
508
508
|
}
|
|
509
509
|
|
|
@@ -511,11 +511,11 @@ function buildFleetRecoveredMessage(
|
|
|
511
511
|
accounts: Array<{ label: string; exhausted: boolean }>,
|
|
512
512
|
): string {
|
|
513
513
|
const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
|
|
514
|
-
const which = healthy.length > 0 ? ` (
|
|
514
|
+
const which = healthy.length > 0 ? ` (\`${escapeHtml(healthy[0]!)}\`)` : "";
|
|
515
515
|
return [
|
|
516
|
-
`🟢
|
|
516
|
+
`🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
|
|
517
517
|
``,
|
|
518
|
-
|
|
518
|
+
`_Agents are back; any deferred scheduled jobs will run on their next occurrence._`,
|
|
519
519
|
].join("\n");
|
|
520
520
|
}
|
|
521
521
|
|
|
@@ -535,21 +535,21 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
|
|
|
535
535
|
|
|
536
536
|
const activeNote = snap.isActive
|
|
537
537
|
? ""
|
|
538
|
-
: `\nThis is a non-active account. Consider
|
|
538
|
+
: `\nThis is a non-active account. Consider \`/auth use ${escapeHtml(snap.label)}\` to switch, or keep it as a fallback reserve.`;
|
|
539
539
|
|
|
540
540
|
const altNote = snap.isActive
|
|
541
|
-
? `\nConsider
|
|
541
|
+
? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
|
|
542
542
|
: "";
|
|
543
543
|
|
|
544
544
|
return [
|
|
545
|
-
`🟡
|
|
545
|
+
`🟡 **Quota approaching limit** — \`${escapeHtml(snap.label)}\``,
|
|
546
546
|
``,
|
|
547
547
|
`${fiveStr} of 5h · ${sevenStr} of 7d`,
|
|
548
548
|
`Binding window: ${winLabel}${resetStr}`,
|
|
549
549
|
`${activeNote}${altNote}`,
|
|
550
550
|
``,
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
`_Threshold: ${THROTTLING_THRESHOLD_PCT}% on either window. Live-probe corroborated (#2495)._`,
|
|
552
|
+
`_Run /auth for full fleet status or /usage for the active account._`,
|
|
553
553
|
]
|
|
554
554
|
.join("\n")
|
|
555
555
|
.replace(/\n\n\n+/g, "\n\n")
|
|
@@ -563,21 +563,16 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
|
|
|
563
563
|
: "Current quota data unavailable.";
|
|
564
564
|
|
|
565
565
|
return [
|
|
566
|
-
`🟢
|
|
566
|
+
`🟢 **Quota back in healthy range** — \`${escapeHtml(snap.label)}\``,
|
|
567
567
|
``,
|
|
568
568
|
utilLine,
|
|
569
569
|
``,
|
|
570
|
-
|
|
570
|
+
`_Below ${THROTTLING_THRESHOLD_PCT}% on both windows._`,
|
|
571
571
|
].join("\n");
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
function escapeHtml(s: string): string {
|
|
575
|
-
return s
|
|
576
|
-
.replace(/&/g, "&")
|
|
577
|
-
.replace(/</g, "<")
|
|
578
|
-
.replace(/>/g, ">")
|
|
579
|
-
.replace(/"/g, """)
|
|
580
|
-
.replace(/'/g, "'");
|
|
575
|
+
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
581
576
|
}
|
|
582
577
|
|
|
583
578
|
// ─── State persistence ────────────────────────────────────────────────────────
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* (raw markdown, since reply tools don't always render HTML).
|
|
16
16
|
*
|
|
17
17
|
* Smoking-gun evidence: klanker chat 12345, msgs 5025 + 5027,
|
|
18
|
-
* 11s apart.
|
|
19
|
-
*
|
|
18
|
+
* 11s apart. The two paths historically rendered identical content two
|
|
19
|
+
* different ways (one HTML-escaped, one raw markdown) and shipped both.
|
|
20
20
|
* Same content, different formatting, two messages.
|
|
21
21
|
*
|
|
22
22
|
* Fix shape: maintain a small in-memory cache of "what we just sent"
|
|
@@ -188,9 +188,9 @@ function makeKey(chatId: string, threadId: number | undefined): string {
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Normalise text for content equality. The bug we're defending
|
|
191
|
-
* against produces the SAME content rendered two different ways
|
|
192
|
-
* one
|
|
193
|
-
*
|
|
191
|
+
* against produces the SAME content rendered two different ways
|
|
192
|
+
* (historically one HTML-rendered, the other raw markdown). Both must
|
|
193
|
+
* hash identically — so we strip both HTML tags and markdown markers.
|
|
194
194
|
*
|
|
195
195
|
* Steps:
|
|
196
196
|
* 1. Strip HTML tags (`<b>foo</b>` → `foo`).
|
|
@@ -218,9 +218,14 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
|
|
|
218
218
|
const db = new Database(path, { create: true })
|
|
219
219
|
applySchema(db)
|
|
220
220
|
try {
|
|
221
|
-
// 0o644 so the switchroom-web container
|
|
222
|
-
//
|
|
221
|
+
// 0o644 on all three SQLite files so the switchroom-web container
|
|
222
|
+
// (different UID, same host bind-mount) can read turn history.
|
|
223
|
+
// WAL mode requires read access to registry.db-shm and registry.db-wal
|
|
224
|
+
// in addition to the main file — all three must be world-readable.
|
|
223
225
|
chmodSync(path, 0o644)
|
|
226
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
227
|
+
try { chmodSync(path + suffix, 0o644) } catch { /* doesn't exist yet */ }
|
|
228
|
+
}
|
|
224
229
|
} catch {
|
|
225
230
|
/* ignore — chmod not supported on some FUSE mounts */
|
|
226
231
|
}
|
|
@@ -498,11 +503,46 @@ export function findRecentTurnsForChat(
|
|
|
498
503
|
*
|
|
499
504
|
* `limit` defaults to 20, max 200.
|
|
500
505
|
*/
|
|
506
|
+
/**
|
|
507
|
+
* Return distinct thread_ids (null = general topic) for a given chat_id.
|
|
508
|
+
* Used by the Hermes adapter to enumerate forum topics as separate sessions.
|
|
509
|
+
*/
|
|
510
|
+
export function listDistinctThreadIds(
|
|
511
|
+
db: SqliteDatabase,
|
|
512
|
+
chatId: string,
|
|
513
|
+
): (string | null)[] {
|
|
514
|
+
const rows = db.prepare(`
|
|
515
|
+
SELECT DISTINCT thread_id FROM turns
|
|
516
|
+
WHERE chat_id = ?
|
|
517
|
+
ORDER BY thread_id ASC
|
|
518
|
+
`).all(chatId) as { thread_id: string | null }[]
|
|
519
|
+
return rows.map((r) => r.thread_id)
|
|
520
|
+
}
|
|
521
|
+
|
|
501
522
|
export function listTurnsForAgent(
|
|
502
523
|
db: SqliteDatabase,
|
|
503
|
-
opts: { limit?: number } = {},
|
|
524
|
+
opts: { limit?: number; chatId?: string; threadId?: string | null } = {},
|
|
504
525
|
): Turn[] {
|
|
505
526
|
const limit = Math.min(Math.max(1, opts.limit ?? 20), 200)
|
|
527
|
+
if (opts.chatId && 'threadId' in opts) {
|
|
528
|
+
// threadId may be a string ID or null (general topic)
|
|
529
|
+
const rows = db.prepare(`
|
|
530
|
+
SELECT * FROM turns
|
|
531
|
+
WHERE chat_id = ? AND thread_id IS ?
|
|
532
|
+
ORDER BY started_at DESC
|
|
533
|
+
LIMIT ?
|
|
534
|
+
`).all(opts.chatId, opts.threadId ?? null, limit) as RawTurnRow[]
|
|
535
|
+
return rows.map(mapRow)
|
|
536
|
+
}
|
|
537
|
+
if (opts.chatId) {
|
|
538
|
+
const rows = db.prepare(`
|
|
539
|
+
SELECT * FROM turns
|
|
540
|
+
WHERE chat_id = ?
|
|
541
|
+
ORDER BY started_at DESC
|
|
542
|
+
LIMIT ?
|
|
543
|
+
`).all(opts.chatId, limit) as RawTurnRow[]
|
|
544
|
+
return rows.map(mapRow)
|
|
545
|
+
}
|
|
506
546
|
const rows = db.prepare(`
|
|
507
547
|
SELECT * FROM turns
|
|
508
548
|
ORDER BY started_at DESC
|
|
@@ -253,24 +253,59 @@ export async function retryWithThreadFallback<T>(
|
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* True when Telegram rejected a message because it couldn't parse the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
256
|
+
* markdown entities we sent on the rich-message path (#2669). There is no
|
|
257
|
+
* new rich-specific error class in grammy 1.44 — a malformed-markdown
|
|
258
|
+
* failure still throws `GrammyError` with the standard
|
|
259
|
+
* `{ ok:false, error_code:400, description }` shape, the same family as the
|
|
260
|
+
* legacy "can't parse entities" 400. These 400s are deliberately NOT
|
|
261
|
+
* swallowed or retried by `retryApiCall` (only not-modified / not-found /
|
|
262
|
+
* thread-not-found are) — they surface to the caller, which recovers by
|
|
263
|
+
* resending the chunk as plain text (no rich-message wrapper, so the
|
|
264
|
+
* parser never runs). Same "caller-level fallback" shape as the
|
|
265
|
+
* THREAD_NOT_FOUND contract above.
|
|
263
266
|
*/
|
|
264
267
|
export function isHtmlParseRejectError(err: unknown): boolean {
|
|
265
268
|
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
269
|
+
// A too-long rejection is a LENGTH error (see isMessageTooLongError) — never
|
|
270
|
+
// route it through the plain-text parse-reject fallback, which would resend
|
|
271
|
+
// the same oversized body and fail again. The caller re-splits instead.
|
|
272
|
+
if (isMessageTooLongError(err)) return false
|
|
266
273
|
const d = (err.description || '').toLowerCase()
|
|
267
274
|
return (
|
|
268
275
|
d.includes("can't parse entities") ||
|
|
269
276
|
d.includes('can’t parse entities') ||
|
|
277
|
+
d.includes("can't parse") ||
|
|
278
|
+
d.includes('can’t parse') ||
|
|
279
|
+
d.includes('parse markdown') ||
|
|
280
|
+
d.includes('parse rich') ||
|
|
270
281
|
d.includes('unsupported start tag') ||
|
|
271
282
|
d.includes('unclosed start tag') ||
|
|
272
283
|
d.includes("can't find end of the entity") ||
|
|
284
|
+
d.includes('can’t find end of the entity') ||
|
|
273
285
|
// covers both "expected end tag" and "unexpected end tag"
|
|
274
286
|
d.includes('expected end tag')
|
|
275
287
|
)
|
|
276
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* True when Telegram rejected the message because the BODY WAS TOO LONG (over
|
|
292
|
+
* the rich-message wire cap), not because the markdown failed to parse.
|
|
293
|
+
*
|
|
294
|
+
* The rich path surfaces this as `RICH_MESSAGE_TEXT_TOO_LONG` (empirically the
|
|
295
|
+
* description for 32769+ chars); the legacy plain-text path used
|
|
296
|
+
* `MESSAGE_TOO_LONG` / "message is too long". A caller that hits this should
|
|
297
|
+
* re-split the body (`splitMarkdownChunks` at a smaller cap) and resend, NOT
|
|
298
|
+
* treat it as a parse-reject (which resends the same oversized payload as
|
|
299
|
+
* plain text). Mirrors rich-send.ts `isLengthError`.
|
|
300
|
+
*/
|
|
301
|
+
export function isMessageTooLongError(err: unknown): boolean {
|
|
302
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
303
|
+
const d = (err.description || '').toLowerCase()
|
|
304
|
+
return (
|
|
305
|
+
d.includes('rich_message_text_too_long') ||
|
|
306
|
+
d.includes('message_too_long') ||
|
|
307
|
+
d.includes('text_too_long') ||
|
|
308
|
+
d.includes('message is too long') ||
|
|
309
|
+
d.includes('text is too long')
|
|
310
|
+
)
|
|
311
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-message send/edit helpers (Bot API 10.1, #2669).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound message in the plugin goes through `sendRichMessage` /
|
|
5
|
+
* `editMessageText({ markdown })` with raw GFM markdown. These two tiny
|
|
6
|
+
* helpers build the canonical call shape so the ~dozens of card surfaces
|
|
7
|
+
* and the gateway core all share ONE rendering path:
|
|
8
|
+
*
|
|
9
|
+
* - `richMessage(text)` → `{ markdown: text }`, the `InputRichMessage`
|
|
10
|
+
* accepted by both `sendRichMessage` and `editMessageText`.
|
|
11
|
+
* - `isParseEntitiesError(err)` → true when Telegram rejected the body
|
|
12
|
+
* because it couldn't parse the markdown entities. There is NO new
|
|
13
|
+
* rich-specific error class in grammy 1.44 — a malformed-markdown
|
|
14
|
+
* failure still throws `GrammyError` with the standard
|
|
15
|
+
* `{ ok:false, error_code:400, description }` shape, the same family
|
|
16
|
+
* as the legacy "can't parse entities" 400. Callers recover by
|
|
17
|
+
* resending the SAME message id as plain text (no rich, no parse_mode).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { GrammyError } from 'grammy'
|
|
21
|
+
|
|
22
|
+
/** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
|
|
23
|
+
export interface InputRichMessageMarkdown {
|
|
24
|
+
markdown: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Wrap raw GFM markdown into the rich-message input object. */
|
|
28
|
+
export function richMessage(markdown: string): InputRichMessageMarkdown {
|
|
29
|
+
return { markdown }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when Telegram rejected a message because it couldn't parse the
|
|
34
|
+
* markdown entities we sent. These 400s are deliberately NOT swallowed or
|
|
35
|
+
* retried by the retry policy — they surface to the caller, which recovers
|
|
36
|
+
* by resending the body as plain text (a literal string, no rich-message
|
|
37
|
+
* wrapper, so the parser never runs). Same "caller-level fallback" contract
|
|
38
|
+
* the old HTML path used.
|
|
39
|
+
*/
|
|
40
|
+
export function isParseEntitiesError(err: unknown): boolean {
|
|
41
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
42
|
+
// A too-long rejection is a LENGTH error, not a parse error — never let the
|
|
43
|
+
// length case fall through here (it would be "recovered" by resending the
|
|
44
|
+
// same oversized body as plain text, which fails again). Classify it
|
|
45
|
+
// separately via isLengthError so the caller re-splits instead.
|
|
46
|
+
if (isLengthError(err)) return false
|
|
47
|
+
const d = (err.description || '').toLowerCase()
|
|
48
|
+
return (
|
|
49
|
+
d.includes("can't parse entities") ||
|
|
50
|
+
d.includes('can’t parse entities') ||
|
|
51
|
+
d.includes("can't parse") ||
|
|
52
|
+
d.includes('can’t parse') ||
|
|
53
|
+
d.includes('parse markdown') ||
|
|
54
|
+
d.includes('parse rich') ||
|
|
55
|
+
d.includes("can't find end of the entity") ||
|
|
56
|
+
d.includes('can’t find end of the entity') ||
|
|
57
|
+
d.includes('unsupported start tag') ||
|
|
58
|
+
d.includes('unclosed start tag') ||
|
|
59
|
+
// covers both "expected end tag" and "unexpected end tag"
|
|
60
|
+
d.includes('expected end tag')
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when Telegram rejected the message because the BODY WAS TOO LONG (over
|
|
66
|
+
* the rich-message wire cap), as opposed to a markdown-parse failure.
|
|
67
|
+
*
|
|
68
|
+
* The rich path surfaces this distinctly: `RICH_MESSAGE_TEXT_TOO_LONG`
|
|
69
|
+
* (empirically the description for a body of 32769+ chars). The legacy
|
|
70
|
+
* plain-text path used `MESSAGE_TOO_LONG` / "message is too long" — both are
|
|
71
|
+
* matched here so a caller that hits either re-splits the body
|
|
72
|
+
* (`splitMarkdownChunks`) and resends, instead of misclassifying it as a parse
|
|
73
|
+
* error (and resending the same oversized payload as plain text) or surfacing
|
|
74
|
+
* the raw 400.
|
|
75
|
+
*/
|
|
76
|
+
export function isLengthError(err: unknown): boolean {
|
|
77
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
78
|
+
const d = (err.description || '').toLowerCase()
|
|
79
|
+
return (
|
|
80
|
+
d.includes('rich_message_text_too_long') ||
|
|
81
|
+
d.includes('message_too_long') ||
|
|
82
|
+
d.includes('text_too_long') ||
|
|
83
|
+
d.includes('message is too long') ||
|
|
84
|
+
d.includes('text is too long')
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -80,7 +80,7 @@ describe("renderVaultCliError", () => {
|
|
|
80
80
|
// appeared in output (so the operator knew which key triggered
|
|
81
81
|
// the card). New copy keeps the key in <code>…</code> form via
|
|
82
82
|
// htmlEscape — assert it.
|
|
83
|
-
expect(out.html).toContain("
|
|
83
|
+
expect(out.html).toContain("`my_key`");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it("renders sandbox_context for verb=set WITHOUT a key (defensive fallback)", () => {
|
|
@@ -92,7 +92,7 @@ describe("renderVaultCliError", () => {
|
|
|
92
92
|
{ verb: "set" },
|
|
93
93
|
);
|
|
94
94
|
expect(out.html).toMatch(/vault_request_save/);
|
|
95
|
-
expect(out.html).not.toContain("
|
|
95
|
+
expect(out.html).not.toContain("``");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
it("renders sandbox_context for verb=get with /vault get", () => {
|
|
@@ -119,7 +119,7 @@ describe("renderVaultCliError", () => {
|
|
|
119
119
|
);
|
|
120
120
|
expect(out.suppressRaw).toBe(true);
|
|
121
121
|
expect(out.html).toContain("operator approval required");
|
|
122
|
-
expect(out.html).toContain("
|
|
122
|
+
expect(out.html).toContain("`telegram_bot_token`");
|
|
123
123
|
expect(out.html).toMatch(/vault_request_save/);
|
|
124
124
|
expect(out.html).not.toMatch(/on the way/i);
|
|
125
125
|
});
|
|
@@ -175,12 +175,12 @@ describe("renderVaultCliError", () => {
|
|
|
175
175
|
expect(out.html).toBe("");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("
|
|
178
|
+
it("keeps the key literal inside the code span (< > stay verbatim, #2669)", () => {
|
|
179
179
|
const out = renderVaultCliError(
|
|
180
180
|
{ kind: "needs_approval", original: "x", key: "key<with>html" },
|
|
181
181
|
{ verb: "save" },
|
|
182
182
|
);
|
|
183
|
-
|
|
184
|
-
expect(out.html).toContain("key
|
|
183
|
+
// < > are literal in rich markdown and ride verbatim in the `code span`.
|
|
184
|
+
expect(out.html).toContain("`key<with>html`");
|
|
185
185
|
});
|
|
186
186
|
});
|