talon-agent 3.34.0 → 3.35.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/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +6 -4
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +290 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +76 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/tools/history.ts +10 -1
- package/src/core/weaver/shuttle.ts +4 -0
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/storage/whatsapp-messages.ts +51 -0
- package/src/util/watchdog.ts +32 -7
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend-agnostic `/model`, `/effort` and backend-switch operations.
|
|
3
|
+
*
|
|
4
|
+
* Telegram and Discord drive these through inline buttons and select
|
|
5
|
+
* menus; a text-only frontend (WhatsApp) has to do the same work from a
|
|
6
|
+
* typed argument — "the third model in the list", "switch to codex",
|
|
7
|
+
* "effort high". The state changes are identical either way, so they
|
|
8
|
+
* live here, shaped as plain data + plain-text outcomes that every
|
|
9
|
+
* frontend can wrap in its own markup. Nothing in this module knows
|
|
10
|
+
* about a socket, a bot API, or a keyboard.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
14
|
+
import type { Backend } from "../../core/agent-runtime/capabilities.js";
|
|
15
|
+
import type { ReasoningEffortLevel } from "../../core/types.js";
|
|
16
|
+
import {
|
|
17
|
+
getChatSettings,
|
|
18
|
+
setChatBackend,
|
|
19
|
+
setChatEffort,
|
|
20
|
+
setChatModelForBackend,
|
|
21
|
+
type EffortLevel,
|
|
22
|
+
} from "../../storage/chat-settings.js";
|
|
23
|
+
import { resolveModelId as resolveModelName } from "../../core/models/catalog.js";
|
|
24
|
+
import { resolveActiveModelForChat } from "../../core/models/active-model.js";
|
|
25
|
+
import {
|
|
26
|
+
getBackendIdForChat,
|
|
27
|
+
hasBackendPool,
|
|
28
|
+
hasChatBackendOverride,
|
|
29
|
+
listAvailableBackends,
|
|
30
|
+
rebindChat,
|
|
31
|
+
releaseChat,
|
|
32
|
+
resolveChatBackend,
|
|
33
|
+
} from "../../core/engine/backend-controller/index.js";
|
|
34
|
+
import { resetSession } from "../../storage/sessions.js";
|
|
35
|
+
import { clearHistory } from "../../storage/history.js";
|
|
36
|
+
import { resetPulseCheckpoint } from "../../core/background/pulse.js";
|
|
37
|
+
import { logWarn } from "../../util/log.js";
|
|
38
|
+
import {
|
|
39
|
+
displayReasoningEffort,
|
|
40
|
+
getActiveReasoningLevels,
|
|
41
|
+
supportsReasoningLevel,
|
|
42
|
+
} from "./reasoning-levels.js";
|
|
43
|
+
|
|
44
|
+
export type ModelCommandDeps = {
|
|
45
|
+
config: TalonConfig;
|
|
46
|
+
gateway?: { backend: Backend | null };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Outcome of a state-changing command, ready to be sent as-is. */
|
|
50
|
+
export type CommandOutcome = { ok: boolean; text: string };
|
|
51
|
+
|
|
52
|
+
/** One selectable catalog entry, numbered for text pickers. */
|
|
53
|
+
type ModelChoice = {
|
|
54
|
+
/** 1-based position in the listing — what `/model <n>` refers to. */
|
|
55
|
+
index: number;
|
|
56
|
+
id: string;
|
|
57
|
+
displayName: string;
|
|
58
|
+
providerName: string;
|
|
59
|
+
free: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ModelOverview = {
|
|
63
|
+
backendId: string;
|
|
64
|
+
backendLabel: string;
|
|
65
|
+
hasBackendOverride: boolean;
|
|
66
|
+
backends: Array<{ id: string; label: string }>;
|
|
67
|
+
/** Resolved active model id, or null when the chat has none to run on. */
|
|
68
|
+
activeModel: string | null;
|
|
69
|
+
activeDisplay: string | null;
|
|
70
|
+
/** True when the chat pins a model other than the backend's default. */
|
|
71
|
+
hasModelOverride: boolean;
|
|
72
|
+
choices: ModelChoice[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The backend + id serving a chat right now (pool-aware, test-safe). */
|
|
76
|
+
export function resolveChatBackendPair(
|
|
77
|
+
chatId: string,
|
|
78
|
+
deps: ModelCommandDeps,
|
|
79
|
+
): { backend: Backend | null; backendId: string } {
|
|
80
|
+
return {
|
|
81
|
+
backend: resolveChatBackend(chatId, deps.gateway?.backend ?? null),
|
|
82
|
+
backendId: hasBackendPool()
|
|
83
|
+
? getBackendIdForChat(chatId)
|
|
84
|
+
: deps.config.backend,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The selectable catalog of the chat's backend, numbered in catalog
|
|
90
|
+
* order. Same call on the listing and on the pick, so `/model 3` names
|
|
91
|
+
* the same entry the user just read. Empty for fixed-model backends.
|
|
92
|
+
*/
|
|
93
|
+
async function listModelChoices(
|
|
94
|
+
backend: Backend | null,
|
|
95
|
+
): Promise<ModelChoice[]> {
|
|
96
|
+
if (!backend?.models?.listModels) return [];
|
|
97
|
+
try {
|
|
98
|
+
const { models } = await backend.models.listModels("all");
|
|
99
|
+
return models
|
|
100
|
+
.filter((m) => m.selectable)
|
|
101
|
+
.map((m, i) => ({
|
|
102
|
+
index: i + 1,
|
|
103
|
+
id: m.id,
|
|
104
|
+
displayName: m.displayName,
|
|
105
|
+
providerName: m.providerName,
|
|
106
|
+
free: m.free === true,
|
|
107
|
+
}));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logWarn(
|
|
110
|
+
"settings",
|
|
111
|
+
`listModels failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
112
|
+
);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Everything a text `/model` listing shows. */
|
|
118
|
+
export async function describeChatModels(
|
|
119
|
+
chatId: string,
|
|
120
|
+
deps: ModelCommandDeps,
|
|
121
|
+
): Promise<ModelOverview> {
|
|
122
|
+
const { backend, backendId } = resolveChatBackendPair(chatId, deps);
|
|
123
|
+
const backends = listAvailableBackends(deps.config);
|
|
124
|
+
const backendLabel =
|
|
125
|
+
backends.find((b) => b.id === backendId)?.label ??
|
|
126
|
+
backend?.label ??
|
|
127
|
+
backendId;
|
|
128
|
+
const { model, ref, source } = await resolveActiveModelForChat(
|
|
129
|
+
chatId,
|
|
130
|
+
backend,
|
|
131
|
+
backendId,
|
|
132
|
+
deps.config,
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
backendId,
|
|
136
|
+
backendLabel,
|
|
137
|
+
hasBackendOverride: hasBackendPool() && hasChatBackendOverride(chatId),
|
|
138
|
+
backends,
|
|
139
|
+
activeModel: model,
|
|
140
|
+
activeDisplay: ref?.displayName ?? model,
|
|
141
|
+
hasModelOverride: source === "override-valid",
|
|
142
|
+
choices: await listModelChoices(backend),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pin a model for the chat. `query` is a catalog id, a fuzzy name the
|
|
148
|
+
* backend resolves, or a 1-based number from `describeChatModels`.
|
|
149
|
+
* Mirrors Telegram's `/model <arg>`: the pick lands in the backend's
|
|
150
|
+
* own slot and the chat is pinned to that backend so a restart can't
|
|
151
|
+
* orphan the id.
|
|
152
|
+
*/
|
|
153
|
+
export async function selectChatModel(
|
|
154
|
+
chatId: string,
|
|
155
|
+
query: string,
|
|
156
|
+
deps: ModelCommandDeps,
|
|
157
|
+
): Promise<CommandOutcome> {
|
|
158
|
+
const { backend, backendId } = resolveChatBackendPair(chatId, deps);
|
|
159
|
+
let wanted = query.trim();
|
|
160
|
+
if (/^\d+$/.test(wanted)) {
|
|
161
|
+
const choices = await listModelChoices(backend);
|
|
162
|
+
const choice = choices[Number(wanted) - 1];
|
|
163
|
+
if (!choice) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
text: choices.length
|
|
167
|
+
? `No model #${wanted} — the list has ${choices.length} entries.`
|
|
168
|
+
: `This backend has no numbered model list; give a model id instead.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
wanted = choice.id;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!backend?.models?.resolveModelInfo) {
|
|
175
|
+
const model = resolveModelName(wanted);
|
|
176
|
+
setChatModelForBackend(chatId, backendId, model);
|
|
177
|
+
setChatBackend(chatId, backendId);
|
|
178
|
+
return { ok: true, text: `Model set to ${model}.` };
|
|
179
|
+
}
|
|
180
|
+
const resolution = await backend.models.resolveModelInfo(wanted);
|
|
181
|
+
if (resolution.kind !== "exact") {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
text:
|
|
185
|
+
backend.models.formatModelError?.(wanted, resolution) ??
|
|
186
|
+
`No model matched "${wanted}".`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!resolution.model.selectable) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
text:
|
|
193
|
+
resolution.model.unavailableReason ??
|
|
194
|
+
`${resolution.model.providerName} is not connected.`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
setChatModelForBackend(chatId, backendId, resolution.storedValue);
|
|
198
|
+
setChatBackend(chatId, backendId);
|
|
199
|
+
const tag = resolution.model.free ? " · free" : "";
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
text: `Model set to ${resolution.storedValue} (${resolution.model.providerName}${tag}).`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Clear the chat's pick on its current backend; other backends' picks stay. */
|
|
207
|
+
export async function resetChatModel(
|
|
208
|
+
chatId: string,
|
|
209
|
+
deps: ModelCommandDeps,
|
|
210
|
+
): Promise<CommandOutcome> {
|
|
211
|
+
const { backend, backendId } = resolveChatBackendPair(chatId, deps);
|
|
212
|
+
setChatModelForBackend(chatId, backendId, undefined);
|
|
213
|
+
const { model } = await resolveActiveModelForChat(
|
|
214
|
+
chatId,
|
|
215
|
+
backend,
|
|
216
|
+
backendId,
|
|
217
|
+
deps.config,
|
|
218
|
+
);
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
text: model
|
|
222
|
+
? `Model reset to default: ${model}.`
|
|
223
|
+
: `Model reset — no default available for backend ${backendId}; pick one with /model.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The backend an argument names, matched by id or label, else null. */
|
|
228
|
+
export function matchBackendArg(
|
|
229
|
+
arg: string,
|
|
230
|
+
config: TalonConfig,
|
|
231
|
+
): { id: string; label: string } | null {
|
|
232
|
+
const wanted = arg.trim().toLowerCase();
|
|
233
|
+
if (!wanted) return null;
|
|
234
|
+
return (
|
|
235
|
+
listAvailableBackends(config).find(
|
|
236
|
+
(b) => b.id.toLowerCase() === wanted || b.label.toLowerCase() === wanted,
|
|
237
|
+
) ?? null
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Drop the session state a backend switch invalidates. Session ids are
|
|
243
|
+
* not portable across backends; each backend's remembered model pick IS
|
|
244
|
+
* kept, so switching back restores it. `keepHistory` is for frontends
|
|
245
|
+
* whose local history store is the only record of the chat.
|
|
246
|
+
*/
|
|
247
|
+
function handOffChatSession(
|
|
248
|
+
chatId: string,
|
|
249
|
+
previous: Backend | null,
|
|
250
|
+
deps: ModelCommandDeps,
|
|
251
|
+
keepHistory: boolean,
|
|
252
|
+
): void {
|
|
253
|
+
resetSession(chatId);
|
|
254
|
+
if (!keepHistory) clearHistory(chatId);
|
|
255
|
+
resetPulseCheckpoint(chatId);
|
|
256
|
+
previous?.sessions?.resetChat?.(chatId);
|
|
257
|
+
const next = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
|
|
258
|
+
// Warming can take seconds on OpenCode/Kilo; the reply must not wait.
|
|
259
|
+
if (!next || next === previous) return;
|
|
260
|
+
void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch((err) =>
|
|
261
|
+
logWarn(
|
|
262
|
+
"settings",
|
|
263
|
+
`[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Model line for the post-switch confirmation. */
|
|
269
|
+
async function describeModelAfterSwitch(
|
|
270
|
+
chatId: string,
|
|
271
|
+
backendId: string,
|
|
272
|
+
deps: ModelCommandDeps,
|
|
273
|
+
): Promise<string> {
|
|
274
|
+
const backend = resolveChatBackend(chatId, deps.gateway?.backend ?? null);
|
|
275
|
+
const { model } = await resolveActiveModelForChat(
|
|
276
|
+
chatId,
|
|
277
|
+
backend,
|
|
278
|
+
backendId,
|
|
279
|
+
deps.config,
|
|
280
|
+
);
|
|
281
|
+
return model ? `model: ${model}` : "no default model — /model to pick one";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rebind the chat to another backend. Same sequence as Telegram's
|
|
286
|
+
* backend submenu; re-selecting the backend already in use only pins
|
|
287
|
+
* the choice and costs the session nothing.
|
|
288
|
+
*/
|
|
289
|
+
export async function switchChatBackend(
|
|
290
|
+
chatId: string,
|
|
291
|
+
target: { id: string; label: string },
|
|
292
|
+
deps: ModelCommandDeps,
|
|
293
|
+
opts: { keepHistory?: boolean } = {},
|
|
294
|
+
): Promise<CommandOutcome> {
|
|
295
|
+
const { backend: previous, backendId: previousId } = resolveChatBackendPair(
|
|
296
|
+
chatId,
|
|
297
|
+
deps,
|
|
298
|
+
);
|
|
299
|
+
if (previousId === target.id) {
|
|
300
|
+
setChatBackend(chatId, target.id);
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
text: `Already on ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}).`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const result = await rebindChat(chatId, target.id, deps.config);
|
|
307
|
+
if (!result.ok) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
text: `Could not switch to ${target.label}: ${result.error ?? "rebind failed"}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
setChatBackend(chatId, target.id);
|
|
314
|
+
handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
text: `Backend: ${target.label} (${await describeModelAfterSwitch(chatId, target.id, deps)}). Session started fresh.`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Drop the per-chat backend override; the chat reverts to the default. */
|
|
322
|
+
export async function resetChatBackend(
|
|
323
|
+
chatId: string,
|
|
324
|
+
deps: ModelCommandDeps,
|
|
325
|
+
opts: { keepHistory?: boolean } = {},
|
|
326
|
+
): Promise<CommandOutcome> {
|
|
327
|
+
const { backend: previous } = resolveChatBackendPair(chatId, deps);
|
|
328
|
+
await releaseChat(chatId);
|
|
329
|
+
setChatBackend(chatId, undefined);
|
|
330
|
+
handOffChatSession(chatId, previous, deps, opts.keepHistory === true);
|
|
331
|
+
const { backendId } = resolveChatBackendPair(chatId, deps);
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
text: `Backend reset to default (${backendId}; ${await describeModelAfterSwitch(chatId, backendId, deps)}).`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export type EffortOverview = {
|
|
339
|
+
current: string;
|
|
340
|
+
levels: ReasoningEffortLevel[];
|
|
341
|
+
activeModel: string | null;
|
|
342
|
+
backendId: string;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** Current effort and the levels the active model accepts. */
|
|
346
|
+
export async function describeChatEffort(
|
|
347
|
+
chatId: string,
|
|
348
|
+
deps: ModelCommandDeps,
|
|
349
|
+
): Promise<EffortOverview> {
|
|
350
|
+
const { backend, backendId } = resolveChatBackendPair(chatId, deps);
|
|
351
|
+
const reasoning = await getActiveReasoningLevels({
|
|
352
|
+
chatId,
|
|
353
|
+
backend,
|
|
354
|
+
backendId,
|
|
355
|
+
config: deps.config,
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
current: displayReasoningEffort(
|
|
359
|
+
getChatSettings(chatId).effort,
|
|
360
|
+
reasoning.levels,
|
|
361
|
+
),
|
|
362
|
+
levels: reasoning.levels,
|
|
363
|
+
activeModel: reasoning.activeModel,
|
|
364
|
+
backendId,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** `/effort <level>` — validated against the active model's levels. */
|
|
369
|
+
export async function setChatEffortLevel(
|
|
370
|
+
chatId: string,
|
|
371
|
+
arg: string,
|
|
372
|
+
deps: ModelCommandDeps,
|
|
373
|
+
): Promise<CommandOutcome> {
|
|
374
|
+
const level = arg.trim().toLowerCase();
|
|
375
|
+
if (level === "reset" || level === "default" || level === "adaptive") {
|
|
376
|
+
setChatEffort(chatId, undefined);
|
|
377
|
+
return {
|
|
378
|
+
ok: true,
|
|
379
|
+
text: "Effort reset to adaptive (model decides when to think).",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const { levels, activeModel, backendId } = await describeChatEffort(
|
|
383
|
+
chatId,
|
|
384
|
+
deps,
|
|
385
|
+
);
|
|
386
|
+
if (levels.length === 0) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
text: `No reasoning levels available for ${activeModel ?? "the active model"} on backend ${backendId}.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (!supportsReasoningLevel(level, levels)) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
text: `Unknown level for this model. Valid: ${levels.join(", ")}, or adaptive.`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
setChatEffort(chatId, level as EffortLevel);
|
|
399
|
+
return { ok: true, text: `Effort set to ${level}.` };
|
|
400
|
+
}
|
|
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import type { TalonConfig } from "../../util/config.js";
|
|
8
8
|
import { files, dirs } from "../../util/paths.js";
|
|
9
9
|
import { tailFile } from "../../util/tail-file.js";
|
|
10
|
-
import { escapeHtml } from "./formatting.js";
|
|
10
|
+
import { escapeHtml, splitMessage } from "./formatting.js";
|
|
11
|
+
import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
|
|
11
12
|
import { resetSession, getAllSessions } from "../../storage/sessions.js";
|
|
12
13
|
import { clearHistory } from "../../storage/history.js";
|
|
13
14
|
import { todayLogDate } from "../../storage/daily-log.js";
|
|
@@ -22,6 +23,72 @@ import { getPulseStatus } from "../../core/background/pulse.js";
|
|
|
22
23
|
import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
|
|
23
24
|
import { formatDuration, formatModelLabel } from "./helpers/index.js";
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Reply with an HTML listing, split across messages when it outgrows
|
|
28
|
+
* Telegram's 4096-char cap. The per-item listings below (chats, cron,
|
|
29
|
+
* pulse) scale with the daemon's chat count, and a single oversized
|
|
30
|
+
* `ctx.reply` fails the whole command with 400 "message is too long".
|
|
31
|
+
* Entries are `\n\n`-separated and each carries balanced tags, so the
|
|
32
|
+
* paragraph-first splitter never cuts through markup.
|
|
33
|
+
*/
|
|
34
|
+
async function replyHtmlChunked(ctx: Context, text: string): Promise<void> {
|
|
35
|
+
for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
|
|
36
|
+
await ctx.reply(chunk, { parse_mode: "HTML" });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `/admin chats` — every active session, newest first, titled via getChat. */
|
|
41
|
+
async function replyActiveChats(
|
|
42
|
+
ctx: Context,
|
|
43
|
+
bot: Bot,
|
|
44
|
+
config: TalonConfig,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const sessions = getAllSessions();
|
|
47
|
+
if (sessions.length === 0) {
|
|
48
|
+
await ctx.reply("No active sessions.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
|
|
52
|
+
|
|
53
|
+
const titles = new Map<string, string>();
|
|
54
|
+
await Promise.all(
|
|
55
|
+
sessions.map(async (s) => {
|
|
56
|
+
try {
|
|
57
|
+
const id = parseInt(s.chatId, 10);
|
|
58
|
+
if (isNaN(id)) return;
|
|
59
|
+
const chat = await bot.api.getChat(id);
|
|
60
|
+
titles.set(
|
|
61
|
+
s.chatId,
|
|
62
|
+
"title" in chat
|
|
63
|
+
? (chat.title ?? "DM")
|
|
64
|
+
: "first_name" in chat
|
|
65
|
+
? (chat.first_name ?? "DM")
|
|
66
|
+
: "DM",
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
/* inaccessible */
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const lines = sessions.map((s) => {
|
|
75
|
+
const age = s.info.lastActive
|
|
76
|
+
? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
|
|
77
|
+
: "?";
|
|
78
|
+
const title = titles.get(s.chatId) ?? s.chatId;
|
|
79
|
+
const model = formatModelLabel(
|
|
80
|
+
getChatSettings(s.chatId).model ?? config.model,
|
|
81
|
+
);
|
|
82
|
+
// `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
|
|
83
|
+
// it gets the same escaping the title already had.
|
|
84
|
+
return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
|
|
85
|
+
});
|
|
86
|
+
await replyHtmlChunked(
|
|
87
|
+
ctx,
|
|
88
|
+
`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
25
92
|
export async function handleAdminCommand(
|
|
26
93
|
ctx: Context,
|
|
27
94
|
bot: Bot,
|
|
@@ -32,52 +99,7 @@ export async function handleAdminCommand(
|
|
|
32
99
|
|
|
33
100
|
switch (subcommand) {
|
|
34
101
|
case "chats": {
|
|
35
|
-
|
|
36
|
-
if (sessions.length === 0) {
|
|
37
|
-
await ctx.reply("No active sessions.");
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
sessions.sort(
|
|
41
|
-
(a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const titles = new Map<string, string>();
|
|
45
|
-
await Promise.all(
|
|
46
|
-
sessions.map(async (s) => {
|
|
47
|
-
try {
|
|
48
|
-
const id = parseInt(s.chatId, 10);
|
|
49
|
-
if (isNaN(id)) return;
|
|
50
|
-
const chat = await bot.api.getChat(id);
|
|
51
|
-
titles.set(
|
|
52
|
-
s.chatId,
|
|
53
|
-
"title" in chat
|
|
54
|
-
? (chat.title ?? "DM")
|
|
55
|
-
: "first_name" in chat
|
|
56
|
-
? (chat.first_name ?? "DM")
|
|
57
|
-
: "DM",
|
|
58
|
-
);
|
|
59
|
-
} catch {
|
|
60
|
-
/* inaccessible */
|
|
61
|
-
}
|
|
62
|
-
}),
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const lines = sessions.map((s) => {
|
|
66
|
-
const age = s.info.lastActive
|
|
67
|
-
? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
|
|
68
|
-
: "?";
|
|
69
|
-
const title = titles.get(s.chatId) ?? s.chatId;
|
|
70
|
-
const model = formatModelLabel(
|
|
71
|
-
getChatSettings(s.chatId).model ?? config.model,
|
|
72
|
-
);
|
|
73
|
-
// `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
|
|
74
|
-
// it gets the same escaping the title already had.
|
|
75
|
-
return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
|
|
76
|
-
});
|
|
77
|
-
await ctx.reply(
|
|
78
|
-
`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
|
|
79
|
-
{ parse_mode: "HTML" },
|
|
80
|
-
);
|
|
102
|
+
await replyActiveChats(ctx, bot, config);
|
|
81
103
|
return;
|
|
82
104
|
}
|
|
83
105
|
|
|
@@ -189,9 +211,9 @@ export async function handleAdminCommand(
|
|
|
189
211
|
: "?";
|
|
190
212
|
return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
|
|
191
213
|
});
|
|
192
|
-
await
|
|
214
|
+
await replyHtmlChunked(
|
|
215
|
+
ctx,
|
|
193
216
|
`<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
|
|
194
|
-
{ parse_mode: "HTML" },
|
|
195
217
|
);
|
|
196
218
|
return;
|
|
197
219
|
}
|
|
@@ -217,9 +239,10 @@ export async function handleAdminCommand(
|
|
|
217
239
|
return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
|
|
218
240
|
}),
|
|
219
241
|
);
|
|
220
|
-
await
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
await replyHtmlChunked(
|
|
243
|
+
ctx,
|
|
244
|
+
`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
|
|
245
|
+
);
|
|
223
246
|
return;
|
|
224
247
|
}
|
|
225
248
|
|