talon-agent 1.5.0 → 1.6.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.
- package/package.json +1 -1
- package/src/__tests__/chat-settings.test.ts +20 -7
- package/src/__tests__/fuzz.test.ts +3 -0
- package/src/__tests__/reload-plugins.test.ts +11 -5
- package/src/backend/claude-sdk/constants.ts +63 -0
- package/src/backend/claude-sdk/handler.ts +236 -0
- package/src/backend/claude-sdk/index.ts +7 -556
- package/src/backend/claude-sdk/models.ts +216 -0
- package/src/backend/claude-sdk/options.ts +129 -0
- package/src/backend/claude-sdk/state.ts +59 -0
- package/src/backend/claude-sdk/stream.ts +221 -0
- package/src/backend/claude-sdk/warm.ts +89 -0
- package/src/bootstrap.ts +19 -5
- package/src/cli.ts +30 -15
- package/src/core/dream.ts +5 -17
- package/src/core/gateway-actions.ts +3 -12
- package/src/core/gateway.ts +5 -2
- package/src/core/heartbeat.ts +4 -17
- package/src/core/models.ts +149 -0
- package/src/core/types.ts +4 -0
- package/src/frontend/teams/index.ts +1 -3
- package/src/frontend/telegram/callbacks.ts +15 -27
- package/src/frontend/telegram/commands.ts +23 -28
- package/src/frontend/telegram/helpers.ts +13 -15
- package/src/frontend/telegram/index.ts +1 -1
- package/src/frontend/terminal/commands.ts +7 -4
- package/src/index.ts +2 -1
- package/src/storage/chat-settings.ts +5 -19
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session warm-up — cold-start optimization.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a throwaway SDK subprocess in streaming input mode, calls
|
|
5
|
+
* getContextUsage() to populate contextWindow and baseline contextTokens,
|
|
6
|
+
* then tears it down. Fire-and-forget — does not block the caller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
10
|
+
import { getSession } from "../../storage/sessions.js";
|
|
11
|
+
import { rebuildSystemPrompt } from "../../util/config.js";
|
|
12
|
+
import { getPluginPromptAdditions } from "../../core/plugin.js";
|
|
13
|
+
import { log, logWarn } from "../../util/log.js";
|
|
14
|
+
import { getConfig } from "./state.js";
|
|
15
|
+
import { buildSdkOptions } from "./options.js";
|
|
16
|
+
|
|
17
|
+
export async function warmSession(chatId: string): Promise<void> {
|
|
18
|
+
// Guard against being called before initAgent()
|
|
19
|
+
try {
|
|
20
|
+
getConfig();
|
|
21
|
+
} catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const abort = new AbortController();
|
|
26
|
+
try {
|
|
27
|
+
rebuildSystemPrompt(getConfig(), getPluginPromptAdditions());
|
|
28
|
+
const { options } = buildSdkOptions(chatId);
|
|
29
|
+
|
|
30
|
+
// Streaming input mode: pass an async iterable that never yields a user message
|
|
31
|
+
const neverYield = async function* (): AsyncGenerator<never> {
|
|
32
|
+
await new Promise<never>((_, reject) => {
|
|
33
|
+
abort.signal.addEventListener("abort", () =>
|
|
34
|
+
reject(new Error("aborted")),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const q = query({
|
|
40
|
+
prompt: neverYield(),
|
|
41
|
+
options: { ...options, abortController: abort },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Drain the stream in the background so the SDK's internal message loop
|
|
45
|
+
// doesn't stall — control responses are processed in readMessages() which
|
|
46
|
+
// needs the inputStream consumer to not back-pressure.
|
|
47
|
+
const drainPromise = (async () => {
|
|
48
|
+
try {
|
|
49
|
+
for await (const _ of q) {
|
|
50
|
+
// discard SDK messages; we only care about the control response
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// expected: abort causes the stream to end with an error
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
// Race getContextUsage against a timeout so /reset doesn't hang
|
|
58
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
60
|
+
timeoutId = setTimeout(
|
|
61
|
+
() => reject(new Error("warm-up timed out")),
|
|
62
|
+
15_000,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
let ctx: Awaited<ReturnType<typeof q.getContextUsage>>;
|
|
66
|
+
try {
|
|
67
|
+
ctx = await Promise.race([q.getContextUsage(), timeout]);
|
|
68
|
+
} finally {
|
|
69
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
70
|
+
}
|
|
71
|
+
const session = getSession(chatId);
|
|
72
|
+
if (ctx.maxTokens > 0) session.usage.contextWindow = ctx.maxTokens;
|
|
73
|
+
if (ctx.totalTokens > 0) session.usage.contextTokens = ctx.totalTokens;
|
|
74
|
+
log(
|
|
75
|
+
"agent",
|
|
76
|
+
`[${chatId}] warm-up: context ${ctx.totalTokens}/${ctx.maxTokens} (${ctx.percentage.toFixed(1)}%) model=${ctx.model}`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
abort.abort();
|
|
80
|
+
await drainPromise;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
abort.abort();
|
|
83
|
+
// Non-fatal — /status will just show 0 until first real message
|
|
84
|
+
logWarn(
|
|
85
|
+
"agent",
|
|
86
|
+
`[${chatId}] warm-up failed: ${err instanceof Error ? err.message : err}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -47,6 +47,10 @@ export type BootstrapResult = {
|
|
|
47
47
|
config: TalonConfig;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
export type BackendAndDispatcherResult = {
|
|
51
|
+
backend: QueryBackend;
|
|
52
|
+
};
|
|
53
|
+
|
|
50
54
|
// ── Bootstrap: config, env, plugins, workspace, storage ──────────────────────
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -102,7 +106,7 @@ export async function bootstrap(
|
|
|
102
106
|
export async function initBackendAndDispatcher(
|
|
103
107
|
config: TalonConfig,
|
|
104
108
|
frontend: Frontend,
|
|
105
|
-
): Promise<
|
|
109
|
+
): Promise<BackendAndDispatcherResult> {
|
|
106
110
|
let backend: QueryBackend;
|
|
107
111
|
|
|
108
112
|
if (config.backend === "opencode") {
|
|
@@ -112,10 +116,18 @@ export async function initBackendAndDispatcher(
|
|
|
112
116
|
backend = { query: (params) => opencodeHandleMessage(params) };
|
|
113
117
|
log("bot", "Backend: OpenCode");
|
|
114
118
|
} else {
|
|
115
|
-
const {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
const {
|
|
120
|
+
initAgent: initClaudeAgent,
|
|
121
|
+
handleMessage: claudeHandleMessage,
|
|
122
|
+
warmSession: claudeWarmSession,
|
|
123
|
+
updateSystemPrompt: claudeUpdateSystemPrompt,
|
|
124
|
+
} = await import("./backend/claude-sdk/index.js");
|
|
125
|
+
await initClaudeAgent(config, frontend.getBridgePort);
|
|
126
|
+
backend = {
|
|
127
|
+
query: (params) => claudeHandleMessage(params),
|
|
128
|
+
warmSession: (chatId) => claudeWarmSession(chatId),
|
|
129
|
+
updateSystemPrompt: (prompt) => claudeUpdateSystemPrompt(prompt),
|
|
130
|
+
};
|
|
119
131
|
log("bot", "Backend: Claude SDK");
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -160,4 +172,6 @@ export async function initBackendAndDispatcher(
|
|
|
160
172
|
claudeBinary: config.claudeBinary,
|
|
161
173
|
workspace: config.workspace,
|
|
162
174
|
});
|
|
175
|
+
|
|
176
|
+
return { backend };
|
|
163
177
|
}
|
package/src/cli.ts
CHANGED
|
@@ -282,23 +282,37 @@ async function runSetup(): Promise<void> {
|
|
|
282
282
|
if (botName) teamsBotDisplayName = botName;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Discover models from SDK; fall back to static list if SDK isn't available
|
|
286
|
+
const {
|
|
287
|
+
registerClaudeModels,
|
|
288
|
+
registerClaudeModelsStatic,
|
|
289
|
+
CLAUDE_MODELS_STATIC,
|
|
290
|
+
} = await import("./backend/claude-sdk/models.js");
|
|
291
|
+
try {
|
|
292
|
+
const { dirs } = await import("./util/paths.js");
|
|
293
|
+
await registerClaudeModels({
|
|
294
|
+
model: config.model,
|
|
295
|
+
cwd: dirs.workspace,
|
|
296
|
+
permissionMode: "bypassPermissions",
|
|
297
|
+
allowDangerouslySkipPermissions: true,
|
|
298
|
+
...(config.claudeBinary
|
|
299
|
+
? { pathToClaudeCodeExecutable: config.claudeBinary }
|
|
300
|
+
: {}),
|
|
301
|
+
});
|
|
302
|
+
} catch {
|
|
303
|
+
// Setup wizard may run before Claude Code is installed — use static list
|
|
304
|
+
registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
|
|
305
|
+
}
|
|
306
|
+
const { getModels } = await import("./core/models.js");
|
|
307
|
+
const registeredModels = getModels();
|
|
308
|
+
|
|
285
309
|
const model = await p.select({
|
|
286
310
|
message: "Default model",
|
|
287
311
|
initialValue: config.model,
|
|
288
|
-
options:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
},
|
|
293
|
-
{
|
|
294
|
-
value: "claude-opus-4-6",
|
|
295
|
-
label: `Opus 4.6 ${pc.dim("\u2014 smartest")}`,
|
|
296
|
-
},
|
|
297
|
-
{
|
|
298
|
-
value: "claude-haiku-4-5",
|
|
299
|
-
label: `Haiku 4.5 ${pc.dim("\u2014 fastest, cheapest")}`,
|
|
300
|
-
},
|
|
301
|
-
],
|
|
312
|
+
options: registeredModels.map((m) => ({
|
|
313
|
+
value: m.id,
|
|
314
|
+
label: `${m.displayName.padEnd(12)}${m.description ? pc.dim(`\u2014 ${m.description}`) : ""}`,
|
|
315
|
+
})),
|
|
302
316
|
});
|
|
303
317
|
if (p.isCancel(model)) {
|
|
304
318
|
p.cancel("Cancelled.");
|
|
@@ -652,7 +666,8 @@ async function startChat(): Promise<void> {
|
|
|
652
666
|
const gateway = new Gateway();
|
|
653
667
|
const frontend = createTerminalFrontend(config, gateway);
|
|
654
668
|
await frontend.init();
|
|
655
|
-
await initBackendAndDispatcher(config, frontend);
|
|
669
|
+
const { backend } = await initBackendAndDispatcher(config, frontend);
|
|
670
|
+
gateway.backend = backend;
|
|
656
671
|
|
|
657
672
|
process.on("SIGINT", () => {
|
|
658
673
|
flushSessions();
|
package/src/core/dream.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
|
20
20
|
import { files as pathFiles, dirs } from "../util/paths.js";
|
|
21
21
|
import { log, logError, logWarn } from "../util/log.js";
|
|
22
22
|
import { getPluginMcpServers } from "./plugin.js";
|
|
23
|
+
import { DISALLOWED_TOOLS_BACKGROUND } from "../backend/claude-sdk/constants.js";
|
|
24
|
+
import { getDefaultModel } from "./models.js";
|
|
23
25
|
|
|
24
26
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
25
27
|
|
|
@@ -178,7 +180,8 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
178
180
|
throw new Error(`Failed to read dream prompt from ${promptPath}`);
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
const model =
|
|
183
|
+
const model =
|
|
184
|
+
configRef.dreamModel ?? configRef.model ?? getDefaultModel("balanced");
|
|
182
185
|
const workspace = configRef.workspace ?? dirs.workspace;
|
|
183
186
|
|
|
184
187
|
// Set up dream log file
|
|
@@ -208,22 +211,7 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
208
211
|
mcpServers: configRef.mempalace
|
|
209
212
|
? getPluginMcpServers("", "dream", ["mempalace"])
|
|
210
213
|
: {},
|
|
211
|
-
disallowedTools: [
|
|
212
|
-
"EnterPlanMode",
|
|
213
|
-
"ExitPlanMode",
|
|
214
|
-
"EnterWorktree",
|
|
215
|
-
"ExitWorktree",
|
|
216
|
-
"TodoWrite",
|
|
217
|
-
"TodoRead",
|
|
218
|
-
"TaskCreate",
|
|
219
|
-
"TaskUpdate",
|
|
220
|
-
"TaskGet",
|
|
221
|
-
"TaskList",
|
|
222
|
-
"TaskOutput",
|
|
223
|
-
"TaskStop",
|
|
224
|
-
"AskUserQuestion",
|
|
225
|
-
"Agent",
|
|
226
|
-
],
|
|
214
|
+
disallowedTools: [...DISALLOWED_TOOLS_BACKGROUND],
|
|
227
215
|
};
|
|
228
216
|
|
|
229
217
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type CronJobType,
|
|
28
28
|
} from "../storage/cron-store.js";
|
|
29
29
|
import { log } from "../util/log.js";
|
|
30
|
-
import type { ActionResult } from "./types.js";
|
|
30
|
+
import type { ActionResult, QueryBackend } from "./types.js";
|
|
31
31
|
|
|
32
32
|
/** Extract readable text from HTML using cheerio (proper DOM parser). */
|
|
33
33
|
function extractText(html: string, maxLength = 8000): string {
|
|
@@ -42,6 +42,7 @@ function extractText(html: string, maxLength = 8000): string {
|
|
|
42
42
|
export async function handleSharedAction(
|
|
43
43
|
body: Record<string, unknown>,
|
|
44
44
|
chatId: number,
|
|
45
|
+
backend?: QueryBackend | null,
|
|
45
46
|
): Promise<ActionResult | null> {
|
|
46
47
|
const action = body.action as string;
|
|
47
48
|
|
|
@@ -324,17 +325,7 @@ export async function handleSharedAction(
|
|
|
324
325
|
// Rebuild system prompt on the freshConfig, then update the backend's
|
|
325
326
|
// live config reference so subsequent messages use the new prompt
|
|
326
327
|
rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
|
|
327
|
-
|
|
328
|
-
const { updateSystemPrompt } =
|
|
329
|
-
await import("../backend/claude-sdk/index.js");
|
|
330
|
-
updateSystemPrompt(freshConfig.systemPrompt);
|
|
331
|
-
} catch (err) {
|
|
332
|
-
// Non-fatal — OpenCode backend doesn't expose updateSystemPrompt
|
|
333
|
-
log(
|
|
334
|
-
"gateway",
|
|
335
|
-
`reload_plugins: could not update backend prompt: ${err instanceof Error ? err.message : err}`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
328
|
+
backend?.updateSystemPrompt?.(freshConfig.systemPrompt);
|
|
338
329
|
|
|
339
330
|
log("gateway", `reload_plugins: ${names.length} plugins loaded`);
|
|
340
331
|
return {
|
package/src/core/gateway.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { getActiveSessionCount } from "../storage/sessions.js";
|
|
|
21
21
|
import { log, logError, logDebug } from "../util/log.js";
|
|
22
22
|
import { handleSharedAction } from "./gateway-actions.js";
|
|
23
23
|
import { handlePluginAction } from "./plugin.js";
|
|
24
|
-
import type { FrontendActionHandler } from "./types.js";
|
|
24
|
+
import type { FrontendActionHandler, QueryBackend } from "./types.js";
|
|
25
25
|
|
|
26
26
|
// ── Per-chat context state ───────────────────────────────────────────────────
|
|
27
27
|
|
|
@@ -80,6 +80,9 @@ export class Gateway {
|
|
|
80
80
|
private server: ReturnType<typeof createServer> | null = null;
|
|
81
81
|
private port = 0;
|
|
82
82
|
|
|
83
|
+
/** The active backend — set by bootstrap after initialization. */
|
|
84
|
+
backend: QueryBackend | null = null;
|
|
85
|
+
|
|
83
86
|
// ── Frontend handler registration ────────────────────────────────────────
|
|
84
87
|
|
|
85
88
|
setFrontendHandler(handler: FrontendActionHandler | null): void {
|
|
@@ -195,7 +198,7 @@ export class Gateway {
|
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
// Shared actions last — provides in-memory fallbacks for history, cron, etc.
|
|
198
|
-
const shared = await handleSharedAction(body, chatId);
|
|
201
|
+
const shared = await handleSharedAction(body, chatId, this.backend);
|
|
199
202
|
if (shared) {
|
|
200
203
|
logDebug(
|
|
201
204
|
"gateway",
|
package/src/core/heartbeat.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { files as pathFiles, dirs } from "../util/paths.js";
|
|
|
18
18
|
import { log, logError, logWarn } from "../util/log.js";
|
|
19
19
|
import { toYMD } from "../util/time.js";
|
|
20
20
|
import { getPluginMcpServers } from "./plugin.js";
|
|
21
|
+
import { DISALLOWED_TOOLS_BACKGROUND } from "../backend/claude-sdk/constants.js";
|
|
22
|
+
import { getDefaultModel } from "./models.js";
|
|
21
23
|
|
|
22
24
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
23
25
|
|
|
@@ -263,7 +265,7 @@ async function runHeartbeatAgent(
|
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
const model =
|
|
266
|
-
configRef.heartbeatModel ?? configRef.model ?? "
|
|
268
|
+
configRef.heartbeatModel ?? configRef.model ?? getDefaultModel("balanced");
|
|
267
269
|
|
|
268
270
|
// Set up heartbeat log file
|
|
269
271
|
const heartbeatLogFile = await createHeartbeatLogFile();
|
|
@@ -292,22 +294,7 @@ async function runHeartbeatAgent(
|
|
|
292
294
|
: {}),
|
|
293
295
|
// Load all registered plugin MCP servers (excludes frontend-specific tools like telegram)
|
|
294
296
|
mcpServers: getPluginMcpServers("", "heartbeat"),
|
|
295
|
-
disallowedTools: [
|
|
296
|
-
"EnterPlanMode",
|
|
297
|
-
"ExitPlanMode",
|
|
298
|
-
"EnterWorktree",
|
|
299
|
-
"ExitWorktree",
|
|
300
|
-
"TodoWrite",
|
|
301
|
-
"TodoRead",
|
|
302
|
-
"TaskCreate",
|
|
303
|
-
"TaskUpdate",
|
|
304
|
-
"TaskGet",
|
|
305
|
-
"TaskList",
|
|
306
|
-
"TaskOutput",
|
|
307
|
-
"TaskStop",
|
|
308
|
-
"AskUserQuestion",
|
|
309
|
-
"Agent",
|
|
310
|
-
],
|
|
297
|
+
disallowedTools: [...DISALLOWED_TOOLS_BACKGROUND],
|
|
311
298
|
};
|
|
312
299
|
|
|
313
300
|
// NOTE: The timeout races against the agent promise but cannot abort the
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model registry — single source of truth for available models.
|
|
3
|
+
*
|
|
4
|
+
* Backends register their models during initialization. Frontends read
|
|
5
|
+
* from the registry to build dynamic model pickers, resolve aliases,
|
|
6
|
+
* and query capabilities. No model names are hardcoded outside this
|
|
7
|
+
* system and the backend-specific model definition files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type ModelTier = "premium" | "balanced" | "economy";
|
|
13
|
+
|
|
14
|
+
export type ModelCapabilities = {
|
|
15
|
+
/** Whether the model supports the 1M token context window. */
|
|
16
|
+
supports1mContext: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ModelInfo = {
|
|
20
|
+
/** Canonical model ID (e.g. "claude-sonnet-4-6"). */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Human-readable display name for UIs (e.g. "Sonnet 4.6"). */
|
|
23
|
+
displayName: string;
|
|
24
|
+
/** Short description for setup wizard (e.g. "fast, balanced"). */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** Aliases that resolve to this model (e.g. ["sonnet", "sonnet-4.6"]). */
|
|
27
|
+
aliases: string[];
|
|
28
|
+
/** Provider identifier (e.g. "anthropic", "openai"). */
|
|
29
|
+
provider: string;
|
|
30
|
+
/** Model capabilities used for backend configuration. */
|
|
31
|
+
capabilities: ModelCapabilities;
|
|
32
|
+
/** Tier for UI grouping and fallback ordering. */
|
|
33
|
+
tier: ModelTier;
|
|
34
|
+
/** Model to fall back to on overload/timeout. */
|
|
35
|
+
fallback?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── Tier sort order ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const TIER_ORDER: Record<ModelTier, number> = {
|
|
41
|
+
premium: 0,
|
|
42
|
+
balanced: 1,
|
|
43
|
+
economy: 2,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Registry state ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const models = new Map<string, ModelInfo>();
|
|
49
|
+
const aliasIndex = new Map<string, string>();
|
|
50
|
+
|
|
51
|
+
// ── Registration ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** Register one or more models. Idempotent — re-registration overwrites. */
|
|
54
|
+
export function registerModels(infos: ModelInfo[]): void {
|
|
55
|
+
for (const info of infos) {
|
|
56
|
+
// Clear stale aliases from any previous registration of this model ID
|
|
57
|
+
const prev = models.get(info.id);
|
|
58
|
+
if (prev) {
|
|
59
|
+
aliasIndex.delete(prev.id.toLowerCase());
|
|
60
|
+
for (const alias of prev.aliases) {
|
|
61
|
+
aliasIndex.delete(alias.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
models.set(info.id, info);
|
|
65
|
+
// Index the canonical ID itself as an alias
|
|
66
|
+
aliasIndex.set(info.id.toLowerCase(), info.id);
|
|
67
|
+
for (const alias of info.aliases) {
|
|
68
|
+
aliasIndex.set(alias.toLowerCase(), info.id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Queries ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Get a model by canonical ID. */
|
|
76
|
+
export function getModel(id: string): ModelInfo | undefined {
|
|
77
|
+
return models.get(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** List all registered models, optionally filtered by provider. Sorted by tier. */
|
|
81
|
+
export function getModels(provider?: string): ModelInfo[] {
|
|
82
|
+
let result = [...models.values()];
|
|
83
|
+
if (provider) {
|
|
84
|
+
result = result.filter((m) => m.provider === provider);
|
|
85
|
+
}
|
|
86
|
+
return result.sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a user input (alias or full ID) to the canonical model ID.
|
|
91
|
+
* Returns the input unchanged if no match is found (passthrough for
|
|
92
|
+
* unknown/custom model names).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveModelId(input: string): string {
|
|
95
|
+
const lower = input.trim().toLowerCase();
|
|
96
|
+
return aliasIndex.get(lower) ?? input.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a user input to the full ModelInfo, or undefined if not found.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveModel(input: string): ModelInfo | undefined {
|
|
103
|
+
const id = resolveModelId(input);
|
|
104
|
+
return models.get(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Get the fallback model ID for a given model, or null if none configured. */
|
|
108
|
+
export function getFallbackModel(modelId: string): string | null {
|
|
109
|
+
return models.get(modelId)?.fallback ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Check whether a model supports the 1M token context window. */
|
|
113
|
+
export function supports1mContext(modelId: string): boolean {
|
|
114
|
+
const info = models.get(modelId);
|
|
115
|
+
// Default to true for unknown models (don't restrict capabilities we can't check)
|
|
116
|
+
return info?.capabilities.supports1mContext ?? true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get the default model for a given tier. Returns the first registered model
|
|
121
|
+
* matching the tier, or the first model overall, or the hardcoded fallback.
|
|
122
|
+
*/
|
|
123
|
+
export function getDefaultModel(tier: ModelTier = "balanced"): string {
|
|
124
|
+
const byTier = [...models.values()].find((m) => m.tier === tier);
|
|
125
|
+
if (byTier) return byTier.id;
|
|
126
|
+
const first = models.values().next();
|
|
127
|
+
if (!first.done) return first.value.id;
|
|
128
|
+
return "claude-sonnet-4-6"; // ultimate fallback if registry is empty
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Provider-scoped clearing ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** Remove all models for a specific provider (and their aliases). */
|
|
134
|
+
export function clearModelsByProvider(provider: string): void {
|
|
135
|
+
for (const [id, info] of models) {
|
|
136
|
+
if (info.provider !== provider) continue;
|
|
137
|
+
aliasIndex.delete(id.toLowerCase());
|
|
138
|
+
for (const alias of info.aliases) {
|
|
139
|
+
aliasIndex.delete(alias.toLowerCase());
|
|
140
|
+
}
|
|
141
|
+
models.delete(id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Clear the entire registry. For tests only. */
|
|
146
|
+
export function clearModels(): void {
|
|
147
|
+
models.clear();
|
|
148
|
+
aliasIndex.clear();
|
|
149
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type QueryResult = {
|
|
|
33
33
|
/** Backend interface — any AI provider implements this. */
|
|
34
34
|
export interface QueryBackend {
|
|
35
35
|
query(params: QueryParams): Promise<QueryResult>;
|
|
36
|
+
/** Pre-warm a session (cold-start optimization). Optional — not all backends support this. */
|
|
37
|
+
warmSession?(chatId: string): Promise<void>;
|
|
38
|
+
/** Update the system prompt on the live backend config. Optional — used by plugin hot-reload. */
|
|
39
|
+
updateSystemPrompt?(prompt: string): void;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// ── Execution context ───────────────────────────────────────────────────────
|
|
@@ -217,9 +217,7 @@ export function createTeamsFrontend(
|
|
|
217
217
|
resetSession(talonChatId);
|
|
218
218
|
clearHistory(talonChatId);
|
|
219
219
|
log("teams", `Session reset by ${msg.senderName}`);
|
|
220
|
-
|
|
221
|
-
await import("../../backend/claude-sdk/index.js");
|
|
222
|
-
await warmSession(talonChatId);
|
|
220
|
+
await gateway.backend?.warmSession?.(talonChatId);
|
|
223
221
|
const card = buildAdaptiveCard("Session cleared.");
|
|
224
222
|
await proxyFetch(webhookUrl, {
|
|
225
223
|
method: "POST",
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { handleCallbackQuery } from "./handlers.js";
|
|
23
23
|
import { escapeHtml } from "./formatting.js";
|
|
24
24
|
import { renderSettingsText, renderSettingsKeyboard } from "./helpers.js";
|
|
25
|
+
import { getModels } from "../../core/models.js";
|
|
25
26
|
|
|
26
27
|
export function registerCallbacks(bot: Bot, config: TalonConfig): void {
|
|
27
28
|
// ── Callback query handler ──────────────────────────────────────────────────
|
|
@@ -214,36 +215,23 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
|
|
|
214
215
|
});
|
|
215
216
|
}
|
|
216
217
|
const current = getChatSettings(cid).model ?? config.model;
|
|
217
|
-
|
|
218
|
+
// Build model buttons dynamically from the registry
|
|
219
|
+
const models = getModels();
|
|
220
|
+
const modelButtons = models.map((m) => ({
|
|
221
|
+
text: current.includes(m.id)
|
|
222
|
+
? `\u2713 ${m.displayName}`
|
|
223
|
+
: m.displayName,
|
|
224
|
+
callback_data: `model:${m.aliases[0] ?? m.id}`,
|
|
225
|
+
}));
|
|
226
|
+
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
|
|
227
|
+
for (let i = 0; i < modelButtons.length; i += 2) {
|
|
228
|
+
rows.push(modelButtons.slice(i, i + 2));
|
|
229
|
+
}
|
|
230
|
+
rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
|
|
218
231
|
try {
|
|
219
232
|
await ctx.editMessageText(
|
|
220
233
|
`<b>Model:</b> <code>${escapeHtml(current)}</code>`,
|
|
221
|
-
{
|
|
222
|
-
parse_mode: "HTML",
|
|
223
|
-
reply_markup: {
|
|
224
|
-
inline_keyboard: [
|
|
225
|
-
[
|
|
226
|
-
{
|
|
227
|
-
text: isModel("sonnet")
|
|
228
|
-
? "\u2713 Sonnet 4.6"
|
|
229
|
-
: "Sonnet 4.6",
|
|
230
|
-
callback_data: "model:sonnet",
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
text: isModel("opus") ? "\u2713 Opus 4.6" : "Opus 4.6",
|
|
234
|
-
callback_data: "model:opus",
|
|
235
|
-
},
|
|
236
|
-
],
|
|
237
|
-
[
|
|
238
|
-
{
|
|
239
|
-
text: isModel("haiku") ? "\u2713 Haiku 4.5" : "Haiku 4.5",
|
|
240
|
-
callback_data: "model:haiku",
|
|
241
|
-
},
|
|
242
|
-
{ text: "Reset to default", callback_data: "model:reset" },
|
|
243
|
-
],
|
|
244
|
-
],
|
|
245
|
-
},
|
|
246
|
-
},
|
|
234
|
+
{ parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
|
|
247
235
|
);
|
|
248
236
|
} catch {
|
|
249
237
|
/* message unchanged */
|
|
@@ -38,7 +38,7 @@ import { appendDailyLog } from "../../storage/daily-log.js";
|
|
|
38
38
|
import { escapeHtml } from "./formatting.js";
|
|
39
39
|
import { handleAdminCommand } from "./admin.js";
|
|
40
40
|
import { getLoadedPlugins } from "../../core/plugin.js";
|
|
41
|
-
import {
|
|
41
|
+
import { getModels } from "../../core/models.js";
|
|
42
42
|
import {
|
|
43
43
|
formatDuration,
|
|
44
44
|
formatTokenCount,
|
|
@@ -56,7 +56,11 @@ export function setAdminUserId(id: number | undefined): void {
|
|
|
56
56
|
ADMIN_USER_ID = id ?? 0;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function registerCommands(
|
|
59
|
+
export function registerCommands(
|
|
60
|
+
bot: Bot,
|
|
61
|
+
config: TalonConfig,
|
|
62
|
+
gateway?: { backend: import("../../core/types.js").QueryBackend | null },
|
|
63
|
+
): void {
|
|
60
64
|
bot.command("start", (ctx) =>
|
|
61
65
|
ctx.reply(
|
|
62
66
|
[
|
|
@@ -142,7 +146,7 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
|
|
|
142
146
|
clearHistory(cid);
|
|
143
147
|
resetPulseCheckpoint(cid);
|
|
144
148
|
// Warm up the new session so /status has context data immediately
|
|
145
|
-
await warmSession(cid);
|
|
149
|
+
await gateway?.backend?.warmSession?.(cid);
|
|
146
150
|
await ctx.reply("Session cleared.");
|
|
147
151
|
});
|
|
148
152
|
|
|
@@ -179,33 +183,24 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
|
|
|
179
183
|
|
|
180
184
|
if (!arg) {
|
|
181
185
|
const current = settings.model ?? config.model;
|
|
182
|
-
|
|
186
|
+
// Build model buttons dynamically from the registry
|
|
187
|
+
const models = getModels();
|
|
188
|
+
const modelButtons = models.map((m) => ({
|
|
189
|
+
text: current.includes(m.id)
|
|
190
|
+
? `\u2713 ${m.displayName}`
|
|
191
|
+
: m.displayName,
|
|
192
|
+
callback_data: `model:${m.aliases[0] ?? m.id}`,
|
|
193
|
+
}));
|
|
194
|
+
// Two models per row, plus a reset button on the last row
|
|
195
|
+
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
|
|
196
|
+
for (let i = 0; i < modelButtons.length; i += 2) {
|
|
197
|
+
rows.push(modelButtons.slice(i, i + 2));
|
|
198
|
+
}
|
|
199
|
+
rows.push([{ text: "Reset to default", callback_data: "model:reset" }]);
|
|
200
|
+
|
|
183
201
|
await ctx.reply(
|
|
184
202
|
`<b>Model:</b> <code>${escapeHtml(current)}</code>\nSelect a model:`,
|
|
185
|
-
{
|
|
186
|
-
parse_mode: "HTML",
|
|
187
|
-
reply_markup: {
|
|
188
|
-
inline_keyboard: [
|
|
189
|
-
[
|
|
190
|
-
{
|
|
191
|
-
text: isModel("sonnet") ? "\u2713 Sonnet 4.6" : "Sonnet 4.6",
|
|
192
|
-
callback_data: "model:sonnet",
|
|
193
|
-
},
|
|
194
|
-
{
|
|
195
|
-
text: isModel("opus") ? "\u2713 Opus 4.6" : "Opus 4.6",
|
|
196
|
-
callback_data: "model:opus",
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
[
|
|
200
|
-
{
|
|
201
|
-
text: isModel("haiku") ? "\u2713 Haiku 4.5" : "Haiku 4.5",
|
|
202
|
-
callback_data: "model:haiku",
|
|
203
|
-
},
|
|
204
|
-
{ text: "Reset to default", callback_data: "model:reset" },
|
|
205
|
-
],
|
|
206
|
-
],
|
|
207
|
-
},
|
|
208
|
-
},
|
|
203
|
+
{ parse_mode: "HTML", reply_markup: { inline_keyboard: rows } },
|
|
209
204
|
);
|
|
210
205
|
return;
|
|
211
206
|
}
|