switchroom 0.18.29 → 0.18.30
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 +2074 -1585
- 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 +73 -20
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +560 -96
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +212 -17
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -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/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
|
|
@@ -5341,6 +5358,13 @@ function endCurrentTurnAtomic(
|
|
|
5341
5358
|
// wedging forever. No-op when this turn delivered, when nothing is
|
|
5342
5359
|
// buffered, or when the serialize feature is off.
|
|
5343
5360
|
armNoReplyDrainTimer(turn)
|
|
5361
|
+
// #3293 finding 1 — resolve any deferred non-operator failure notice against
|
|
5362
|
+
// this turn's outcome: replied → the turn recovered from the error line, the
|
|
5363
|
+
// gate drops the notice; reply-less → the turn genuinely died, the notice is
|
|
5364
|
+
// sent now. replyCalled covers the short-answer/#2624 shape where
|
|
5365
|
+
// finalAnswerDelivered stays false despite an explicit reply. No-op when
|
|
5366
|
+
// nothing is pending (the overwhelmingly common path).
|
|
5367
|
+
flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled)
|
|
5344
5368
|
return turnEndedAt
|
|
5345
5369
|
}
|
|
5346
5370
|
|
|
@@ -8544,7 +8568,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
8544
8568
|
`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
|
|
8545
8569
|
(opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
|
|
8546
8570
|
)
|
|
8547
|
-
|
|
8571
|
+
// Ken's deterministic error-surfacing policy: an OPERATOR-ACTIONABLE fault
|
|
8572
|
+
// (credentials / credit / proxy-misconfig) must not reach non-operator users
|
|
8573
|
+
// as a raw or misleading card they can't act on. Split the audience — the
|
|
8574
|
+
// operator (allowlist head) gets the full card; every other allowlist chat
|
|
8575
|
+
// gets, at most, a brief plain-language "it's on our side" notice. Non-
|
|
8576
|
+
// actionable kinds keep their existing broadcast (all chats are operatorChats).
|
|
8577
|
+
const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
|
|
8578
|
+
kind,
|
|
8579
|
+
access.allowFrom,
|
|
8580
|
+
access.allowFrom[0],
|
|
8581
|
+
)
|
|
8582
|
+
if (userNoticeChats.length > 0) {
|
|
8583
|
+
process.stderr.write(
|
|
8584
|
+
`telegram gateway: operator-event operator-only routing agent=${agent} kind=${kind} operatorChats=${operatorChats.length} userNoticeChats=${userNoticeChats.length}\n`,
|
|
8585
|
+
)
|
|
8586
|
+
}
|
|
8587
|
+
|
|
8588
|
+
for (const chat_id of operatorChats) {
|
|
8548
8589
|
// The resolved topic is valid ONLY in the agent's supergroup — attaching
|
|
8549
8590
|
// it to an operator DM recipient yields 400 "message thread not found" and
|
|
8550
8591
|
// the event silently fails to deliver (the marko #2096 class). Guard it:
|
|
@@ -8581,6 +8622,63 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
8581
8622
|
)
|
|
8582
8623
|
})
|
|
8583
8624
|
}
|
|
8625
|
+
|
|
8626
|
+
// Non-operator users: only the plain-language failure notice, and ONLY when
|
|
8627
|
+
// the turn genuinely dies (userNoticeChats is empty for every non-actionable
|
|
8628
|
+
// kind — see decideOperatorEventAudience). #3293 review finding 1: an error
|
|
8629
|
+
// line is NOT proof of turn failure — the LiteLLM fallback can 401 while a
|
|
8630
|
+
// retry / another deployment still serves the turn, and sending "couldn't
|
|
8631
|
+
// complete that" for a turn that completed is a false failure report. So the
|
|
8632
|
+
// notice is never sent here: it is SCHEDULED on the pendingUserNoticeGate and
|
|
8633
|
+
// resolved at the turn-end funnel (endCurrentTurnAtomic) — dropped when the
|
|
8634
|
+
// turn delivered a reply (recovered), sent when it ended reply-less (died).
|
|
8635
|
+
// Un-resolved notices expire after PENDING_USER_NOTICE_TTL_MS (bias to
|
|
8636
|
+
// silence over a false failure claim). Operator cards above stay immediate.
|
|
8637
|
+
if (userNoticeChats.length > 0) {
|
|
8638
|
+
pendingUserNoticeGate.schedule({
|
|
8639
|
+
chatIds: userNoticeChats,
|
|
8640
|
+
text: renderUserFacingFailureNotice(),
|
|
8641
|
+
agent,
|
|
8642
|
+
kind,
|
|
8643
|
+
atMs: Date.now(),
|
|
8644
|
+
})
|
|
8645
|
+
process.stderr.write(
|
|
8646
|
+
`telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length}\n`,
|
|
8647
|
+
)
|
|
8648
|
+
}
|
|
8649
|
+
}
|
|
8650
|
+
|
|
8651
|
+
/**
|
|
8652
|
+
* Turn-end resolution of deferred user failure notices (#3293 finding 1).
|
|
8653
|
+
* Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
|
|
8654
|
+
* passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
|
|
8655
|
+
* (the model explicitly replied → the turn recovered → notices are dropped by
|
|
8656
|
+
* the gate). Only a reply-less turn end flushes the pending notices to the
|
|
8657
|
+
* non-operator chats, so the user notice fires IFF the turn genuinely died.
|
|
8658
|
+
*/
|
|
8659
|
+
function flushPendingUserFailureNotices(turnDeliveredReply: boolean): void {
|
|
8660
|
+
const notices = pendingUserNoticeGate.resolveTurnEnd(turnDeliveredReply)
|
|
8661
|
+
if (notices.length === 0) return
|
|
8662
|
+
const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
8663
|
+
const noticeSupergroup = resolveAgentSupergroupChatId()
|
|
8664
|
+
for (const notice of notices) {
|
|
8665
|
+
process.stderr.write(
|
|
8666
|
+
`telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
|
|
8667
|
+
)
|
|
8668
|
+
for (const chat_id of notice.chatIds) {
|
|
8669
|
+
const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
|
|
8670
|
+
const opts = {
|
|
8671
|
+
...(thread != null ? { message_thread_id: thread } : {}),
|
|
8672
|
+
}
|
|
8673
|
+
// allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
|
|
8674
|
+
void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
|
|
8675
|
+
.catch(e => {
|
|
8676
|
+
process.stderr.write(
|
|
8677
|
+
`telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
|
|
8678
|
+
)
|
|
8679
|
+
})
|
|
8680
|
+
}
|
|
8681
|
+
}
|
|
8584
8682
|
}
|
|
8585
8683
|
|
|
8586
8684
|
/**
|
|
@@ -9678,6 +9776,18 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
9678
9776
|
process.stderr.write(
|
|
9679
9777
|
`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
|
|
9680
9778
|
)
|
|
9779
|
+
// #3284 — this boot ACQUIRED the boot lock, so it is the surviving healthy
|
|
9780
|
+
// session. Consume the session-model carrier now (delete the carrier + the
|
|
9781
|
+
// bounded-retry attempt counter). start.sh no longer deletes the carrier
|
|
9782
|
+
// before apply: an apply-boot that wedged before reaching this point leaves
|
|
9783
|
+
// the carrier in place so the retry boot RE-APPLIES the intended model
|
|
9784
|
+
// instead of silently reverting to the configured default (the marko/klanker
|
|
9785
|
+
// boot.lock_stale_recovered_boot_mismatch revert). Best-effort, gated on
|
|
9786
|
+
// lock ownership so a LOSING double-boot never clears the winner's carrier.
|
|
9787
|
+
{
|
|
9788
|
+
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9789
|
+
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9790
|
+
}
|
|
9681
9791
|
// We WON the startup mutex — this gateway is the sole live owner of the
|
|
9682
9792
|
// shared per-agent status-pin store, so it's now safe to clean up orphaned
|
|
9683
9793
|
// pins from a prior (dead) session. Gated here (not at import time) so a
|
|
@@ -9701,6 +9811,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
9701
9811
|
// probe + 409-retry loop is still the liveness guard on this path. A
|
|
9702
9812
|
// successful writePidFile here means no live holder was detected, so
|
|
9703
9813
|
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
9814
|
+
// #3284: same healthy-boot carrier consume as the mutex-acquired path —
|
|
9815
|
+
// a successful writePidFile means no live holder was detected, so this is
|
|
9816
|
+
// the surviving session.
|
|
9817
|
+
{
|
|
9818
|
+
const carrierAgentDir = resolveAgentDirFromEnv()
|
|
9819
|
+
if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
|
|
9820
|
+
}
|
|
9704
9821
|
// #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
|
|
9705
9822
|
void runBootPinCleanupAndDmSweep()
|
|
9706
9823
|
} catch (writeErr) {
|
|
@@ -22890,9 +23007,14 @@ async function runSwitchroomCommand(
|
|
|
22890
23007
|
// commands) preserve in-place reply behavior. /logs /audit
|
|
22891
23008
|
// /upgradestatus /memory pass 'heavy' to route to admin alias.
|
|
22892
23009
|
classification: 'query' | 'mutation' | 'heavy' = 'query',
|
|
23010
|
+
// Optional DISPLAY-ONLY transform applied to the stripped command output
|
|
23011
|
+
// before formatting (never mutates the underlying stored logs). /logs uses
|
|
23012
|
+
// this to render leading UTC ISO docker-log timestamps in local am/pm.
|
|
23013
|
+
transformOutput?: (raw: string) => string,
|
|
22893
23014
|
): Promise<void> {
|
|
22894
23015
|
try {
|
|
22895
|
-
const
|
|
23016
|
+
const stripped = stripAnsi(switchroomExec(args))
|
|
23017
|
+
const output = transformOutput ? transformOutput(stripped) : stripped
|
|
22896
23018
|
const formatted = formatSwitchroomOutput(output)
|
|
22897
23019
|
if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
|
|
22898
23020
|
else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
|
|
@@ -26961,7 +27083,23 @@ bot.command('logs', async ctx => {
|
|
|
26961
27083
|
const lines = linesArg ? parseInt(linesArg, 10) : 20
|
|
26962
27084
|
const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
|
|
26963
27085
|
// PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
|
|
26964
|
-
|
|
27086
|
+
// #tz-fix audit (MEDIUM gap 2): `--timestamps` makes docker prefix every
|
|
27087
|
+
// line with its own UTC ISO-8601-Z stamp — the only deterministic per-line
|
|
27088
|
+
// timestamp (raw app lines often carry no stamp, or a local-zone one that
|
|
27089
|
+
// must NOT be re-shifted). We then render that stamp in local am/pm at
|
|
27090
|
+
// DISPLAY time so `/logs` doesn't surface UTC (competing with the
|
|
27091
|
+
// local-time hint). The zone is the GATEWAY/operator zone
|
|
27092
|
+
// (resolveEnvTimezone of this process), not the target agent's zone —
|
|
27093
|
+
// intended: /logs is an operator surface. Stored logs are untouched —
|
|
27094
|
+
// the transform runs only on the text sent to chat.
|
|
27095
|
+
const tz = resolveEnvTimezone()
|
|
27096
|
+
await runSwitchroomCommand(
|
|
27097
|
+
ctx,
|
|
27098
|
+
['agent', 'logs', name, '--lines', String(lineCount), '--timestamps'],
|
|
27099
|
+
`logs ${name}`,
|
|
27100
|
+
'heavy',
|
|
27101
|
+
(raw) => renderLogTimestampsLocal(raw, tz),
|
|
27102
|
+
)
|
|
26965
27103
|
})
|
|
26966
27104
|
|
|
26967
27105
|
bot.command('memory', async ctx => {
|
|
@@ -29292,6 +29430,34 @@ bot.on('message:pinned_message', async ctx => {
|
|
|
29292
29430
|
}
|
|
29293
29431
|
})
|
|
29294
29432
|
|
|
29433
|
+
// ─── Terminal catch-all for unhandled message content types (#3300) ───────
|
|
29434
|
+
//
|
|
29435
|
+
// MUST stay registered LAST among the `message`/`message:*` handlers.
|
|
29436
|
+
//
|
|
29437
|
+
// grammy's `bot.on('message:<type>')` is filtering middleware: internally
|
|
29438
|
+
// `on → filter(pred, handler) → branch(pred, handler, pass)`. When the filter
|
|
29439
|
+
// matches, grammy runs the leaf handler, which never calls `next()`, so the
|
|
29440
|
+
// chain STOPS — a specific `message:text`/`:photo`/… match above consumes the
|
|
29441
|
+
// update and this catch-all never sees it (specific handler always wins; no
|
|
29442
|
+
// "already handled" guard is needed, the ordering is the guarantee). When NO
|
|
29443
|
+
// specific `message:*` filter matches, grammy ^1.44 would otherwise SILENTLY
|
|
29444
|
+
// drop the update — no log, no ack, no history row (the zero-observability
|
|
29445
|
+
// failure class behind the 2026-07-16 dropped-message incident: message_id
|
|
29446
|
+
// 19090 allocated in the DM with no gateway trace at all). This handler
|
|
29447
|
+
// closes the class: every inbound `message` is either delivered as a turn or
|
|
29448
|
+
// explicitly logged (known-noise service messages → log-only, no turn — see
|
|
29449
|
+
// SERVICE_NOISE_KEYS), so nothing is silently dropped at this layer again.
|
|
29450
|
+
//
|
|
29451
|
+
// Access gating is NOT re-implemented here — routing through
|
|
29452
|
+
// handleInboundCoalesced (the exact path `message:text` uses) applies the
|
|
29453
|
+
// same gate()/allowFrom checks, and parseForwardOrigin runs inside it so
|
|
29454
|
+
// forwarded-message provenance is preserved.
|
|
29455
|
+
installUnhandledMessageCatchAll(
|
|
29456
|
+
bot,
|
|
29457
|
+
(ctx, text) => handleInboundCoalesced(ctx, text, undefined),
|
|
29458
|
+
line => process.stderr.write(line),
|
|
29459
|
+
)
|
|
29460
|
+
|
|
29295
29461
|
// ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
|
|
29296
29462
|
//
|
|
29297
29463
|
// Bot-message reactions in the configured allowlist trigger a synthetic
|
|
@@ -30737,20 +30903,49 @@ void (async () => {
|
|
|
30737
30903
|
// this is the single card the operator sees for the switch.
|
|
30738
30904
|
if (modelSwitchReason != null && modelSwitchMarkerChat) {
|
|
30739
30905
|
const chat = modelSwitchMarkerChat
|
|
30740
|
-
|
|
30741
|
-
|
|
30742
|
-
|
|
30743
|
-
//
|
|
30744
|
-
|
|
30745
|
-
|
|
30746
|
-
|
|
30747
|
-
|
|
30748
|
-
|
|
30749
|
-
|
|
30750
|
-
|
|
30751
|
-
|
|
30752
|
-
|
|
30906
|
+
// Derive the confirmation from the DETERMINISTIC post-boot
|
|
30907
|
+
// signals. A non-default switch that reverted to the configured
|
|
30908
|
+
// default (a wedged/consumed apply-boot — the silent-revert bug)
|
|
30909
|
+
// must WARN, not print a misleading green "✅ Now running
|
|
30910
|
+
// <default>" card. `applied` / `default` keep the honest green
|
|
30911
|
+
// card (N4: the default/revert case still confirms).
|
|
30912
|
+
const confirmation = classifyModelSwitchConfirmation({
|
|
30913
|
+
reason: modelSwitchReason,
|
|
30914
|
+
launched,
|
|
30915
|
+
configured,
|
|
30916
|
+
})
|
|
30917
|
+
// LOW-2 dedup: the config-default-changed / proxy-down revert
|
|
30918
|
+
// paths in start.sh write a TAILORED `.session-model-alert`
|
|
30919
|
+
// (relayed to operators below) that already explains why the
|
|
30920
|
+
// switch didn't apply and how to re-issue it. Suppress the
|
|
30921
|
+
// generic not-applied card when such an alert is present for
|
|
30922
|
+
// this boot so the operator isn't double-warned — the alert is
|
|
30923
|
+
// the more specific message. The not-applied card still fires
|
|
30924
|
+
// for the plain wedge/revert case (no alert on disk).
|
|
30925
|
+
const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
|
|
30926
|
+
if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
|
|
30927
|
+
process.stderr.write(
|
|
30928
|
+
`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
30929
|
)
|
|
30930
|
+
} else {
|
|
30931
|
+
const body =
|
|
30932
|
+
confirmation.kind === 'applied'
|
|
30933
|
+
? `✅ 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.`
|
|
30934
|
+
: confirmation.kind === 'not-applied'
|
|
30935
|
+
? `⚠️ 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.`
|
|
30936
|
+
: `✅ Now running \`${confirmation.launched}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
|
|
30937
|
+
// allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
|
|
30938
|
+
void lockedBot.api
|
|
30939
|
+
.sendMessage(chat.chatId, body, {
|
|
30940
|
+
parse_mode: 'Markdown',
|
|
30941
|
+
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
30942
|
+
})
|
|
30943
|
+
.catch((err: unknown) =>
|
|
30944
|
+
process.stderr.write(
|
|
30945
|
+
`telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
30946
|
+
),
|
|
30947
|
+
)
|
|
30948
|
+
}
|
|
30754
30949
|
}
|
|
30755
30950
|
} catch { /* leave override as-is on a bad read */ }
|
|
30756
30951
|
}
|
|
@@ -84,6 +84,110 @@ export function isClaudeModel(name: string): boolean {
|
|
|
84
84
|
return lower.startsWith('claude-')
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The outcome of a `/model` apply-boot, derived purely from the DETERMINISTIC
|
|
89
|
+
* post-boot signals (never optimistic):
|
|
90
|
+
*
|
|
91
|
+
* - `reason` — the clean-shutdown marker reason that keyed this boot as a
|
|
92
|
+
* `/model` apply-boot, e.g.
|
|
93
|
+
* `user: /model fable (session-only relaunch, menu)`.
|
|
94
|
+
* - `launched` — the contents of `.active-session-model`: the model start.sh
|
|
95
|
+
* actually passed to `claude --model` this boot.
|
|
96
|
+
* - `configured` — the resolved configured default model.
|
|
97
|
+
*
|
|
98
|
+
* Three outcomes:
|
|
99
|
+
* - `applied` — the launched model differs from the configured default, so
|
|
100
|
+
* the switch landed (a session-only override).
|
|
101
|
+
* - `default` — the operator asked for the configured default (`/model
|
|
102
|
+
* default`, or `/model <configured>`) and got it.
|
|
103
|
+
* - `not-applied` — the operator asked for a NON-default model, but the boot
|
|
104
|
+
* came back on the configured default: the switch SILENTLY
|
|
105
|
+
* failed to apply. The consume-once carrier was consumed by a
|
|
106
|
+
* boot that never launched the target (e.g. a wedged apply-boot
|
|
107
|
+
* that hit boot.lock_stale_recovered_boot_mismatch and reverted
|
|
108
|
+
* to the default). This is the case that previously emitted a
|
|
109
|
+
* MISLEADING green "✅ Now running <default> (the configured
|
|
110
|
+
* default)" card with no signal that the requested switch was
|
|
111
|
+
* lost. It self-corrects across boots: a later boot that
|
|
112
|
+
* genuinely launches the target reports `applied`.
|
|
113
|
+
*/
|
|
114
|
+
export type ModelSwitchConfirmation =
|
|
115
|
+
| { kind: 'applied'; launched: string }
|
|
116
|
+
| { kind: 'default'; launched: string }
|
|
117
|
+
| { kind: 'not-applied'; target: string; revertedTo: string }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract the requested `/model <target>` token from a clean-shutdown reason
|
|
121
|
+
* (e.g. `user: /model fable (session-only relaunch, menu)` → `fable`). Returns
|
|
122
|
+
* null when the reason carries no `/model <token>` (a non-switch reason).
|
|
123
|
+
*/
|
|
124
|
+
export function parseModelSwitchTarget(reason: string): string | null {
|
|
125
|
+
const m = reason.match(/\/model\s+(\S+)/)
|
|
126
|
+
return m ? m[1] : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reduce a model token to a comparable FAMILY key so an alias and its resolved
|
|
131
|
+
* full id compare equal (`sonnet` ≡ `claude-sonnet-5` ≡ `claude-sonnet-5-<date>`).
|
|
132
|
+
*
|
|
133
|
+
* This is the normalization the silent-revert classifier needs: `target` from
|
|
134
|
+
* the clean-shutdown reason is the RAW user token (an alias like `sonnet`),
|
|
135
|
+
* while `.active-session-model` / the configured default may be the RESOLVED
|
|
136
|
+
* full id start.sh wrote on a revert-with-alert path (config-default-changed at
|
|
137
|
+
* start.sh.hbs:1228, proxy-down at :1234). A naive string compare would flag
|
|
138
|
+
* `/model sonnet` on a `claude-sonnet-5`-default agent as "didn't apply" even
|
|
139
|
+
* though sonnet IS the default.
|
|
140
|
+
*
|
|
141
|
+
* NB `resolveMainModel` (scaffold.ts:1358) is NOT sufficient here — it only
|
|
142
|
+
* remaps the `default` alias / unset to the switchroom default; it passes
|
|
143
|
+
* `sonnet`/`opus`/etc. through unchanged. The alias↔full-id equivalence is a
|
|
144
|
+
* FAMILY reduction (same rule model-label.ts uses one-way), done here:
|
|
145
|
+
* - `claude-<family>-…` → `<family>` (e.g. `claude-sonnet-5` → `sonnet`)
|
|
146
|
+
* - a bare alias / any other token → itself, lowercased (`sonnet`, `sr-glm-5`)
|
|
147
|
+
* sr-* ids never carry a `claude-` prefix, so they stay verbatim — correct,
|
|
148
|
+
* since the reason token and `.active-session-model` both hold the same
|
|
149
|
+
* already-expanded sr-* id and compare equal directly.
|
|
150
|
+
*/
|
|
151
|
+
export function modelFamilyToken(token: string): string {
|
|
152
|
+
const t = token.trim().toLowerCase()
|
|
153
|
+
if (t.startsWith('claude-')) {
|
|
154
|
+
const family = t.slice('claude-'.length).split('-').filter((p) => p.length > 0)[0]
|
|
155
|
+
return family ?? t
|
|
156
|
+
}
|
|
157
|
+
return t
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify a `/model` apply-boot outcome from the post-boot signals. Pure so the
|
|
162
|
+
* confirmation-card decision is unit-testable without booting the gateway. The
|
|
163
|
+
* `not-applied` branch is the fix for the silent-revert bug: a non-default switch
|
|
164
|
+
* that reverted to the configured default must warn, not print a green ✅.
|
|
165
|
+
*/
|
|
166
|
+
export function classifyModelSwitchConfirmation(input: {
|
|
167
|
+
reason: string
|
|
168
|
+
launched: string
|
|
169
|
+
configured: string
|
|
170
|
+
}): ModelSwitchConfirmation {
|
|
171
|
+
const { reason, launched, configured } = input
|
|
172
|
+
const isApplyBoot = launched.length > 0 && launched !== configured
|
|
173
|
+
if (isApplyBoot) return { kind: 'applied', launched }
|
|
174
|
+
// launched === configured (or empty): either an intended default/revert, or a
|
|
175
|
+
// NON-default switch that silently reverted to the configured default.
|
|
176
|
+
const target = parseModelSwitchTarget(reason)
|
|
177
|
+
const revertedTo = launched.length > 0 ? launched : configured
|
|
178
|
+
// Family-normalize both sides so an alias target (`sonnet`) matches its
|
|
179
|
+
// resolved full-id revert (`claude-sonnet-5`) — otherwise a `/model sonnet`
|
|
180
|
+
// that reverted to the sonnet default would emit a WRONG "didn't apply" card.
|
|
181
|
+
if (
|
|
182
|
+
target != null &&
|
|
183
|
+
target.toLowerCase() !== 'default' &&
|
|
184
|
+
modelFamilyToken(target) !== modelFamilyToken(revertedTo)
|
|
185
|
+
) {
|
|
186
|
+
return { kind: 'not-applied', target, revertedTo }
|
|
187
|
+
}
|
|
188
|
+
return { kind: 'default', launched: revertedTo }
|
|
189
|
+
}
|
|
190
|
+
|
|
87
191
|
export type ParsedModelCommand =
|
|
88
192
|
| { kind: 'show' }
|
|
89
193
|
| { kind: 'set'; model: string }
|