talon-agent 1.0.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/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All /command handlers for the Telegram bot.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Bot } from "grammy";
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { resolve, dirname } from "node:path";
|
|
10
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
11
|
+
import { files } from "../../util/paths.js";
|
|
12
|
+
import {
|
|
13
|
+
resetSession,
|
|
14
|
+
getSessionInfo,
|
|
15
|
+
getActiveSessionCount,
|
|
16
|
+
} from "../../storage/sessions.js";
|
|
17
|
+
import { clearHistory } from "../../storage/history.js";
|
|
18
|
+
import {
|
|
19
|
+
getChatSettings,
|
|
20
|
+
setChatModel,
|
|
21
|
+
setChatEffort,
|
|
22
|
+
setChatPulseInterval,
|
|
23
|
+
resolveModelName,
|
|
24
|
+
EFFORT_LEVELS,
|
|
25
|
+
type EffortLevel,
|
|
26
|
+
} from "../../storage/chat-settings.js";
|
|
27
|
+
import {
|
|
28
|
+
registerChat,
|
|
29
|
+
disablePulse,
|
|
30
|
+
enablePulse,
|
|
31
|
+
isPulseEnabled,
|
|
32
|
+
resetPulseCheckpoint,
|
|
33
|
+
} from "../../core/pulse.js";
|
|
34
|
+
import { forceDream } from "../../core/dream.js";
|
|
35
|
+
import { isUserClientReady } from "./userbot.js";
|
|
36
|
+
import { getWorkspaceDiskUsage } from "../../util/workspace.js";
|
|
37
|
+
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
38
|
+
import { escapeHtml } from "./formatting.js";
|
|
39
|
+
import { handleAdminCommand } from "./admin.js";
|
|
40
|
+
import { getLoadedPlugins } from "../../core/plugin.js";
|
|
41
|
+
import {
|
|
42
|
+
formatDuration,
|
|
43
|
+
formatTokenCount,
|
|
44
|
+
formatBytes,
|
|
45
|
+
parseInterval,
|
|
46
|
+
renderSettingsText,
|
|
47
|
+
renderSettingsKeyboard,
|
|
48
|
+
} from "./helpers.js";
|
|
49
|
+
|
|
50
|
+
// Admin user ID is set via talon.json or TALON_ADMIN_USER_ID env var
|
|
51
|
+
let ADMIN_USER_ID = 0;
|
|
52
|
+
|
|
53
|
+
/** Set the admin user ID (called from config at startup). */
|
|
54
|
+
export function setAdminUserId(id: number | undefined): void {
|
|
55
|
+
ADMIN_USER_ID = id ?? 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerCommands(bot: Bot, config: TalonConfig): void {
|
|
59
|
+
bot.command("start", (ctx) =>
|
|
60
|
+
ctx.reply(
|
|
61
|
+
[
|
|
62
|
+
"<b>\uD83E\uDD85 Talon</b>",
|
|
63
|
+
"",
|
|
64
|
+
"Claude-powered Telegram assistant with 31 tools.",
|
|
65
|
+
"",
|
|
66
|
+
"Send a message, photo, doc, or voice note.",
|
|
67
|
+
"In groups, @mention or reply to activate.",
|
|
68
|
+
"",
|
|
69
|
+
"/status /reset /help",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
{ parse_mode: "HTML" },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
bot.command("help", (ctx) =>
|
|
76
|
+
ctx.reply(
|
|
77
|
+
[
|
|
78
|
+
"<b>\uD83E\uDD85 Talon -- Help</b>",
|
|
79
|
+
"",
|
|
80
|
+
"<b>\uD83E\uDD85 Settings</b>",
|
|
81
|
+
" /settings -- view and change all chat settings",
|
|
82
|
+
" /model -- show or change model (sonnet, opus, haiku)",
|
|
83
|
+
" /effort -- set thinking effort (off, low, medium, high, max)",
|
|
84
|
+
" /pulse -- toggle periodic check-ins (on/off)",
|
|
85
|
+
"",
|
|
86
|
+
"<b>Session</b>",
|
|
87
|
+
" /status -- session info, usage, and stats",
|
|
88
|
+
" /memory -- view what Talon remembers",
|
|
89
|
+
" /dream -- force memory consolidation now",
|
|
90
|
+
" /ping -- health check with latency",
|
|
91
|
+
" /reset -- clear session and start fresh",
|
|
92
|
+
" /restart -- restart the bot process",
|
|
93
|
+
" /plugins -- list loaded plugins",
|
|
94
|
+
" /help -- this message",
|
|
95
|
+
"",
|
|
96
|
+
"<b>Input</b>",
|
|
97
|
+
" Text, photos, documents, voice notes, audio, videos, GIFs, stickers, video notes, forwarded messages, reply context",
|
|
98
|
+
"",
|
|
99
|
+
"<b>Messaging</b>",
|
|
100
|
+
" Send, reply, edit, delete, forward, copy, pin/unpin messages. Inline keyboards with callback buttons. Scheduled messages.",
|
|
101
|
+
"",
|
|
102
|
+
"<b>Media</b>",
|
|
103
|
+
" Send photos, videos, GIFs, voice notes, stickers, files, polls, locations, contacts, dice.",
|
|
104
|
+
"",
|
|
105
|
+
"<b>Chat</b>",
|
|
106
|
+
" Read history, search messages, list members, get chat info, manage titles and descriptions.",
|
|
107
|
+
"",
|
|
108
|
+
"<b>Web</b>",
|
|
109
|
+
" Ask Talon to read a URL — it can fetch and summarize web pages.",
|
|
110
|
+
"",
|
|
111
|
+
"<b>Groups</b>",
|
|
112
|
+
" Mention @" +
|
|
113
|
+
escapeHtml(ctx.me.username ?? "bot") +
|
|
114
|
+
" or reply to activate.",
|
|
115
|
+
"",
|
|
116
|
+
"<b>Files</b>",
|
|
117
|
+
" Ask me to create a file and I'll send it as an attachment.",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
{ parse_mode: "HTML" },
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
bot.command("reset", async (ctx) => {
|
|
124
|
+
const cid = String(ctx.chat.id);
|
|
125
|
+
const info = getSessionInfo(cid);
|
|
126
|
+
|
|
127
|
+
if (info.turns > 0) {
|
|
128
|
+
const duration = info.createdAt
|
|
129
|
+
? formatDuration(Date.now() - info.createdAt)
|
|
130
|
+
: "unknown";
|
|
131
|
+
const modelNote =
|
|
132
|
+
info.turns > 5 && info.lastModel ? ` | model: ${info.lastModel}` : "";
|
|
133
|
+
const nameNote = info.sessionName ? ` "${info.sessionName}"` : "";
|
|
134
|
+
appendDailyLog(
|
|
135
|
+
"System",
|
|
136
|
+
`Session reset${nameNote}: ${info.turns} turns, ${duration}${modelNote}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
resetSession(cid);
|
|
141
|
+
clearHistory(cid);
|
|
142
|
+
resetPulseCheckpoint(cid);
|
|
143
|
+
await ctx.reply("Session cleared.");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
bot.command("ping", async (ctx) => {
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
const sent = await ctx.reply("...");
|
|
149
|
+
const latency = Date.now() - start;
|
|
150
|
+
|
|
151
|
+
const bridgeOk = true;
|
|
152
|
+
const userbotOk = isUserClientReady();
|
|
153
|
+
const uptime = formatDuration(process.uptime() * 1000);
|
|
154
|
+
|
|
155
|
+
const statusLine = [
|
|
156
|
+
`Bridge: ${bridgeOk ? "\u2713" : "\u2717"}`,
|
|
157
|
+
`Userbot: ${userbotOk ? "\u2713" : "\u2717"}`,
|
|
158
|
+
`Uptime: ${uptime}`,
|
|
159
|
+
].join(" | ");
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await bot.api.editMessageText(
|
|
163
|
+
ctx.chat.id,
|
|
164
|
+
sent.message_id,
|
|
165
|
+
`Pong! ${latency}ms\n${statusLine}`,
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore edit failure
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
bot.command("model", async (ctx) => {
|
|
173
|
+
const cid = String(ctx.chat.id);
|
|
174
|
+
const arg = ctx.match?.trim();
|
|
175
|
+
const settings = getChatSettings(cid);
|
|
176
|
+
|
|
177
|
+
if (!arg) {
|
|
178
|
+
const current = settings.model ?? config.model;
|
|
179
|
+
const isModel = (id: string) => current.includes(id);
|
|
180
|
+
await ctx.reply(
|
|
181
|
+
`<b>Model:</b> <code>${escapeHtml(current)}</code>\nSelect a model:`,
|
|
182
|
+
{
|
|
183
|
+
parse_mode: "HTML",
|
|
184
|
+
reply_markup: {
|
|
185
|
+
inline_keyboard: [
|
|
186
|
+
[
|
|
187
|
+
{
|
|
188
|
+
text: isModel("sonnet") ? "\u2713 Sonnet 4.6" : "Sonnet 4.6",
|
|
189
|
+
callback_data: "model:sonnet",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
text: isModel("opus") ? "\u2713 Opus 4.6" : "Opus 4.6",
|
|
193
|
+
callback_data: "model:opus",
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
{
|
|
198
|
+
text: isModel("haiku") ? "\u2713 Haiku 4.5" : "Haiku 4.5",
|
|
199
|
+
callback_data: "model:haiku",
|
|
200
|
+
},
|
|
201
|
+
{ text: "Reset to default", callback_data: "model:reset" },
|
|
202
|
+
],
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (arg === "reset" || arg === "default") {
|
|
211
|
+
setChatModel(cid, undefined);
|
|
212
|
+
await ctx.reply(
|
|
213
|
+
`Model reset to default: <code>${escapeHtml(config.model)}</code>`,
|
|
214
|
+
{ parse_mode: "HTML" },
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const model = resolveModelName(arg);
|
|
220
|
+
setChatModel(cid, model);
|
|
221
|
+
await ctx.reply(
|
|
222
|
+
`Model set to <code>${escapeHtml(model)}</code>.`,
|
|
223
|
+
{ parse_mode: "HTML" },
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
bot.command("effort", async (ctx) => {
|
|
228
|
+
const cid = String(ctx.chat.id);
|
|
229
|
+
const arg = ctx.match?.trim().toLowerCase();
|
|
230
|
+
const settings = getChatSettings(cid);
|
|
231
|
+
|
|
232
|
+
if (!arg) {
|
|
233
|
+
const current = settings.effort ?? "adaptive";
|
|
234
|
+
await ctx.reply(`<b>Effort:</b> ${current}\nSelect a level:`, {
|
|
235
|
+
parse_mode: "HTML",
|
|
236
|
+
reply_markup: {
|
|
237
|
+
inline_keyboard: [
|
|
238
|
+
[
|
|
239
|
+
{
|
|
240
|
+
text: current === "off" ? "\u2713 Off" : "Off",
|
|
241
|
+
callback_data: "effort:off",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
text: current === "low" ? "\u2713 Low" : "Low",
|
|
245
|
+
callback_data: "effort:low",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
text: current === "medium" ? "\u2713 Med" : "Med",
|
|
249
|
+
callback_data: "effort:medium",
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
[
|
|
253
|
+
{
|
|
254
|
+
text: current === "high" ? "\u2713 High" : "High",
|
|
255
|
+
callback_data: "effort:high",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
text: current === "max" ? "\u2713 Max" : "Max",
|
|
259
|
+
callback_data: "effort:max",
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
text: current === "adaptive" ? "\u2713 Auto" : "Auto",
|
|
263
|
+
callback_data: "effort:adaptive",
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (arg === "reset" || arg === "default" || arg === "adaptive") {
|
|
273
|
+
setChatEffort(cid, undefined);
|
|
274
|
+
await ctx.reply(
|
|
275
|
+
"Effort reset to <b>adaptive</b> (Claude decides when to think)",
|
|
276
|
+
{ parse_mode: "HTML" },
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (EFFORT_LEVELS.includes(arg as EffortLevel)) {
|
|
282
|
+
setChatEffort(cid, arg as EffortLevel);
|
|
283
|
+
await ctx.reply(`Effort set to <b>${arg}</b>`, { parse_mode: "HTML" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await ctx.reply(
|
|
288
|
+
"Unknown level. Use: off, low, medium, high, max, or adaptive.",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
bot.command("pulse", async (ctx) => {
|
|
293
|
+
const cid = String(ctx.chat.id);
|
|
294
|
+
const arg = ctx.match?.trim().toLowerCase();
|
|
295
|
+
|
|
296
|
+
if (!arg || arg === "status") {
|
|
297
|
+
const enabled = isPulseEnabled(cid);
|
|
298
|
+
await ctx.reply(
|
|
299
|
+
[
|
|
300
|
+
`<b>🔔 Pulse:</b> ${enabled ? "on" : "off"}`,
|
|
301
|
+
"",
|
|
302
|
+
"Reads along every few minutes and jumps in when there's something to add.",
|
|
303
|
+
].join("\n"),
|
|
304
|
+
{
|
|
305
|
+
parse_mode: "HTML",
|
|
306
|
+
reply_markup: {
|
|
307
|
+
inline_keyboard: [[
|
|
308
|
+
{ text: enabled ? "✓ On" : "On", callback_data: "pulse:on" },
|
|
309
|
+
{ text: !enabled ? "✓ Off" : "Off", callback_data: "pulse:off" },
|
|
310
|
+
]],
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (arg === "on" || arg === "enable") {
|
|
318
|
+
enablePulse(cid);
|
|
319
|
+
registerChat(cid);
|
|
320
|
+
await ctx.reply("🔔 Pulse enabled.");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (arg === "off" || arg === "disable") {
|
|
325
|
+
disablePulse(cid);
|
|
326
|
+
await ctx.reply("🔔 Pulse disabled.");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const intervalMs = parseInterval(arg);
|
|
331
|
+
if (intervalMs && intervalMs >= 5 * 60 * 1000) {
|
|
332
|
+
setChatPulseInterval(cid, intervalMs);
|
|
333
|
+
enablePulse(cid);
|
|
334
|
+
registerChat(cid);
|
|
335
|
+
await ctx.reply(
|
|
336
|
+
`🔔 Pulse cooldown set to <b>${formatDuration(intervalMs)}</b>`,
|
|
337
|
+
{ parse_mode: "HTML" },
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (intervalMs) {
|
|
343
|
+
await ctx.reply("Minimum interval is 5 minutes.");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await ctx.reply(
|
|
348
|
+
"Use: /pulse on, /pulse off, /pulse 30m, /pulse 2h",
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
bot.command("memory", async (ctx) => {
|
|
353
|
+
try {
|
|
354
|
+
const memoryPath = files.memory;
|
|
355
|
+
if (!existsSync(memoryPath)) {
|
|
356
|
+
await ctx.reply("No memory file yet. I'll create one as I learn about you.");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const content = readFileSync(memoryPath, "utf-8").trim();
|
|
360
|
+
if (!content) {
|
|
361
|
+
await ctx.reply("Memory file is empty. I'll update it as we chat.");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Truncate for Telegram's 4096 char limit
|
|
365
|
+
const display = content.length > 3500
|
|
366
|
+
? content.slice(0, 3500) + "\n\n... (truncated)"
|
|
367
|
+
: content;
|
|
368
|
+
await ctx.reply(display);
|
|
369
|
+
} catch {
|
|
370
|
+
await ctx.reply("Could not read memory file.");
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
bot.command("settings", async (ctx) => {
|
|
375
|
+
const cid = String(ctx.chat.id);
|
|
376
|
+
const chatSets = getChatSettings(cid);
|
|
377
|
+
const activeModel = chatSets.model ?? config.model;
|
|
378
|
+
const effortName = chatSets.effort ?? "adaptive";
|
|
379
|
+
const pulseOn = isPulseEnabled(cid);
|
|
380
|
+
|
|
381
|
+
await ctx.reply(
|
|
382
|
+
renderSettingsText(
|
|
383
|
+
activeModel,
|
|
384
|
+
effortName,
|
|
385
|
+
pulseOn,
|
|
386
|
+
chatSets.pulseIntervalMs,
|
|
387
|
+
),
|
|
388
|
+
{
|
|
389
|
+
parse_mode: "HTML",
|
|
390
|
+
reply_markup: {
|
|
391
|
+
inline_keyboard: renderSettingsKeyboard(
|
|
392
|
+
activeModel,
|
|
393
|
+
effortName,
|
|
394
|
+
pulseOn,
|
|
395
|
+
),
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
bot.command("admin", async (ctx) => {
|
|
402
|
+
if (ctx.from?.id !== ADMIN_USER_ID) {
|
|
403
|
+
await ctx.reply("Not authorized.");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
await handleAdminCommand(ctx, bot, config);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
bot.command("status", async (ctx) => {
|
|
410
|
+
const cid = String(ctx.chat.id);
|
|
411
|
+
const info = getSessionInfo(cid);
|
|
412
|
+
const u = info.usage;
|
|
413
|
+
const uptime = formatDuration(process.uptime() * 1000);
|
|
414
|
+
const sessionAge = info.createdAt
|
|
415
|
+
? formatDuration(Date.now() - info.createdAt)
|
|
416
|
+
: "\u2014";
|
|
417
|
+
const chatSets = getChatSettings(cid);
|
|
418
|
+
const activeModel = chatSets.model ?? config.model;
|
|
419
|
+
const effortName = chatSets.effort ?? "adaptive";
|
|
420
|
+
const pulseOn = isPulseEnabled(cid);
|
|
421
|
+
|
|
422
|
+
const contextMax = activeModel.includes("haiku") ? 200_000 : 1_000_000;
|
|
423
|
+
const contextUsed = u.lastPromptTokens;
|
|
424
|
+
const contextPct =
|
|
425
|
+
contextMax > 0
|
|
426
|
+
? Math.min(100, Math.round((contextUsed / contextMax) * 100))
|
|
427
|
+
: 0;
|
|
428
|
+
const barLen = 20;
|
|
429
|
+
const filled = Math.round((contextPct / 100) * barLen);
|
|
430
|
+
const contextBar = "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
431
|
+
const contextWarn = contextPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
|
|
432
|
+
|
|
433
|
+
const totalPrompt = u.totalInputTokens + u.totalCacheRead + u.totalCacheWrite;
|
|
434
|
+
const cacheHitPct =
|
|
435
|
+
totalPrompt > 0 ? Math.round((u.totalCacheRead / totalPrompt) * 100) : 0;
|
|
436
|
+
|
|
437
|
+
const avgResponseMs =
|
|
438
|
+
info.turns > 0 && u.totalResponseMs
|
|
439
|
+
? Math.round(u.totalResponseMs / info.turns)
|
|
440
|
+
: 0;
|
|
441
|
+
const lastResponseMs = u.lastResponseMs || 0;
|
|
442
|
+
const fastestMs = u.fastestResponseMs === Infinity ? 0 : (u.fastestResponseMs || 0);
|
|
443
|
+
|
|
444
|
+
const diskBytes = getWorkspaceDiskUsage(config.workspace);
|
|
445
|
+
const diskStr = formatBytes(diskBytes);
|
|
446
|
+
|
|
447
|
+
const lines = [
|
|
448
|
+
`<b>\uD83E\uDD85 Talon</b> \u00B7 <code>${escapeHtml(activeModel)}</code> \u00B7 effort: ${effortName}`,
|
|
449
|
+
"",
|
|
450
|
+
`<b>Context</b> ${formatTokenCount(contextUsed)} / ${formatTokenCount(contextMax)} (${contextPct}%)${contextWarn}`,
|
|
451
|
+
`<code>${contextBar}</code>`,
|
|
452
|
+
"",
|
|
453
|
+
`<b>Session Stats</b>`,
|
|
454
|
+
` Response last ${lastResponseMs ? formatDuration(lastResponseMs) : "\u2014"} \u00B7 avg ${avgResponseMs ? formatDuration(avgResponseMs) : "\u2014"} \u00B7 best ${fastestMs ? formatDuration(fastestMs) : "\u2014"}`,
|
|
455
|
+
` Turns ${info.turns}${info.lastModel ? ` (${info.lastModel.replace("claude-", "")})` : ""}`,
|
|
456
|
+
"",
|
|
457
|
+
`<b>Cache</b> ${cacheHitPct}% hit`,
|
|
458
|
+
` Read ${formatTokenCount(u.totalCacheRead)} Write ${formatTokenCount(u.totalCacheWrite)}`,
|
|
459
|
+
` Input ${formatTokenCount(u.totalInputTokens)} Output ${formatTokenCount(u.totalOutputTokens)}`,
|
|
460
|
+
"",
|
|
461
|
+
`<b>Pulse</b> ${pulseOn ? "on" : "off"}`,
|
|
462
|
+
`<b>Workspace</b> ${diskStr}`,
|
|
463
|
+
`<b>Session</b> ${info.sessionName ? `"${escapeHtml(info.sessionName)}" ` : ""}${info.sessionId ? "<code>" + escapeHtml(info.sessionId.slice(0, 8)) + "...</code>" : "<i>(new)</i>"} \u00B7 ${sessionAge} old`,
|
|
464
|
+
`<b>Uptime</b> ${uptime} \u00B7 ${getActiveSessionCount()} active session${getActiveSessionCount() === 1 ? "" : "s"}`,
|
|
465
|
+
];
|
|
466
|
+
await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
bot.command("dream", async (ctx) => {
|
|
470
|
+
if (ADMIN_USER_ID && ctx.from?.id !== ADMIN_USER_ID) {
|
|
471
|
+
await ctx.reply("Not authorized.");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const sent = await ctx.reply("🌙 Dream mode starting...");
|
|
475
|
+
const start = Date.now();
|
|
476
|
+
// Fire-and-forget — don't await, so grammY can keep processing other updates
|
|
477
|
+
forceDream()
|
|
478
|
+
.then(async () => {
|
|
479
|
+
const elapsed = formatDuration(Date.now() - start);
|
|
480
|
+
await bot.api.editMessageText(
|
|
481
|
+
ctx.chat.id,
|
|
482
|
+
sent.message_id,
|
|
483
|
+
`🌙 Dream complete — memory consolidated in ${elapsed}.`,
|
|
484
|
+
);
|
|
485
|
+
})
|
|
486
|
+
.catch(async (err: unknown) => {
|
|
487
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
488
|
+
await bot.api.editMessageText(
|
|
489
|
+
ctx.chat.id,
|
|
490
|
+
sent.message_id,
|
|
491
|
+
`🌙 Dream failed: ${escapeHtml(msg)}`,
|
|
492
|
+
{ parse_mode: "HTML" },
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
bot.command("restart", async (ctx) => {
|
|
498
|
+
if (ADMIN_USER_ID && ctx.from?.id !== ADMIN_USER_ID) {
|
|
499
|
+
await ctx.reply("Not authorized.");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
await ctx.reply("♻️ Restarting...");
|
|
503
|
+
|
|
504
|
+
setTimeout(() => {
|
|
505
|
+
// Try `talon restart` (handles daemon stop+start cleanly).
|
|
506
|
+
// Fall back to the local bin if talon isn't on PATH globally.
|
|
507
|
+
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
508
|
+
const localBin = resolve(projectRoot, "bin/talon.js");
|
|
509
|
+
|
|
510
|
+
const trySpawn = (cmd: string, args: string[]): Promise<void> =>
|
|
511
|
+
new Promise((res, rej) => {
|
|
512
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
513
|
+
child.on("error", rej);
|
|
514
|
+
child.on("spawn", () => { child.unref(); res(); });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Try global first, then local bin, then just exit (let process manager restart)
|
|
518
|
+
trySpawn("talon", ["restart"])
|
|
519
|
+
.catch(() => trySpawn(process.execPath, [localBin, "restart"]))
|
|
520
|
+
.catch(() => {})
|
|
521
|
+
.finally(() => process.exit(0));
|
|
522
|
+
}, 500);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
bot.command("plugins", async (ctx) => {
|
|
526
|
+
const plugins = getLoadedPlugins();
|
|
527
|
+
if (plugins.length === 0) {
|
|
528
|
+
await ctx.reply("No plugins loaded.");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const lines = plugins.map((p) => {
|
|
532
|
+
const ver = p.plugin.version ? ` v${p.plugin.version}` : "";
|
|
533
|
+
const desc = p.plugin.description ? ` — ${p.plugin.description}` : "";
|
|
534
|
+
const mcp = p.plugin.mcpServerPath ? " [MCP]" : "";
|
|
535
|
+
const fe = p.plugin.frontends?.length ? ` (${p.plugin.frontends.join(", ")})` : "";
|
|
536
|
+
return `• <b>${escapeHtml(p.plugin.name)}</b>${ver}${mcp}${fe}${desc}`;
|
|
537
|
+
});
|
|
538
|
+
await ctx.reply(
|
|
539
|
+
`<b>Plugins (${plugins.length})</b>\n\n${lines.join("\n")}`,
|
|
540
|
+
{ parse_mode: "HTML" },
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram message formatting and splitting utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Split a message into chunks that fit Telegram's 4096 char limit. */
|
|
6
|
+
export function splitMessage(text: string, max: number): string[] {
|
|
7
|
+
if (text.length <= max) return [text];
|
|
8
|
+
const chunks: string[] = [];
|
|
9
|
+
let rest = text;
|
|
10
|
+
while (rest.length > 0) {
|
|
11
|
+
if (rest.length <= max) {
|
|
12
|
+
chunks.push(rest);
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
// Prefer splitting at paragraph breaks, then newlines, then spaces
|
|
16
|
+
let at = rest.lastIndexOf("\n\n", max);
|
|
17
|
+
if (at <= max * 0.3) at = rest.lastIndexOf("\n", max);
|
|
18
|
+
if (at <= max * 0.3) at = rest.lastIndexOf(" ", max);
|
|
19
|
+
if (at <= 0) at = max;
|
|
20
|
+
chunks.push(rest.slice(0, at));
|
|
21
|
+
rest = rest.slice(at).trimStart();
|
|
22
|
+
}
|
|
23
|
+
return chunks;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Escape HTML special characters for Telegram HTML parse mode.
|
|
28
|
+
* Must be applied to all text that is NOT inside an HTML tag.
|
|
29
|
+
*/
|
|
30
|
+
export function escapeHtml(text: string): string {
|
|
31
|
+
return text
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert Claude's Markdown output to Telegram-safe HTML.
|
|
39
|
+
*
|
|
40
|
+
* Handles: bold, italic, inline code, fenced code blocks, links.
|
|
41
|
+
* Escapes HTML entities in non-formatted text.
|
|
42
|
+
*/
|
|
43
|
+
export function markdownToTelegramHtml(text: string): string {
|
|
44
|
+
// Step 1: Extract fenced code blocks to avoid processing their contents.
|
|
45
|
+
// We replace them with placeholders and restore after all inline processing.
|
|
46
|
+
const codeBlocks: string[] = [];
|
|
47
|
+
let processed = text.replace(
|
|
48
|
+
/```(\w*)\n([\s\S]*?)```/g,
|
|
49
|
+
(_match, lang: string, code: string) => {
|
|
50
|
+
const escaped = escapeHtml(code.replace(/\n$/, ""));
|
|
51
|
+
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
52
|
+
const placeholder = `\x00CODEBLOCK${codeBlocks.length}\x00`;
|
|
53
|
+
codeBlocks.push(`<pre><code${langAttr}>${escaped}</code></pre>`);
|
|
54
|
+
return placeholder;
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Step 2: Extract inline code spans to protect them from further processing.
|
|
59
|
+
const inlineCode: string[] = [];
|
|
60
|
+
processed = processed.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
61
|
+
const placeholder = `\x00INLINECODE${inlineCode.length}\x00`;
|
|
62
|
+
inlineCode.push(`<code>${escapeHtml(code)}</code>`);
|
|
63
|
+
return placeholder;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Step 3: Escape HTML in remaining plain text (before applying formatting).
|
|
67
|
+
// Escape HTML in plain text segments (skip placeholders marked with \x00)
|
|
68
|
+
// oxlint-disable-next-line no-control-regex
|
|
69
|
+
processed = processed.replace(/[^`\x00]+/g, (segment) => escapeHtml(segment));
|
|
70
|
+
|
|
71
|
+
// Step 4: Apply inline formatting.
|
|
72
|
+
// Bold: **text**
|
|
73
|
+
processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
74
|
+
// Italic: *text* (not preceded by another *)
|
|
75
|
+
processed = processed.replace(
|
|
76
|
+
/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g,
|
|
77
|
+
"<i>$1</i>",
|
|
78
|
+
);
|
|
79
|
+
// Italic: _text_ (surrounded by non-word or start/end)
|
|
80
|
+
processed = processed.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
|
|
81
|
+
// Links: [text](url) — only allow safe URL schemes
|
|
82
|
+
processed = processed.replace(
|
|
83
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
84
|
+
(_, text, url) =>
|
|
85
|
+
/^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
|
|
86
|
+
);
|
|
87
|
+
// Strikethrough: ~~text~~
|
|
88
|
+
processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
|
89
|
+
|
|
90
|
+
// Step 5: Restore inline code spans.
|
|
91
|
+
for (let i = 0; i < inlineCode.length; i++) {
|
|
92
|
+
processed = processed.replace(`\x00INLINECODE${i}\x00`, inlineCode[i]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 6: Restore fenced code blocks.
|
|
96
|
+
for (let i = 0; i < codeBlocks.length; i++) {
|
|
97
|
+
processed = processed.replace(`\x00CODEBLOCK${i}\x00`, codeBlocks[i]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return processed;
|
|
101
|
+
}
|