switchroom 0.17.10 → 0.18.6
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
|
|
|
116
116
|
if (input.turnInFlight) return 'buffer-until-idle'
|
|
117
117
|
return 'deliver'
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* #2917 — atomic check-and-reserve for per-chat outbound FIFO.
|
|
122
|
+
*
|
|
123
|
+
* `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
|
|
124
|
+
* `handleInbound` path that decision and the busy-mark that records "a turn is
|
|
125
|
+
* now in flight for this chat" are separated by an `await` (attachment
|
|
126
|
+
* download, composer-clear). Two same-chat inbounds can therefore each read
|
|
127
|
+
* "idle" during the other's async lead-in and both deliver — the replies then
|
|
128
|
+
* come back reordered. This helper couples the decision with a `reserve` flag:
|
|
129
|
+
* a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
|
|
130
|
+
* any await) so the next same-chat inbound sees it and buffers behind it.
|
|
131
|
+
*
|
|
132
|
+
* `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
|
|
133
|
+
* inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
|
|
134
|
+
* (reserving would wedge the running turn's key). A buffered decision never
|
|
135
|
+
* reserves.
|
|
136
|
+
*/
|
|
137
|
+
export function reserveInboundDelivery(
|
|
138
|
+
input: InboundDeliveryGateInput,
|
|
139
|
+
): { decision: InboundDeliveryDecision; reserve: boolean } {
|
|
140
|
+
const decision = decideInboundDelivery(input)
|
|
141
|
+
const reserve =
|
|
142
|
+
decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
|
|
143
|
+
return { decision, reserve }
|
|
144
|
+
}
|
|
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
|
|
|
52
52
|
* Shape gate for the model argument. This string is typed literally
|
|
53
53
|
* into the agent's tmux pane, so the gate is strict by construction:
|
|
54
54
|
* one token, alphanumeric start, then alphanumerics plus the chars
|
|
55
|
-
* that appear in real model ids (`.` `_` `-` and the `[1m]`-style
|
|
56
|
-
* variant brackets).
|
|
55
|
+
* that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
|
|
56
|
+
* variant brackets). `/` is allowed for OpenRouter-style
|
|
57
|
+
* `sr-vendor/model` ids; it is not a shell metachar inside the
|
|
58
|
+
* double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
|
|
59
|
+
* break the launch. No whitespace means no second token can ride
|
|
57
60
|
* along; no control characters means no newline/Enter smuggling.
|
|
58
61
|
*/
|
|
59
|
-
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._
|
|
62
|
+
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
|
|
60
63
|
|
|
61
64
|
export function isValidModelArg(arg: string): boolean {
|
|
62
65
|
return MODEL_ARG_RE.test(arg)
|
|
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
|
|
|
133
136
|
* mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
|
|
134
137
|
*/
|
|
135
138
|
scheduleRestart: (reason: string) => Promise<void>
|
|
139
|
+
/**
|
|
140
|
+
* Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
|
|
141
|
+
* model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
|
|
142
|
+
* inject can't set them. Instead the gateway writes the chosen token to the
|
|
143
|
+
* `.session-model-override` carrier file and gracefully restarts the agent;
|
|
144
|
+
* the next boot launches `claude --model <token>` directly (LiteLLM routes
|
|
145
|
+
* it, no picker validation). Session-only: reverts to the configured default
|
|
146
|
+
* on the following restart. Wired to the same restart dispatch as
|
|
147
|
+
* `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
|
|
148
|
+
* (already alias-expanded); `reason` is stamped as the restart reason.
|
|
149
|
+
*/
|
|
150
|
+
scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
export interface ModelCommandReply {
|
|
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
|
152
167
|
'\`/model\` — show the configured model',
|
|
153
168
|
`\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
|
|
154
169
|
`_OpenRouter shortcuts:_ ${srAliasExamples}`,
|
|
170
|
+
'_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
|
|
155
171
|
PERSIST_NOTE,
|
|
156
172
|
)
|
|
157
173
|
return { text: lines.join('\n'), html: true }
|
|
@@ -214,6 +230,30 @@ export async function handleModelCommand(
|
|
|
214
230
|
}
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
|
|
234
|
+
// native `/model` picker rejects the unknown `sr-*` id ("Model not found").
|
|
235
|
+
// Carry the token across a graceful restart and relaunch `claude --model
|
|
236
|
+
// sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
|
|
237
|
+
// default on the next restart. The sr-* → Claude direction is handled above.
|
|
238
|
+
if (isSrModel(model)) {
|
|
239
|
+
try {
|
|
240
|
+
await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
243
|
+
return {
|
|
244
|
+
text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
|
|
245
|
+
html: true,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
text: [
|
|
250
|
+
`Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
|
|
251
|
+
'_Session-only — reverts to the configured default on the next restart._',
|
|
252
|
+
].join('\n'),
|
|
253
|
+
html: true,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
|
|
218
258
|
let result: InjectResult
|
|
219
259
|
try {
|
|
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
|
|
|
358
398
|
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
359
399
|
* LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
|
|
360
400
|
* so the Anthropic OAuth credential is NEVER forwarded — safe to route to
|
|
361
|
-
* OpenRouter. Names here are display-only
|
|
362
|
-
*
|
|
401
|
+
* OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
|
|
402
|
+
* and switch confirmations); the raw `sr-*` id is what gets injected into the
|
|
403
|
+
* agent's session. This table is a SUPERSET of the menu-reachable set
|
|
404
|
+
* (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
|
|
405
|
+
* the full `/model sr-<name>` (manual passthrough), so those still get a
|
|
406
|
+
* friendly name without appearing as a keyboard button.
|
|
407
|
+
* See reference/rfcs/litellm-max-subscription-invariants.md § I6.
|
|
363
408
|
*/
|
|
364
409
|
export const SR_MODEL_LABELS: Record<string, string> = {
|
|
365
410
|
'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
366
411
|
'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
367
412
|
'sr-deepseek-r1': 'DeepSeek R1',
|
|
368
413
|
'sr-deepseek-v3': 'DeepSeek V3',
|
|
369
|
-
|
|
414
|
+
// sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
|
|
415
|
+
'sr-glm-5': 'GLM-5.2',
|
|
370
416
|
'sr-codex-5.5': 'Codex 5.5',
|
|
417
|
+
// OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
|
|
418
|
+
'sr-gpt-oss-20b': 'GPT-OSS 20B',
|
|
419
|
+
'sr-gpt-oss-120b': 'GPT-OSS 120B',
|
|
420
|
+
'sr-gpt-5.5': 'GPT-5.5',
|
|
421
|
+
'sr-gpt-5-codex': 'GPT-5 Codex',
|
|
422
|
+
'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
|
|
423
|
+
'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
|
|
424
|
+
'sr-minimax-m3': 'MiniMax M3',
|
|
425
|
+
'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
|
|
371
426
|
}
|
|
372
427
|
|
|
373
428
|
/**
|
|
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
|
|
|
448
503
|
* requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
|
|
449
504
|
* process. switchroom never sets that env on the gateway, so in production
|
|
450
505
|
* discoverSrModels() always returns [] and the external group was silently
|
|
451
|
-
* empty. The six SR_MODEL_ALIASES targets are the sr-*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
506
|
+
* empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
|
|
507
|
+
* seeding from them makes the group reliable without the missing env — while
|
|
508
|
+
* still merging any live results on hosts that do configure discovery.
|
|
509
|
+
*
|
|
510
|
+
* Deliberately a SUBSET: the litellm config exposes more sr-* models than this
|
|
511
|
+
* (the full OpenRouter catalogue). Those extras are display-labelled in
|
|
512
|
+
* SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
|
|
513
|
+
* passthrough — the set path is a shape gate, no whitelist), but they are kept
|
|
514
|
+
* OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
|
|
455
515
|
*
|
|
456
516
|
* Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
|
|
457
517
|
* gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time-only, chat-scoped resolution of a raw Telegram id/username into
|
|
3
|
+
* a human `person_id` (e.g. "Lisa") for display in the `<channel>` tag's
|
|
4
|
+
* `user` attribute.
|
|
5
|
+
*
|
|
6
|
+
* Design (converged after adversarial review — see the PR description for
|
|
7
|
+
* the tradeoffs, do not re-litigate here):
|
|
8
|
+
*
|
|
9
|
+
* - No MCP tool. Not callable by agents — display-only, gateway-internal.
|
|
10
|
+
* - No hot-reload. `PersonDirectory` is built ONCE at gateway boot from
|
|
11
|
+
* the static in-memory `people.json` the scaffold projected from
|
|
12
|
+
* `switchroom.yaml`'s `users:` block. A config change requires an agent
|
|
13
|
+
* restart to take effect — this file never re-reads anything.
|
|
14
|
+
* - `access.json` (the fail-CLOSED allow-list) is a completely separate
|
|
15
|
+
* concern and is never touched here. This feature is fail-OPEN: an
|
|
16
|
+
* unresolved id/username falls back to today's behavior (the caller
|
|
17
|
+
* keeps using the raw id/username) — it never blocks or denies
|
|
18
|
+
* anything.
|
|
19
|
+
* - Chat-scoped: a resolved name is only ever returned for a chat/group
|
|
20
|
+
* the person is actually a member of. In a DM the chat IS the sender,
|
|
21
|
+
* so resolution always applies. In a group, resolution only applies if
|
|
22
|
+
* the sender's id or username is explicitly present in that group's
|
|
23
|
+
* `allowFrom` (read from the EXISTING `access.json`/`loadAccess()`
|
|
24
|
+
* data — no new membership source). An empty/unset group `allowFrom`
|
|
25
|
+
* means membership can't be positively confirmed, so we conservatively
|
|
26
|
+
* do NOT resolve (the raw id/username is shown instead) — a name safe
|
|
27
|
+
* in a 1:1 DM could be a bigger leak in a shared group.
|
|
28
|
+
* - Per-entry validation: a malformed `users:` entry (duplicate
|
|
29
|
+
* `person_id` claimed by two different keys, empty/invalid
|
|
30
|
+
* `person_id`, no `telegram_ids`) drops ONLY that one entry — it never
|
|
31
|
+
* blanks resolution for the whole fleet. `buildPersonDirectory` never
|
|
32
|
+
* throws; every failure mode is reported via its `dropped` return
|
|
33
|
+
* value instead, so the caller can route it through the existing
|
|
34
|
+
* fleet-alert path (`emitGatewayOperatorEvent` in gateway.ts) at
|
|
35
|
+
* low/config severity — this must never page like a real outage.
|
|
36
|
+
*
|
|
37
|
+
* Fix note (accepted soft mitigation — see docs/configuration.md): there is
|
|
38
|
+
* no automated enforcement that a configured `person_id` stays safe to show
|
|
39
|
+
* if a group's membership changes AFTER the entry is written. This is an
|
|
40
|
+
* operator-discipline convention, not a closed gap. Low severity today —
|
|
41
|
+
* only two `person_id`s are configured fleet-wide.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface RawPersonEntry {
|
|
45
|
+
/** The `users:` map key (e.g. "lisa") — carried through for alert text. */
|
|
46
|
+
key: string
|
|
47
|
+
person_id: string
|
|
48
|
+
telegram_ids: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PersonDirectoryEntry {
|
|
52
|
+
key: string
|
|
53
|
+
personId: string
|
|
54
|
+
/** Normalized (lowercased, no leading "@") telegram ids/usernames. */
|
|
55
|
+
telegramKeys: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PersonDirectory {
|
|
59
|
+
/** normalized telegram id/username -> directory entry */
|
|
60
|
+
byTelegramKey: Record<string, PersonDirectoryEntry>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DroppedPersonEntry {
|
|
64
|
+
key: string
|
|
65
|
+
reason: string
|
|
66
|
+
/**
|
|
67
|
+
* Name-scrubbed reason CLASS — the same rejection without the embedded
|
|
68
|
+
* `person_id` human-name value or the colliding telegram id. Used to
|
|
69
|
+
* build the broadcast `alertDetail` so a config-warning card (which
|
|
70
|
+
* `emitGatewayOperatorEvent` sends to EVERY `allowFrom` chat, including
|
|
71
|
+
* group chats where the named person may NOT be a member) never surfaces
|
|
72
|
+
* a human name that the display path (`resolvePersonName`) deliberately
|
|
73
|
+
* chat-scopes. The verbose `reason` is still carried for the operator's
|
|
74
|
+
* own stderr `logLine` (private, not broadcast).
|
|
75
|
+
*/
|
|
76
|
+
reasonClass: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BuildPersonDirectoryResult {
|
|
80
|
+
directory: PersonDirectory
|
|
81
|
+
dropped: DroppedPersonEntry[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Normalize a telegram id or @username for keying: trim, strip a leading
|
|
85
|
+
* "@", lowercase (Telegram usernames are case-insensitive). */
|
|
86
|
+
function normalizeTelegramKey(raw: string): string {
|
|
87
|
+
return raw.trim().replace(/^@/, '').toLowerCase()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate and dedupe raw `users:` `person_id` entries into a lookup
|
|
92
|
+
* directory. Never throws — every rejection is reported via `dropped`
|
|
93
|
+
* instead, per the fail-open / drop-only-that-entry design.
|
|
94
|
+
*
|
|
95
|
+
* Drop reasons:
|
|
96
|
+
* - missing/blank `key` or `person_id`
|
|
97
|
+
* - `telegram_ids` missing or empty (nothing to key the entry by)
|
|
98
|
+
* - `person_id` already claimed by an earlier (different) entry key —
|
|
99
|
+
* first-declared entry wins, the later duplicate is dropped whole
|
|
100
|
+
* (not just the colliding id) so the alert is unambiguous about which
|
|
101
|
+
* entry lost
|
|
102
|
+
* - a `telegram_id`/username already claimed by an earlier (different)
|
|
103
|
+
* entry key — same first-declared-wins convention: the later duplicate
|
|
104
|
+
* is dropped whole so the alert is unambiguous about which entry lost
|
|
105
|
+
* and we never silently last-write-wins a numeric id into the wrong
|
|
106
|
+
* person's name
|
|
107
|
+
*/
|
|
108
|
+
export function buildPersonDirectory(entries: readonly RawPersonEntry[]): BuildPersonDirectoryResult {
|
|
109
|
+
const byTelegramKey: Record<string, PersonDirectoryEntry> = {}
|
|
110
|
+
const dropped: DroppedPersonEntry[] = []
|
|
111
|
+
const personIdOwner = new Map<string, string>() // person_id (lowercased) -> owning entry key
|
|
112
|
+
const telegramKeyOwner = new Map<string, string>() // normalized telegram id/username -> owning entry key
|
|
113
|
+
|
|
114
|
+
for (const raw of entries) {
|
|
115
|
+
const key = typeof raw.key === 'string' ? raw.key.trim() : ''
|
|
116
|
+
const personId = typeof raw.person_id === 'string' ? raw.person_id.trim() : ''
|
|
117
|
+
|
|
118
|
+
if (key.length === 0) {
|
|
119
|
+
dropped.push({ key: raw.key || '(unknown)', reason: 'missing users: map key', reasonClass: 'missing users: map key' })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (personId.length === 0) {
|
|
123
|
+
dropped.push({ key, reason: 'empty or missing person_id', reasonClass: 'empty or missing person_id' })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(raw.telegram_ids) || raw.telegram_ids.length === 0) {
|
|
127
|
+
dropped.push({ key, reason: 'no telegram_ids to resolve against', reasonClass: 'no telegram_ids to resolve against' })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const personIdLower = personId.toLowerCase()
|
|
132
|
+
const existingOwner = personIdOwner.get(personIdLower)
|
|
133
|
+
if (existingOwner != null && existingOwner !== key) {
|
|
134
|
+
dropped.push({
|
|
135
|
+
key,
|
|
136
|
+
// Verbose reason (stderr logLine only — private to the operator):
|
|
137
|
+
// names the person_id value + the owning config key.
|
|
138
|
+
reason: `duplicate person_id "${personId}" already claimed by users.${existingOwner}`,
|
|
139
|
+
// reasonClass (broadcast alertDetail): scrubs the human-name
|
|
140
|
+
// person_id value; keeps the entry key + collision class. A
|
|
141
|
+
// config-warning card fans out to every allowFrom chat, so the
|
|
142
|
+
// name must not travel further than the display path allows.
|
|
143
|
+
reasonClass: `duplicate person_id already claimed by users.${existingOwner}`,
|
|
144
|
+
})
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
personIdOwner.set(personIdLower, key)
|
|
148
|
+
|
|
149
|
+
const telegramKeys = [...new Set(raw.telegram_ids.map(normalizeTelegramKey).filter((k) => k.length > 0))]
|
|
150
|
+
if (telegramKeys.length === 0) {
|
|
151
|
+
dropped.push({ key, reason: 'telegram_ids contained no usable id/username', reasonClass: 'telegram_ids contained no usable id/username' })
|
|
152
|
+
personIdOwner.delete(personIdLower)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const collidingTelegramKey = telegramKeys.find((tk) => {
|
|
157
|
+
const existingTkOwner = telegramKeyOwner.get(tk)
|
|
158
|
+
return existingTkOwner != null && existingTkOwner !== key
|
|
159
|
+
})
|
|
160
|
+
if (collidingTelegramKey != null) {
|
|
161
|
+
const existingTkOwner = telegramKeyOwner.get(collidingTelegramKey)
|
|
162
|
+
dropped.push({
|
|
163
|
+
key,
|
|
164
|
+
reason: `duplicate telegram_id "${collidingTelegramKey}" already claimed by users.${existingTkOwner}`,
|
|
165
|
+
// Scrub the colliding telegram id/username from the broadcast
|
|
166
|
+
// reason; keep the owning config key (operator-chosen slug).
|
|
167
|
+
reasonClass: `duplicate telegram_id already claimed by users.${existingTkOwner}`,
|
|
168
|
+
})
|
|
169
|
+
personIdOwner.delete(personIdLower)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const entry: PersonDirectoryEntry = { key, personId, telegramKeys }
|
|
174
|
+
for (const tk of telegramKeys) {
|
|
175
|
+
telegramKeyOwner.set(tk, key)
|
|
176
|
+
byTelegramKey[tk] = entry
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { directory: { byTelegramKey }, dropped }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface PersonDirectoryBootResult {
|
|
184
|
+
directory: PersonDirectory
|
|
185
|
+
/** Non-null when the caller should route an alert through the fleet
|
|
186
|
+
* alert path (`emitGatewayOperatorEvent`, kind: 'config-warning'). */
|
|
187
|
+
alertDetail: string | null
|
|
188
|
+
/** Always present — one human-readable line for stderr. */
|
|
189
|
+
logLine: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Orchestrate the ONE-TIME boot-time check (requirement: boot-time-only,
|
|
194
|
+
* NOT periodic — call this exactly once, at gateway boot, never on a
|
|
195
|
+
* timer/interval). Extracted as a pure(ish) function — taking the file
|
|
196
|
+
* read as an injected dependency and RETURNING what to log/alert rather
|
|
197
|
+
* than performing the I/O itself — so it's unit-testable without booting
|
|
198
|
+
* the real gateway process.
|
|
199
|
+
*
|
|
200
|
+
* Dead-man's-switch: wraps the whole check in try/catch. If anything
|
|
201
|
+
* throws before completing (including `readEntries` itself), that failure
|
|
202
|
+
* is ALSO surfaced via `alertDetail` — mirrors a past incident where
|
|
203
|
+
* `access.json` validation failed silently in the wrong (fail-open)
|
|
204
|
+
* direction, undetected for hours. `PERSON_DIRECTORY` falls back to an
|
|
205
|
+
* empty directory on crash (fail-open: raw ids/usernames keep showing).
|
|
206
|
+
*/
|
|
207
|
+
export function runPersonDirectoryBootCheck(readEntries: () => RawPersonEntry[]): PersonDirectoryBootResult {
|
|
208
|
+
try {
|
|
209
|
+
const rawEntries = readEntries()
|
|
210
|
+
const { directory, dropped } = buildPersonDirectory(rawEntries)
|
|
211
|
+
|
|
212
|
+
if (dropped.length > 0) {
|
|
213
|
+
// Broadcast-safe summary for `alertDetail` (fans out to EVERY
|
|
214
|
+
// allowFrom chat via emitGatewayOperatorEvent, including group chats
|
|
215
|
+
// where the named person may not be a member): built from
|
|
216
|
+
// `reasonClass`, which scrubs the embedded person_id human-name
|
|
217
|
+
// value and the colliding telegram id. The verbose `reason`
|
|
218
|
+
// (names the values) is kept for the operator's own stderr
|
|
219
|
+
// `logLine` — private, never broadcast.
|
|
220
|
+
const alertSummary = dropped.map((d) => `${d.key} (${d.reasonClass})`).join('; ')
|
|
221
|
+
const logSummary = dropped.map((d) => `${d.key} (${d.reason})`).join('; ')
|
|
222
|
+
const plural = dropped.length === 1 ? 'y' : 'ies'
|
|
223
|
+
return {
|
|
224
|
+
directory,
|
|
225
|
+
alertDetail: `person_id: dropped ${dropped.length} malformed users: entr${plural} at boot — ${alertSummary}`,
|
|
226
|
+
logLine: `telegram gateway: person_id boot validation dropped ${dropped.length} entr${plural}: ${logSummary}`,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
directory,
|
|
232
|
+
alertDetail: null,
|
|
233
|
+
logLine: rawEntries.length > 0
|
|
234
|
+
? `telegram gateway: person_id boot validation ok — ${Object.keys(directory.byTelegramKey).length} telegram id/username(s) resolved`
|
|
235
|
+
: `telegram gateway: person_id boot validation ok — no person_id entries configured`,
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
239
|
+
return {
|
|
240
|
+
directory: { byTelegramKey: {} },
|
|
241
|
+
// Crash alertDetail scrubs the raw error message too — it can
|
|
242
|
+
// carry a filesystem path or other host detail that shouldn't fan
|
|
243
|
+
// out to every allowFrom chat. The full message stays in logLine.
|
|
244
|
+
alertDetail: `person_id boot validation crashed — name resolution disabled this boot (fail-open, raw ids/usernames will show); see gateway stderr for detail`,
|
|
245
|
+
logLine: `telegram gateway: person_id boot validation CRASHED (name resolution disabled this boot, raw ids will show — fail-open): ${msg}`,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface ResolvePersonOptions {
|
|
251
|
+
telegramId: string
|
|
252
|
+
username?: string | undefined
|
|
253
|
+
isDm: boolean
|
|
254
|
+
/** That chat's/group's configured allowFrom, if any (from access.json). */
|
|
255
|
+
groupAllowFrom?: readonly string[] | undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve a sender's display name for THIS chat, chat-scoped per the
|
|
260
|
+
* module doc above. Returns undefined (fall back to raw id/username) when
|
|
261
|
+
* unresolved OR when membership in a group chat can't be positively
|
|
262
|
+
* confirmed.
|
|
263
|
+
*/
|
|
264
|
+
export function resolvePersonName(directory: PersonDirectory, opts: ResolvePersonOptions): string | undefined {
|
|
265
|
+
const idKey = normalizeTelegramKey(opts.telegramId)
|
|
266
|
+
const usernameKey = opts.username ? normalizeTelegramKey(opts.username) : undefined
|
|
267
|
+
|
|
268
|
+
const entry = directory.byTelegramKey[idKey] ?? (usernameKey ? directory.byTelegramKey[usernameKey] : undefined)
|
|
269
|
+
if (!entry) return undefined
|
|
270
|
+
|
|
271
|
+
// DM: the chat IS the sender, so resolution always applies.
|
|
272
|
+
if (opts.isDm) return entry.personId
|
|
273
|
+
|
|
274
|
+
// Group: only resolve if the sender is explicitly present in that
|
|
275
|
+
// chat's allowFrom (the existing membership source) — conservative
|
|
276
|
+
// fallback (undefined) otherwise, since we can't positively confirm
|
|
277
|
+
// membership from an empty/unset list.
|
|
278
|
+
const allowFrom = opts.groupAllowFrom ?? []
|
|
279
|
+
const allowFromNormalized = allowFrom.map(normalizeTelegramKey)
|
|
280
|
+
const memberConfirmed =
|
|
281
|
+
allowFromNormalized.includes(idKey) || (usernameKey != null && allowFromNormalized.includes(usernameKey))
|
|
282
|
+
return memberConfirmed ? entry.personId : undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fail-open call-site wrapper around `resolvePersonName`, for use at the
|
|
287
|
+
* `handleInbound` call site (gateway.ts). This feature's whole design
|
|
288
|
+
* point is "never blocks or denies anything" (module doc above) — a throw
|
|
289
|
+
* from resolution must fall back to the raw id/username, not abort message
|
|
290
|
+
* handling. Mirrors the same defensive pattern already used for
|
|
291
|
+
* `readPeopleFile` (gateway.ts) and `runPersonDirectoryBootCheck` (this
|
|
292
|
+
* file): catch, fall back, never propagate.
|
|
293
|
+
*/
|
|
294
|
+
export function safeResolvePersonName(
|
|
295
|
+
directory: PersonDirectory,
|
|
296
|
+
opts: ResolvePersonOptions,
|
|
297
|
+
rawFallback: string,
|
|
298
|
+
): string {
|
|
299
|
+
try {
|
|
300
|
+
return resolvePersonName(directory, opts) ?? rawFallback
|
|
301
|
+
} catch {
|
|
302
|
+
return rawFallback
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -126,7 +126,27 @@ export function classifyRejection(
|
|
|
126
126
|
// handler shut the process down. reconcileStatusPin now absorbs its own
|
|
127
127
|
// errors (primary fix); this entry is defense-in-depth so ANY leaked
|
|
128
128
|
// pin-rights 400 from any path is log-only, not fatal.
|
|
129
|
-
desc.includes('not enough rights')
|
|
129
|
+
desc.includes('not enough rights') ||
|
|
130
|
+
// 'group chat was upgraded to a supergroup chat' fires when a send
|
|
131
|
+
// targets a basic-group chat_id that Telegram has since migrated to
|
|
132
|
+
// a supergroup (new id format -100xxxxxxxxxx). Telegram sometimes
|
|
133
|
+
// surfaces the replacement id via `error.parameters.migrate_to_chat_id`
|
|
134
|
+
// but not reliably for every send method, so this handler can't always
|
|
135
|
+
// auto-repair the stale id — it can only make the failure non-fatal.
|
|
136
|
+
// This crashed marko's gateway on 2026-07-09 (and recurred from the
|
|
137
|
+
// same stale-id root cause on 2026-06-07 and 2026-06-09): a send to an
|
|
138
|
+
// old pre-migration group id crashed the WHOLE gateway process for
|
|
139
|
+
// every agent/chat, not just the one stale chat. A single unmigrated
|
|
140
|
+
// chat_id in cached state must never be fatal — log it so the stale id
|
|
141
|
+
// can be tracked down and fixed, but keep serving every other chat.
|
|
142
|
+
desc.includes('group chat was upgraded to a supergroup chat') ||
|
|
143
|
+
// Broader class: any 400 whose description signals the target
|
|
144
|
+
// chat_id itself is no longer valid (migrated, deactivated, or
|
|
145
|
+
// otherwise unresolvable). These are all "this one destination is
|
|
146
|
+
// broken", never "the gateway is broken" — same log-only posture as
|
|
147
|
+
// 'chat not found' above.
|
|
148
|
+
desc.includes('chat_id is empty') ||
|
|
149
|
+
desc.includes('group chat was deactivated')
|
|
130
150
|
) {
|
|
131
151
|
return 'log_only'
|
|
132
152
|
}
|