switchroom 0.19.42 → 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
stripExcessBold,
|
|
30
30
|
addParagraphSpacers,
|
|
31
31
|
splitMarkdownChunks,
|
|
32
|
+
splitPlainTextToCap,
|
|
32
33
|
hardSliceToCap,
|
|
33
34
|
RICH_MESSAGE_MAX_CHARS,
|
|
34
35
|
} from '../format.js'
|
|
@@ -36,6 +37,12 @@ import { scrubVoice } from '../text-voice-scrub.js'
|
|
|
36
37
|
import { normalizeTemporal } from '../temporal-normalize.js'
|
|
37
38
|
import { resolveEnvTimezone } from '../shared/local-time.js'
|
|
38
39
|
import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
|
|
40
|
+
import {
|
|
41
|
+
buildReplyParameters,
|
|
42
|
+
dropQuoteFromSendOpts,
|
|
43
|
+
isQuoteRejectionError,
|
|
44
|
+
sendOptsHaveQuote,
|
|
45
|
+
} from '../reply-quote.js'
|
|
39
46
|
|
|
40
47
|
// ── send-orchestration façade imports (#2996 P2) ──
|
|
41
48
|
// Pure/deterministic helpers are imported; stateful or side-effecting gateway
|
|
@@ -52,6 +59,14 @@ import {
|
|
|
52
59
|
} from '../format.js'
|
|
53
60
|
import { richMessage } from '../rich-send.js'
|
|
54
61
|
import { journalExternalDelivery } from './outbox-sweep.js'
|
|
62
|
+
import { sha256Hex } from '../outbox.js'
|
|
63
|
+
// #4141: the ONE shared framing implementation, also used by the outbox sweep
|
|
64
|
+
// and (for the classifier half) the unbundled Stop hooks.
|
|
65
|
+
import {
|
|
66
|
+
applyReplyThrowFraming,
|
|
67
|
+
formatReplyThrowFraming,
|
|
68
|
+
shouldFrameReplyThrow,
|
|
69
|
+
} from '../hooks/audience-classify.mjs'
|
|
55
70
|
import { queueFloodBlockedReply } from './flood-reply-queue.js'
|
|
56
71
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
57
72
|
import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
@@ -416,16 +431,58 @@ export async function sendReplyChunks(
|
|
|
416
431
|
// rejects our markdown — better an unformatted answer than a
|
|
417
432
|
// vanished one. The raw markdown source is itself readable prose, so
|
|
418
433
|
// we send it verbatim rather than strip anything.
|
|
434
|
+
//
|
|
435
|
+
// #4043: chunks were split for the RICH cap (up to RICH_MESSAGE_MAX_CHARS =
|
|
436
|
+
// 32768), but this fallback ships them through the PLAIN `sendMessage`
|
|
437
|
+
// endpoint, which caps at 4096. Without a re-cap, the rescue send itself
|
|
438
|
+
// 400s with `message is too long` and the answer vanishes — the exact
|
|
439
|
+
// failure the fallback exists to prevent. Re-cap at safe boundaries first.
|
|
440
|
+
//
|
|
441
|
+
// Per-piece trailers: `opts` was built for ONE message, so shipping it
|
|
442
|
+
// verbatim to every piece duplicates the MESSAGE-scoped params.
|
|
443
|
+
// - `reply_markup` (an inline keyboard) would render a LIVE button on
|
|
444
|
+
// every piece. `single_use` only strips the keyboard from the message
|
|
445
|
+
// that was tapped, so the user can fire the same action N times. It
|
|
446
|
+
// rides the FINAL piece only — the same treatment the sweep's own
|
|
447
|
+
// fallback applies (`outbox-sweep.ts` `sendChunkRich`/`withoutMarkup`),
|
|
448
|
+
// and the same message the caller attached it to (the last chunk).
|
|
449
|
+
// - `reply_parameters` would quote-reply from every piece. Only the
|
|
450
|
+
// FIRST piece quotes, matching the caller's `replyMode !== 'all'`
|
|
451
|
+
// rule that quotes chunk 0 only.
|
|
452
|
+
// Thread / link-preview / protect_content / disable_notification are
|
|
453
|
+
// per-message policy and correctly ride every piece, unchanged.
|
|
419
454
|
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
420
455
|
const plain =
|
|
421
456
|
chunks[i].length > 0
|
|
422
457
|
? chunks[i]
|
|
423
458
|
: '⚠️ (a fragment could not be rendered for Telegram)'
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
459
|
+
const pieces = splitPlainTextToCap(plain)
|
|
460
|
+
const pieceOpts = (p: number): Record<string, unknown> => {
|
|
461
|
+
// Single piece ⇒ it is both first and final; pass `opts` through
|
|
462
|
+
// byte-identically so the non-split path stays exactly as it was.
|
|
463
|
+
if (pieces.length === 1) return opts
|
|
464
|
+
const o = { ...opts }
|
|
465
|
+
if (p !== pieces.length - 1) delete o.reply_markup
|
|
466
|
+
if (p !== 0) delete o.reply_parameters
|
|
467
|
+
return o
|
|
468
|
+
}
|
|
469
|
+
for (let p = 0; p < pieces.length; p++) {
|
|
470
|
+
const sent = await deps.sendLiteralRaw(pieceOpts(p), pieces[p])
|
|
471
|
+
sentIds.push(sent.message_id)
|
|
472
|
+
deps.logOutbound(
|
|
473
|
+
'reply',
|
|
474
|
+
chatId,
|
|
475
|
+
sent.message_id,
|
|
476
|
+
pieces[p].length,
|
|
477
|
+
pieces.length > 1
|
|
478
|
+
? `chunk=${i + 1}/${chunks.length} plaintext-fallback piece=${p + 1}/${pieces.length}`
|
|
479
|
+
: `chunk=${i + 1}/${chunks.length} plaintext-fallback`,
|
|
480
|
+
)
|
|
481
|
+
}
|
|
427
482
|
deps.stderr(
|
|
428
|
-
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text
|
|
483
|
+
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text` +
|
|
484
|
+
(pieces.length > 1 ? ` in ${pieces.length} piece(s) (4096-char plain cap)` : '') +
|
|
485
|
+
`\n`,
|
|
429
486
|
)
|
|
430
487
|
}
|
|
431
488
|
|
|
@@ -499,6 +556,20 @@ export async function sendReplyChunks(
|
|
|
499
556
|
else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
500
557
|
else throw retryErr
|
|
501
558
|
}
|
|
559
|
+
} else if (isQuoteRejectionError(err) && sendOptsHaveQuote(sendOpts)) {
|
|
560
|
+
// Surgical-quote rejection: the quote must be an EXACT substring of the
|
|
561
|
+
// message being replied to, and Telegram 400s when it isn't found (a
|
|
562
|
+
// model paraphrase, a re-rendered fragment, an emoji/entity mismatch).
|
|
563
|
+
// That is a failure of the HIGHLIGHT, not of the answer — so drop the
|
|
564
|
+
// quote and land the reply as an ordinary reply to the same message,
|
|
565
|
+
// rather than throwing the composed answer away.
|
|
566
|
+
const retryOpts = dropQuoteFromSendOpts(sendOpts)
|
|
567
|
+
const sent = await sendChunk(retryOpts, false)
|
|
568
|
+
sentIds.push(sent.message_id)
|
|
569
|
+
deps.logOutbound('reply', chatId, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length} quote-dropped`)
|
|
570
|
+
deps.stderr(
|
|
571
|
+
`telegram gateway: quote not found in the replied-to message — resent chunk ${i + 1}/${chunks.length} without the quote\n`,
|
|
572
|
+
)
|
|
502
573
|
} else if (isMessageTooLongError(err)) {
|
|
503
574
|
await sendChunkResplit(sendOpts)
|
|
504
575
|
} else if (isHtmlParseRejectError(err)) {
|
|
@@ -1949,10 +2020,10 @@ export async function sendReply(
|
|
|
1949
2020
|
return {
|
|
1950
2021
|
...(shouldReplyTo
|
|
1951
2022
|
? {
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2023
|
+
// `quote` is a Bot API String and `quote_position` a separate
|
|
2024
|
+
// sibling Integer — see reply-quote.ts. Emitting an object here
|
|
2025
|
+
// 400'd every quoted reply the fleet ever sent.
|
|
2026
|
+
reply_parameters: buildReplyParameters(reply_to!, quoteText),
|
|
1956
2027
|
}
|
|
1957
2028
|
: {}),
|
|
1958
2029
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
@@ -2514,6 +2585,15 @@ export async function deliverCapturedProse(
|
|
|
2514
2585
|
text: string
|
|
2515
2586
|
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
2516
2587
|
turnDurationMs?: number
|
|
2588
|
+
/**
|
|
2589
|
+
* #4141 — this turn's reply tool threw, so `text` is prose the model wrote
|
|
2590
|
+
* AFTER its delivery attempt failed, not text it chose to send as the answer.
|
|
2591
|
+
* When `true` the send is prefixed with a one-line provenance banner. This is
|
|
2592
|
+
* the ELECTED-path counterpart of the outbox sweep's framing: on this path
|
|
2593
|
+
* the Stop hook defers to the single-writer election and writes NO outbox
|
|
2594
|
+
* record, so the sweep's framing can never reach the message.
|
|
2595
|
+
*/
|
|
2596
|
+
replyToolThrewThisTurn?: boolean
|
|
2517
2597
|
},
|
|
2518
2598
|
): Promise<void> {
|
|
2519
2599
|
const {
|
|
@@ -2523,6 +2603,18 @@ export async function deliverCapturedProse(
|
|
|
2523
2603
|
} = deps
|
|
2524
2604
|
const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
|
|
2525
2605
|
const now = Date.now()
|
|
2606
|
+
// #4141 — framing is a rendering concern, decided ONCE here and applied only
|
|
2607
|
+
// to the bytes handed to Telegram. Everything keyed on identity (the dedup
|
|
2608
|
+
// check + record below, and the journal's `textSha256`) deliberately keeps
|
|
2609
|
+
// using the RAW `text`, so the label cannot change what counts as "the same
|
|
2610
|
+
// answer" — a later reply-tool retry of the same prose still dedups, and the
|
|
2611
|
+
// exactly-once-among-backstops journal key is unchanged. Same
|
|
2612
|
+
// additive-by-construction property the sweep path has, re-established here
|
|
2613
|
+
// because this path has its own send ladder and its own dedup.
|
|
2614
|
+
const framed = shouldFrameReplyThrow(
|
|
2615
|
+
{ replyToolThrewThisTurn: args.replyToolThrewThisTurn },
|
|
2616
|
+
{ frameEnabled: process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING !== '0' },
|
|
2617
|
+
)
|
|
2526
2618
|
// #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
|
|
2527
2619
|
// failed) all funnel through the pure `settleCapturedProseDelivery` core so
|
|
2528
2620
|
// the failure posture is deterministic and unit-tested. `outcome` is set on
|
|
@@ -2530,7 +2622,7 @@ export async function deliverCapturedProse(
|
|
|
2530
2622
|
let outcome: CapturedProseSendOutcome
|
|
2531
2623
|
const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
|
|
2532
2624
|
if (already == null) {
|
|
2533
|
-
let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
2625
|
+
let out = normalizeParagraphBreaks(repairEscapedWhitespace(framed ? applyReplyThrowFraming(text) : text))
|
|
2534
2626
|
out = redactOutboundText(out, 'captured_prose')
|
|
2535
2627
|
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
2536
2628
|
const sentIds: number[] = []
|
|
@@ -2577,11 +2669,31 @@ export async function deliverCapturedProse(
|
|
|
2577
2669
|
// journal line from this site following a reply-tool line under the same
|
|
2578
2670
|
// nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
|
|
2579
2671
|
// proof of a bridge double-send.
|
|
2580
|
-
journalExternalDelivery({
|
|
2672
|
+
journalExternalDelivery({
|
|
2673
|
+
turnNonce: originTurnId,
|
|
2674
|
+
text,
|
|
2675
|
+
tgMessageId: sentIds[sentIds.length - 1],
|
|
2676
|
+
replyAlreadyDeliveredThisTurn: false,
|
|
2677
|
+
// #4141 durable terminal stamp — parity with the sweep's
|
|
2678
|
+
// `DeliveredEntry.framedProvenance`, so "was this labelled?" is
|
|
2679
|
+
// answerable from the journal alone, hours later.
|
|
2680
|
+
...(framed ? { framedProvenance: 'reply-throw' as const } : {}),
|
|
2681
|
+
})
|
|
2581
2682
|
process.stderr.write(
|
|
2582
2683
|
`telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
|
|
2583
2684
|
`transcript scan (chat=${chatId} origin=${originTurnId})\n`,
|
|
2584
2685
|
)
|
|
2686
|
+
if (framed) {
|
|
2687
|
+
process.stderr.write(
|
|
2688
|
+
formatReplyThrowFraming({
|
|
2689
|
+
turnNonce: originTurnId,
|
|
2690
|
+
turnId: originTurnId,
|
|
2691
|
+
chatId,
|
|
2692
|
+
textSha256: sha256Hex(text),
|
|
2693
|
+
source: 'captured-prose-bridge',
|
|
2694
|
+
}),
|
|
2695
|
+
)
|
|
2696
|
+
}
|
|
2585
2697
|
outcome = 'sent'
|
|
2586
2698
|
} catch (err) {
|
|
2587
2699
|
// #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
|
|
@@ -2660,7 +2772,11 @@ export async function deliverCapturedProse(
|
|
|
2660
2772
|
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
2661
2773
|
)
|
|
2662
2774
|
const plain = redactOutboundText(text, 'captured_prose')
|
|
2663
|
-
|
|
2775
|
+
// #4043: these pieces go through the PLAIN `sendMessage` endpoint (4096
|
|
2776
|
+
// cap), not the rich one — splitting at RICH_MESSAGE_MAX_CHARS produced
|
|
2777
|
+
// pieces Telegram rejects with `message is too long`, so a long recovered
|
|
2778
|
+
// answer fell through to the generic apology and was lost.
|
|
2779
|
+
const plainChunks = splitPlainTextToCap(plain)
|
|
2664
2780
|
try {
|
|
2665
2781
|
let liveThreadId: number | undefined = threadId
|
|
2666
2782
|
const plainSentIds: number[] = []
|