talon-agent 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -7,13 +7,13 @@ 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 { escapeHtml } from "./formatting.js";
10
- import {
11
- resetSession,
12
- getAllSessions,
13
- } from "../../storage/sessions.js";
10
+ import { resetSession, getAllSessions } from "../../storage/sessions.js";
14
11
  import { clearHistory } from "../../storage/history.js";
15
12
  import { getChatSettings } from "../../storage/chat-settings.js";
16
- import { getAllCronJobs, validateCronExpression } from "../../storage/cron-store.js";
13
+ import {
14
+ getAllCronJobs,
15
+ validateCronExpression,
16
+ } from "../../storage/cron-store.js";
17
17
  import { getActiveCount } from "../../core/dispatcher.js";
18
18
  import { getPulseStatus } from "../../core/pulse.js";
19
19
  import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
@@ -24,52 +24,90 @@ export async function handleAdminCommand(
24
24
  bot: Bot,
25
25
  config: TalonConfig,
26
26
  ): Promise<void> {
27
- const args = (ctx.match as string ?? "").trim();
27
+ const args = ((ctx.match as string) ?? "").trim();
28
28
  const [subcommand, ...rest] = args.split(/\s+/);
29
29
 
30
30
  switch (subcommand) {
31
31
  case "chats": {
32
32
  const sessions = getAllSessions();
33
- if (sessions.length === 0) { await ctx.reply("No active sessions."); return; }
34
- sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
33
+ if (sessions.length === 0) {
34
+ await ctx.reply("No active sessions.");
35
+ return;
36
+ }
37
+ sessions.sort(
38
+ (a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
39
+ );
35
40
 
36
41
  const titles = new Map<string, string>();
37
- await Promise.all(sessions.map(async (s) => {
38
- try {
39
- const id = parseInt(s.chatId, 10);
40
- if (isNaN(id)) return;
41
- const chat = await bot.api.getChat(id);
42
- titles.set(s.chatId, "title" in chat ? (chat.title ?? "DM") : "first_name" in chat ? (chat.first_name ?? "DM") : "DM");
43
- } catch { /* inaccessible */ }
44
- }));
42
+ await Promise.all(
43
+ sessions.map(async (s) => {
44
+ try {
45
+ const id = parseInt(s.chatId, 10);
46
+ if (isNaN(id)) return;
47
+ const chat = await bot.api.getChat(id);
48
+ titles.set(
49
+ s.chatId,
50
+ "title" in chat
51
+ ? (chat.title ?? "DM")
52
+ : "first_name" in chat
53
+ ? (chat.first_name ?? "DM")
54
+ : "DM",
55
+ );
56
+ } catch {
57
+ /* inaccessible */
58
+ }
59
+ }),
60
+ );
45
61
 
46
62
  const lines = sessions.map((s) => {
47
- const age = s.info.lastActive ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago` : "?";
63
+ const age = s.info.lastActive
64
+ ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
65
+ : "?";
48
66
  const title = titles.get(s.chatId) ?? s.chatId;
49
- const model = (getChatSettings(s.chatId).model ?? config.model).replace("claude-", "");
67
+ const model = (getChatSettings(s.chatId).model ?? config.model).replace(
68
+ "claude-",
69
+ "",
70
+ );
50
71
  return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${model}`;
51
72
  });
52
- await ctx.reply(`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"), { parse_mode: "HTML" });
73
+ await ctx.reply(
74
+ `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
75
+ { parse_mode: "HTML" },
76
+ );
53
77
  return;
54
78
  }
55
79
 
56
80
  case "broadcast": {
57
81
  const text = rest.join(" ");
58
- if (!text) { await ctx.reply("Usage: /admin broadcast <text>"); return; }
82
+ if (!text) {
83
+ await ctx.reply("Usage: /admin broadcast <text>");
84
+ return;
85
+ }
59
86
  const sessions = getAllSessions();
60
- let sent = 0, failed = 0;
87
+ let sent = 0,
88
+ failed = 0;
61
89
  for (const s of sessions) {
62
90
  const id = parseInt(s.chatId, 10);
63
91
  if (isNaN(id)) continue;
64
- try { await bot.api.sendMessage(id, text); sent++; } catch { failed++; }
92
+ try {
93
+ await bot.api.sendMessage(id, text);
94
+ sent++;
95
+ } catch {
96
+ failed++;
97
+ }
65
98
  }
66
- await ctx.reply(`Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`);
99
+ await ctx.reply(
100
+ `Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
101
+ );
67
102
  return;
68
103
  }
69
104
 
70
105
  case "kill": {
71
106
  const target = rest[0];
72
- if (!target) { await ctx.reply("Usage: /admin kill <chatId>"); return; }
107
+ if (!target) {
108
+ await ctx.reply("Usage: /admin kill <chatId>");
109
+ return;
110
+ }
73
111
  resetSession(target);
74
112
  clearHistory(target);
75
113
  await ctx.reply(`Session ${target} reset.`);
@@ -79,16 +117,26 @@ export async function handleAdminCommand(
79
117
  case "logs": {
80
118
  const logPath = files.log;
81
119
  try {
82
- const { statSync, openSync, readSync, closeSync } = await import("node:fs");
120
+ const { statSync, openSync, readSync, closeSync } =
121
+ await import("node:fs");
83
122
  const stat = statSync(logPath);
84
123
  const size = Math.min(8192, stat.size);
85
124
  const buf = Buffer.alloc(size);
86
125
  const fd = openSync(logPath, "r");
87
126
  readSync(fd, buf, 0, size, Math.max(0, stat.size - size));
88
127
  closeSync(fd);
89
- const lines = buf.toString("utf-8").trim().split("\n").slice(-20).join("\n");
90
- await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, { parse_mode: "HTML" });
91
- } catch { await ctx.reply(`Could not read ${logPath}`); }
128
+ const lines = buf
129
+ .toString("utf-8")
130
+ .trim()
131
+ .split("\n")
132
+ .slice(-20)
133
+ .join("\n");
134
+ await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
135
+ parse_mode: "HTML",
136
+ });
137
+ } catch {
138
+ await ctx.reply(`Could not read ${logPath}`);
139
+ }
92
140
  return;
93
141
  }
94
142
 
@@ -97,56 +145,89 @@ export async function handleAdminCommand(
97
145
  const sessions = getAllSessions();
98
146
  const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
99
147
  const mem = process.memoryUsage();
100
- await ctx.reply([
101
- `<b>\uD83E\uDD85 Talon Stats</b>`, "",
102
- `<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
103
- `<b>Messages:</b> ${h.totalMessagesProcessed}`,
104
- `<b>Sessions:</b> ${sessions.length}`,
105
- `<b>Turns:</b> ${turns}`,
106
- `<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`, "",
107
- `<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
108
- `<b>Queue:</b> ${getActiveCount()}`,
109
- `<b>Errors:</b> ${h.recentErrorCount}`,
110
- ].join("\n"), { parse_mode: "HTML" });
148
+ await ctx.reply(
149
+ [
150
+ `<b>\uD83E\uDD85 Talon Stats</b>`,
151
+ "",
152
+ `<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
153
+ `<b>Messages:</b> ${h.totalMessagesProcessed}`,
154
+ `<b>Sessions:</b> ${sessions.length}`,
155
+ `<b>Turns:</b> ${turns}`,
156
+ `<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
157
+ "",
158
+ `<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
159
+ `<b>Queue:</b> ${getActiveCount()}`,
160
+ `<b>Errors:</b> ${h.recentErrorCount}`,
161
+ ].join("\n"),
162
+ { parse_mode: "HTML" },
163
+ );
111
164
  return;
112
165
  }
113
166
 
114
167
  case "errors": {
115
168
  const errors = getRecentErrors(5);
116
- if (errors.length === 0) { await ctx.reply("No recent errors."); return; }
117
- const lines = errors.map((e) => `<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`);
118
- await ctx.reply(`<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"), { parse_mode: "HTML" });
169
+ if (errors.length === 0) {
170
+ await ctx.reply("No recent errors.");
171
+ return;
172
+ }
173
+ const lines = errors.map(
174
+ (e) =>
175
+ `<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
176
+ );
177
+ await ctx.reply(
178
+ `<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
179
+ { parse_mode: "HTML" },
180
+ );
119
181
  return;
120
182
  }
121
183
 
122
184
  case "cron": {
123
185
  const jobs = getAllCronJobs();
124
- if (jobs.length === 0) { await ctx.reply("No cron jobs."); return; }
186
+ if (jobs.length === 0) {
187
+ await ctx.reply("No cron jobs.");
188
+ return;
189
+ }
125
190
  const lines = jobs.map((j) => {
126
191
  const v = validateCronExpression(j.schedule, j.timezone);
127
- const last = j.lastRunAt ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
128
- const next = v.next ? new Date(v.next).toISOString().slice(0, 16).replace("T", " ") : "?";
192
+ const last = j.lastRunAt
193
+ ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
194
+ : "never";
195
+ const next = v.next
196
+ ? new Date(v.next).toISOString().slice(0, 16).replace("T", " ")
197
+ : "?";
129
198
  return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${j.schedule}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
130
199
  });
131
- await ctx.reply(`<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"), { parse_mode: "HTML" });
200
+ await ctx.reply(
201
+ `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
202
+ { parse_mode: "HTML" },
203
+ );
132
204
  return;
133
205
  }
134
206
 
135
207
  case "pulse": {
136
208
  const chats = getPulseStatus();
137
- if (chats.length === 0) { await ctx.reply("No pulse chats."); return; }
138
- const lines = await Promise.all(chats.map(async (p) => {
139
- let title = p.chatId;
140
- try {
141
- const id = parseInt(p.chatId, 10);
142
- if (!isNaN(id)) {
143
- const chat = await bot.api.getChat(id);
144
- title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
209
+ if (chats.length === 0) {
210
+ await ctx.reply("No pulse chats.");
211
+ return;
212
+ }
213
+ const lines = await Promise.all(
214
+ chats.map(async (p) => {
215
+ let title = p.chatId;
216
+ try {
217
+ const id = parseInt(p.chatId, 10);
218
+ if (!isNaN(id)) {
219
+ const chat = await bot.api.getChat(id);
220
+ title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
221
+ }
222
+ } catch {
223
+ /* skip */
145
224
  }
146
- } catch { /* skip */ }
147
- return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
148
- }));
149
- await ctx.reply(`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"), { parse_mode: "HTML" });
225
+ return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
226
+ }),
227
+ );
228
+ await ctx.reply(`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"), {
229
+ parse_mode: "HTML",
230
+ });
150
231
  return;
151
232
  }
152
233
 
@@ -156,23 +237,32 @@ export async function handleAdminCommand(
156
237
  try {
157
238
  const content = readFileSync(logPath, "utf-8");
158
239
  const lines = content.trim().split("\n").slice(-30).join("\n");
159
- await ctx.reply(`<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, { parse_mode: "HTML" });
160
- } catch { await ctx.reply(`No daily log for ${today}.`); }
240
+ await ctx.reply(
241
+ `<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
242
+ { parse_mode: "HTML" },
243
+ );
244
+ } catch {
245
+ await ctx.reply(`No daily log for ${today}.`);
246
+ }
161
247
  return;
162
248
  }
163
249
 
164
250
  default:
165
- await ctx.reply([
166
- "<b>/admin commands</b>", "",
167
- " stats uptime, messages, memory",
168
- " errors last 5 errors",
169
- " chats list all active chats",
170
- " daily today's interaction log",
171
- " pulse pulse status per chat",
172
- " cron list all cron jobs",
173
- " broadcast &lt;text&gt; send to all chats",
174
- " kill &lt;chatId&gt; reset a chat session",
175
- " logs last 20 lines of log",
176
- ].join("\n"), { parse_mode: "HTML" });
251
+ await ctx.reply(
252
+ [
253
+ "<b>/admin commands</b>",
254
+ "",
255
+ " stats uptime, messages, memory",
256
+ " errors last 5 errors",
257
+ " chats list all active chats",
258
+ " daily today's interaction log",
259
+ " pulse pulse status per chat",
260
+ " cron list all cron jobs",
261
+ " broadcast &lt;text&gt; send to all chats",
262
+ " kill &lt;chatId&gt; reset a chat session",
263
+ " logs last 20 lines of log",
264
+ ].join("\n"),
265
+ { parse_mode: "HTML" },
266
+ );
177
267
  }
178
268
  }
@@ -21,10 +21,7 @@ import {
21
21
  } from "../../core/pulse.js";
22
22
  import { handleCallbackQuery } from "./handlers.js";
23
23
  import { escapeHtml } from "./formatting.js";
24
- import {
25
- renderSettingsText,
26
- renderSettingsKeyboard,
27
- } from "./helpers.js";
24
+ import { renderSettingsText, renderSettingsKeyboard } from "./helpers.js";
28
25
 
29
26
  export function registerCallbacks(bot: Bot, config: TalonConfig): void {
30
27
  // ── Callback query handler ──────────────────────────────────────────────────
@@ -130,14 +127,21 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
130
127
  {
131
128
  parse_mode: "HTML",
132
129
  reply_markup: {
133
- inline_keyboard: [[
134
- { text: enabled ? "✓ On" : "On", callback_data: "pulse:on" },
135
- { text: !enabled ? "✓ Off" : "Off", callback_data: "pulse:off" },
136
- ]],
130
+ inline_keyboard: [
131
+ [
132
+ { text: enabled ? "✓ On" : "On", callback_data: "pulse:on" },
133
+ {
134
+ text: !enabled ? "✓ Off" : "Off",
135
+ callback_data: "pulse:off",
136
+ },
137
+ ],
138
+ ],
137
139
  },
138
140
  },
139
141
  );
140
- } catch { /* unchanged */ }
142
+ } catch {
143
+ /* unchanged */
144
+ }
141
145
  return;
142
146
  }
143
147
 
@@ -220,7 +224,9 @@ export function registerCallbacks(bot: Bot, config: TalonConfig): void {
220
224
  inline_keyboard: [
221
225
  [
222
226
  {
223
- text: isModel("sonnet") ? "\u2713 Sonnet 4.6" : "Sonnet 4.6",
227
+ text: isModel("sonnet")
228
+ ? "\u2713 Sonnet 4.6"
229
+ : "Sonnet 4.6",
224
230
  callback_data: "model:sonnet",
225
231
  },
226
232
  {
@@ -218,10 +218,9 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
218
218
 
219
219
  const model = resolveModelName(arg);
220
220
  setChatModel(cid, model);
221
- await ctx.reply(
222
- `Model set to <code>${escapeHtml(model)}</code>.`,
223
- { parse_mode: "HTML" },
224
- );
221
+ await ctx.reply(`Model set to <code>${escapeHtml(model)}</code>.`, {
222
+ parse_mode: "HTML",
223
+ });
225
224
  });
226
225
 
227
226
  bot.command("effort", async (ctx) => {
@@ -304,10 +303,15 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
304
303
  {
305
304
  parse_mode: "HTML",
306
305
  reply_markup: {
307
- inline_keyboard: [[
308
- { text: enabled ? "✓ On" : "On", callback_data: "pulse:on" },
309
- { text: !enabled ? "✓ Off" : "Off", callback_data: "pulse:off" },
310
- ]],
306
+ inline_keyboard: [
307
+ [
308
+ { text: enabled ? "✓ On" : "On", callback_data: "pulse:on" },
309
+ {
310
+ text: !enabled ? "✓ Off" : "Off",
311
+ callback_data: "pulse:off",
312
+ },
313
+ ],
314
+ ],
311
315
  },
312
316
  },
313
317
  );
@@ -344,16 +348,16 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
344
348
  return;
345
349
  }
346
350
 
347
- await ctx.reply(
348
- "Use: /pulse on, /pulse off, /pulse 30m, /pulse 2h",
349
- );
351
+ await ctx.reply("Use: /pulse on, /pulse off, /pulse 30m, /pulse 2h");
350
352
  });
351
353
 
352
354
  bot.command("memory", async (ctx) => {
353
355
  try {
354
356
  const memoryPath = files.memory;
355
357
  if (!existsSync(memoryPath)) {
356
- await ctx.reply("No memory file yet. I'll create one as I learn about you.");
358
+ await ctx.reply(
359
+ "No memory file yet. I'll create one as I learn about you.",
360
+ );
357
361
  return;
358
362
  }
359
363
  const content = readFileSync(memoryPath, "utf-8").trim();
@@ -362,9 +366,10 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
362
366
  return;
363
367
  }
364
368
  // Truncate for Telegram's 4096 char limit
365
- const display = content.length > 3500
366
- ? content.slice(0, 3500) + "\n\n... (truncated)"
367
- : content;
369
+ const display =
370
+ content.length > 3500
371
+ ? content.slice(0, 3500) + "\n\n... (truncated)"
372
+ : content;
368
373
  await ctx.reply(display);
369
374
  } catch {
370
375
  await ctx.reply("Could not read memory file.");
@@ -427,10 +432,12 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
427
432
  : 0;
428
433
  const barLen = 20;
429
434
  const filled = Math.round((contextPct / 100) * barLen);
430
- const contextBar = "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
435
+ const contextBar =
436
+ "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
431
437
  const contextWarn = contextPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
432
438
 
433
- const totalPrompt = u.totalInputTokens + u.totalCacheRead + u.totalCacheWrite;
439
+ const totalPrompt =
440
+ u.totalInputTokens + u.totalCacheRead + u.totalCacheWrite;
434
441
  const cacheHitPct =
435
442
  totalPrompt > 0 ? Math.round((u.totalCacheRead / totalPrompt) * 100) : 0;
436
443
 
@@ -439,7 +446,8 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
439
446
  ? Math.round(u.totalResponseMs / info.turns)
440
447
  : 0;
441
448
  const lastResponseMs = u.lastResponseMs || 0;
442
- const fastestMs = u.fastestResponseMs === Infinity ? 0 : (u.fastestResponseMs || 0);
449
+ const fastestMs =
450
+ u.fastestResponseMs === Infinity ? 0 : u.fastestResponseMs || 0;
443
451
 
444
452
  const diskBytes = getWorkspaceDiskUsage(config.workspace);
445
453
  const diskStr = formatBytes(diskBytes);
@@ -504,14 +512,20 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
504
512
  setTimeout(() => {
505
513
  // Try `talon restart` (handles daemon stop+start cleanly).
506
514
  // Fall back to the local bin if talon isn't on PATH globally.
507
- const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
515
+ const projectRoot = resolve(
516
+ dirname(fileURLToPath(import.meta.url)),
517
+ "../../../..",
518
+ );
508
519
  const localBin = resolve(projectRoot, "bin/talon.js");
509
520
 
510
521
  const trySpawn = (cmd: string, args: string[]): Promise<void> =>
511
522
  new Promise((res, rej) => {
512
523
  const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
513
524
  child.on("error", rej);
514
- child.on("spawn", () => { child.unref(); res(); });
525
+ child.on("spawn", () => {
526
+ child.unref();
527
+ res();
528
+ });
515
529
  });
516
530
 
517
531
  // Try global first, then local bin, then just exit (let process manager restart)
@@ -532,7 +546,9 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
532
546
  const ver = p.plugin.version ? ` v${p.plugin.version}` : "";
533
547
  const desc = p.plugin.description ? ` — ${p.plugin.description}` : "";
534
548
  const mcp = p.plugin.mcpServerPath ? " [MCP]" : "";
535
- const fe = p.plugin.frontends?.length ? ` (${p.plugin.frontends.join(", ")})` : "";
549
+ const fe = p.plugin.frontends?.length
550
+ ? ` (${p.plugin.frontends.join(", ")})`
551
+ : "";
536
552
  return `• <b>${escapeHtml(p.plugin.name)}</b>${ver}${mcp}${fe}${desc}`;
537
553
  });
538
554
  await ctx.reply(
@@ -79,10 +79,8 @@ export function markdownToTelegramHtml(text: string): string {
79
79
  // Italic: _text_ (surrounded by non-word or start/end)
80
80
  processed = processed.replace(/(?<!\w)_(.+?)_(?!\w)/g, "<i>$1</i>");
81
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,
82
+ processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) =>
83
+ /^https?:\/\//i.test(url) ? `<a href="${url}">${text}</a>` : text,
86
84
  );
87
85
  // Strikethrough: ~~text~~
88
86
  processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");