switchroom 0.18.29 → 0.18.31
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/bin/handoff-briefing.sh +8 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -16
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2089 -1587
- package/dist/host-control/main.js +110 -13
- package/dist/vault/approvals/kernel-server.js +116 -13
- package/dist/vault/broker/server.js +314 -145
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +172 -22
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +601 -104
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +280 -33
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +43 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -1
- package/telegram-plugin/tests/model-command.test.ts +133 -0
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +35 -0
|
@@ -17019,6 +17019,81 @@ function truncateDetailPreservingRequestId(detail, max) {
|
|
|
17019
17019
|
return `${detail.slice(0, headBudget)}${suffix}`;
|
|
17020
17020
|
}
|
|
17021
17021
|
|
|
17022
|
+
// quota-check.ts
|
|
17023
|
+
var init_quota_check = () => {};
|
|
17024
|
+
|
|
17025
|
+
// text-voice-scrub.ts
|
|
17026
|
+
var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
|
|
17027
|
+
var init_text_voice_scrub = __esm(() => {
|
|
17028
|
+
FENCE_PH = `${NULL}VS_FENCE`;
|
|
17029
|
+
INLINE_PH = `${NULL}VS_INLINE`;
|
|
17030
|
+
HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
|
|
17031
|
+
HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
|
|
17032
|
+
URL_PH = `${NULL}VS_URL`;
|
|
17033
|
+
});
|
|
17034
|
+
|
|
17035
|
+
// card-format.ts
|
|
17036
|
+
var init_card_format = __esm(() => {
|
|
17037
|
+
init_format();
|
|
17038
|
+
init_text_voice_scrub();
|
|
17039
|
+
});
|
|
17040
|
+
|
|
17041
|
+
// model-unavailable.ts
|
|
17042
|
+
function isTransientUpstreamSignal(text) {
|
|
17043
|
+
if (typeof text !== "string" || text.length === 0)
|
|
17044
|
+
return false;
|
|
17045
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17046
|
+
const lower = sample.toLowerCase();
|
|
17047
|
+
return transientUpstreamSignals.some((s) => lower.includes(s));
|
|
17048
|
+
}
|
|
17049
|
+
function isLitellmProxyLocal429(text) {
|
|
17050
|
+
if (typeof text !== "string" || text.length === 0)
|
|
17051
|
+
return false;
|
|
17052
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17053
|
+
const lower = sample.toLowerCase();
|
|
17054
|
+
if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
|
|
17055
|
+
return true;
|
|
17056
|
+
return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
|
|
17057
|
+
}
|
|
17058
|
+
function isLitellmProxyAuthMisconfig(text) {
|
|
17059
|
+
if (typeof text !== "string" || text.length === 0)
|
|
17060
|
+
return false;
|
|
17061
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17062
|
+
const lower = sample.toLowerCase();
|
|
17063
|
+
if (lower.includes("x-api-key header is required"))
|
|
17064
|
+
return true;
|
|
17065
|
+
const isAuthErr = lower.includes("authentication_error") || lower.includes("authenticationerror");
|
|
17066
|
+
if (!isAuthErr)
|
|
17067
|
+
return false;
|
|
17068
|
+
return lower.includes("fallback") && lower.includes("x-api-key");
|
|
17069
|
+
}
|
|
17070
|
+
var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
|
|
17071
|
+
var init_model_unavailable = __esm(() => {
|
|
17072
|
+
init_quota_check();
|
|
17073
|
+
init_card_format();
|
|
17074
|
+
transientUpstreamSignals = [
|
|
17075
|
+
"not your usage limit",
|
|
17076
|
+
"not your account",
|
|
17077
|
+
"not your account's",
|
|
17078
|
+
"temporarily limiting requests",
|
|
17079
|
+
"temporarily rate",
|
|
17080
|
+
"server is temporarily",
|
|
17081
|
+
"would exceed your account\u2019s rate limit",
|
|
17082
|
+
"would exceed your account's rate limit"
|
|
17083
|
+
];
|
|
17084
|
+
litellmProxyLocal429Signals = [
|
|
17085
|
+
"deployment over user-defined ratelimit",
|
|
17086
|
+
"model rate limit exceeded. tpm limit",
|
|
17087
|
+
"model rate limit exceeded. rpm limit",
|
|
17088
|
+
"deployment over defined rpm limit",
|
|
17089
|
+
"no deployments available for selected model",
|
|
17090
|
+
"litellm rate limit handler",
|
|
17091
|
+
"crossed tpm / rpm",
|
|
17092
|
+
"max parallel request limit reached"
|
|
17093
|
+
];
|
|
17094
|
+
litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
|
|
17095
|
+
});
|
|
17096
|
+
|
|
17022
17097
|
// operator-events.ts
|
|
17023
17098
|
function classifyClaudeError(raw) {
|
|
17024
17099
|
try {
|
|
@@ -17036,6 +17111,12 @@ function classifyInner(raw) {
|
|
|
17036
17111
|
const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
|
|
17037
17112
|
const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
|
|
17038
17113
|
const sdkCode = extractString(obj, "error_code") ?? "";
|
|
17114
|
+
if (isLitellmProxyAuthMisconfig(`${errorType}
|
|
17115
|
+
${errorCode}
|
|
17116
|
+
${sdkCode}
|
|
17117
|
+
${message}`)) {
|
|
17118
|
+
return "proxy-misconfig";
|
|
17119
|
+
}
|
|
17039
17120
|
if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
|
|
17040
17121
|
const msg = message.toLowerCase();
|
|
17041
17122
|
if (msg.includes("expired") || msg.includes("refresh")) {
|
|
@@ -17081,74 +17162,18 @@ function getNestedObj(obj, key) {
|
|
|
17081
17162
|
const v = obj[key];
|
|
17082
17163
|
return typeof v === "object" && v != null ? v : {};
|
|
17083
17164
|
}
|
|
17084
|
-
var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap;
|
|
17165
|
+
var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap, OPERATOR_ACTIONABLE_KINDS;
|
|
17085
17166
|
var init_operator_events = __esm(() => {
|
|
17086
17167
|
init_format();
|
|
17168
|
+
init_model_unavailable();
|
|
17087
17169
|
DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
|
|
17088
17170
|
cooldownMap = new Map;
|
|
17089
|
-
|
|
17090
|
-
|
|
17091
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
17095
|
-
var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
|
|
17096
|
-
var init_text_voice_scrub = __esm(() => {
|
|
17097
|
-
FENCE_PH = `${NULL}VS_FENCE`;
|
|
17098
|
-
INLINE_PH = `${NULL}VS_INLINE`;
|
|
17099
|
-
HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
|
|
17100
|
-
HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
|
|
17101
|
-
URL_PH = `${NULL}VS_URL`;
|
|
17102
|
-
});
|
|
17103
|
-
|
|
17104
|
-
// card-format.ts
|
|
17105
|
-
var init_card_format = __esm(() => {
|
|
17106
|
-
init_format();
|
|
17107
|
-
init_text_voice_scrub();
|
|
17108
|
-
});
|
|
17109
|
-
|
|
17110
|
-
// model-unavailable.ts
|
|
17111
|
-
function isTransientUpstreamSignal(text) {
|
|
17112
|
-
if (typeof text !== "string" || text.length === 0)
|
|
17113
|
-
return false;
|
|
17114
|
-
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17115
|
-
const lower = sample.toLowerCase();
|
|
17116
|
-
return transientUpstreamSignals.some((s) => lower.includes(s));
|
|
17117
|
-
}
|
|
17118
|
-
function isLitellmProxyLocal429(text) {
|
|
17119
|
-
if (typeof text !== "string" || text.length === 0)
|
|
17120
|
-
return false;
|
|
17121
|
-
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17122
|
-
const lower = sample.toLowerCase();
|
|
17123
|
-
if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
|
|
17124
|
-
return true;
|
|
17125
|
-
return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
|
|
17126
|
-
}
|
|
17127
|
-
var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
|
|
17128
|
-
var init_model_unavailable = __esm(() => {
|
|
17129
|
-
init_quota_check();
|
|
17130
|
-
init_card_format();
|
|
17131
|
-
transientUpstreamSignals = [
|
|
17132
|
-
"not your usage limit",
|
|
17133
|
-
"not your account",
|
|
17134
|
-
"not your account's",
|
|
17135
|
-
"temporarily limiting requests",
|
|
17136
|
-
"temporarily rate",
|
|
17137
|
-
"server is temporarily",
|
|
17138
|
-
"would exceed your account\u2019s rate limit",
|
|
17139
|
-
"would exceed your account's rate limit"
|
|
17140
|
-
];
|
|
17141
|
-
litellmProxyLocal429Signals = [
|
|
17142
|
-
"deployment over user-defined ratelimit",
|
|
17143
|
-
"model rate limit exceeded. tpm limit",
|
|
17144
|
-
"model rate limit exceeded. rpm limit",
|
|
17145
|
-
"deployment over defined rpm limit",
|
|
17146
|
-
"no deployments available for selected model",
|
|
17147
|
-
"litellm rate limit handler",
|
|
17148
|
-
"crossed tpm / rpm",
|
|
17149
|
-
"max parallel request limit reached"
|
|
17150
|
-
];
|
|
17151
|
-
litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
|
|
17171
|
+
OPERATOR_ACTIONABLE_KINDS = new Set([
|
|
17172
|
+
"credentials-expired",
|
|
17173
|
+
"credentials-invalid",
|
|
17174
|
+
"credit-exhausted",
|
|
17175
|
+
"proxy-misconfig"
|
|
17176
|
+
]);
|
|
17152
17177
|
});
|
|
17153
17178
|
|
|
17154
17179
|
// tool-label-sidecar.ts
|
|
@@ -102,7 +102,11 @@ import {
|
|
|
102
102
|
forwardOriginDateIso,
|
|
103
103
|
type ForwardOriginInfo,
|
|
104
104
|
} from './forward-origin.js'
|
|
105
|
-
import {
|
|
105
|
+
import {
|
|
106
|
+
installUpdateTap,
|
|
107
|
+
installUnhandledMessageCatchAll,
|
|
108
|
+
} from './unhandled-message.js'
|
|
109
|
+
import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
|
|
106
110
|
import { StatusReactionController } from '../status-reactions.js'
|
|
107
111
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
108
112
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
@@ -324,9 +328,12 @@ import { autoClassifyMidTurnInbound } from './auto-classify-mid-turn.js'
|
|
|
324
328
|
import {
|
|
325
329
|
renderOperatorEvent,
|
|
326
330
|
shouldEmitOperatorEvent,
|
|
331
|
+
decideOperatorEventAudience,
|
|
332
|
+
renderUserFacingFailureNotice,
|
|
327
333
|
type OperatorEvent,
|
|
328
334
|
type OperatorEventKind,
|
|
329
335
|
} from '../operator-events.js'
|
|
336
|
+
import { pendingUserNoticeGate } from '../pending-user-notice.js'
|
|
330
337
|
import { recordOperatorEvent } from '../operator-events-history.js'
|
|
331
338
|
import {
|
|
332
339
|
parseLlmError,
|
|
@@ -472,6 +479,7 @@ import {
|
|
|
472
479
|
resolveStaleAwareBusy,
|
|
473
480
|
modelCommandReceiptLine,
|
|
474
481
|
handleModelCommand,
|
|
482
|
+
classifyModelSwitchConfirmation,
|
|
475
483
|
buildModelMenu,
|
|
476
484
|
handleModelMenuCallback,
|
|
477
485
|
isValidModelArg,
|
|
@@ -495,6 +503,7 @@ import {
|
|
|
495
503
|
readSessionModelFileRaw,
|
|
496
504
|
restoreSessionModelFileRaw,
|
|
497
505
|
clearSessionModelFile,
|
|
506
|
+
consumeSessionModelCarrierOnHealthyBoot,
|
|
498
507
|
readConfiguredDefaultModel,
|
|
499
508
|
writeSessionEffortFile,
|
|
500
509
|
clearSessionEffortFile,
|
|
@@ -1290,6 +1299,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
|
|
|
1290
1299
|
const bot = new Bot(TOKEN)
|
|
1291
1300
|
installTgPostLogger(bot)
|
|
1292
1301
|
|
|
1302
|
+
// ─── Diagnostic update tap (#3300) ────────────────────────────────────────
|
|
1303
|
+
// One compact line per received update, logged BEFORE any specific handler
|
|
1304
|
+
// runs, so a drop at the grammy-routing layer is always diagnosable from the
|
|
1305
|
+
// logs. Pass-through middleware — never consumes an update or alters routing.
|
|
1306
|
+
// Rate-limited inside installUpdateTap (per-minute cap + suppression summary
|
|
1307
|
+
// line so even a flood is never invisible).
|
|
1308
|
+
installUpdateTap(bot, line => process.stderr.write(line))
|
|
1309
|
+
|
|
1293
1310
|
// ─── getUpdates heartbeat ─────────────────────────────────────────────────
|
|
1294
1311
|
// Tracks the last time getUpdates completed (success OR error). Used by
|
|
1295
1312
|
// the poll health check as a secondary stall signal: if getMe succeeds but
|
|
@@ -2382,6 +2399,13 @@ async function deliverAnswer(args: {
|
|
|
2382
2399
|
text: string
|
|
2383
2400
|
turnId: string
|
|
2384
2401
|
cardMessageId: number | null
|
|
2402
|
+
/** S4 (fable red-team 2026-07-17) — the inbound message this turn answers
|
|
2403
|
+
* (`turn.sourceMessageId`). When present, the FIRST chunk is sent as a
|
|
2404
|
+
* native quote-reply so the flushed answer anchors to the user's question,
|
|
2405
|
+
* matching `executeReply`'s default. `allow_sending_without_reply` keeps
|
|
2406
|
+
* the send alive if the user deleted their message. Null for synthesized
|
|
2407
|
+
* turns (cron/handback) — those send bare, as before. */
|
|
2408
|
+
replyToMessageId: number | null
|
|
2385
2409
|
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2386
2410
|
const { chatId, turnId } = args
|
|
2387
2411
|
// Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
|
|
@@ -2430,8 +2454,17 @@ async function deliverAnswer(args: {
|
|
|
2430
2454
|
// landed message id(s) (a length-resplit chunk may land >1); throws on an
|
|
2431
2455
|
// unrecoverable send failure so the retry orchestrator can resume.
|
|
2432
2456
|
let liveThreadId = args.threadId
|
|
2433
|
-
const sendChunk = async (
|
|
2457
|
+
const sendChunk = async (chunkIndex: number, text: string): Promise<number[]> => {
|
|
2434
2458
|
const chunkIds: number[] = []
|
|
2459
|
+
// S4 — quote-anchor the FIRST chunk only (chunkIndex is the OVERALL chunk
|
|
2460
|
+
// index across the retry orchestrator; each sendReplyChunks call here
|
|
2461
|
+
// carries exactly one chunk, so the inner index is always 0). Mirrors
|
|
2462
|
+
// executeReply's first-chunk-only default. The ledger resumes retries at
|
|
2463
|
+
// the first UNSENT chunk, so chunk 0 keeps its anchor across retries.
|
|
2464
|
+
const anchor =
|
|
2465
|
+
chunkIndex === 0 && args.replyToMessageId != null
|
|
2466
|
+
? { reply_parameters: { message_id: args.replyToMessageId, allow_sending_without_reply: true } }
|
|
2467
|
+
: {}
|
|
2435
2468
|
const res = await sendReplyChunks(deps, {
|
|
2436
2469
|
chatId,
|
|
2437
2470
|
chunks: [text],
|
|
@@ -2441,6 +2474,7 @@ async function deliverAnswer(args: {
|
|
|
2441
2474
|
previewMessageId: null,
|
|
2442
2475
|
sentIds: chunkIds,
|
|
2443
2476
|
buildSendOpts: (_i, _isLast, tid) => ({
|
|
2477
|
+
...anchor,
|
|
2444
2478
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
2445
2479
|
link_preview_options: { is_disabled: true },
|
|
2446
2480
|
}),
|
|
@@ -5341,6 +5375,13 @@ function endCurrentTurnAtomic(
|
|
|
5341
5375
|
// wedging forever. No-op when this turn delivered, when nothing is
|
|
5342
5376
|
// buffered, or when the serialize feature is off.
|
|
5343
5377
|
armNoReplyDrainTimer(turn)
|
|
5378
|
+
// #3293 finding 1 — resolve any deferred non-operator failure notice against
|
|
5379
|
+
// this turn's outcome: replied → the turn recovered from the error line, the
|
|
5380
|
+
// gate drops the notice; reply-less → the turn genuinely died, the notice is
|
|
5381
|
+
// sent now. replyCalled covers the short-answer/#2624 shape where
|
|
5382
|
+
// finalAnswerDelivered stays false despite an explicit reply. No-op when
|
|
5383
|
+
// nothing is pending (the overwhelmingly common path).
|
|
5384
|
+
flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled)
|
|
5344
5385
|
return turnEndedAt
|
|
5345
5386
|
}
|
|
5346
5387
|
|
|
@@ -8544,7 +8585,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
8544
8585
|
`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
|
|
8545
8586
|
(opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
|
|
8546
8587
|
)
|
|
8547
|
-
|
|
8588
|
+
// Ken's deterministic error-surfacing policy: an OPERATOR-ACTIONABLE fault
|
|
8589
|
+
// (credentials / credit / proxy-misconfig) must not reach non-operator users
|
|
8590
|
+
// as a raw or misleading card they can't act on. Split the audience — the
|
|
8591
|
+
// operator (allowlist head) gets the full card; every other allowlist chat
|
|
8592
|
+
// gets, at most, a brief plain-language "it's on our side" notice. Non-
|
|
8593
|
+
// actionable kinds keep their existing broadcast (all chats are operatorChats).
|
|
8594
|
+
const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
|
|
8595
|
+
kind,
|
|
8596
|
+
access.allowFrom,
|
|
8597
|
+
access.allowFrom[0],
|
|
8598
|
+
)
|
|
8599
|
+
if (userNoticeChats.length > 0) {
|
|
8600
|
+
process.stderr.write(
|
|
8601
|
+
`telegram gateway: operator-event operator-only routing agent=${agent} kind=${kind} operatorChats=${operatorChats.length} userNoticeChats=${userNoticeChats.length}\n`,
|
|
8602
|
+
)
|
|
8603
|
+
}
|
|
8604
|
+
|
|
8605
|
+
for (const chat_id of operatorChats) {
|
|
8548
8606
|
// The resolved topic is valid ONLY in the agent's supergroup — attaching
|
|
8549
8607
|
// it to an operator DM recipient yields 400 "message thread not found" and
|
|
8550
8608
|
// the event silently fails to deliver (the marko #2096 class). Guard it:
|
|
@@ -8581,6 +8639,63 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
8581
8639
|
)
|
|
8582
8640
|
})
|
|
8583
8641
|
}
|
|
8642
|
+
|
|
8643
|
+
// Non-operator users: only the plain-language failure notice, and ONLY when
|
|
8644
|
+
// the turn genuinely dies (userNoticeChats is empty for every non-actionable
|
|
8645
|
+
// kind — see decideOperatorEventAudience). #3293 review finding 1: an error
|
|
8646
|
+
// line is NOT proof of turn failure — the LiteLLM fallback can 401 while a
|
|
8647
|
+
// retry / another deployment still serves the turn, and sending "couldn't
|
|
8648
|
+
// complete that" for a turn that completed is a false failure report. So the
|
|
8649
|
+
// notice is never sent here: it is SCHEDULED on the pendingUserNoticeGate and
|
|
8650
|
+
// resolved at the turn-end funnel (endCurrentTurnAtomic) — dropped when the
|
|
8651
|
+
// turn delivered a reply (recovered), sent when it ended reply-less (died).
|
|
8652
|
+
// Un-resolved notices expire after PENDING_USER_NOTICE_TTL_MS (bias to
|
|
8653
|
+
// silence over a false failure claim). Operator cards above stay immediate.
|
|
8654
|
+
if (userNoticeChats.length > 0) {
|
|
8655
|
+
pendingUserNoticeGate.schedule({
|
|
8656
|
+
chatIds: userNoticeChats,
|
|
8657
|
+
text: renderUserFacingFailureNotice(),
|
|
8658
|
+
agent,
|
|
8659
|
+
kind,
|
|
8660
|
+
atMs: Date.now(),
|
|
8661
|
+
})
|
|
8662
|
+
process.stderr.write(
|
|
8663
|
+
`telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length}\n`,
|
|
8664
|
+
)
|
|
8665
|
+
}
|
|
8666
|
+
}
|
|
8667
|
+
|
|
8668
|
+
/**
|
|
8669
|
+
* Turn-end resolution of deferred user failure notices (#3293 finding 1).
|
|
8670
|
+
* Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
|
|
8671
|
+
* passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
|
|
8672
|
+
* (the model explicitly replied → the turn recovered → notices are dropped by
|
|
8673
|
+
* the gate). Only a reply-less turn end flushes the pending notices to the
|
|
8674
|
+
* non-operator chats, so the user notice fires IFF the turn genuinely died.
|
|
8675
|
+
*/
|
|
8676
|
+
function flushPendingUserFailureNotices(turnDeliveredReply: boolean): void {
|
|
8677
|
+
const notices = pendingUserNoticeGate.resolveTurnEnd(turnDeliveredReply)
|
|
8678
|
+
if (notices.length === 0) return
|
|
8679
|
+
const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
8680
|
+
const noticeSupergroup = resolveAgentSupergroupChatId()
|
|
8681
|
+
for (const notice of notices) {
|
|
8682
|
+
process.stderr.write(
|
|
8683
|
+
`telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
|
|
8684
|
+
)
|
|
8685
|
+
for (const chat_id of notice.chatIds) {
|
|
8686
|
+
const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
|
|
8687
|
+
const opts = {
|
|
8688
|
+
...(thread != null ? { message_thread_id: thread } : {}),
|
|
8689
|
+
}
|
|
8690
|
+
// allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
|
|
8691
|
+
void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
|
|
8692
|
+
.catch(e => {
|
|
8693
|
+
process.stderr.write(
|
|
8694
|
+
`telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
|
|
8695
|
+
)
|
|
8696
|
+
})
|
|
8697
|
+
}
|
|
8698
|
+
}
|
|
8584
8699
|
}
|
|
8585
8700
|
|
|
8586
8701
|
/**
|
|
@@ -9678,6 +9793,18 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
9678
9793
|
process.stderr.write(
|
|
9679
9794
|
`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
|
|
9680
9795
|
)
|
|
9796
|
+
// #3284 — this boot ACQUIRED the boot lock, so it is the surviving healthy
|
|
9797
|
+
// session. Consume the session-model carrier now (delete the carrier + the
|
|
9798
|
+
// bounded-retry attempt counter). start.sh no longer deletes the carrier
|
|
9799
|
+
// before apply: an apply-boot that wedged before reaching this point leaves
|
|
9800
|
+
// the carrier in place so the retry boot RE-APPLIES the intended model
|
|
9801
|
+
// instead of silently reverting to the configured default (the marko/klanker
|
|
9802
|
+
// boot.lock_stale_recovered_boot_mismatch revert). Best-effort, gated on
|
|
9803
|
+
// lock ownership so a LOSING double-boot never clears the winner's carrier.
|
|
9804
|
+
{
|
|
9805
|
+
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9806
|
+
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9807
|
+
}
|
|
9681
9808
|
// We WON the startup mutex — this gateway is the sole live owner of the
|
|
9682
9809
|
// shared per-agent status-pin store, so it's now safe to clean up orphaned
|
|
9683
9810
|
// pins from a prior (dead) session. Gated here (not at import time) so a
|
|
@@ -9701,6 +9828,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
9701
9828
|
// probe + 409-retry loop is still the liveness guard on this path. A
|
|
9702
9829
|
// successful writePidFile here means no live holder was detected, so
|
|
9703
9830
|
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
9831
|
+
// #3284: same healthy-boot carrier consume as the mutex-acquired path —
|
|
9832
|
+
// a successful writePidFile means no live holder was detected, so this is
|
|
9833
|
+
// the surviving session.
|
|
9834
|
+
{
|
|
9835
|
+
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9836
|
+
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9837
|
+
}
|
|
9704
9838
|
// #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
|
|
9705
9839
|
void runBootPinCleanupAndDmSweep()
|
|
9706
9840
|
} catch (writeErr) {
|
|
@@ -17980,6 +18114,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17980
18114
|
// — read `turn` once, don't re-read currentTurn after any await.
|
|
17981
18115
|
const turn = currentTurn
|
|
17982
18116
|
if (turn == null) return
|
|
18117
|
+
// S2 fix (fable red-team 2026-07-17) — a thinking block means the model
|
|
18118
|
+
// is still working, not quiescent. Without this, "prose → >1s thinking
|
|
18119
|
+
// pause → trailing NO_REPLY" let the answer-ready quiescence timer fire
|
|
18120
|
+
// mid-pause and deliver a turn the model was about to mark silent
|
|
18121
|
+
// (#2053 in miniature). Re-arm (not just clear): `reset()` re-verifies
|
|
18122
|
+
// via `decideTurnFlush` and pushes the debounce out by a fresh window,
|
|
18123
|
+
// so the trailing sentinel gets to land before any fire; if no further
|
|
18124
|
+
// text arrives, the flush still fires one window after the LAST
|
|
18125
|
+
// thinking event — the fast path is deferred, never lost.
|
|
18126
|
+
resetAnswerReadyFlushTimeout()
|
|
17983
18127
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
17984
18128
|
if (ctrl) ctrl.setThinking()
|
|
17985
18129
|
return
|
|
@@ -19091,10 +19235,28 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19091
19235
|
await new Promise<void>(resolve => setTimeout(resolve, 500))
|
|
19092
19236
|
if (HISTORY_ENABLED) {
|
|
19093
19237
|
try {
|
|
19094
|
-
|
|
19095
|
-
|
|
19096
|
-
|
|
19097
|
-
|
|
19238
|
+
// S1 fix (fable red-team 2026-07-17) — the old predicate here,
|
|
19239
|
+
// `getRecentOutboundCount(chatId, 2) > 0`, counted ANY assistant
|
|
19240
|
+
// row in the WHOLE chat: a worker `progress_update`, a command
|
|
19241
|
+
// ack / restart notice, or a reply in a DIFFERENT forum topic all
|
|
19242
|
+
// suppressed the flush and (worse) CLOSED the obligation below —
|
|
19243
|
+
// silently dropping the user's real answer. The scoped predicate
|
|
19244
|
+
// (same thread, length ≥ min(answerLength, 200)) lives in
|
|
19245
|
+
// `turn-flush-suppression.ts`; `hasOutboundDeliveredSince` is the
|
|
19246
|
+
// durable oracle whose thread/length semantics history tests pin.
|
|
19247
|
+
const { hasOutboundDeliveredSince } = await import('../history.js')
|
|
19248
|
+
const { shouldSuppressTurnFlush } = await import('./turn-flush-suppression.js')
|
|
19249
|
+
const suppress = shouldSuppressTurnFlush(
|
|
19250
|
+
{ hasSubstantiveOutbound: hasOutboundDeliveredSince },
|
|
19251
|
+
{
|
|
19252
|
+
chatId: backstopChatId,
|
|
19253
|
+
threadId: backstopThreadId ?? null,
|
|
19254
|
+
answerLength: capturedText.length,
|
|
19255
|
+
nowMs: Date.now(),
|
|
19256
|
+
},
|
|
19257
|
+
)
|
|
19258
|
+
if (suppress) {
|
|
19259
|
+
process.stderr.write(`telegram gateway: turn-flush suppressed — a substantive same-thread outbound landed within 2s\n`)
|
|
19098
19260
|
// Do NOT finalize the status reaction here. As of #1713
|
|
19099
19261
|
// the reaction is only finalized by the `turn_end` IPC
|
|
19100
19262
|
// handler — mid-turn delivery proofs (local history,
|
|
@@ -19106,19 +19268,23 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19106
19268
|
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
19107
19269
|
// emitting an inconsistent shadow trace. Removed.
|
|
19108
19270
|
//
|
|
19109
|
-
// PR B —
|
|
19110
|
-
// the flush was legitimately suppressed. Emit
|
|
19111
|
-
// as 'suppressed'
|
|
19112
|
-
// finalAnswerDelivered). Not a failure.
|
|
19271
|
+
// PR B — a substantive same-thread outbound just delivered this
|
|
19272
|
+
// turn's answer, so the flush was legitimately suppressed. Emit
|
|
19273
|
+
// the deferred record as 'suppressed'. Not a failure.
|
|
19113
19274
|
if (backstopTurnEndedAt != null) {
|
|
19114
19275
|
turn.deliveryOutcome = 'suppressed'
|
|
19115
|
-
//
|
|
19116
|
-
//
|
|
19117
|
-
//
|
|
19118
|
-
//
|
|
19119
|
-
//
|
|
19120
|
-
//
|
|
19121
|
-
|
|
19276
|
+
// S1 fix — do NOT close the obligation here. When the recent
|
|
19277
|
+
// outbound is genuinely this turn's answer (a raced reply /
|
|
19278
|
+
// stream materialization), the reply path closes its own
|
|
19279
|
+
// obligation idempotently; when the suppression is a false
|
|
19280
|
+
// positive (a long same-thread non-answer inside the 2s
|
|
19281
|
+
// window — the residual the scoped predicate can't
|
|
19282
|
+
// discriminate), closing here made the drop PERMANENT.
|
|
19283
|
+
// Leaving it open lets the obligation sweep arbitrate: it
|
|
19284
|
+
// stands down silently if a substantive outbound answered
|
|
19285
|
+
// the user, and re-presents otherwise. `noteTurnEnded` arms
|
|
19286
|
+
// the liveness floor exactly as the send-failed path does.
|
|
19287
|
+
if (OBLIGATION_LEDGER_ENABLED) obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
19122
19288
|
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
19123
19289
|
}
|
|
19124
19290
|
return
|
|
@@ -19166,6 +19332,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19166
19332
|
text: capturedText,
|
|
19167
19333
|
turnId: turn.turnId,
|
|
19168
19334
|
cardMessageId: backstopCardMessageId,
|
|
19335
|
+
// S4 — anchor the flushed answer to the inbound it answers
|
|
19336
|
+
// (null for synthesized turns, which send bare as before).
|
|
19337
|
+
replyToMessageId: turn.sourceMessageId,
|
|
19169
19338
|
})
|
|
19170
19339
|
sentIds = delivery.sentIds
|
|
19171
19340
|
chunkCount = delivery.chunkCount
|
|
@@ -22890,9 +23059,14 @@ async function runSwitchroomCommand(
|
|
|
22890
23059
|
// commands) preserve in-place reply behavior. /logs /audit
|
|
22891
23060
|
// /upgradestatus /memory pass 'heavy' to route to admin alias.
|
|
22892
23061
|
classification: 'query' | 'mutation' | 'heavy' = 'query',
|
|
23062
|
+
// Optional DISPLAY-ONLY transform applied to the stripped command output
|
|
23063
|
+
// before formatting (never mutates the underlying stored logs). /logs uses
|
|
23064
|
+
// this to render leading UTC ISO docker-log timestamps in local am/pm.
|
|
23065
|
+
transformOutput?: (raw: string) => string,
|
|
22893
23066
|
): Promise<void> {
|
|
22894
23067
|
try {
|
|
22895
|
-
const
|
|
23068
|
+
const stripped = stripAnsi(switchroomExec(args))
|
|
23069
|
+
const output = transformOutput ? transformOutput(stripped) : stripped
|
|
22896
23070
|
const formatted = formatSwitchroomOutput(output)
|
|
22897
23071
|
if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
|
|
22898
23072
|
else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
|
|
@@ -26961,7 +27135,23 @@ bot.command('logs', async ctx => {
|
|
|
26961
27135
|
const lines = linesArg ? parseInt(linesArg, 10) : 20
|
|
26962
27136
|
const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
|
|
26963
27137
|
// PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
|
|
26964
|
-
|
|
27138
|
+
// #tz-fix audit (MEDIUM gap 2): `--timestamps` makes docker prefix every
|
|
27139
|
+
// line with its own UTC ISO-8601-Z stamp — the only deterministic per-line
|
|
27140
|
+
// timestamp (raw app lines often carry no stamp, or a local-zone one that
|
|
27141
|
+
// must NOT be re-shifted). We then render that stamp in local am/pm at
|
|
27142
|
+
// DISPLAY time so `/logs` doesn't surface UTC (competing with the
|
|
27143
|
+
// local-time hint). The zone is the GATEWAY/operator zone
|
|
27144
|
+
// (resolveEnvTimezone of this process), not the target agent's zone —
|
|
27145
|
+
// intended: /logs is an operator surface. Stored logs are untouched —
|
|
27146
|
+
// the transform runs only on the text sent to chat.
|
|
27147
|
+
const tz = resolveEnvTimezone()
|
|
27148
|
+
await runSwitchroomCommand(
|
|
27149
|
+
ctx,
|
|
27150
|
+
['agent', 'logs', name, '--lines', String(lineCount), '--timestamps'],
|
|
27151
|
+
`logs ${name}`,
|
|
27152
|
+
'heavy',
|
|
27153
|
+
(raw) => renderLogTimestampsLocal(raw, tz),
|
|
27154
|
+
)
|
|
26965
27155
|
})
|
|
26966
27156
|
|
|
26967
27157
|
bot.command('memory', async ctx => {
|
|
@@ -29292,6 +29482,34 @@ bot.on('message:pinned_message', async ctx => {
|
|
|
29292
29482
|
}
|
|
29293
29483
|
})
|
|
29294
29484
|
|
|
29485
|
+
// ─── Terminal catch-all for unhandled message content types (#3300) ───────
|
|
29486
|
+
//
|
|
29487
|
+
// MUST stay registered LAST among the `message`/`message:*` handlers.
|
|
29488
|
+
//
|
|
29489
|
+
// grammy's `bot.on('message:<type>')` is filtering middleware: internally
|
|
29490
|
+
// `on → filter(pred, handler) → branch(pred, handler, pass)`. When the filter
|
|
29491
|
+
// matches, grammy runs the leaf handler, which never calls `next()`, so the
|
|
29492
|
+
// chain STOPS — a specific `message:text`/`:photo`/… match above consumes the
|
|
29493
|
+
// update and this catch-all never sees it (specific handler always wins; no
|
|
29494
|
+
// "already handled" guard is needed, the ordering is the guarantee). When NO
|
|
29495
|
+
// specific `message:*` filter matches, grammy ^1.44 would otherwise SILENTLY
|
|
29496
|
+
// drop the update — no log, no ack, no history row (the zero-observability
|
|
29497
|
+
// failure class behind the 2026-07-16 dropped-message incident: message_id
|
|
29498
|
+
// 19090 allocated in the DM with no gateway trace at all). This handler
|
|
29499
|
+
// closes the class: every inbound `message` is either delivered as a turn or
|
|
29500
|
+
// explicitly logged (known-noise service messages → log-only, no turn — see
|
|
29501
|
+
// SERVICE_NOISE_KEYS), so nothing is silently dropped at this layer again.
|
|
29502
|
+
//
|
|
29503
|
+
// Access gating is NOT re-implemented here — routing through
|
|
29504
|
+
// handleInboundCoalesced (the exact path `message:text` uses) applies the
|
|
29505
|
+
// same gate()/allowFrom checks, and parseForwardOrigin runs inside it so
|
|
29506
|
+
// forwarded-message provenance is preserved.
|
|
29507
|
+
installUnhandledMessageCatchAll(
|
|
29508
|
+
bot,
|
|
29509
|
+
(ctx, text) => handleInboundCoalesced(ctx, text, undefined),
|
|
29510
|
+
line => process.stderr.write(line),
|
|
29511
|
+
)
|
|
29512
|
+
|
|
29295
29513
|
// ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
|
|
29296
29514
|
//
|
|
29297
29515
|
// Bot-message reactions in the configured allowlist trigger a synthetic
|
|
@@ -30737,20 +30955,49 @@ void (async () => {
|
|
|
30737
30955
|
// this is the single card the operator sees for the switch.
|
|
30738
30956
|
if (modelSwitchReason != null && modelSwitchMarkerChat) {
|
|
30739
30957
|
const chat = modelSwitchMarkerChat
|
|
30740
|
-
|
|
30741
|
-
|
|
30742
|
-
|
|
30743
|
-
//
|
|
30744
|
-
|
|
30745
|
-
|
|
30746
|
-
|
|
30747
|
-
|
|
30748
|
-
|
|
30749
|
-
|
|
30750
|
-
|
|
30751
|
-
|
|
30752
|
-
|
|
30958
|
+
// Derive the confirmation from the DETERMINISTIC post-boot
|
|
30959
|
+
// signals. A non-default switch that reverted to the configured
|
|
30960
|
+
// default (a wedged/consumed apply-boot — the silent-revert bug)
|
|
30961
|
+
// must WARN, not print a misleading green "✅ Now running
|
|
30962
|
+
// <default>" card. `applied` / `default` keep the honest green
|
|
30963
|
+
// card (N4: the default/revert case still confirms).
|
|
30964
|
+
const confirmation = classifyModelSwitchConfirmation({
|
|
30965
|
+
reason: modelSwitchReason,
|
|
30966
|
+
launched,
|
|
30967
|
+
configured,
|
|
30968
|
+
})
|
|
30969
|
+
// LOW-2 dedup: the config-default-changed / proxy-down revert
|
|
30970
|
+
// paths in start.sh write a TAILORED `.session-model-alert`
|
|
30971
|
+
// (relayed to operators below) that already explains why the
|
|
30972
|
+
// switch didn't apply and how to re-issue it. Suppress the
|
|
30973
|
+
// generic not-applied card when such an alert is present for
|
|
30974
|
+
// this boot so the operator isn't double-warned — the alert is
|
|
30975
|
+
// the more specific message. The not-applied card still fires
|
|
30976
|
+
// for the plain wedge/revert case (no alert on disk).
|
|
30977
|
+
const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
|
|
30978
|
+
if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
|
|
30979
|
+
process.stderr.write(
|
|
30980
|
+
`telegram gateway: gw /model relaunch applied — suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=${getMyAgentName()} target=${confirmation.target}\n`,
|
|
30753
30981
|
)
|
|
30982
|
+
} else {
|
|
30983
|
+
const body =
|
|
30984
|
+
confirmation.kind === 'applied'
|
|
30985
|
+
? `✅ Now running \`${confirmation.launched}\` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
|
|
30986
|
+
: confirmation.kind === 'not-applied'
|
|
30987
|
+
? `⚠️ Your switch to \`${confirmation.target}\` didn't apply — the agent reverted to \`${confirmation.revertedTo}\` (the apply-boot didn't complete). Re-issue \`/model ${confirmation.target}\` to try again.`
|
|
30988
|
+
: `✅ Now running \`${confirmation.launched}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
|
|
30989
|
+
// allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
|
|
30990
|
+
void lockedBot.api
|
|
30991
|
+
.sendMessage(chat.chatId, body, {
|
|
30992
|
+
parse_mode: 'Markdown',
|
|
30993
|
+
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
30994
|
+
})
|
|
30995
|
+
.catch((err: unknown) =>
|
|
30996
|
+
process.stderr.write(
|
|
30997
|
+
`telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
30998
|
+
),
|
|
30999
|
+
)
|
|
31000
|
+
}
|
|
30754
31001
|
}
|
|
30755
31002
|
} catch { /* leave override as-is on a bad read */ }
|
|
30756
31003
|
}
|