talon-agent 4.3.2 → 4.3.3
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/backend/claude-sdk/doctor.ts +1 -1
- package/src/backend/codex/doctor.ts +1 -1
- package/src/backend/openai-agents/doctor.ts +1 -1
- package/src/bootstrap.ts +1 -1
- package/src/cli/doctor.ts +1 -1
- package/src/core/agent-runtime/backend-registry.ts +1 -1
- package/src/core/auth/expiry-monitor.ts +1 -1
- package/src/core/{doctor.ts → doctor/index.ts} +11 -11
- package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
- package/src/core/plugin/native-runtimes.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/telegram/actions/index.ts +1 -1
- package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
- package/src/frontend/telegram/actions/moderation/index.ts +74 -0
- package/src/frontend/telegram/actions/moderation/members.ts +77 -0
- package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
- package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
- package/src/frontend/telegram/actions/moderation/types.ts +16 -0
- package/src/frontend/telegram/admin/background.ts +66 -0
- package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
- package/src/frontend/telegram/admin/health.ts +81 -0
- package/src/frontend/telegram/admin/sessions.ts +105 -0
- package/src/frontend/telegram/admin.ts +56 -261
- package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
- package/src/frontend/telegram/callbacks/model/control.ts +29 -0
- package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
- package/src/frontend/telegram/callbacks/model/select.ts +100 -0
- package/src/frontend/telegram/callbacks/model/types.ts +39 -0
- package/src/frontend/telegram/callbacks/model/views.ts +116 -0
- package/src/frontend/telegram/callbacks/model.ts +61 -331
- package/src/frontend/telegram/commands/admin.ts +108 -77
- package/src/frontend/telegram/commands/settings.ts +65 -43
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
- package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
- package/src/frontend/telegram/middleware.ts +166 -201
- package/src/frontend/terminal/builtins/context.ts +184 -0
- package/src/frontend/terminal/builtins/help.ts +35 -0
- package/src/frontend/terminal/builtins/index.ts +23 -0
- package/src/frontend/terminal/builtins/model.ts +198 -0
- package/src/frontend/terminal/builtins/session.ts +101 -0
- package/src/frontend/terminal/builtins/status.ts +202 -0
- package/src/frontend/terminal/command-registry.ts +80 -0
- package/src/frontend/terminal/commands.ts +11 -674
- package/src/frontend/terminal/input/history.ts +64 -0
- package/src/frontend/terminal/input/keys.ts +143 -0
- package/src/frontend/terminal/input/parts.ts +33 -0
- package/src/frontend/terminal/input/paste.ts +53 -0
- package/src/frontend/terminal/input/render.ts +42 -0
- package/src/frontend/terminal/input/state.ts +60 -0
- package/src/frontend/terminal/input.ts +27 -342
- package/src/frontend/whatsapp/connection.ts +1 -1
- package/src/frontend/whatsapp/index.ts +1 -1
- package/src/frontend/whatsapp/pairing-service.ts +1 -1
- package/src/plugins/github/provision.ts +1 -1
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/plugins/playwright/provision.ts +1 -1
- package/src/frontend/telegram/actions/moderation.ts +0 -255
- /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
- /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin stats` / `errors` / `logs` / `daily` — the daemon-health
|
|
3
|
+
* subcommands.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Context } from "grammy";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { files, dirs } from "../../../util/paths.js";
|
|
9
|
+
import { tailFile } from "../../../util/tail-file.js";
|
|
10
|
+
import { escapeHtml } from "../formatting.js";
|
|
11
|
+
import { getAllSessions } from "../../../storage/sessions.js";
|
|
12
|
+
import { todayLogDate } from "../../../storage/daily-log.js";
|
|
13
|
+
import { getActiveCount } from "../../../core/engine/dispatcher.js";
|
|
14
|
+
import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
|
|
15
|
+
import { formatDuration } from "../helpers/index.js";
|
|
16
|
+
|
|
17
|
+
export async function replyStats(ctx: Context): Promise<void> {
|
|
18
|
+
const h = getHealthStatus();
|
|
19
|
+
const sessions = getAllSessions();
|
|
20
|
+
const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
|
|
21
|
+
const mem = process.memoryUsage();
|
|
22
|
+
await ctx.reply(
|
|
23
|
+
[
|
|
24
|
+
`<b>🦅 Talon Stats</b>`,
|
|
25
|
+
"",
|
|
26
|
+
`<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
|
|
27
|
+
`<b>Messages:</b> ${h.totalMessagesProcessed}`,
|
|
28
|
+
`<b>Sessions:</b> ${sessions.length}`,
|
|
29
|
+
`<b>Turns:</b> ${turns}`,
|
|
30
|
+
`<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
|
|
31
|
+
"",
|
|
32
|
+
`<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
|
|
33
|
+
`<b>Queue:</b> ${getActiveCount()}`,
|
|
34
|
+
`<b>Errors:</b> ${h.recentErrorCount}`,
|
|
35
|
+
].join("\n"),
|
|
36
|
+
{ parse_mode: "HTML" },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function replyRecentErrors(ctx: Context): Promise<void> {
|
|
41
|
+
const errors = getRecentErrors(5);
|
|
42
|
+
if (errors.length === 0) {
|
|
43
|
+
await ctx.reply("No recent errors.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const lines = errors.map(
|
|
47
|
+
(e) =>
|
|
48
|
+
`<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
|
|
49
|
+
);
|
|
50
|
+
await ctx.reply(
|
|
51
|
+
`<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
|
|
52
|
+
{ parse_mode: "HTML" },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function replyLogTail(ctx: Context): Promise<void> {
|
|
57
|
+
const logPath = files.log;
|
|
58
|
+
try {
|
|
59
|
+
const lines = tailFile(logPath);
|
|
60
|
+
await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
|
|
61
|
+
parse_mode: "HTML",
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
await ctx.reply(`Could not read ${logPath}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function replyDailyLog(ctx: Context): Promise<void> {
|
|
69
|
+
const today = todayLogDate();
|
|
70
|
+
const logPath = `${dirs.logs}/${today}.md`;
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(logPath, "utf-8");
|
|
73
|
+
const lines = content.trim().split("\n").slice(-30).join("\n");
|
|
74
|
+
await ctx.reply(
|
|
75
|
+
`<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
|
|
76
|
+
{ parse_mode: "HTML" },
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
await ctx.reply(`No daily log for ${today}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin chats` / `broadcast` / `kill` — the session-level subcommands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Bot, Context } from "grammy";
|
|
6
|
+
import type { TalonConfig } from "../../../util/config.js";
|
|
7
|
+
import { escapeHtml } from "../formatting.js";
|
|
8
|
+
import { resetSession, getAllSessions } from "../../../storage/sessions.js";
|
|
9
|
+
import { clearHistory } from "../../../storage/history.js";
|
|
10
|
+
import { getChatSettings } from "../../../storage/chat-settings.js";
|
|
11
|
+
import { formatModelLabel } from "../helpers/index.js";
|
|
12
|
+
import { replyHtmlChunked } from "./chunked-reply.js";
|
|
13
|
+
|
|
14
|
+
/** `/admin chats` — every active session, newest first, titled via getChat. */
|
|
15
|
+
export async function replyActiveChats(
|
|
16
|
+
ctx: Context,
|
|
17
|
+
_rest: string[],
|
|
18
|
+
bot: Bot,
|
|
19
|
+
config: TalonConfig,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const sessions = getAllSessions();
|
|
22
|
+
if (sessions.length === 0) {
|
|
23
|
+
await ctx.reply("No active sessions.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
|
|
27
|
+
|
|
28
|
+
const titles = new Map<string, string>();
|
|
29
|
+
await Promise.all(
|
|
30
|
+
sessions.map(async (s) => {
|
|
31
|
+
try {
|
|
32
|
+
const id = parseInt(s.chatId, 10);
|
|
33
|
+
if (isNaN(id)) return;
|
|
34
|
+
const chat = await bot.api.getChat(id);
|
|
35
|
+
titles.set(
|
|
36
|
+
s.chatId,
|
|
37
|
+
"title" in chat
|
|
38
|
+
? (chat.title ?? "DM")
|
|
39
|
+
: "first_name" in chat
|
|
40
|
+
? (chat.first_name ?? "DM")
|
|
41
|
+
: "DM",
|
|
42
|
+
);
|
|
43
|
+
} catch {
|
|
44
|
+
/* inaccessible */
|
|
45
|
+
}
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const lines = sessions.map((s) => {
|
|
50
|
+
const age = s.info.lastActive
|
|
51
|
+
? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
|
|
52
|
+
: "?";
|
|
53
|
+
const title = titles.get(s.chatId) ?? s.chatId;
|
|
54
|
+
const model = formatModelLabel(
|
|
55
|
+
getChatSettings(s.chatId).model ?? config.model,
|
|
56
|
+
);
|
|
57
|
+
// `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
|
|
58
|
+
// it gets the same escaping the title already had.
|
|
59
|
+
return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
|
|
60
|
+
});
|
|
61
|
+
await replyHtmlChunked(
|
|
62
|
+
ctx,
|
|
63
|
+
`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function broadcast(
|
|
68
|
+
ctx: Context,
|
|
69
|
+
rest: string[],
|
|
70
|
+
bot: Bot,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const text = rest.join(" ");
|
|
73
|
+
if (!text) {
|
|
74
|
+
await ctx.reply("Usage: /admin broadcast <text>");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sessions = getAllSessions();
|
|
78
|
+
let sent = 0,
|
|
79
|
+
failed = 0;
|
|
80
|
+
for (const s of sessions) {
|
|
81
|
+
const id = parseInt(s.chatId, 10);
|
|
82
|
+
if (isNaN(id)) continue;
|
|
83
|
+
try {
|
|
84
|
+
await bot.api.sendMessage(id, text);
|
|
85
|
+
sent++;
|
|
86
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
87
|
+
} catch {
|
|
88
|
+
failed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await ctx.reply(
|
|
92
|
+
`Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function killSession(ctx: Context, rest: string[]): Promise<void> {
|
|
97
|
+
const target = rest[0];
|
|
98
|
+
if (!target) {
|
|
99
|
+
await ctx.reply("Usage: /admin kill <chatId>");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
resetSession(target);
|
|
103
|
+
clearHistory(target);
|
|
104
|
+
await ctx.reply(`Session ${target} reset.`);
|
|
105
|
+
}
|
|
@@ -1,91 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Admin command handlers — /admin subcommands for bot operators.
|
|
3
|
+
*
|
|
4
|
+
* The table maps each subcommand to its handler in `admin/`:
|
|
5
|
+
* - `sessions` — chats / broadcast / kill
|
|
6
|
+
* - `health` — stats / errors / logs / daily
|
|
7
|
+
* - `background` — cron / pulse
|
|
8
|
+
* Anything else (including no subcommand) gets the usage listing.
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
11
|
import type { Bot, Context } from "grammy";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
7
12
|
import type { TalonConfig } from "../../util/config.js";
|
|
8
|
-
import {
|
|
9
|
-
import { tailFile } from "../../util/tail-file.js";
|
|
10
|
-
import { escapeHtml, splitMessage } from "./formatting.js";
|
|
11
|
-
import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
|
|
12
|
-
import { resetSession, getAllSessions } from "../../storage/sessions.js";
|
|
13
|
-
import { clearHistory } from "../../storage/history.js";
|
|
14
|
-
import { todayLogDate } from "../../storage/daily-log.js";
|
|
15
|
-
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
13
|
+
import { replyActiveChats, broadcast, killSession } from "./admin/sessions.js";
|
|
16
14
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
|
|
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(
|
|
15
|
+
replyStats,
|
|
16
|
+
replyRecentErrors,
|
|
17
|
+
replyLogTail,
|
|
18
|
+
replyDailyLog,
|
|
19
|
+
} from "./admin/health.js";
|
|
20
|
+
import { replyCronJobs, replyPulseStatus } from "./admin/background.js";
|
|
21
|
+
|
|
22
|
+
type AdminSubcommand = (
|
|
42
23
|
ctx: Context,
|
|
24
|
+
rest: string[],
|
|
43
25
|
bot: Bot,
|
|
44
26
|
config: TalonConfig,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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"),
|
|
27
|
+
) => Promise<void>;
|
|
28
|
+
|
|
29
|
+
// Null-prototype so `/admin constructor` can't resolve an inherited
|
|
30
|
+
// Object.prototype method via `handlers[subcommand]`.
|
|
31
|
+
const ADMIN_SUBCOMMANDS: Record<string, AdminSubcommand> = Object.assign(
|
|
32
|
+
Object.create(null),
|
|
33
|
+
{
|
|
34
|
+
chats: replyActiveChats,
|
|
35
|
+
broadcast,
|
|
36
|
+
kill: killSession,
|
|
37
|
+
logs: replyLogTail,
|
|
38
|
+
stats: replyStats,
|
|
39
|
+
errors: replyRecentErrors,
|
|
40
|
+
cron: replyCronJobs,
|
|
41
|
+
pulse: replyPulseStatus,
|
|
42
|
+
daily: replyDailyLog,
|
|
43
|
+
} satisfies Record<string, AdminSubcommand>,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function replyUsage(ctx: Context): Promise<void> {
|
|
47
|
+
await ctx.reply(
|
|
48
|
+
[
|
|
49
|
+
"<b>/admin commands</b>",
|
|
50
|
+
"",
|
|
51
|
+
" stats uptime, messages, memory",
|
|
52
|
+
" errors last 5 errors",
|
|
53
|
+
" chats list all active chats",
|
|
54
|
+
" daily today's interaction log",
|
|
55
|
+
" pulse pulse status per chat",
|
|
56
|
+
" cron list all cron jobs",
|
|
57
|
+
" broadcast <text> send to all chats",
|
|
58
|
+
" kill <chatId> reset a chat session",
|
|
59
|
+
" logs last 20 lines of log",
|
|
60
|
+
].join("\n"),
|
|
61
|
+
{ parse_mode: "HTML" },
|
|
89
62
|
);
|
|
90
63
|
}
|
|
91
64
|
|
|
@@ -96,188 +69,10 @@ export async function handleAdminCommand(
|
|
|
96
69
|
): Promise<void> {
|
|
97
70
|
const args = ((ctx.match as string) ?? "").trim();
|
|
98
71
|
const [subcommand, ...rest] = args.split(/\s+/);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
case "broadcast": {
|
|
107
|
-
const text = rest.join(" ");
|
|
108
|
-
if (!text) {
|
|
109
|
-
await ctx.reply("Usage: /admin broadcast <text>");
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
const sessions = getAllSessions();
|
|
113
|
-
let sent = 0,
|
|
114
|
-
failed = 0;
|
|
115
|
-
for (const s of sessions) {
|
|
116
|
-
const id = parseInt(s.chatId, 10);
|
|
117
|
-
if (isNaN(id)) continue;
|
|
118
|
-
try {
|
|
119
|
-
await bot.api.sendMessage(id, text);
|
|
120
|
-
sent++;
|
|
121
|
-
await new Promise((r) => setTimeout(r, 40));
|
|
122
|
-
} catch {
|
|
123
|
-
failed++;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
await ctx.reply(
|
|
127
|
-
`Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
|
|
128
|
-
);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
case "kill": {
|
|
133
|
-
const target = rest[0];
|
|
134
|
-
if (!target) {
|
|
135
|
-
await ctx.reply("Usage: /admin kill <chatId>");
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
resetSession(target);
|
|
139
|
-
clearHistory(target);
|
|
140
|
-
await ctx.reply(`Session ${target} reset.`);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
case "logs": {
|
|
145
|
-
const logPath = files.log;
|
|
146
|
-
try {
|
|
147
|
-
const lines = tailFile(logPath);
|
|
148
|
-
await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
|
|
149
|
-
parse_mode: "HTML",
|
|
150
|
-
});
|
|
151
|
-
} catch {
|
|
152
|
-
await ctx.reply(`Could not read ${logPath}`);
|
|
153
|
-
}
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
case "stats": {
|
|
158
|
-
const h = getHealthStatus();
|
|
159
|
-
const sessions = getAllSessions();
|
|
160
|
-
const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
|
|
161
|
-
const mem = process.memoryUsage();
|
|
162
|
-
await ctx.reply(
|
|
163
|
-
[
|
|
164
|
-
`<b>\uD83E\uDD85 Talon Stats</b>`,
|
|
165
|
-
"",
|
|
166
|
-
`<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
|
|
167
|
-
`<b>Messages:</b> ${h.totalMessagesProcessed}`,
|
|
168
|
-
`<b>Sessions:</b> ${sessions.length}`,
|
|
169
|
-
`<b>Turns:</b> ${turns}`,
|
|
170
|
-
`<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
|
|
171
|
-
"",
|
|
172
|
-
`<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
|
|
173
|
-
`<b>Queue:</b> ${getActiveCount()}`,
|
|
174
|
-
`<b>Errors:</b> ${h.recentErrorCount}`,
|
|
175
|
-
].join("\n"),
|
|
176
|
-
{ parse_mode: "HTML" },
|
|
177
|
-
);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
case "errors": {
|
|
182
|
-
const errors = getRecentErrors(5);
|
|
183
|
-
if (errors.length === 0) {
|
|
184
|
-
await ctx.reply("No recent errors.");
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const lines = errors.map(
|
|
188
|
-
(e) =>
|
|
189
|
-
`<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
|
|
190
|
-
);
|
|
191
|
-
await ctx.reply(
|
|
192
|
-
`<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
|
|
193
|
-
{ parse_mode: "HTML" },
|
|
194
|
-
);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
case "cron": {
|
|
199
|
-
const jobs = getAllCronJobs();
|
|
200
|
-
if (jobs.length === 0) {
|
|
201
|
-
await ctx.reply("No cron jobs.");
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const lines = jobs.map((j) => {
|
|
205
|
-
const nextMs = nextRunAt(j);
|
|
206
|
-
const last = j.lastRunAt
|
|
207
|
-
? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
|
|
208
|
-
: "never";
|
|
209
|
-
const next = nextMs
|
|
210
|
-
? new Date(nextMs).toISOString().slice(0, 16).replace("T", " ")
|
|
211
|
-
: "?";
|
|
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}`;
|
|
213
|
-
});
|
|
214
|
-
await replyHtmlChunked(
|
|
215
|
-
ctx,
|
|
216
|
-
`<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
|
|
217
|
-
);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
case "pulse": {
|
|
222
|
-
const chats = getPulseStatus();
|
|
223
|
-
if (chats.length === 0) {
|
|
224
|
-
await ctx.reply("No pulse chats.");
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
const lines = await Promise.all(
|
|
228
|
-
chats.map(async (p) => {
|
|
229
|
-
let title = p.chatId;
|
|
230
|
-
try {
|
|
231
|
-
const id = parseInt(p.chatId, 10);
|
|
232
|
-
if (!isNaN(id)) {
|
|
233
|
-
const chat = await bot.api.getChat(id);
|
|
234
|
-
title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
|
|
235
|
-
}
|
|
236
|
-
} catch {
|
|
237
|
-
/* skip */
|
|
238
|
-
}
|
|
239
|
-
return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
242
|
-
await replyHtmlChunked(
|
|
243
|
-
ctx,
|
|
244
|
-
`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
|
|
245
|
-
);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
case "daily": {
|
|
250
|
-
const today = todayLogDate();
|
|
251
|
-
const logPath = `${dirs.logs}/${today}.md`;
|
|
252
|
-
try {
|
|
253
|
-
const content = readFileSync(logPath, "utf-8");
|
|
254
|
-
const lines = content.trim().split("\n").slice(-30).join("\n");
|
|
255
|
-
await ctx.reply(
|
|
256
|
-
`<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
|
|
257
|
-
{ parse_mode: "HTML" },
|
|
258
|
-
);
|
|
259
|
-
} catch {
|
|
260
|
-
await ctx.reply(`No daily log for ${today}.`);
|
|
261
|
-
}
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
default:
|
|
266
|
-
await ctx.reply(
|
|
267
|
-
[
|
|
268
|
-
"<b>/admin commands</b>",
|
|
269
|
-
"",
|
|
270
|
-
" stats uptime, messages, memory",
|
|
271
|
-
" errors last 5 errors",
|
|
272
|
-
" chats list all active chats",
|
|
273
|
-
" daily today's interaction log",
|
|
274
|
-
" pulse pulse status per chat",
|
|
275
|
-
" cron list all cron jobs",
|
|
276
|
-
" broadcast <text> send to all chats",
|
|
277
|
-
" kill <chatId> reset a chat session",
|
|
278
|
-
" logs last 20 lines of log",
|
|
279
|
-
].join("\n"),
|
|
280
|
-
{ parse_mode: "HTML" },
|
|
281
|
-
);
|
|
72
|
+
const run = ADMIN_SUBCOMMANDS[subcommand];
|
|
73
|
+
if (!run) {
|
|
74
|
+
await replyUsage(ctx);
|
|
75
|
+
return;
|
|
282
76
|
}
|
|
77
|
+
await run(ctx, rest, bot, config);
|
|
283
78
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend switching — `model:backend:<id>` and `model:backend-default`.
|
|
3
|
+
* Both drop Talon's per-chat stores for the outgoing backend and hand the
|
|
4
|
+
* session over (see `handOffBackendSession`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Context } from "grammy";
|
|
8
|
+
import { setChatBackend } from "../../../../storage/chat-settings.js";
|
|
9
|
+
import { resetSession } from "../../../../storage/sessions.js";
|
|
10
|
+
import { clearHistory } from "../../../../storage/history.js";
|
|
11
|
+
import {
|
|
12
|
+
getBackendIdForChat,
|
|
13
|
+
listAvailableBackends,
|
|
14
|
+
rebindChat,
|
|
15
|
+
releaseChat,
|
|
16
|
+
} from "../../../../core/engine/backend-controller/index.js";
|
|
17
|
+
import { resetPulseCheckpoint } from "../../../../core/background/pulse.js";
|
|
18
|
+
import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
|
|
19
|
+
import { logWarn } from "../../../../util/log.js";
|
|
20
|
+
import type { ModelCallback } from "../../model-callbacks.js";
|
|
21
|
+
import { resolveBackendForChat } from "../../model-menu.js";
|
|
22
|
+
import { answerCallbackQuerySafe, type CallbackDeps } from "../shared.js";
|
|
23
|
+
import type { ModelOutcome } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the backend-side half of a chat's session handoff.
|
|
27
|
+
*
|
|
28
|
+
* A backend switch already clears Talon's own stores (session row,
|
|
29
|
+
* history, pulse checkpoint), but those are only half the state.
|
|
30
|
+
* `performSessionReset` also drives the backend capability slots, and
|
|
31
|
+
* the switch path skipped them entirely:
|
|
32
|
+
*
|
|
33
|
+
* - the OUTGOING backend keeps in-process per-chat state that no
|
|
34
|
+
* amount of clearing Talon's stores touches. openai-agents holds a
|
|
35
|
+
* `MemorySession` map keyed by chat id, so switching away and back
|
|
36
|
+
* resurrected the old conversation the operator meant to drop.
|
|
37
|
+
* - the INCOMING backend was never warmed, so the first turn after a
|
|
38
|
+
* switch paid the full cold start — on OpenCode/Kilo that is session
|
|
39
|
+
* creation plus a per-plugin MCP registration sweep.
|
|
40
|
+
*
|
|
41
|
+
* The warm is deliberately fire-and-forget: it can take seconds, and the
|
|
42
|
+
* callback still has a toast to answer and a menu to redraw. Telegram
|
|
43
|
+
* expires an unanswered callback query, so blocking here would trade a
|
|
44
|
+
* cold first turn for a visibly stuck button.
|
|
45
|
+
*/
|
|
46
|
+
function handOffBackendSession(
|
|
47
|
+
chatId: string,
|
|
48
|
+
previousBackend: ReturnType<typeof resolveBackendForChat>,
|
|
49
|
+
gateway: CallbackDeps["gateway"],
|
|
50
|
+
): void {
|
|
51
|
+
previousBackend?.sessions?.resetChat?.(chatId);
|
|
52
|
+
const nextBackend = resolveBackendForChat(chatId, gateway);
|
|
53
|
+
// Same instance on a no-op switch — warming it again is harmless
|
|
54
|
+
// (every step is idempotent) but pointless, so skip it.
|
|
55
|
+
if (!nextBackend || nextBackend === previousBackend) return;
|
|
56
|
+
void Promise.resolve(nextBackend.sessions?.warmSession?.(chatId)).catch(
|
|
57
|
+
(err) =>
|
|
58
|
+
logWarn(
|
|
59
|
+
"bot",
|
|
60
|
+
`[${chatId}] warm after backend switch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleBackendSelect(
|
|
66
|
+
ctx: Context,
|
|
67
|
+
action: Extract<ModelCallback, { kind: "backend-select" }>,
|
|
68
|
+
cid: string,
|
|
69
|
+
{ config, gateway }: CallbackDeps,
|
|
70
|
+
): Promise<ModelOutcome | undefined> {
|
|
71
|
+
// Rebind chat to the chosen backend. Verify the requested id
|
|
72
|
+
// is on the enabled list to keep the menu and the operation
|
|
73
|
+
// in sync — `config.enabledBackends` is a UX filter and we
|
|
74
|
+
// honour it here too.
|
|
75
|
+
const available = listAvailableBackends(config);
|
|
76
|
+
if (!available.some((b) => b.id === action.backendId)) {
|
|
77
|
+
await answerCallbackQuerySafe(ctx, {
|
|
78
|
+
text: "Backend not available",
|
|
79
|
+
});
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
// Resolve the outgoing backend BEFORE rebinding — afterwards this
|
|
83
|
+
// chat already points at the new one and the old in-process state
|
|
84
|
+
// would be unreachable.
|
|
85
|
+
const previousBackend = resolveBackendForChat(cid, gateway);
|
|
86
|
+
const result = await rebindChat(cid, action.backendId, config);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
await answerCallbackQuerySafe(ctx, {
|
|
89
|
+
text: result.error?.slice(0, 200) ?? "Rebind failed",
|
|
90
|
+
});
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
setChatBackend(cid, action.backendId);
|
|
94
|
+
// Switching backends drops the previous backend's per-chat
|
|
95
|
+
// session state (it's not portable across backends). We DO
|
|
96
|
+
// NOT clear `modelByBackend` — keeping each backend's prior
|
|
97
|
+
// pick means switching back-and-forth restores each side's
|
|
98
|
+
// last choice automatically (Codex chat keeps gpt-5.5,
|
|
99
|
+
// OpenRouter chat keeps owl-alpha, etc).
|
|
100
|
+
resetSession(cid);
|
|
101
|
+
clearHistory(cid);
|
|
102
|
+
resetPulseCheckpoint(cid);
|
|
103
|
+
handOffBackendSession(cid, previousBackend, gateway);
|
|
104
|
+
const label =
|
|
105
|
+
available.find((b) => b.id === action.backendId)?.label ?? action.backendId;
|
|
106
|
+
// Toast names the model the new backend will actually run
|
|
107
|
+
// (per-chat slot if remembered, else canonical / operator
|
|
108
|
+
// default; "no default" if catalog-driven with no config).
|
|
109
|
+
const newBackend = resolveBackendForChat(cid, gateway);
|
|
110
|
+
const { model: resolvedNewModel } = await resolveActiveModelForChat(
|
|
111
|
+
cid,
|
|
112
|
+
newBackend,
|
|
113
|
+
action.backendId,
|
|
114
|
+
config,
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
toast: resolvedNewModel
|
|
118
|
+
? `Backend: ${label} (model: ${resolvedNewModel})`
|
|
119
|
+
: `Backend: ${label} — no default model, /model to pick one`,
|
|
120
|
+
view: { kind: "menu" },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function handleBackendDefault(
|
|
125
|
+
_ctx: Context,
|
|
126
|
+
_action: Extract<ModelCallback, { kind: "backend-default" }>,
|
|
127
|
+
cid: string,
|
|
128
|
+
{ config, gateway }: CallbackDeps,
|
|
129
|
+
): Promise<ModelOutcome> {
|
|
130
|
+
// Drop the per-chat backend override; chat reverts to the
|
|
131
|
+
// global chat-role backend. Per-backend model picks are
|
|
132
|
+
// preserved (modelByBackend stays intact) so reverting and
|
|
133
|
+
// switching back later still restores prior choices.
|
|
134
|
+
const previousBackend = resolveBackendForChat(cid, gateway);
|
|
135
|
+
await releaseChat(cid);
|
|
136
|
+
setChatBackend(cid, undefined);
|
|
137
|
+
resetSession(cid);
|
|
138
|
+
clearHistory(cid);
|
|
139
|
+
resetPulseCheckpoint(cid);
|
|
140
|
+
handOffBackendSession(cid, previousBackend, gateway);
|
|
141
|
+
// Resolve the now-default backend's model for the toast.
|
|
142
|
+
const defaultBackend = resolveBackendForChat(cid, gateway);
|
|
143
|
+
const defaultBackendId = getBackendIdForChat(cid);
|
|
144
|
+
const { model: resolvedRoleModel } = await resolveActiveModelForChat(
|
|
145
|
+
cid,
|
|
146
|
+
defaultBackend,
|
|
147
|
+
defaultBackendId,
|
|
148
|
+
config,
|
|
149
|
+
);
|
|
150
|
+
return {
|
|
151
|
+
toast: resolvedRoleModel
|
|
152
|
+
? `Backend reset to default (model: ${resolvedRoleModel})`
|
|
153
|
+
: `Backend reset to default — no model picked, /model to pick one`,
|
|
154
|
+
view: { kind: "menu" },
|
|
155
|
+
};
|
|
156
|
+
}
|