talon-agent 4.3.2 → 4.4.0

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.
Files changed (146) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +1 -1
  3. package/src/backend/claude-sdk/doctor.ts +1 -1
  4. package/src/backend/claude-sdk/state.ts +1 -1
  5. package/src/backend/codex/doctor.ts +1 -1
  6. package/src/backend/codex/init.ts +1 -1
  7. package/src/backend/codex/state.ts +1 -1
  8. package/src/backend/openai-agents/doctor.ts +1 -1
  9. package/src/backend/openai-agents/init.ts +1 -1
  10. package/src/backend/openai-agents/state.ts +1 -1
  11. package/src/backend/remote-server/factory.ts +1 -1
  12. package/src/backend/remote-server/mcp.ts +1 -1
  13. package/src/backend/remote-server/server-bindings.ts +1 -1
  14. package/src/backend/remote-server/state.ts +1 -1
  15. package/src/backend/shared/system-prompt.ts +1 -1
  16. package/src/bootstrap.ts +3 -3
  17. package/src/cli/chat.ts +1 -1
  18. package/src/cli/config.ts +2 -2
  19. package/src/cli/doctor.ts +1 -1
  20. package/src/cli/index.ts +8 -0
  21. package/src/cli/memory.ts +166 -0
  22. package/src/cli/setup.ts +1 -1
  23. package/src/core/agent-runtime/backend-registry.ts +2 -2
  24. package/src/core/agent-runtime/model-ref.ts +2 -2
  25. package/src/core/auth/expiry-monitor.ts +1 -1
  26. package/src/{util/config.ts → core/config/index.ts} +8 -8
  27. package/src/core/{doctor.ts → doctor/index.ts} +11 -11
  28. package/src/core/engine/backend-controller/legacy.ts +1 -1
  29. package/src/core/engine/backend-controller/pool.ts +1 -1
  30. package/src/core/engine/backend-controller/rebind.ts +1 -1
  31. package/src/core/engine/backend-controller/state.ts +1 -1
  32. package/src/core/engine/gateway-actions/plugins.ts +1 -1
  33. package/src/core/engine/model-audit.ts +1 -1
  34. package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
  35. package/src/core/frontend-runtime/capabilities.ts +1 -1
  36. package/src/core/models/active-model.ts +1 -1
  37. package/src/core/plugin/builtins.ts +2 -2
  38. package/src/core/plugin/manage.ts +1 -1
  39. package/src/core/plugin/native-runtimes.ts +1 -1
  40. package/src/frontend/discord/admin.ts +1 -1
  41. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  42. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  43. package/src/frontend/discord/callbacks/components/types.ts +1 -1
  44. package/src/frontend/discord/callbacks/modals.ts +1 -1
  45. package/src/frontend/discord/commands/admin.ts +2 -2
  46. package/src/frontend/discord/commands/definitions.ts +1 -1
  47. package/src/frontend/discord/commands/info.ts +1 -1
  48. package/src/frontend/discord/commands/router.ts +1 -1
  49. package/src/frontend/discord/commands/session.ts +1 -1
  50. package/src/frontend/discord/commands/settings.ts +1 -1
  51. package/src/frontend/discord/handlers/delivery.ts +1 -1
  52. package/src/frontend/discord/handlers/messages.ts +1 -1
  53. package/src/frontend/discord/handlers/queue.ts +1 -1
  54. package/src/frontend/discord/handlers/state.ts +1 -1
  55. package/src/frontend/discord/index.ts +1 -1
  56. package/src/frontend/discord/middleware.ts +1 -1
  57. package/src/frontend/discord/render.ts +1 -1
  58. package/src/frontend/discord/runtime.ts +1 -1
  59. package/src/frontend/native/extensions.ts +4 -1
  60. package/src/frontend/native/index.ts +1 -1
  61. package/src/frontend/native/runtime.ts +1 -1
  62. package/src/frontend/native/settings.ts +1 -1
  63. package/src/frontend/shared/model-commands.ts +1 -1
  64. package/src/frontend/shared/plan-usage-report.ts +1 -1
  65. package/src/frontend/shared/reasoning-levels.ts +1 -1
  66. package/src/frontend/shared/session-status.ts +1 -1
  67. package/src/frontend/teams/index.ts +1 -1
  68. package/src/frontend/teams/runtime.ts +1 -1
  69. package/src/frontend/telegram/actions/chat-info.ts +1 -1
  70. package/src/frontend/telegram/actions/coerce.ts +15 -0
  71. package/src/frontend/telegram/actions/index.ts +2 -2
  72. package/src/frontend/telegram/actions/media.ts +1 -1
  73. package/src/frontend/telegram/actions/messaging.ts +3 -4
  74. package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
  75. package/src/frontend/telegram/actions/moderation/index.ts +74 -0
  76. package/src/frontend/telegram/actions/moderation/members.ts +77 -0
  77. package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
  78. package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
  79. package/src/frontend/telegram/actions/moderation/types.ts +16 -0
  80. package/src/frontend/telegram/actions/rich-messages.ts +51 -0
  81. package/src/frontend/telegram/actions/{shared.ts → send.ts} +9 -59
  82. package/src/frontend/telegram/admin/background.ts +66 -0
  83. package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
  84. package/src/frontend/telegram/admin/health.ts +81 -0
  85. package/src/frontend/telegram/admin/sessions.ts +105 -0
  86. package/src/frontend/telegram/admin.ts +57 -262
  87. package/src/frontend/telegram/callbacks/auth.ts +1 -1
  88. package/src/frontend/telegram/callbacks/effort.ts +1 -1
  89. package/src/frontend/telegram/callbacks/index.ts +3 -3
  90. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  91. package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
  92. package/src/frontend/telegram/callbacks/model/control.ts +29 -0
  93. package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
  94. package/src/frontend/telegram/callbacks/model/select.ts +100 -0
  95. package/src/frontend/telegram/callbacks/model/types.ts +39 -0
  96. package/src/frontend/telegram/callbacks/model/views.ts +116 -0
  97. package/src/frontend/telegram/callbacks/model.ts +61 -331
  98. package/src/frontend/telegram/callbacks/pulse.ts +1 -1
  99. package/src/frontend/telegram/callbacks/{shared.ts → query.ts} +3 -2
  100. package/src/frontend/telegram/callbacks/settings.ts +1 -1
  101. package/src/frontend/telegram/callbacks/whatsapp.ts +1 -1
  102. package/src/frontend/telegram/commands/admin.ts +108 -77
  103. package/src/frontend/telegram/commands/index.ts +1 -1
  104. package/src/frontend/telegram/commands/settings.ts +65 -43
  105. package/src/frontend/telegram/commands/state.ts +1 -1
  106. package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
  107. package/src/frontend/telegram/handlers/context.ts +1 -1
  108. package/src/frontend/telegram/handlers/delivery.ts +2 -2
  109. package/src/frontend/telegram/handlers/messages.ts +1 -1
  110. package/src/frontend/telegram/handlers/queue.ts +1 -1
  111. package/src/frontend/telegram/handlers/state.ts +1 -1
  112. package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
  113. package/src/frontend/telegram/index.ts +1 -1
  114. package/src/frontend/telegram/middleware.ts +167 -202
  115. package/src/frontend/telegram/model-menu.ts +1 -1
  116. package/src/frontend/terminal/builtins/context.ts +184 -0
  117. package/src/frontend/terminal/builtins/help.ts +35 -0
  118. package/src/frontend/terminal/builtins/index.ts +23 -0
  119. package/src/frontend/terminal/builtins/model.ts +198 -0
  120. package/src/frontend/terminal/builtins/session.ts +101 -0
  121. package/src/frontend/terminal/builtins/status.ts +202 -0
  122. package/src/frontend/terminal/command-registry.ts +80 -0
  123. package/src/frontend/terminal/commands.ts +11 -674
  124. package/src/frontend/terminal/index.ts +1 -1
  125. package/src/frontend/terminal/input/history.ts +64 -0
  126. package/src/frontend/terminal/input/keys.ts +143 -0
  127. package/src/frontend/terminal/input/parts.ts +33 -0
  128. package/src/frontend/terminal/input/paste.ts +53 -0
  129. package/src/frontend/terminal/input/render.ts +42 -0
  130. package/src/frontend/terminal/input/state.ts +60 -0
  131. package/src/frontend/terminal/input.ts +27 -342
  132. package/src/frontend/whatsapp/connection.ts +1 -1
  133. package/src/frontend/whatsapp/index.ts +2 -2
  134. package/src/frontend/whatsapp/pairing-service.ts +1 -1
  135. package/src/frontend/whatsapp/runtime.ts +1 -1
  136. package/src/plugins/github/provision.ts +1 -1
  137. package/src/plugins/mempalace/provision.ts +1 -1
  138. package/src/plugins/playwright/provision.ts +1 -1
  139. package/src/storage/memory.ts +505 -0
  140. package/src/storage/repositories/memory-repo.ts +308 -0
  141. package/src/storage/sql/memory.sql +95 -0
  142. package/src/storage/sql/schema.sql +81 -0
  143. package/src/storage/sql/statements.generated.ts +146 -0
  144. package/src/frontend/telegram/actions/moderation.ts +0 -255
  145. /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
  146. /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Model selection — `model:<id>`, `model:reset`, `model:toggle-free`.
