switchroom 0.18.24 → 0.18.26
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 +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* unit-testable without booting the bot.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type { InjectResult } from '../../src/agents/inject.js'
|
|
27
|
+
import type { InjectResult, InjectOpts } from '../../src/agents/inject.js'
|
|
28
28
|
import {
|
|
29
29
|
labelTag,
|
|
30
30
|
type DiscoverResult,
|
|
@@ -185,8 +185,17 @@ export function modelCommandReceiptLine(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export interface ModelCommandDeps {
|
|
188
|
-
/**
|
|
189
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Inject primitive — wired to injectSlashCommand in the gateway. The optional
|
|
190
|
+
* third argument forwards the #3241 poll-until-signal opts (successPattern /
|
|
191
|
+
* errorPattern / settleBeforeSendMs); the set path passes them so the `/model`
|
|
192
|
+
* confirmation scrape is deterministic instead of racing a fixed window.
|
|
193
|
+
*/
|
|
194
|
+
inject: (
|
|
195
|
+
agent: string,
|
|
196
|
+
command: string,
|
|
197
|
+
opts?: Pick<InjectOpts, 'successPattern' | 'errorPattern' | 'settleBeforeSendMs'>,
|
|
198
|
+
) => Promise<InjectResult>
|
|
190
199
|
/**
|
|
191
200
|
* True while the agent is mid-turn. A typed `/model <name>` switch drives
|
|
192
201
|
* claude's session (either an inject into the input box, or a carrier-backed
|
|
@@ -248,6 +257,17 @@ export interface ModelCommandReply {
|
|
|
248
257
|
* lies to `/status`.
|
|
249
258
|
*/
|
|
250
259
|
selectedModel?: string
|
|
260
|
+
/**
|
|
261
|
+
* True when `selectedModel` was recorded OPTIMISTICALLY (#3241 part B): the
|
|
262
|
+
* inject SEND succeeded and NO explicit error line was scraped, but claude's
|
|
263
|
+
* confirmation line was not read either. Poll-until-signal already waited the
|
|
264
|
+
* full window, so a missing line means a silent switch (or a confirmation
|
|
265
|
+
* that scrolled off) — NOT a failure — and we record the requested model so
|
|
266
|
+
* `/status` is right. The switch is retracted (no `selectedModel`) only when
|
|
267
|
+
* an error line IS scraped. Purely a wording hint; the gateway records
|
|
268
|
+
* `selectedModel` the same way whether confirmed or optimistic.
|
|
269
|
+
*/
|
|
270
|
+
optimistic?: boolean
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
const PERSIST_NOTE =
|
|
@@ -379,7 +399,18 @@ export async function handleModelCommand(
|
|
|
379
399
|
const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
|
|
380
400
|
let result: InjectResult
|
|
381
401
|
try {
|
|
382
|
-
|
|
402
|
+
// #3241 part A — poll-until-signal. Hand the inject primitive the exact
|
|
403
|
+
// confirmation / error line shapes so its capture loop keeps polling until
|
|
404
|
+
// claude's "Set model to …" (or an error) actually lands, instead of
|
|
405
|
+
// breaking at a fixed settle window on the first pane change (which the
|
|
406
|
+
// async access banner tripped, capturing the banner and missing the
|
|
407
|
+
// confirmation). settleBeforeSendMs waits for a clean prompt so the keys
|
|
408
|
+
// aren't typed into a still-animating pane (symptom 2's silent no-op).
|
|
409
|
+
result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
|
|
410
|
+
successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
|
|
411
|
+
errorPattern: MODEL_SWITCH_ERROR_RE,
|
|
412
|
+
settleBeforeSendMs: 1500,
|
|
413
|
+
})
|
|
383
414
|
} catch (err) {
|
|
384
415
|
const msg = err instanceof Error ? err.message : String(err)
|
|
385
416
|
return {
|
|
@@ -388,33 +419,43 @@ export async function handleModelCommand(
|
|
|
388
419
|
}
|
|
389
420
|
}
|
|
390
421
|
|
|
391
|
-
if (result.outcome === 'ok') {
|
|
392
|
-
// claude's `/model <name>`
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
422
|
+
if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
|
|
423
|
+
// claude's `/model <name>` prints a "Set model to X" acknowledgement, an
|
|
424
|
+
// error line ("Model not found"), a "Kept model as X" no-op, or (rarely)
|
|
425
|
+
// switches with the confirmation scrolled off. `result.output` on a silent
|
|
426
|
+
// path is just pane scrollback (the agent's previous prose) — NEVER a
|
|
427
|
+
// confirmation, and it must not be dumped back as a code block (screenshot-
|
|
397
428
|
// confirmed leak on klanker, v0.16.47).
|
|
398
429
|
//
|
|
399
|
-
// Honest reporting
|
|
400
|
-
// "
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
const confirmation = modelSwitchConfirmationLine(result.output)
|
|
430
|
+
// Honest reporting (#3241 part B inverts the old "record nothing unless
|
|
431
|
+
// confirmed" to "record optimistically, retract only on a scraped error").
|
|
432
|
+
// Order matters (#3242 review MEDIUM 1): check the CONFIRMATION line FIRST —
|
|
433
|
+
// a genuine switch always prints one, so it can never be flipped to a failure
|
|
434
|
+
// by a stray availability/denial word (the widened MODEL_SWITCH_ERROR_RE) in
|
|
435
|
+
// the same region. Then:
|
|
436
|
+
// (1) "Kept model as X" → genuine no-op; report it, record NOTHING.
|
|
437
|
+
// (2) other confirmation → verified switch; relay it, record the display
|
|
438
|
+
// name for /status.
|
|
439
|
+
// (3) error/denial line scraped (bad id OR access denial) → switch FAILED.
|
|
440
|
+
// Report it; record NOTHING (the retract — /status keeps the prior model).
|
|
441
|
+
// (4) no line either way → poll-until-signal already waited the full
|
|
442
|
+
// window, so this is a SILENT success, not a failure. Record the
|
|
443
|
+
// requested model OPTIMISTICALLY (normalized to display form) so
|
|
444
|
+
// /status is right, and say so.
|
|
445
|
+
const confirmation = result.outcome === 'ok' ? modelSwitchConfirmationLine(result.output) : null
|
|
417
446
|
if (confirmation) {
|
|
447
|
+
if (isKeptModelConfirmation(confirmation)) {
|
|
448
|
+
// "Kept model as X" — nothing changed. Relay it, record no override.
|
|
449
|
+
return {
|
|
450
|
+
text: [
|
|
451
|
+
`${verbHtml}`,
|
|
452
|
+
deps.preBlock(confirmation),
|
|
453
|
+
...(result.truncated ? ['_truncated_'] : []),
|
|
454
|
+
PERSIST_NOTE,
|
|
455
|
+
].join('\n'),
|
|
456
|
+
html: true,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
418
459
|
const confirmed = sessionModelFromConfirmation(confirmation) ?? model
|
|
419
460
|
return {
|
|
420
461
|
text: [
|
|
@@ -424,27 +465,35 @@ export async function handleModelCommand(
|
|
|
424
465
|
PERSIST_NOTE,
|
|
425
466
|
].join('\n'),
|
|
426
467
|
html: true,
|
|
427
|
-
|
|
428
|
-
// a "Kept model as" line means no change — don't overwrite the override.
|
|
429
|
-
...(isKeptModelConfirmation(confirmation) ? {} : { selectedModel: confirmed }),
|
|
468
|
+
selectedModel: confirmed,
|
|
430
469
|
}
|
|
431
470
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
471
|
+
const errLine = result.outcome === 'ok' ? modelSwitchErrorLine(result.output) : null
|
|
472
|
+
if (errLine) {
|
|
473
|
+
return {
|
|
474
|
+
text: [
|
|
475
|
+
`❌ ${verbHtml} — the switch did not take:`,
|
|
476
|
+
deps.preBlock(errLine),
|
|
477
|
+
'Check \`/model\` for a valid, available model.',
|
|
478
|
+
].join('\n'),
|
|
479
|
+
html: true,
|
|
480
|
+
}
|
|
438
481
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
482
|
+
// No confirmation and no error — optimistic record (#3241 part B). The
|
|
483
|
+
// Telegram copy stays PROVISIONAL (#3242 review FIX 2): we couldn't read a
|
|
484
|
+
// confirmation, and if the CLI denied the switch with wording our error
|
|
485
|
+
// regex misses, an affirmative "recorded X" would be a lie that never
|
|
486
|
+
// self-corrects. `/status` DOES self-heal (the override is reclaimed by the
|
|
487
|
+
// next transcript line), so point the user there rather than assert success.
|
|
488
|
+
const optimisticLabel = optimisticModelRecordLabel(model)
|
|
442
489
|
return {
|
|
443
490
|
text: [
|
|
444
|
-
`${verbHtml} — sent, but
|
|
491
|
+
`${verbHtml} — sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
|
|
445
492
|
PERSIST_NOTE,
|
|
446
493
|
].join('\n'),
|
|
447
494
|
html: true,
|
|
495
|
+
selectedModel: optimisticLabel,
|
|
496
|
+
optimistic: true,
|
|
448
497
|
}
|
|
449
498
|
}
|
|
450
499
|
|
|
@@ -617,6 +666,32 @@ export function srFriendlyLabel(srName: string): string {
|
|
|
617
666
|
return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
|
|
618
667
|
}
|
|
619
668
|
|
|
669
|
+
/**
|
|
670
|
+
* #3242 review LOW 4 — display normalization for the OPTIMISTIC `/status` record.
|
|
671
|
+
* The confirmed path records the display name claude printed (e.g. "Fable 5" via
|
|
672
|
+
* `sessionModelFromConfirmation`); the optimistic path only has the requested
|
|
673
|
+
* arg. Without a confirmation we can't know the version suffix, so we normalize
|
|
674
|
+
* a bare Claude alias to the same DISPLAY style — Title-case ("fable" → "Fable")
|
|
675
|
+
* — and leave a full `claude-*` id as-is (already canonical).
|
|
676
|
+
*
|
|
677
|
+
* #3242 review FIX 1 (MEDIUM) — sr-* tokens are returned UNCHANGED (with the
|
|
678
|
+
* `sr-` prefix). The stored `selectedModel` doubles as the sr-*→Claude sentinel:
|
|
679
|
+
* `gateway.ts` `isSrToClaudeTransition` checks `prevModel?.startsWith('sr-')` to
|
|
680
|
+
* decide whether a later Claude switch needs the graceful restart that tears
|
|
681
|
+
* down LiteLLM routing. De-prefixing here (as the earlier LOW-4 pass did via
|
|
682
|
+
* `srFriendlyLabel`) would silently break that restart. So this helper never
|
|
683
|
+
* de-prefixes: the caller normalizes only the DISPLAY text separately (see
|
|
684
|
+
* `srFriendlyLabel`), never the stored token.
|
|
685
|
+
*/
|
|
686
|
+
export function optimisticModelRecordLabel(token: string): string {
|
|
687
|
+
if (isSrModel(token)) return token
|
|
688
|
+
const lower = token.toLowerCase()
|
|
689
|
+
if ((MODEL_ALIASES as readonly string[]).includes(lower)) {
|
|
690
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1)
|
|
691
|
+
}
|
|
692
|
+
return token
|
|
693
|
+
}
|
|
694
|
+
|
|
620
695
|
/**
|
|
621
696
|
* Split picker-discovered options into native Claude options and sr-*
|
|
622
697
|
* (LiteLLM non-Anthropic) options. Options with "/" in the label or
|
|
@@ -973,7 +1048,14 @@ export async function handleModelMenuCallback(
|
|
|
973
1048
|
}
|
|
974
1049
|
let aliasResult: InjectResult
|
|
975
1050
|
try {
|
|
976
|
-
|
|
1051
|
+
// #3241 part A — same poll-until-signal + clean-prompt opts as the typed
|
|
1052
|
+
// set path so the alias (e.g. Fable) confirmation scrape is deterministic
|
|
1053
|
+
// and immune to the async access banner.
|
|
1054
|
+
aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
|
|
1055
|
+
successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
|
|
1056
|
+
errorPattern: MODEL_SWITCH_ERROR_RE,
|
|
1057
|
+
settleBeforeSendMs: 1500,
|
|
1058
|
+
})
|
|
977
1059
|
} catch (err) {
|
|
978
1060
|
const msg = err instanceof Error ? err.message : String(err)
|
|
979
1061
|
return {
|
|
@@ -981,20 +1063,54 @@ export async function handleModelMenuCallback(
|
|
|
981
1063
|
reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
|
|
982
1064
|
}
|
|
983
1065
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1066
|
+
// #3242 review MEDIUM 2 — the alias BUTTON (the primary Fable UI, the exact
|
|
1067
|
+
// async-banner scenario) must be symmetric with the typed set path: handle
|
|
1068
|
+
// BOTH `ok` and `ok_no_output` with record-on-send / retract-on-scraped-error.
|
|
1069
|
+
// Previously `ok_no_output` fell through to "Switch failed — agent may be
|
|
1070
|
+
// mid-turn" and dropped the override, so a silent successful button-switch
|
|
1071
|
+
// was reported as a failure while the identical typed command recorded.
|
|
1072
|
+
if (aliasResult.outcome === 'ok' || aliasResult.outcome === 'ok_no_output') {
|
|
1073
|
+
// Confirmation first (a genuine switch always prints one) so a stray
|
|
1074
|
+
// availability/denial word can't flip it to a failure.
|
|
1075
|
+
const confirmation = aliasResult.outcome === 'ok'
|
|
1076
|
+
? modelSwitchConfirmationLine(aliasResult.output)
|
|
1077
|
+
: null
|
|
1078
|
+
if (confirmation) {
|
|
1079
|
+
// "Kept model as X" means no change — don't overwrite the override.
|
|
1080
|
+
const kept = isKeptModelConfirmation(confirmation)
|
|
1081
|
+
return {
|
|
1082
|
+
answer: confirmation,
|
|
1083
|
+
reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
1084
|
+
...(kept ? {} : {
|
|
1085
|
+
selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
|
|
1086
|
+
selectedModelToken: alias,
|
|
1087
|
+
}),
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
// Scraped error/denial (bad id OR access denial) → genuine failure, record
|
|
1091
|
+
// nothing (retract).
|
|
1092
|
+
const aliasErr = aliasResult.outcome === 'ok' ? modelSwitchErrorLine(aliasResult.output) : null
|
|
1093
|
+
if (aliasErr) {
|
|
1094
|
+
return {
|
|
1095
|
+
answer: 'Switch failed',
|
|
1096
|
+
reply: await menuWithBanner(
|
|
1097
|
+
deps,
|
|
1098
|
+
`❌ Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`,
|
|
1099
|
+
),
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// Silent success (no confirmation, no error) → optimistic record, same as
|
|
1103
|
+
// the typed path. PROVISIONAL copy (#3242 review FIX 2): don't assert the
|
|
1104
|
+
// switch succeeded — we couldn't read a confirmation, and /status self-heals.
|
|
1105
|
+
const optimisticLabel = optimisticModelRecordLabel(alias)
|
|
991
1106
|
return {
|
|
992
|
-
answer:
|
|
993
|
-
reply: await menuWithBannerStatic(
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1107
|
+
answer: `Sent /model ${alias} — check /status`,
|
|
1108
|
+
reply: await menuWithBannerStatic(
|
|
1109
|
+
deps,
|
|
1110
|
+
`Sent \`/model ${deps.escapeHtml(alias)}\` — couldn’t read a confirmation line. \`/status\` will show the live model once it’s confirmed.`,
|
|
1111
|
+
),
|
|
1112
|
+
selectedModel: optimisticLabel,
|
|
1113
|
+
selectedModelToken: alias,
|
|
998
1114
|
}
|
|
999
1115
|
}
|
|
1000
1116
|
return {
|
|
@@ -1185,9 +1301,25 @@ export function modelSwitchConfirmationLine(output: string): string | null {
|
|
|
1185
1301
|
* 2026-07-10): `/model claude-bogus-99` prints
|
|
1186
1302
|
* `⎿ Model 'claude-bogus-99' not found` — glyph prefix `⎿`, quoted model
|
|
1187
1303
|
* name between "Model" and "not found". Both shapes are covered.
|
|
1304
|
+
*
|
|
1305
|
+
* #3242 review MEDIUM 1 — ACCESS/ENTITLEMENT DENIAL. A bad-id shape is not the
|
|
1306
|
+
* only failure: `/model fable` on a plan that lacks it prints an availability /
|
|
1307
|
+
* access-denial line ("Fable is not available on your plan", "access denied",
|
|
1308
|
+
* "requires a subscription", "not enabled for your account", "no access to …").
|
|
1309
|
+
* Those match neither the bad-id shapes nor the confirmation prefix, so the poll
|
|
1310
|
+
* loop would expire and the optimistic branch would falsely record the switch —
|
|
1311
|
+
* exactly the Fable-entitlement case this PR is about. The second alternation
|
|
1312
|
+
* group covers those phrasings. It allows up to four leading words (a model
|
|
1313
|
+
* name + a linking adverb etc.) BEFORE the denial phrase — unlike the bad-id
|
|
1314
|
+
* branches, which keep
|
|
1315
|
+
* their original tight line-start anchoring so ordinary scrollback that merely
|
|
1316
|
+
* says "model not found" mid-sentence still can't false-fail a silent switch.
|
|
1317
|
+
* The handler checks the confirmation line FIRST (below), so a genuine switch —
|
|
1318
|
+
* which always prints a confirmation — is never flipped to a failure by a stray
|
|
1319
|
+
* availability word in the same region.
|
|
1188
1320
|
*/
|
|
1189
1321
|
const MODEL_SWITCH_ERROR_RE =
|
|
1190
|
-
/^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model)\b/i
|
|
1322
|
+
/^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model|(?:[\w'’.\-]+\s+){0,4}(?:(?:is |are )?(?:not available|unavailable|not enabled|not supported)|access denied|requires\b[^\n]{0,40}\b(?:subscription|plan)|no access)\b)/i
|
|
1191
1323
|
|
|
1192
1324
|
/** The single capture line that reads as a claude model-switch error, or null. */
|
|
1193
1325
|
export function modelSwitchErrorLine(output: string): string | null {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-survival redelivery — the PURE decision predicate.
|
|
3
|
+
*
|
|
4
|
+
* When the gateway process crashes (or the hang-watchdog kills it) BEFORE the
|
|
5
|
+
* in-memory flush timer fires, the model's completed final answer — captured
|
|
6
|
+
* only in the in-memory buffer — is lost, and the user gets permanent silence.
|
|
7
|
+
* The answer text is, however, still durable on disk in the claude session
|
|
8
|
+
* transcript. On the next boot we can re-project that trailing text and re-send
|
|
9
|
+
* it. This module owns the "should we re-send, and how" decision so it is
|
|
10
|
+
* unit-testable without booting a gateway (the boot block only wires I/O to it).
|
|
11
|
+
*
|
|
12
|
+
* The delivery oracle is DURABLE TEXT-IDENTITY, not a chat+time window: we
|
|
13
|
+
* redeliver only if the PROJECTED final-answer text was NOT already delivered
|
|
14
|
+
* (matched against delivered `messages` rows — see `hasOutboundWithText`). A
|
|
15
|
+
* time-windowed "any assistant row since started_at" oracle is UNSOUND here:
|
|
16
|
+
* an interim `progress_update` or a streamed partial chunk lands a mid-turn
|
|
17
|
+
* `role='assistant'` row and would false-positive "delivered", suppressing a
|
|
18
|
+
* genuinely-undelivered final answer (the exact MISS this fix exists to close).
|
|
19
|
+
*
|
|
20
|
+
* The redelivered text is FRAMED as a recovered/interrupted draft rather than
|
|
21
|
+
* presented as a clean final answer: the transcript cannot durably distinguish
|
|
22
|
+
* "final answer complete" from "answer-so-far, more tools intended", so we never
|
|
23
|
+
* assert it is the finished answer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The short preamble that frames a redelivered draft. */
|
|
27
|
+
export const REDELIVERY_PREFIX = 'Recovered from an interrupted turn:'
|
|
28
|
+
|
|
29
|
+
export interface RedeliverDecisionInput {
|
|
30
|
+
/**
|
|
31
|
+
* The trailing assistant text re-projected from the interrupted turn's own
|
|
32
|
+
* transcript (after the last tool_use). Empty string when the transcript had
|
|
33
|
+
* no trailing text to redeliver.
|
|
34
|
+
*/
|
|
35
|
+
capturedText: string
|
|
36
|
+
/**
|
|
37
|
+
* True when the trailing transcript content AFTER the last tool_use is text
|
|
38
|
+
* (not a dangling tool_use). Bounds the preamble-vs-final ambiguity (R2): a
|
|
39
|
+
* turn whose last on-disk event was a tool_use is mid-stream, not a finished
|
|
40
|
+
* answer, so we do not redeliver it.
|
|
41
|
+
*/
|
|
42
|
+
trailingIsText: boolean
|
|
43
|
+
/**
|
|
44
|
+
* Result of the DURABLE text-identity oracle: true iff the projected answer
|
|
45
|
+
* text was already delivered to this chat/thread (`hasOutboundWithText`).
|
|
46
|
+
*/
|
|
47
|
+
hasDeliveredText: boolean
|
|
48
|
+
/** True iff `answer_redelivered_at` is already stamped (at-most-once ledger). */
|
|
49
|
+
alreadyRedelivered: boolean
|
|
50
|
+
/** Age of the interrupted turn in ms (`now - started_at`). */
|
|
51
|
+
ageMs: number
|
|
52
|
+
/** Staleness failsafe (`RESUME_MAX_AGE_MS`, default 3h). */
|
|
53
|
+
maxAgeMs: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RedeliverSkipReason =
|
|
57
|
+
| 'empty-text'
|
|
58
|
+
| 'trailing-not-text'
|
|
59
|
+
| 'already-delivered'
|
|
60
|
+
| 'already-redelivered'
|
|
61
|
+
| 'stale'
|
|
62
|
+
|
|
63
|
+
export interface RedeliverDecision {
|
|
64
|
+
redeliver: boolean
|
|
65
|
+
/** Present iff `redeliver` is true — the framed text to send. */
|
|
66
|
+
framedText?: string
|
|
67
|
+
/** Present iff `redeliver` is false — why we declined. */
|
|
68
|
+
skipReason?: RedeliverSkipReason
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether to CAPTURE an interrupted turn as a redelivery candidate at boot.
|
|
73
|
+
* This is the mutual-exclusion gate against the resume synthetic — decided
|
|
74
|
+
* BEFORE any transcript projection, purely from the boot-resume outcome.
|
|
75
|
+
*/
|
|
76
|
+
export interface RedeliverCaptureInput {
|
|
77
|
+
/**
|
|
78
|
+
* True iff the boot-resume path will RE-RUN this turn's work in a fresh
|
|
79
|
+
* session (bootResumeKind === 'resume'). A resumed turn emits a fresh answer
|
|
80
|
+
* that supersedes any recovered draft, so redelivering as well is a
|
|
81
|
+
* double-send. Every non-resume outcome (watchdog report, defer-suppressed,
|
|
82
|
+
* defer-loop, none) does NOT auto-re-answer, so redelivery is the correct and
|
|
83
|
+
* only recovery send there.
|
|
84
|
+
*/
|
|
85
|
+
willBeResumed: boolean
|
|
86
|
+
/**
|
|
87
|
+
* True iff the interrupted turn has a durably-pinned `session_id`. Without it
|
|
88
|
+
* we cannot resolve the exact transcript to re-project, so we cannot redeliver.
|
|
89
|
+
*/
|
|
90
|
+
hasSessionId: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type RedeliverCaptureSkip = 'will-be-resumed' | 'no-session-id'
|
|
94
|
+
|
|
95
|
+
export interface RedeliverCaptureDecision {
|
|
96
|
+
capture: boolean
|
|
97
|
+
/** Present iff `capture` is false — why we declined to stage redelivery. */
|
|
98
|
+
skipReason?: RedeliverCaptureSkip
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decide whether to stage an interrupted turn for crash-survival redelivery.
|
|
103
|
+
* Pure: the mutual-exclusion rule with resume lives here so it is testable
|
|
104
|
+
* without booting a gateway. `will-be-resumed` takes precedence over the
|
|
105
|
+
* session-id check because a to-be-resumed turn must never redeliver even if it
|
|
106
|
+
* has a session_id — the fresh re-answer is the send.
|
|
107
|
+
*/
|
|
108
|
+
export function decideRedeliverCapture(input: RedeliverCaptureInput): RedeliverCaptureDecision {
|
|
109
|
+
if (input.willBeResumed) return { capture: false, skipReason: 'will-be-resumed' }
|
|
110
|
+
if (!input.hasSessionId) return { capture: false, skipReason: 'no-session-id' }
|
|
111
|
+
return { capture: true }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Frame a captured draft for redelivery — a short recovered-draft preamble
|
|
116
|
+
* above the model's own words. Pure; exported for the boot wiring + tests.
|
|
117
|
+
*/
|
|
118
|
+
export function frameRedelivery(capturedText: string): string {
|
|
119
|
+
return `${REDELIVERY_PREFIX}\n\n${capturedText.trim()}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decide whether — and with what framed text — to redeliver an interrupted
|
|
124
|
+
* turn's captured-but-undelivered final answer. Pure: no clock, no I/O.
|
|
125
|
+
*
|
|
126
|
+
* Precedence of skip reasons is deliberate (most-specific first): a turn with
|
|
127
|
+
* no trailing text can never redeliver regardless of delivery/age; a delivered
|
|
128
|
+
* or already-redelivered answer is skipped before the age failsafe so the
|
|
129
|
+
* at-most-once guarantee never depends on staleness.
|
|
130
|
+
*/
|
|
131
|
+
export function decideRedeliver(input: RedeliverDecisionInput): RedeliverDecision {
|
|
132
|
+
const text = input.capturedText.trim()
|
|
133
|
+
if (text.length === 0) return { redeliver: false, skipReason: 'empty-text' }
|
|
134
|
+
if (!input.trailingIsText) return { redeliver: false, skipReason: 'trailing-not-text' }
|
|
135
|
+
if (input.alreadyRedelivered) return { redeliver: false, skipReason: 'already-redelivered' }
|
|
136
|
+
if (input.hasDeliveredText) return { redeliver: false, skipReason: 'already-delivered' }
|
|
137
|
+
if (input.ageMs > input.maxAgeMs) return { redeliver: false, skipReason: 'stale' }
|
|
138
|
+
return { redeliver: true, framedText: frameRedelivery(text) }
|
|
139
|
+
}
|
|
@@ -149,21 +149,62 @@ export function buildVaultGrantDeniedInbound(opts: {
|
|
|
149
149
|
* @param key Vault key (rendered inline-code).
|
|
150
150
|
* @param days Grant TTL in whole days.
|
|
151
151
|
* @param grantId Broker-returned grant id.
|
|
152
|
+
* @param reasonEscaped Optional original request reason the agent gave,
|
|
153
|
+
* already run through `escapeHtmlForTg` (it is
|
|
154
|
+
* agent-supplied free text). Rendered as a trailing
|
|
155
|
+
* italic clause for audit visibility. Omitted when
|
|
156
|
+
* absent/empty so no dangling "Reason:" label shows.
|
|
157
|
+
* Placed BEFORE the footer so the auth-mode note
|
|
158
|
+
* stays last.
|
|
152
159
|
* @param footer Optional trailing footer (e.g. the telegram-id
|
|
153
160
|
* auth-mode note). Empty string when absent.
|
|
154
161
|
*/
|
|
162
|
+
/** Max characters of agent-supplied reason rendered on the grant card.
|
|
163
|
+
* Keeps the edited card well under Telegram's 4096-char editMessageText
|
|
164
|
+
* limit — the edit is fire-and-forget (`.catch(() => {})`), so an
|
|
165
|
+
* over-long reason would silently fail the edit and leave the pending
|
|
166
|
+
* approve/deny buttons showing even though the grant already succeeded. */
|
|
167
|
+
export const MAX_GRANT_REASON_CHARS = 300
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Normalize an agent-supplied grant reason for single-line rendering
|
|
171
|
+
* inside the GFM italic clause `_Reason: …_`:
|
|
172
|
+
* - collapse ALL whitespace runs to a single space (a newline breaks
|
|
173
|
+
* the `_…_` emphasis — the opening `_` renders literal),
|
|
174
|
+
* - trim, so a whitespace-only reason cleanly no-ops (returns ''),
|
|
175
|
+
* - cap length with a trailing ellipsis so the card edit can't blow
|
|
176
|
+
* past Telegram's message-length limit.
|
|
177
|
+
*
|
|
178
|
+
* Returns UNescaped text — the caller MUST HTML-escape the result before
|
|
179
|
+
* rendering (it is agent-supplied free text). Returns '' for
|
|
180
|
+
* empty/whitespace-only/undefined input so the caller can skip the clause.
|
|
181
|
+
*/
|
|
182
|
+
export function normalizeGrantReason(raw: string | undefined | null): string {
|
|
183
|
+
if (raw == null) return ''
|
|
184
|
+
const collapsed = raw.replace(/\s+/g, ' ').trim()
|
|
185
|
+
if (collapsed.length === 0) return ''
|
|
186
|
+
return collapsed.length > MAX_GRANT_REASON_CHARS
|
|
187
|
+
? collapsed.slice(0, MAX_GRANT_REASON_CHARS - 1) + '…'
|
|
188
|
+
: collapsed
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
export function buildVaultGrantApprovedCardText(opts: {
|
|
156
192
|
agentEscaped: string
|
|
157
193
|
scope: 'read' | 'write'
|
|
158
194
|
key: string
|
|
159
195
|
days: number
|
|
160
196
|
grantId: string
|
|
197
|
+
reasonEscaped?: string
|
|
161
198
|
footer?: string
|
|
162
199
|
}): string {
|
|
200
|
+
const reasonClause =
|
|
201
|
+
opts.reasonEscaped != null && opts.reasonEscaped.length > 0
|
|
202
|
+
? ` _Reason: ${opts.reasonEscaped}_`
|
|
203
|
+
: ''
|
|
163
204
|
return (
|
|
164
205
|
`✅ Granted **${opts.agentEscaped}** ${opts.scope} access to ` +
|
|
165
206
|
`\`${opts.key}\` for ${opts.days}d. ` +
|
|
166
|
-
`(grant \`${opts.grantId}\`)` + (opts.footer ?? '')
|
|
207
|
+
`(grant \`${opts.grantId}\`)` + reasonClause + (opts.footer ?? '')
|
|
167
208
|
)
|
|
168
209
|
}
|
|
169
210
|
|
|
@@ -740,6 +740,124 @@ export function hasOutboundDeliveredSince(
|
|
|
740
740
|
}
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
/**
|
|
744
|
+
* DURABLE text-identity delivery oracle for crash-survival redelivery.
|
|
745
|
+
*
|
|
746
|
+
* Returns true iff a substantive outbound (`role='assistant'`) row whose text
|
|
747
|
+
* matches `text` has been delivered to `chatId` (and optionally `threadId`).
|
|
748
|
+
* Unlike `hasOutboundDeliveredSince`, this keys on the ANSWER TEXT itself, not
|
|
749
|
+
* a chat+time window — so an interim `progress_update` ("on it…") or a streamed
|
|
750
|
+
* partial chunk sent earlier in the same turn does NOT false-positive as "the
|
|
751
|
+
* final answer was delivered" (the exact MISS bug that would otherwise suppress
|
|
752
|
+
* a genuinely-undelivered final answer and re-open the permanent-silence hole).
|
|
753
|
+
*
|
|
754
|
+
* Matching is on the NORMALIZED first chunk (see `normalizeDeliveryText`): a
|
|
755
|
+
* multi-chunk answer's chunk-1 is compared against delivered rows so a crash
|
|
756
|
+
* after chunk-1 is detected as delivered (avoids double-sending chunk-1). Both
|
|
757
|
+
* sides are normalized identically. Empty/whitespace text never matches.
|
|
758
|
+
*
|
|
759
|
+
* Durable (reads committed SQLite), survives restart — unlike the in-memory
|
|
760
|
+
* `outboundDedup` ring, whose window is destroyed by the crash.
|
|
761
|
+
*
|
|
762
|
+
* SCOPING (crash-redelivery diff-review defect #1). Two guards keep a match from
|
|
763
|
+
* being over-broad and suppressing a genuine redelivery (a MISS = permanent
|
|
764
|
+
* silence):
|
|
765
|
+
* 1. `sinceMs` — when provided (the interrupted turn's `started_at`), only
|
|
766
|
+
* rows delivered AT/AFTER that instant are considered, so an UNRELATED
|
|
767
|
+
* earlier turn's message can never satisfy the match. This is the primary
|
|
768
|
+
* scope: a redelivery candidate only cares whether THIS turn's own answer
|
|
769
|
+
* already went out.
|
|
770
|
+
* 2. Short-text exactness — the bidirectional-prefix rule (which lets a
|
|
771
|
+
* delivered multi-chunk chunk-1 satisfy a longer projected answer) is only
|
|
772
|
+
* applied when the shorter side is at least `MIN_PREFIX_MATCH_CHARS`. For a
|
|
773
|
+
* SHORT final answer (e.g. "Done.") a prefix match against a longer row
|
|
774
|
+
* ("Done, deploying now.") would false-positive; short texts therefore
|
|
775
|
+
* require full normalized EQUALITY. Real answer chunks are far longer than
|
|
776
|
+
* the floor, so multi-chunk detection is unaffected.
|
|
777
|
+
*
|
|
778
|
+
* Falls back to false (safe: never suppresses a needed redelivery) if history
|
|
779
|
+
* is not initialised or the query fails — the `answer_redelivered_at` marker is
|
|
780
|
+
* the second guard against a double-send in that degraded case.
|
|
781
|
+
*/
|
|
782
|
+
export function hasOutboundWithText(
|
|
783
|
+
chatId: string,
|
|
784
|
+
text: string,
|
|
785
|
+
threadId?: number | null,
|
|
786
|
+
sinceMs?: number,
|
|
787
|
+
): boolean {
|
|
788
|
+
const needle = normalizeDeliveryText(text)
|
|
789
|
+
if (needle.length === 0) return false
|
|
790
|
+
try {
|
|
791
|
+
// Compare on the normalized prefix so trailing-whitespace / spacer
|
|
792
|
+
// differences between the rendered outbound and the re-projected answer do
|
|
793
|
+
// not defeat the match. We fetch candidate assistant rows and normalize in
|
|
794
|
+
// JS (SQLite lacks the same normalize) — bounded by chat/thread scope.
|
|
795
|
+
const params: unknown[] = [chatId]
|
|
796
|
+
let sql = "SELECT text FROM messages WHERE chat_id = ? AND role = 'assistant'"
|
|
797
|
+
if (threadId !== undefined) {
|
|
798
|
+
if (threadId === null) {
|
|
799
|
+
sql += ' AND thread_id IS NULL'
|
|
800
|
+
} else {
|
|
801
|
+
sql += ' AND thread_id = ?'
|
|
802
|
+
params.push(threadId)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (sinceMs != null && Number.isFinite(sinceMs)) {
|
|
806
|
+
// history `ts` is unix SECONDS (recordOutbound); started_at is wall-clock
|
|
807
|
+
// ms. Scope out any prior turn's rows so an unrelated earlier message can
|
|
808
|
+
// never satisfy the match.
|
|
809
|
+
sql += ' AND ts >= ?'
|
|
810
|
+
params.push(Math.floor(sinceMs / 1000))
|
|
811
|
+
}
|
|
812
|
+
// Newest first: a redelivery candidate's match is almost always recent.
|
|
813
|
+
sql += ' ORDER BY ts DESC LIMIT 500'
|
|
814
|
+
const rows = requireDb()
|
|
815
|
+
.prepare(sql)
|
|
816
|
+
.all(...(params as [unknown, ...unknown[]])) as { text: string | null }[]
|
|
817
|
+
for (const r of rows) {
|
|
818
|
+
const hay = normalizeDeliveryText(r.text ?? '')
|
|
819
|
+
if (hay.length === 0) continue
|
|
820
|
+
if (deliveryTextMatch(hay, needle)) return true
|
|
821
|
+
}
|
|
822
|
+
return false
|
|
823
|
+
} catch {
|
|
824
|
+
return false
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Normalize outbound text for durable text-identity delivery matching. Collapses
|
|
830
|
+
* all whitespace runs to single spaces and trims — so paragraph-spacer / render
|
|
831
|
+
* differences between a stored outbound and a re-projected transcript answer do
|
|
832
|
+
* not defeat an otherwise-identical match. Pure; shared by the redelivery path.
|
|
833
|
+
*/
|
|
834
|
+
export function normalizeDeliveryText(text: string): string {
|
|
835
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Minimum length (of the SHORTER side) at which the bidirectional-prefix rule is
|
|
840
|
+
* allowed. Below this, a delivery match requires full normalized equality — so a
|
|
841
|
+
* short final answer can't be falsely suppressed by an unrelated longer row that
|
|
842
|
+
* merely shares a prefix. Real answer chunks (chunk-1 of a multi-chunk send) are
|
|
843
|
+
* far longer than this floor, so multi-chunk detection is unaffected.
|
|
844
|
+
*/
|
|
845
|
+
export const MIN_PREFIX_MATCH_CHARS = 40
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Text-identity match for the delivery oracle. Exact normalized equality always
|
|
849
|
+
* matches. The bidirectional-prefix relaxation (which lets a delivered chunk-1
|
|
850
|
+
* satisfy a longer projected answer, and vice-versa) applies ONLY when the
|
|
851
|
+
* shorter side is at least `MIN_PREFIX_MATCH_CHARS` — otherwise a short answer
|
|
852
|
+
* would false-positive against any longer row sharing its prefix. Pure.
|
|
853
|
+
*/
|
|
854
|
+
export function deliveryTextMatch(hay: string, needle: string): boolean {
|
|
855
|
+
if (hay === needle) return true
|
|
856
|
+
const shorter = Math.min(hay.length, needle.length)
|
|
857
|
+
if (shorter < MIN_PREFIX_MATCH_CHARS) return false
|
|
858
|
+
return hay.startsWith(needle) || needle.startsWith(hay)
|
|
859
|
+
}
|
|
860
|
+
|
|
743
861
|
export function query(opts: QueryOptions): RecordedMessage[] {
|
|
744
862
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
|
|
745
863
|
const params: unknown[] = [opts.chat_id]
|