3
+ *
4
+ * Every branch resolves the *per-chat* backend so the pick is validated
5
+ * against, and stored under, the backend the chat actually runs on.
6
+ */
7
+
8
+ import type { Context } from "grammy";
9
+ import {
10
+ setChatModelForBackend,
11
+ setChatBackend,
12
+ } from "../../../../storage/chat-settings.js";
13
+ import { resolveModelId as resolveModelName } from "../../../../core/models/catalog.js";
14
+ import { getBackendIdForChat } from "../../../../core/engine/backend-controller/index.js";
15
+ import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
16
+ import type { ModelCallback } from "../../model-callbacks.js";
17
+ import { resolveBackendForChat, toggleChatFreeOnly } from "../../model-menu.js";
18
+ import { answerCallbackQuerySafe, type CallbackDeps } from "../query.js";
19
+ import type { ModelOutcome } from "./types.js";
20
+
21
+ export async function handleSelect(
22
+ ctx: Context,
23
+ action: Extract<ModelCallback, { kind: "select" }>,
24
+ cid: string,
25
+ { config, gateway }: CallbackDeps,
26
+ ): Promise<ModelOutcome | undefined> {
27
+ // Resolve selection against the *per-chat* backend — if the
28
+ // chat has switched to openai-agents we must validate the id
29
+ // against that catalog, not the global default's.
30
+ const be = resolveBackendForChat(cid, gateway);
31
+ const beId = getBackendIdForChat(cid);
32
+ if (be?.models?.resolveModelInfo) {
33
+ const resolution = await be.models?.resolveModelInfo(action.modelId);
34
+ if (resolution.kind !== "exact" || !resolution.model.selectable) {
35
+ await answerCallbackQuerySafe(ctx, {
36
+ text:
37
+ resolution.kind === "exact"
38
+ ? (resolution.model.unavailableReason ?? "Unavailable")
39
+ : "Model is unavailable",
40
+ });
41
+ return undefined;
42
+ }
43
+ // Persist the pick into the chat's *backend-specific* slot.
44
+ // Switching backends later restores each side's prior choice
45
+ // automatically — the slot isn't shared across backends.
46
+ setChatModelForBackend(cid, beId, resolution.storedValue);
47
+ // Also pin the chat to this backend so a restart doesn't
48
+ // unbind to the role-default and orphan the model id.
49
+ setChatBackend(cid, beId);
50
+ return {
51
+ toast: `Model: ${resolution.model.displayName}`,
52
+ view: { kind: "menu" },
53
+ };
54
+ }
55
+ setChatModelForBackend(cid, beId, resolveModelName(action.modelId));
56
+ setChatBackend(cid, beId);
57
+ const { model: resolved } = await resolveActiveModelForChat(
58
+ cid,
59
+ be,
60
+ beId,
61
+ config,
62
+ );
63
+ return { toast: `Model: ${resolved ?? "(unset)"}`, view: { kind: "menu" } };
64
+ }
65
+
66
+ export async function handleReset(
67
+ _ctx: Context,
68
+ _action: Extract<ModelCallback, { kind: "reset" }>,
69
+ cid: string,
70
+ { config, gateway }: CallbackDeps,
71
+ ): Promise<ModelOutcome> {
72
+ // Clear this backend's slot only — other backends' picks stay.
73
+ // Compute the toast through the resolver so the label names the
74
+ // backend's actual default (or "No model selected" when there
75
+ // isn't one — catalog-driven backend, no operator config).
76
+ const be = resolveBackendForChat(cid, gateway);
77
+ const beId = getBackendIdForChat(cid);
78
+ setChatModelForBackend(cid, beId, undefined);
79
+ const { model: resolvedDefault } = await resolveActiveModelForChat(
80
+ cid,
81
+ be,
82
+ beId,
83
+ config,
84
+ );
85
+ return {
86
+ toast: resolvedDefault
87
+ ? `Model reset to default (${resolvedDefault})`
88
+ : `Model reset — no default available, pick one`,
89
+ view: { kind: "menu" },
90
+ };
91
+ }
92
+
93
+ export async function handleToggleFree(
94
+ _ctx: Context,
95
+ _action: Extract<ModelCallback, { kind: "toggle-free" }>,
96
+ cid: string,
97
+ ): Promise<ModelOutcome> {
98
+ const next = toggleChatFreeOnly(cid);
99
+ return { toast: `Free only: ${next ? "on" : "off"}`, view: { kind: "menu" } };
100
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Types for the `model:*` callback handlers.
3
+ *
4
+ * A handler mutates at most one piece of chat-settings (or pool) state and
5
+ * hands the router what to do next: a toast to answer the query with (or a
6
+ * plain ack) and the view to redraw. It returns `undefined` when it already
7
+ * answered the query itself — an error toast, or the "done" close — and
8
+ * there is nothing to redraw.
9
+ */
10
+
11
+ import type { Context } from "grammy";
12
+ import type { ModelCallback } from "../../model-callbacks.js";
13
+ import type { CallbackDeps } from "../query.js";
14
+
15
+ export type ModelView =
16
+ | { kind: "menu" }
17
+ | { kind: "backends" }
18
+ | {
19
+ kind: "browse";
20
+ page?: number;
21
+ filter?: "all" | "free";
22
+ provider?: string;
23
+ backToGroups?: boolean;
24
+ };
25
+
26
+ export type ModelOutcome = { toast?: string; view: ModelView };
27
+
28
+ export type ModelCallbackHandler<A extends ModelCallback = ModelCallback> = (
29
+ ctx: Context,
30
+ action: A,
31
+ cid: string,
32
+ deps: CallbackDeps,
33
+ ) => Promise<ModelOutcome | undefined>;
34
+
35
+ export type ModelCallbackHandlers = {
36
+ [K in ModelCallback["kind"]]: ModelCallbackHandler<
37
+ Extract<ModelCallback, { kind: K }>
38
+ >;
39
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Re-render the /model message in one of its three views: main menu,
3
+ * backend submenu, or browse.
4
+ */
5
+
6
+ import type { Context } from "grammy";
7
+ import { escapeHtml } from "../../formatting.js";
8
+ import {
9
+ renderModelMenuText,
10
+ renderModelMenuKeyboard,
11
+ renderModelBrowseKeyboard,
12
+ renderBackendMenuKeyboard,
13
+ renderBackendMenuText,
14
+ } from "../../helpers/index.js";
15
+ import {
16
+ buildModelMenuViewForChat,
17
+ buildModelBrowseViewForChat,
18
+ buildBackendMenuViewForChat,
19
+ } from "../../model-menu.js";
20
+ import { editOrIgnoreSame, type CallbackDeps } from "../query.js";
21
+ import type { ModelView } from "./types.js";
22
+
23
+ export async function renderModelView(
24
+ ctx: Context,
25
+ view: ModelView,
26
+ cid: string,
27
+ deps: CallbackDeps,
28
+ ): Promise<void> {
29
+ if (view.kind === "menu") return renderMenu(ctx, cid, deps);
30
+ if (view.kind === "backends") return renderBackends(ctx, cid, deps);
31
+ return renderBrowse(ctx, view, cid, deps);
32
+ }
33
+
34
+ async function renderMenu(
35
+ ctx: Context,
36
+ cid: string,
37
+ { config, gateway }: CallbackDeps,
38
+ ): Promise<void> {
39
+ const view = await buildModelMenuViewForChat(cid, config, gateway);
40
+ if (!view) return;
41
+ await editOrIgnoreSame(
42
+ ctx,
43
+ renderModelMenuText(view.state),
44
+ renderModelMenuKeyboard(view.state),
45
+ );
46
+ }
47
+
48
+ async function renderBackends(
49
+ ctx: Context,
50
+ cid: string,
51
+ { config }: CallbackDeps,
52
+ ): Promise<void> {
53
+ const menu = buildBackendMenuViewForChat(cid, config);
54
+ await editOrIgnoreSame(
55
+ ctx,
56
+ renderBackendMenuText({
57
+ activeBackend: menu.activeBackend,
58
+ hasBackendOverride: menu.hasBackendOverride,
59
+ defaultBackendLabel: menu.defaultBackendLabel,
60
+ }),
61
+ renderBackendMenuKeyboard({
62
+ available: menu.available,
63
+ activeBackendId: menu.activeBackend.id,
64
+ hasBackendOverride: menu.hasBackendOverride,
65
+ }),
66
+ );
67
+ }
68
+
69
+ // browse view — controller picks the per-chat backend and
70
+ // applies the chat's freeOnly preference when the caller
71
+ // doesn't override the filter explicitly. `backToGroups`
72
+ // means "drop any cached provider drill" so we omit `provider`.
73
+ async function renderBrowse(
74
+ ctx: Context,
75
+ view: Extract<ModelView, { kind: "browse" }>,
76
+ cid: string,
77
+ { config, gateway }: CallbackDeps,
78
+ ): Promise<void> {
79
+ const browse = await buildModelBrowseViewForChat(
80
+ cid,
81
+ config,
82
+ {
83
+ ...(view.filter !== undefined ? { filter: view.filter } : {}),
84
+ ...(view.page !== undefined ? { page: view.page } : {}),
85
+ ...(view.provider !== undefined && !view.backToGroups
86
+ ? { provider: view.provider }
87
+ : {}),
88
+ },
89
+ gateway,
90
+ );
91
+ if (!browse) return;
92
+ const lines = [
93
+ `<b>Model:</b> <code>${escapeHtml(browse.activeDisplay)}</code>`,
94
+ ...browse.modelDetails.map(escapeHtml),
95
+ ...(browse.filter === "free" && browse.freeCount > 0
96
+ ? ["<i>Filter: free-tier only.</i>"]
97
+ : []),
98
+ ];
99
+ await editOrIgnoreSame(
100
+ ctx,
101
+ lines.join("\n"),
102
+ renderModelBrowseKeyboard(
103
+ browse.modelButtons,
104
+ {
105
+ page: browse.page,
106
+ totalPages: browse.totalPages,
107
+ filter: browse.filter,
108
+ freeCount: browse.freeCount,
109
+ totalCount: browse.totalCount,
110
+ },
111
+ browse.view,
112
+ browse.provider,
113
+ "model:menu",
114
+ ),
115
+ );
116
+ }
@@ -1,359 +1,89 @@
1
1
  /**
2
- * `model:*` callbacks — the /model menu controller. Each branch mutates at
3
- * most one piece of chat-settings (or pool) state and then re-renders one of
4
- * three views: main menu, backend submenu, or browse.
2
+ * `model:*` callbacks — the /model menu controller. Each handler mutates at
3
+ * most one piece of chat-settings (or pool) state, then the router
4
+ * re-renders one of three views: main menu, backend submenu, or browse.
5
5
  *
6
6
  * The controller always resolves the *per-chat* backend so the menu reflects
7
7
  * active per-chat overrides instead of the global chat-role default.
8
+ *
9
+ * Callback data is parsed by `parseModelCallback` (model-callbacks.ts); the
10
+ * table below maps each parsed `kind` to its handler in `model/`:
11
+ * - `control` — done / noop / unknown (ack only)
12
+ * - `select` — select / reset / toggle-free
13
+ * - `nav` — menu / browse / nav-* / backends (view changes only)
14
+ * - `backend` — backend-select / backend-default (rebind + handoff)
15
+ * - `views` — the three renderers
8
16
  */
9
17
 
10
18
  import type { Context } from "grammy";
11
- import {
12
- setChatModelForBackend,
13
- setChatBackend,
14
- } from "../../../storage/chat-settings.js";
15
- import { resolveModelId as resolveModelName } from "../../../core/models/catalog.js";
16
- import { resetSession } from "../../../storage/sessions.js";
17
- import { clearHistory } from "../../../storage/history.js";
18
- import {
19
- getBackendIdForChat,
20
- listAvailableBackends,
21
- rebindChat,
22
- releaseChat,
23
- } from "../../../core/engine/backend-controller/index.js";
24
- import { resetPulseCheckpoint } from "../../../core/background/pulse.js";
25
- import { escapeHtml } from "../formatting.js";
26
- import {
27
- renderModelMenuText,
28
- renderModelMenuKeyboard,
29
- renderModelBrowseKeyboard,
30
- renderBackendMenuKeyboard,
31
- renderBackendMenuText,
32
- } from "../helpers/index.js";
33
19
  import { parseModelCallback } from "../model-callbacks.js";
20
+ import { answerCallbackQuerySafe, type CallbackDeps } from "./query.js";
21
+ import { handleDone, handleNoop } from "./model/control.js";
22
+ import { handleSelect, handleReset, handleToggleFree } from "./model/select.js";
34
23
  import {
35
- buildModelMenuViewForChat,
36
- buildModelBrowseViewForChat,
37
- buildBackendMenuViewForChat,
38
- resolveBackendForChat,
39
- toggleChatFreeOnly,
40
- } from "../model-menu.js";
41
- import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
42
- import {
43
- answerCallbackQuerySafe,
44
- editOrIgnoreSame,
45
- type CallbackDeps,
46
- } from "./shared.js";
47
- import { logWarn } from "../../../util/log.js";
24
+ showMenu,
25
+ showBrowse,
26
+ navBackToProviders,
27
+ navProvider,
28
+ navPage,
29
+ navFilter,
30
+ showBackends,
31
+ } from "./model/nav.js";
32
+ import { handleBackendSelect, handleBackendDefault } from "./model/backend.js";
33
+ import { renderModelView } from "./model/views.js";
34
+ import type {
35
+ ModelCallbackHandler,
36
+ ModelCallbackHandlers,
37
+ } from "./model/types.js";
48
38
 
49
- /**
50
- * Run the backend-side half of a chat's session handoff.
51
- *
52
- * A backend switch already clears Talon's own stores (session row,
53
- * history, pulse checkpoint), but those are only half the state.
54
- * `performSessionReset` also drives the backend capability slots, and
55
- * the switch path skipped them entirely:
56
- *
57
- * - the OUTGOING backend keeps in-process per-chat state that no
58
- * amount of clearing Talon's stores touches. openai-agents holds a
59
- * `MemorySession` map keyed by chat id, so switching away and back
60
- * resurrected the old conversation the operator meant to drop.
61
- * - the INCOMING backend was never warmed, so the first turn after a
62
- * switch paid the full cold start — on OpenCode/Kilo that is session
63
- * creation plus a per-plugin MCP registration sweep.
64
- *
65
- * The warm is deliberately fire-and-forget: it can take seconds, and the
66
- * callback still has a toast to answer and a menu to redraw. Telegram
67
- * expires an unanswered callback query, so blocking here would trade a
68
- * cold first turn for a visibly stuck button.
69
- */
70
- function handOffBackendSession(
71
- chatId: string,
72
- previousBackend: ReturnType<typeof resolveBackendForChat>,
73
- gateway: CallbackDeps["gateway"],
74
- ): void {
75
- previousBackend?.sessions?.resetChat?.(chatId);
76
- const nextBackend = resolveBackendForChat(chatId, gateway);
77
- // Same instance on a no-op switch — warming it again is harmless
78
- // (every step is idempotent) but pointless, so skip it.
79
- if (!nextBackend || nextBackend === previousBackend) return;
80
- void Promise.resolve(nextBackend.sessions?.warmSession?.(chatId)).catch(
81
- (err) =>
82
- logWarn(
83
- "bot",
84
- `[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
85
- ),
86
- );
87
- }
39
+ // Null-prototype so a parsed kind can never resolve an inherited
40
+ // Object.prototype method via `handlers[kind]`.
41
+ export const MODEL_CALLBACK_HANDLERS: ModelCallbackHandlers = Object.assign(
42
+ Object.create(null),
43
+ {
44
+ done: handleDone,
45
+ noop: handleNoop,
46
+ unknown: handleNoop,
47
+ select: handleSelect,
48
+ reset: handleReset,
49
+ "toggle-free": handleToggleFree,
50
+ menu: showMenu,
51
+ browse: showBrowse,
52
+ "nav-back-to-providers": navBackToProviders,
53
+ "nav-provider": navProvider,
54
+ "nav-page": navPage,
55
+ "nav-filter": navFilter,
56
+ backends: showBackends,
57
+ "backend-select": handleBackendSelect,
58
+ "backend-default": handleBackendDefault,
59
+ } satisfies ModelCallbackHandlers,
60
+ );
88
61
 
89
62
  export async function handleModelCallback(
90
63
  ctx: Context,
91
64
  data: string,
92
65
  cid: string,
93
- { config, gateway }: CallbackDeps,
66
+ deps: CallbackDeps,
94
67
  ): Promise<void> {
95
68
  const action = parseModelCallback(data);
96
-
97
- // Acknowledge fast (within Telegram's 30s window) so the user
98
- // doesn't see a perpetual loading spinner.
99
- if (action.kind === "done") {
100
- await answerCallbackQuerySafe(ctx, { text: "Done" });
101
- try {
102
- await ctx.deleteMessage();
103
- } catch {
104
- /* might lack delete permission */
105
- }
106
- return;
107
- }
108
- if (action.kind === "noop" || action.kind === "unknown") {
69
+ // Every parsed kind has a handler; the cast only widens the parameter
70
+ // type so the union action can be passed through.
71
+ const handler = MODEL_CALLBACK_HANDLERS[action.kind] as
72
+ ModelCallbackHandler | undefined;
73
+ if (!handler) {
109
74
  await answerCallbackQuerySafe(ctx);
110
75
  return;
111
76
  }
112
-
113
- // State-mutating actions.
114
- let toast: string | undefined;
115
- let viewAfter: "menu" | "browse" | "backends" = "menu";
116
- let browsePage: number | undefined;
117
- let browseFilter: "all" | "free" | undefined;
118
- let browseProvider: string | undefined;
119
- let browseBackToGroups = false;
120
-
121
- if (action.kind === "select") {
122
- // Resolve selection against the *per-chat* backend — if the
123
- // chat has switched to openai-agents we must validate the id
124
- // against that catalog, not the global default's.
125
- const be = resolveBackendForChat(cid, gateway);
126
- const beId = getBackendIdForChat(cid);
127
- if (be?.models?.resolveModelInfo) {
128
- const resolution = await be.models?.resolveModelInfo(action.modelId);
129
- if (resolution.kind !== "exact" || !resolution.model.selectable) {
130
- await answerCallbackQuerySafe(ctx, {
131
- text:
132
- resolution.kind === "exact"
133
- ? (resolution.model.unavailableReason ?? "Unavailable")
134
- : "Model is unavailable",
135
- });
136
- return;
137
- }
138
- // Persist the pick into the chat's *backend-specific* slot.
139
- // Switching backends later restores each side's prior choice
140
- // automatically — the slot isn't shared across backends.
141
- setChatModelForBackend(cid, beId, resolution.storedValue);
142
- // Also pin the chat to this backend so a restart doesn't
143
- // unbind to the role-default and orphan the model id.
144
- setChatBackend(cid, beId);
145
- toast = `Model: ${resolution.model.displayName}`;
146
- } else {
147
- setChatModelForBackend(cid, beId, resolveModelName(action.modelId));
148
- setChatBackend(cid, beId);
149
- const { model: resolved } = await resolveActiveModelForChat(
150
- cid,
151
- be,
152
- beId,
153
- config,
154
- );
155
- toast = `Model: ${resolved ?? "(unset)"}`;
156
- }
157
- } else if (action.kind === "reset") {
158
- // Clear this backend's slot only — other backends' picks stay.
159
- // Compute the toast through the resolver so the label names the
160
- // backend's actual default (or "No model selected" when there
161
- // isn't one — catalog-driven backend, no operator config).
162
- const be = resolveBackendForChat(cid, gateway);
163
- const beId = getBackendIdForChat(cid);
164
- setChatModelForBackend(cid, beId, undefined);
165
- const { model: resolvedDefault } = await resolveActiveModelForChat(
166
- cid,
167
- be,
168
- beId,
169
- config,
170
- );
171
- toast = resolvedDefault
172
- ? `Model reset to default (${resolvedDefault})`
173
- : `Model reset — no default available, pick one`;
174
- } else if (action.kind === "toggle-free") {
175
- const next = toggleChatFreeOnly(cid);
176
- toast = `Free only: ${next ? "on" : "off"}`;
177
- } else if (action.kind === "menu") {
178
- viewAfter = "menu";
179
- } else if (action.kind === "browse") {
180
- viewAfter = "browse";
181
- } else if (action.kind === "nav-back-to-providers") {
182
- viewAfter = "browse";
183
- browseBackToGroups = true;
184
- browsePage = 1;
185
- } else if (action.kind === "nav-provider") {
186
- viewAfter = "browse";
187
- browseProvider = action.provider;
188
- browsePage = 1;
189
- } else if (action.kind === "nav-page") {
190
- viewAfter = "browse";
191
- browsePage = action.page;
192
- browseFilter = action.filter;
193
- browseProvider = action.provider;
194
- } else if (action.kind === "nav-filter") {
195
- // Legacy support — current UX promotes free-toggle on the main
196
- // menu, but a `model:nav:filter:*` payload from an old message
197
- // still routes to the browse view with that filter applied.
198
- viewAfter = "browse";
199
- browseFilter = action.filter;
200
- browsePage = 1;
201
- } else if (action.kind === "backends") {
202
- viewAfter = "backends";
203
- } else if (action.kind === "backend-select") {
204
- // Rebind chat to the chosen backend. Verify the requested id
205
- // is on the enabled list to keep the menu and the operation
206
- // in sync — `config.enabledBackends` is a UX filter and we
207
- // honour it here too.
208
- const available = listAvailableBackends(config);
209
- if (!available.some((b) => b.id === action.backendId)) {
210
- await answerCallbackQuerySafe(ctx, {
211
- text: "Backend not available",
212
- });
213
- return;
214
- }
215
- // Resolve the outgoing backend BEFORE rebinding — afterwards this
216
- // chat already points at the new one and the old in-process state
217
- // would be unreachable.
218
- const previousBackend = resolveBackendForChat(cid, gateway);
219
- const result = await rebindChat(cid, action.backendId, config);
220
- if (!result.ok) {
221
- await answerCallbackQuerySafe(ctx, {
222
- text: result.error?.slice(0, 200) ?? "Rebind failed",
223
- });
224
- return;
225
- }
226
- setChatBackend(cid, action.backendId);
227
- // Switching backends drops the previous backend's per-chat
228
- // session state (it's not portable across backends). We DO
229
- // NOT clear `modelByBackend` — keeping each backend's prior
230
- // pick means switching back-and-forth restores each side's
231
- // last choice automatically (Codex chat keeps gpt-5.5,
232
- // OpenRouter chat keeps owl-alpha, etc).
233
- resetSession(cid);
234
- clearHistory(cid);
235
- resetPulseCheckpoint(cid);
236
- handOffBackendSession(cid, previousBackend, gateway);
237
- const label =
238
- available.find((b) => b.id === action.backendId)?.label ??
239
- action.backendId;
240
- // Toast names the model the new backend will actually run
241
- // (per-chat slot if remembered, else canonical / operator
242
- // default; "no default" if catalog-driven with no config).
243
- const newBackend = resolveBackendForChat(cid, gateway);
244
- const { model: resolvedNewModel } = await resolveActiveModelForChat(
245
- cid,
246
- newBackend,
247
- action.backendId,
248
- config,
249
- );
250
- toast = resolvedNewModel
251
- ? `Backend: ${label} (model: ${resolvedNewModel})`
252
- : `Backend: ${label} — no default model, /model to pick one`;
253
- viewAfter = "menu";
254
- } else if (action.kind === "backend-default") {
255
- // Drop the per-chat backend override; chat reverts to the
256
- // global chat-role backend. Per-backend model picks are
257
- // preserved (modelByBackend stays intact) so reverting and
258
- // switching back later still restores prior choices.
259
- const previousBackend = resolveBackendForChat(cid, gateway);
260
- await releaseChat(cid);
261
- setChatBackend(cid, undefined);
262
- resetSession(cid);
263
- clearHistory(cid);
264
- resetPulseCheckpoint(cid);
265
- handOffBackendSession(cid, previousBackend, gateway);
266
- // Resolve the now-default backend's model for the toast.
267
- const defaultBackend = resolveBackendForChat(cid, gateway);
268
- const defaultBackendId = getBackendIdForChat(cid);
269
- const { model: resolvedRoleModel } = await resolveActiveModelForChat(
270
- cid,
271
- defaultBackend,
272
- defaultBackendId,
273
- config,
274
- );
275
- toast = resolvedRoleModel
276
- ? `Backend reset to default (model: ${resolvedRoleModel})`
277
- : `Backend reset to default — no model picked, /model to pick one`;
278
- viewAfter = "menu";
279
- }
77
+ const outcome = await handler(ctx, action, cid, deps);
78
+ if (!outcome) return;
280
79
 
281
80
  // Selection / reset / toggle confirmations toast briefly.
282
- if (toast !== undefined) {
283
- await answerCallbackQuerySafe(ctx, { text: toast });
81
+ if (outcome.toast !== undefined) {
82
+ await answerCallbackQuerySafe(ctx, { text: outcome.toast });
284
83
  } else {
285
84
  await answerCallbackQuerySafe(ctx);
286
85
  }
287
86
 
288
87
  // Re-render the message in the appropriate view.
289
- if (viewAfter === "menu") {
290
- const view = await buildModelMenuViewForChat(cid, config, gateway);
291
- if (!view) return;
292
- await editOrIgnoreSame(
293
- ctx,
294
- renderModelMenuText(view.state),
295
- renderModelMenuKeyboard(view.state),
296
- );
297
- return;
298
- }
299
-
300
- if (viewAfter === "backends") {
301
- const menu = buildBackendMenuViewForChat(cid, config);
302
- await editOrIgnoreSame(
303
- ctx,
304
- renderBackendMenuText({
305
- activeBackend: menu.activeBackend,
306
- hasBackendOverride: menu.hasBackendOverride,
307
- defaultBackendLabel: menu.defaultBackendLabel,
308
- }),
309
- renderBackendMenuKeyboard({
310
- available: menu.available,
311
- activeBackendId: menu.activeBackend.id,
312
- hasBackendOverride: menu.hasBackendOverride,
313
- }),
314
- );
315
- return;
316
- }
317
-
318
- // browse view — controller picks the per-chat backend and
319
- // applies the chat's freeOnly preference when the caller
320
- // doesn't override the filter explicitly. `browseBackToGroups`
321
- // means "drop any cached provider drill" so we omit `provider`.
322
- const browse = await buildModelBrowseViewForChat(
323
- cid,
324
- config,
325
- {
326
- ...(browseFilter !== undefined ? { filter: browseFilter } : {}),
327
- ...(browsePage !== undefined ? { page: browsePage } : {}),
328
- ...(browseProvider !== undefined && !browseBackToGroups
329
- ? { provider: browseProvider }
330
- : {}),
331
- },
332
- gateway,
333
- );
334
- if (!browse) return;
335
- const lines = [
336
- `<b>Model:</b> <code>${escapeHtml(browse.activeDisplay)}</code>`,
337
- ...browse.modelDetails.map(escapeHtml),
338
- ...(browse.filter === "free" && browse.freeCount > 0
339
- ? ["<i>Filter: free-tier only.</i>"]
340
- : []),
341
- ];
342
- await editOrIgnoreSame(
343
- ctx,
344
- lines.join("\n"),
345
- renderModelBrowseKeyboard(
346
- browse.modelButtons,
347
- {
348
- page: browse.page,
349
- totalPages: browse.totalPages,
350
- filter: browse.filter,
351
- freeCount: browse.freeCount,
352
- totalCount: browse.totalCount,
353
- },
354
- browse.view,
355
- browse.provider,
356
- "model:menu",
357
- ),
358
- );
88
+ await renderModelView(ctx, outcome.view, cid, deps);
359
89
  }
@@ -9,7 +9,7 @@ import {
9
9
  enablePulse,
10
10
  isPulseEnabled,
11
11
  } from "../../../core/background/pulse.js";
12
- import { answerCallbackQuerySafe } from "./shared.js";
12
+ import { answerCallbackQuerySafe } from "./query.js";
13
13
 
14
14
  export async function handlePulseCallback(
15
15
  ctx: Context,