omnikey-cli 1.5.3 → 1.5.4

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.
@@ -0,0 +1,901 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.initTelegram = initTelegram;
7
+ exports.notify = notify;
8
+ exports.setupMessageListener = setupMessageListener;
9
+ const node_telegram_bot_api_1 = __importDefault(require("node-telegram-bot-api"));
10
+ const agentClient_1 = require("./agentClient");
11
+ const db_1 = require("./db");
12
+ let bot = null;
13
+ function initTelegram(botToken) {
14
+ if (!botToken)
15
+ throw new Error("Missing telegram bot token");
16
+ bot = new node_telegram_bot_api_1.default(botToken, { polling: true });
17
+ return bot;
18
+ }
19
+ async function notify(logger, message, options = {}) {
20
+ if (!bot) {
21
+ throw new Error("Telegram bot not initialized. Call initTelegram first.");
22
+ }
23
+ const chatId = options.chatId ?? process.env.TELEGRAM_CHAT_ID;
24
+ if (!chatId) {
25
+ throw new Error("Missing chat ID");
26
+ }
27
+ const parseMode = options.parseMode ?? "Markdown";
28
+ try {
29
+ return await bot.sendMessage(chatId, message, { parse_mode: parseMode });
30
+ }
31
+ catch (err) {
32
+ logger.error("Failed to send Telegram message:", err);
33
+ throw err;
34
+ }
35
+ }
36
+ const pendingPrompts = new Map();
37
+ const runningSessions = new Map();
38
+ // Callback-data prefixes. Telegram limits callback_data to 64 bytes — using
39
+ // short prefixes + indices keeps every payload comfortably under the cap.
40
+ const CB_SESSION = "s:"; // session picker; "s:new" or "s:<idx>"
41
+ const CB_INSTRUCTION = "t:"; // instruction picker; "t:skip" or "t:<idx>"
42
+ const CB_PROJECT = "g:"; // project picker; "g:skip" or "g:<idx>"
43
+ const CB_CANCEL = "x:cancel";
44
+ function isAuthorizedChat(chatId) {
45
+ const allowed = parseInt(process.env.TELEGRAM_CHAT_ID || "0", 10);
46
+ return allowed !== 0 && chatId === allowed;
47
+ }
48
+ function truncate(text, max) {
49
+ if (text.length <= max)
50
+ return text;
51
+ return text.slice(0, max - 1) + "…";
52
+ }
53
+ /**
54
+ * Strip XML-ish agent tags, code fences, markdown emphasis, and collapse
55
+ * whitespace so reasoning/final-answer blocks read as plain prose in Telegram.
56
+ */
57
+ function cleanForTelegram(text) {
58
+ return text
59
+ .replace(/<\/?(?:shell_script|final_answer|user_input|stored_instructions|project_context|shell_function_calls)[^>]*>/gi, "")
60
+ .replace(/```[a-zA-Z0-9_-]*\n?/g, "")
61
+ .replace(/```/g, "")
62
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
63
+ .replace(/\*([^*\n]+)\*/g, "$1")
64
+ .replace(/`([^`\n]+)`/g, "$1")
65
+ .replace(/[ \t]+\n/g, "\n")
66
+ .replace(/\n{3,}/g, "\n\n")
67
+ .trim();
68
+ }
69
+ /**
70
+ * Strip the agent's XML envelope tags but keep markdown intact, then convert
71
+ * a sensible subset of CommonMark into Telegram-flavoured HTML so the final
72
+ * answer renders with bold, italics, code blocks, links and lists.
73
+ *
74
+ * Telegram HTML supports: <b>, <i>, <u>, <s>, <code>, <pre>,
75
+ * <pre><code class="language-...">, <a href="...">, <blockquote>.
76
+ */
77
+ function escapeHtml(s) {
78
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
79
+ }
80
+ function markdownToTelegramHtml(input) {
81
+ // Strip agent envelope tags but preserve the body's markdown.
82
+ let text = input.replace(/<\/?(?:shell_script|final_answer|user_input|stored_instructions|project_context|shell_function_calls)[^>]*>/gi, "");
83
+ // 1. Extract fenced code blocks and inline code into placeholders so
84
+ // subsequent transforms do not corrupt their contents.
85
+ const placeholders = [];
86
+ const stash = (html) => {
87
+ const idx = placeholders.push(html) - 1;
88
+ return `\u0000PH${idx}\u0000`;
89
+ };
90
+ text = text.replace(/```([a-zA-Z0-9_+.-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
91
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
92
+ return stash(`<pre><code${cls}>${escapeHtml(body.replace(/\n$/, ""))}</code></pre>`);
93
+ });
94
+ text = text.replace(/`([^`\n]+)`/g, (_m, body) => stash(`<code>${escapeHtml(body)}</code>`));
95
+ // 1b. GFM tables → fixed-width <pre> blocks (Telegram HTML has no <table>).
96
+ // Detect a header row, a separator row of dashes/colons, and any
97
+ // number of body rows, then render with per-column padding.
98
+ text = text.replace(/(^|\n)([^\n]*\|[^\n]*)\n[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*\n((?:[^\n]*\|[^\n]*(?:\n|$))+)/g, (_m, lead, header, body) => {
99
+ const splitRow = (row) => {
100
+ let r = row.trim();
101
+ if (r.startsWith("|"))
102
+ r = r.slice(1);
103
+ if (r.endsWith("|"))
104
+ r = r.slice(0, -1);
105
+ return r.split("|").map((c) => c.trim());
106
+ };
107
+ const headerCells = splitRow(header);
108
+ const bodyRows = body
109
+ .split("\n")
110
+ .map((l) => l.trim())
111
+ .filter((l) => l.length > 0)
112
+ .map(splitRow);
113
+ const colCount = Math.max(headerCells.length, ...bodyRows.map((r) => r.length));
114
+ const pad = (cells) => {
115
+ const out = cells.slice();
116
+ while (out.length < colCount)
117
+ out.push("");
118
+ return out;
119
+ };
120
+ const allRows = [pad(headerCells), ...bodyRows.map(pad)];
121
+ // Strip inline markdown that won't render inside <pre>.
122
+ const cleanCell = (s) => s
123
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
124
+ .replace(/__([^_]+)__/g, "$1")
125
+ .replace(/`([^`]+)`/g, "$1")
126
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
127
+ const cleaned = allRows.map((row) => row.map(cleanCell));
128
+ const widths = [];
129
+ for (let c = 0; c < colCount; c++) {
130
+ let w = 0;
131
+ for (const row of cleaned)
132
+ w = Math.max(w, row[c]?.length ?? 0);
133
+ widths[c] = w;
134
+ }
135
+ const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i], " ")).join(" │ ");
136
+ const sep = widths.map((w) => "─".repeat(w)).join("─┼─");
137
+ const lines = [];
138
+ lines.push(renderRow(cleaned[0]));
139
+ lines.push(sep);
140
+ for (let i = 1; i < cleaned.length; i++)
141
+ lines.push(renderRow(cleaned[i]));
142
+ return `${lead}${stash(`<pre>${escapeHtml(lines.join("\n"))}</pre>`)}\n`;
143
+ });
144
+ // 2. Everything outside code is HTML-escaped now.
145
+ text = escapeHtml(text);
146
+ // 3. Inline-link conversion before emphasis so the URL never gets italicised.
147
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => `<a href="${href}">${label}</a>`);
148
+ // 4. Bold / italic / strike-through. Order matters — handle ** before *.
149
+ text = text.replace(/\*\*([^\n*]+)\*\*/g, "<b>$1</b>");
150
+ text = text.replace(/__([^\n_]+)__/g, "<b>$1</b>");
151
+ text = text.replace(/(^|[^*])\*([^\n*]+)\*(?!\*)/g, "$1<i>$2</i>");
152
+ text = text.replace(/(^|[^_])_([^\n_]+)_(?!_)/g, "$1<i>$2</i>");
153
+ text = text.replace(/~~([^\n~]+)~~/g, "<s>$1</s>");
154
+ // 5. Headings (#, ##, ###) → bold line. Telegram has no real heading style.
155
+ text = text.replace(/^[ \t]*#{1,6}[ \t]+(.+)$/gm, "<b>$1</b>");
156
+ // 6. Bullet lists: turn "- " / "* " / "+ " into "• ".
157
+ text = text.replace(/^[ \t]*[-*+][ \t]+/gm, "• ");
158
+ // 7. Block quotes — Telegram supports <blockquote>.
159
+ text = text.replace(/(^|\n)((?:&gt; .*(?:\n|$))+)/g, (_m, lead, block) => {
160
+ const inner = block
161
+ .split(/\n/)
162
+ .filter((l) => l.length)
163
+ .map((l) => l.replace(/^&gt; ?/, ""))
164
+ .join("\n");
165
+ return `${lead}<blockquote>${inner}</blockquote>\n`;
166
+ });
167
+ // 8. Collapse trailing whitespace and excessive blank lines.
168
+ text = text
169
+ .replace(/[ \t]+\n/g, "\n")
170
+ .replace(/\n{3,}/g, "\n\n")
171
+ .trim();
172
+ // 9. Restore the stashed code segments.
173
+ text = text.replace(/\u0000PH(\d+)\u0000/g, (_m, idx) => placeholders[Number(idx)] ?? "");
174
+ return text;
175
+ }
176
+ /**
177
+ * Split rendered HTML into chunks under Telegram's 4096-char per-message cap.
178
+ * Splits on paragraph boundaries when possible, never inside a <pre>/<code>
179
+ * block, falling back to hard slicing for pathological inputs.
180
+ */
181
+ function splitForTelegram(html, max = 3800) {
182
+ if (html.length <= max)
183
+ return [html];
184
+ const chunks = [];
185
+ const paragraphs = html.split(/\n\n+/);
186
+ let current = "";
187
+ const flush = () => {
188
+ if (current.trim())
189
+ chunks.push(current.trim());
190
+ current = "";
191
+ };
192
+ for (const p of paragraphs) {
193
+ if (p.length > max) {
194
+ flush();
195
+ for (let i = 0; i < p.length; i += max) {
196
+ chunks.push(p.slice(i, i + max));
197
+ }
198
+ continue;
199
+ }
200
+ if (current.length + p.length + 2 > max) {
201
+ flush();
202
+ }
203
+ current += (current ? "\n\n" : "") + p;
204
+ }
205
+ flush();
206
+ return chunks;
207
+ }
208
+ // ─── Inline-keyboard builders ────────────────────────────────────────────────
209
+ function buildSessionKeyboard(sessions) {
210
+ const rows = [
211
+ [{ text: "🆕 New session", callback_data: `${CB_SESSION}new` }],
212
+ ];
213
+ sessions.forEach((s, idx) => {
214
+ const label = truncate(s.title || s.id, 48);
215
+ rows.push([
216
+ {
217
+ text: `💬 ${label} · ${s.turns}↻`,
218
+ callback_data: `${CB_SESSION}${idx}`,
219
+ },
220
+ ]);
221
+ });
222
+ rows.push([{ text: "✕ Cancel", callback_data: CB_CANCEL }]);
223
+ return rows;
224
+ }
225
+ function buildInstructionKeyboard(templates) {
226
+ const rows = [];
227
+ templates.forEach((t, idx) => {
228
+ const marker = t.isDefault ? "⭐" : "📝";
229
+ rows.push([
230
+ {
231
+ text: `${marker} ${truncate(t.heading, 50)}`,
232
+ callback_data: `${CB_INSTRUCTION}${idx}`,
233
+ },
234
+ ]);
235
+ });
236
+ rows.push([
237
+ { text: "⏭ Skip instructions", callback_data: `${CB_INSTRUCTION}skip` },
238
+ ]);
239
+ rows.push([{ text: "✕ Cancel", callback_data: CB_CANCEL }]);
240
+ return rows;
241
+ }
242
+ function buildProjectKeyboard(groups) {
243
+ const rows = [];
244
+ groups.forEach((g, idx) => {
245
+ rows.push([
246
+ {
247
+ text: `📁 ${truncate(g.groupName, 50)}`,
248
+ callback_data: `${CB_PROJECT}${idx}`,
249
+ },
250
+ ]);
251
+ });
252
+ rows.push([{ text: "⏭ Skip project", callback_data: `${CB_PROJECT}skip` }]);
253
+ rows.push([{ text: "✕ Cancel", callback_data: CB_CANCEL }]);
254
+ return rows;
255
+ }
256
+ function renderInstructionStep(state) {
257
+ if (state.templates.length === 0) {
258
+ return {
259
+ text: [
260
+ "*Step 1 of 3 · Task instructions*",
261
+ "",
262
+ "_No saved templates. Skip to continue._",
263
+ ].join("\n"),
264
+ keyboard: [
265
+ [
266
+ {
267
+ text: "⏭ Skip instructions",
268
+ callback_data: `${CB_INSTRUCTION}skip`,
269
+ },
270
+ ],
271
+ [{ text: "✕ Cancel", callback_data: CB_CANCEL }],
272
+ ],
273
+ };
274
+ }
275
+ return {
276
+ text: [
277
+ "*Step 1 of 3 · Task instructions*",
278
+ "",
279
+ "Pick a saved template to prepend to your prompt, or skip.",
280
+ ].join("\n"),
281
+ keyboard: buildInstructionKeyboard(state.templates),
282
+ };
283
+ }
284
+ function renderProjectStep(state) {
285
+ const heading = state.chosenInstructionsHeading
286
+ ? `✓ Instructions: *${state.chosenInstructionsHeading}*`
287
+ : "✓ Instructions: _skipped_";
288
+ if (state.groups.length === 0) {
289
+ return {
290
+ text: [
291
+ "*Step 2 of 3 · Project*",
292
+ heading,
293
+ "",
294
+ "_No projects yet. Skip to continue._",
295
+ ].join("\n"),
296
+ keyboard: [
297
+ [{ text: "⏭ Skip project", callback_data: `${CB_PROJECT}skip` }],
298
+ [{ text: "✕ Cancel", callback_data: CB_CANCEL }],
299
+ ],
300
+ };
301
+ }
302
+ return {
303
+ text: [
304
+ "*Step 2 of 3 · Project*",
305
+ heading,
306
+ "",
307
+ "Pick a project for context, or skip.",
308
+ ].join("\n"),
309
+ keyboard: buildProjectKeyboard(state.groups),
310
+ };
311
+ }
312
+ function renderPromptStep(state) {
313
+ const lines = [
314
+ "*Step 3 of 3 · Prompt*",
315
+ state.chosenInstructionsHeading
316
+ ? `✓ Instructions: *${state.chosenInstructionsHeading}*`
317
+ : "✓ Instructions: _skipped_",
318
+ state.chosenGroupName
319
+ ? `✓ Project: *${state.chosenGroupName}*`
320
+ : "✓ Project: _skipped_",
321
+ "",
322
+ "Send your prompt as the next message.",
323
+ ];
324
+ return {
325
+ text: lines.join("\n"),
326
+ keyboard: [[{ text: "✕ Cancel", callback_data: CB_CANCEL }]],
327
+ };
328
+ }
329
+ async function showStep(logger, chatId, state) {
330
+ if (!bot)
331
+ return;
332
+ const copy = state.phase === "selectInstruction"
333
+ ? renderInstructionStep(state)
334
+ : state.phase === "selectProject"
335
+ ? renderProjectStep(state)
336
+ : renderPromptStep(state);
337
+ if (state.wizardMessageId == null) {
338
+ const sent = await bot.sendMessage(chatId, copy.text, {
339
+ parse_mode: "Markdown",
340
+ reply_markup: { inline_keyboard: copy.keyboard },
341
+ });
342
+ state.wizardMessageId = sent.message_id;
343
+ return;
344
+ }
345
+ try {
346
+ await bot.editMessageText(copy.text, {
347
+ chat_id: chatId,
348
+ message_id: state.wizardMessageId,
349
+ parse_mode: "Markdown",
350
+ reply_markup: { inline_keyboard: copy.keyboard },
351
+ });
352
+ }
353
+ catch (err) {
354
+ // Fall back to a fresh message if the previous one is no longer editable.
355
+ logger.warn("Failed to edit wizard message; sending a new one", {
356
+ error: err.message,
357
+ });
358
+ const sent = await bot.sendMessage(chatId, copy.text, {
359
+ parse_mode: "Markdown",
360
+ reply_markup: { inline_keyboard: copy.keyboard },
361
+ });
362
+ state.wizardMessageId = sent.message_id;
363
+ }
364
+ }
365
+ async function finishWizardMessage(chatId, state, finalText) {
366
+ if (!bot || state.wizardMessageId == null)
367
+ return;
368
+ try {
369
+ await bot.editMessageText(finalText, {
370
+ chat_id: chatId,
371
+ message_id: state.wizardMessageId,
372
+ parse_mode: "Markdown",
373
+ reply_markup: { inline_keyboard: [] },
374
+ });
375
+ }
376
+ catch {
377
+ /* ignore */
378
+ }
379
+ }
380
+ // ─── Pickers / commands ──────────────────────────────────────────────────────
381
+ async function sendSessionPicker(logger, chatId, sessions, verbose) {
382
+ if (!bot)
383
+ throw new Error("Bot not initialized");
384
+ const verboseTag = verbose ? " · 🔍 _verbose_" : "";
385
+ const text = sessions.length === 0
386
+ ? `*OmniKey Agent*${verboseTag}\n\nNo previous sessions. Start a new one?`
387
+ : `*OmniKey Agent*${verboseTag}\n\nResume a recent session or start fresh:`;
388
+ await bot.sendMessage(chatId, text, {
389
+ parse_mode: "Markdown",
390
+ reply_markup: { inline_keyboard: buildSessionKeyboard(sessions) },
391
+ });
392
+ logger.info("Sent /cmd session picker", {
393
+ chatId,
394
+ count: sessions.length,
395
+ verbose,
396
+ });
397
+ }
398
+ async function handleCmdCommand(logger, chatId, verbose) {
399
+ pendingPrompts.delete(chatId);
400
+ pendingVerbose.set(chatId, verbose);
401
+ try {
402
+ const sessions = await (0, agentClient_1.listRecentSessions)(logger, 5);
403
+ // Cache the session list so the callback can resolve by index.
404
+ sessionListCache.set(chatId, sessions);
405
+ await sendSessionPicker(logger, chatId, sessions, verbose);
406
+ }
407
+ catch (err) {
408
+ logger.error("Failed to list recent sessions", {
409
+ error: err.message,
410
+ });
411
+ await notify(logger, `❌ Failed to load sessions: ${err.message}`, { chatId });
412
+ }
413
+ }
414
+ // chatId -> last session list shown by /cmd, for index resolution
415
+ const sessionListCache = new Map();
416
+ // chatId -> verbose flag captured at /cmd time, applied when the user picks
417
+ // a session (new or resume) so the flag survives the async picker step.
418
+ const pendingVerbose = new Map();
419
+ async function handleTaskCommand(logger, chatId) {
420
+ // 1. If a session is currently running for this chat, show last reasoning.
421
+ const running = runningSessions.get(chatId);
422
+ if (running) {
423
+ const text = running.lastReasoning
424
+ ? `🏃 *Running session* \`${running.sessionId}\`\n\n${truncate(running.lastReasoning, 3500)}`
425
+ : `🏃 *Running session* \`${running.sessionId}\`\n\n_No reasoning emitted yet._`;
426
+ await notify(logger, text, { chatId });
427
+ return;
428
+ }
429
+ // 2. Otherwise show final answer from the most recent completed session.
430
+ try {
431
+ const session = (0, db_1.getMostRecentSession)();
432
+ if (!session) {
433
+ await notify(logger, "🗒️ No sessions found.", { chatId });
434
+ return;
435
+ }
436
+ const finalAnswer = (0, agentClient_1.extractFinalAnswerFromHistory)(session.historyJson);
437
+ if (!finalAnswer) {
438
+ await notify(logger, `🗒️ Most recent session \`${session.id}\` has no final answer yet.`, { chatId });
439
+ return;
440
+ }
441
+ const title = session.title || session.id;
442
+ const html = markdownToTelegramHtml(finalAnswer);
443
+ const header = `✅ <b>${escapeHtml(truncate(title, 80))}</b>\n\n`;
444
+ const chunks = splitForTelegram(header + html);
445
+ if (!bot)
446
+ return;
447
+ for (const chunk of chunks) {
448
+ try {
449
+ await bot.sendMessage(chatId, chunk, {
450
+ parse_mode: "HTML",
451
+ disable_web_page_preview: true,
452
+ });
453
+ }
454
+ catch (err) {
455
+ logger.warn("HTML render failed for /task; falling back to plain", {
456
+ error: err.message,
457
+ });
458
+ await bot.sendMessage(chatId, chunk.replace(/<[^>]+>/g, ""));
459
+ }
460
+ }
461
+ }
462
+ catch (err) {
463
+ logger.error("Failed to handle /task", { error: err.message });
464
+ await notify(logger, `❌ /task failed: ${err.message}`, {
465
+ chatId,
466
+ });
467
+ }
468
+ }
469
+ async function startNewSessionWizard(logger, chatId) {
470
+ if (!bot)
471
+ return;
472
+ let templates = [];
473
+ let groups = [];
474
+ try {
475
+ [templates, groups] = await Promise.all([
476
+ (0, agentClient_1.listTaskTemplates)(logger).catch((e) => {
477
+ logger.warn("Failed to load task templates", {
478
+ error: e.message,
479
+ });
480
+ return [];
481
+ }),
482
+ (0, agentClient_1.listProjectGroups)(logger).catch((e) => {
483
+ logger.warn("Failed to load project groups", {
484
+ error: e.message,
485
+ });
486
+ return [];
487
+ }),
488
+ ]);
489
+ }
490
+ catch (err) {
491
+ logger.error("Failed to load wizard data", {
492
+ error: err.message,
493
+ });
494
+ }
495
+ const state = {
496
+ phase: "selectInstruction",
497
+ sessionId: null,
498
+ wizardMessageId: null,
499
+ templates,
500
+ groups,
501
+ chosenInstructions: null,
502
+ chosenInstructionsHeading: null,
503
+ chosenGroupName: null,
504
+ verbose: pendingVerbose.get(chatId) ?? false,
505
+ };
506
+ pendingPrompts.set(chatId, state);
507
+ await showStep(logger, chatId, state);
508
+ }
509
+ async function startResumeSession(logger, chatId, sessionId) {
510
+ if (!bot)
511
+ return;
512
+ const verbose = pendingVerbose.get(chatId) ?? false;
513
+ const state = {
514
+ phase: "awaitPrompt",
515
+ sessionId,
516
+ wizardMessageId: null,
517
+ templates: [],
518
+ groups: [],
519
+ chosenInstructions: null,
520
+ chosenInstructionsHeading: null,
521
+ chosenGroupName: null,
522
+ verbose,
523
+ };
524
+ pendingPrompts.set(chatId, state);
525
+ await bot.sendMessage(chatId, [
526
+ `*Resuming session* \`${sessionId}\`${verbose ? " · 🔍 _verbose_" : ""}`,
527
+ "",
528
+ "Send your prompt as the next message.",
529
+ ].join("\n"), {
530
+ parse_mode: "Markdown",
531
+ reply_markup: {
532
+ inline_keyboard: [[{ text: "✕ Cancel", callback_data: CB_CANCEL }]],
533
+ },
534
+ });
535
+ }
536
+ async function handleCallbackQuery(logger, query) {
537
+ if (!bot)
538
+ return;
539
+ const data = query.data || "";
540
+ const chatId = query.message?.chat.id;
541
+ if (!chatId || !isAuthorizedChat(chatId)) {
542
+ await bot.answerCallbackQuery(query.id, { text: "Unauthorized" });
543
+ return;
544
+ }
545
+ // Cancel from anywhere — clear state and acknowledge.
546
+ if (data === CB_CANCEL) {
547
+ const state = pendingPrompts.get(chatId);
548
+ pendingPrompts.delete(chatId);
549
+ sessionListCache.delete(chatId);
550
+ pendingVerbose.delete(chatId);
551
+ if (state?.wizardMessageId) {
552
+ await finishWizardMessage(chatId, state, "✕ Cancelled.");
553
+ }
554
+ await bot.answerCallbackQuery(query.id, { text: "Cancelled" });
555
+ return;
556
+ }
557
+ // Step 0 — session picker
558
+ if (data.startsWith(CB_SESSION)) {
559
+ const tok = data.slice(CB_SESSION.length);
560
+ if (tok === "new") {
561
+ await bot.answerCallbackQuery(query.id);
562
+ await startNewSessionWizard(logger, chatId);
563
+ return;
564
+ }
565
+ const idx = Number(tok);
566
+ const sessions = sessionListCache.get(chatId) ?? [];
567
+ const chosen = Number.isInteger(idx) ? sessions[idx] : undefined;
568
+ if (!chosen) {
569
+ await bot.answerCallbackQuery(query.id, {
570
+ text: "Session no longer available",
571
+ });
572
+ return;
573
+ }
574
+ await bot.answerCallbackQuery(query.id);
575
+ await startResumeSession(logger, chatId, chosen.id);
576
+ return;
577
+ }
578
+ // Step 1 — instruction picker
579
+ if (data.startsWith(CB_INSTRUCTION)) {
580
+ const state = pendingPrompts.get(chatId);
581
+ if (!state || state.phase !== "selectInstruction") {
582
+ await bot.answerCallbackQuery(query.id, { text: "Step expired" });
583
+ return;
584
+ }
585
+ const tok = data.slice(CB_INSTRUCTION.length);
586
+ if (tok === "skip") {
587
+ state.chosenInstructions = null;
588
+ state.chosenInstructionsHeading = null;
589
+ }
590
+ else {
591
+ const idx = Number(tok);
592
+ const t = state.templates[idx];
593
+ if (!t) {
594
+ await bot.answerCallbackQuery(query.id, { text: "Template not found" });
595
+ return;
596
+ }
597
+ // Don't prepend the body to the user prompt — instead promote this
598
+ // template to the backend default so stored_instructions picks it up
599
+ // automatically on every subsequent agent run.
600
+ try {
601
+ if (!t.isDefault) {
602
+ await (0, agentClient_1.setDefaultTaskTemplate)(logger, t.id);
603
+ }
604
+ state.chosenInstructions = null;
605
+ state.chosenInstructionsHeading = t.heading;
606
+ }
607
+ catch (err) {
608
+ logger.error("Failed to set default task template", {
609
+ templateId: t.id,
610
+ error: err.message,
611
+ });
612
+ await bot.answerCallbackQuery(query.id, {
613
+ text: "Failed to set default",
614
+ });
615
+ return;
616
+ }
617
+ }
618
+ state.phase = "selectProject";
619
+ await bot.answerCallbackQuery(query.id);
620
+ await showStep(logger, chatId, state);
621
+ return;
622
+ }
623
+ // Step 2 — project picker
624
+ if (data.startsWith(CB_PROJECT)) {
625
+ const state = pendingPrompts.get(chatId);
626
+ if (!state || state.phase !== "selectProject") {
627
+ await bot.answerCallbackQuery(query.id, { text: "Step expired" });
628
+ return;
629
+ }
630
+ const tok = data.slice(CB_PROJECT.length);
631
+ if (tok === "skip") {
632
+ state.chosenGroupName = null;
633
+ }
634
+ else {
635
+ const idx = Number(tok);
636
+ const g = state.groups[idx];
637
+ if (!g) {
638
+ await bot.answerCallbackQuery(query.id, { text: "Project not found" });
639
+ return;
640
+ }
641
+ state.chosenGroupName = g.groupName;
642
+ }
643
+ state.phase = "awaitPrompt";
644
+ await bot.answerCallbackQuery(query.id);
645
+ await showStep(logger, chatId, state);
646
+ return;
647
+ }
648
+ await bot.answerCallbackQuery(query.id);
649
+ }
650
+ async function runAgentForChat(logger, chatId, pending, prompt) {
651
+ pendingPrompts.delete(chatId);
652
+ if (runningSessions.has(chatId)) {
653
+ await notify(logger, "⏳ A session is already running. Wait for it to finish.", { chatId });
654
+ return;
655
+ }
656
+ // Mark the wizard as resolved so the user sees a clean trail of choices.
657
+ if (pending.wizardMessageId) {
658
+ const summary = [
659
+ "✅ *Session started*",
660
+ pending.chosenInstructionsHeading
661
+ ? `📝 Instructions: *${pending.chosenInstructionsHeading}*`
662
+ : "📝 Instructions: _none_",
663
+ pending.chosenGroupName
664
+ ? `📁 Project: *${pending.chosenGroupName}*`
665
+ : "📁 Project: _none_",
666
+ pending.verbose ? "🔍 Verbose: *on*" : "",
667
+ ]
668
+ .filter(Boolean)
669
+ .join("\n");
670
+ await finishWizardMessage(chatId, pending, summary);
671
+ }
672
+ // The selected task template is already promoted to default on the
673
+ // backend (see CB_INSTRUCTION handler), so the agent will pick it up via
674
+ // stored_instructions on the server side. We send the user's prompt
675
+ // verbatim — no preamble injection here.
676
+ const composedPrompt = prompt;
677
+ const placeholderId = pending.sessionId ?? "(new)";
678
+ const abortController = new AbortController();
679
+ const state = {
680
+ sessionId: placeholderId,
681
+ startedAt: Date.now(),
682
+ lastReasoning: null,
683
+ abortController,
684
+ stoppedByUser: false,
685
+ };
686
+ runningSessions.set(chatId, state);
687
+ await notify(logger, "🚀 Starting agent run… (send /stop to cancel)", {
688
+ chatId,
689
+ });
690
+ const REASONING_MAX = 1200;
691
+ let lastSent = null;
692
+ const sendPlain = async (body) => {
693
+ if (!bot)
694
+ return;
695
+ try {
696
+ await bot.sendMessage(chatId, body);
697
+ }
698
+ catch (e) {
699
+ logger.warn("Failed to forward block to telegram", {
700
+ error: e.message,
701
+ });
702
+ }
703
+ };
704
+ const sendHtml = async (body) => {
705
+ if (!bot)
706
+ return;
707
+ try {
708
+ await bot.sendMessage(chatId, body, {
709
+ parse_mode: "HTML",
710
+ disable_web_page_preview: true,
711
+ });
712
+ }
713
+ catch (e) {
714
+ // Fall back to plain text if Telegram rejects the HTML payload.
715
+ logger.warn("HTML render failed; falling back to plain text", {
716
+ error: e.message,
717
+ });
718
+ try {
719
+ await bot.sendMessage(chatId, body.replace(/<[^>]+>/g, ""));
720
+ }
721
+ catch (e2) {
722
+ logger.warn("Plain-text fallback also failed", {
723
+ error: e2.message,
724
+ });
725
+ }
726
+ }
727
+ };
728
+ try {
729
+ const result = await (0, agentClient_1.runAgentTurn)(logger, {
730
+ sessionId: pending.sessionId ?? undefined,
731
+ prompt: composedPrompt,
732
+ groupName: pending.chosenGroupName ?? undefined,
733
+ signal: abortController.signal,
734
+ onBlock: async (block) => {
735
+ if (block.kind === "reasoning") {
736
+ const cleaned = cleanForTelegram(block.text);
737
+ if (!cleaned)
738
+ return;
739
+ state.lastReasoning = cleaned;
740
+ if (cleaned === lastSent)
741
+ return;
742
+ lastSent = cleaned;
743
+ const prefix = pending.verbose ? "💭 " : "";
744
+ await sendPlain(prefix + truncate(cleaned, REASONING_MAX));
745
+ return;
746
+ }
747
+ if (block.kind === "finalAnswer") {
748
+ // finalAnswer — render markdown as Telegram HTML and split into
749
+ // 4096-char-safe chunks so long answers are never truncated.
750
+ const html = markdownToTelegramHtml(block.text);
751
+ if (!html)
752
+ return;
753
+ lastSent = html;
754
+ const chunks = splitForTelegram(html);
755
+ for (const chunk of chunks) {
756
+ await sendHtml(chunk);
757
+ }
758
+ return;
759
+ }
760
+ // Non-reasoning, non-final blocks (shell/web/mcp/image/terminal)
761
+ // are only forwarded in verbose mode.
762
+ if (!pending.verbose)
763
+ return;
764
+ const VERBOSE_MAX = 1500;
765
+ switch (block.kind) {
766
+ case "shellCommand": {
767
+ const body = truncate(block.text.trim(), VERBOSE_MAX);
768
+ await sendHtml(`🛠 <b>Shell command</b>\n<pre><code class="language-bash">${escapeHtml(body)}</code></pre>`);
769
+ return;
770
+ }
771
+ case "terminalOutput": {
772
+ const body = truncate(block.text.trim(), VERBOSE_MAX);
773
+ if (!body)
774
+ return;
775
+ await sendHtml(`📤 <b>Terminal output</b>\n<pre>${escapeHtml(body)}</pre>`);
776
+ return;
777
+ }
778
+ case "webCall": {
779
+ const body = truncate(cleanForTelegram(block.text) || block.text, VERBOSE_MAX);
780
+ await sendHtml(`🌐 <b>Web call</b>\n${escapeHtml(body)}`);
781
+ return;
782
+ }
783
+ case "mcpCall": {
784
+ const body = truncate(cleanForTelegram(block.text) || block.text, VERBOSE_MAX);
785
+ await sendHtml(`🔌 <b>MCP call</b>\n${escapeHtml(body)}`);
786
+ return;
787
+ }
788
+ case "imageRendering": {
789
+ const body = truncate(cleanForTelegram(block.text) || block.text, VERBOSE_MAX);
790
+ await sendHtml(`🖼 <b>Image</b>\n${escapeHtml(body)}`);
791
+ return;
792
+ }
793
+ }
794
+ },
795
+ });
796
+ state.sessionId = result.sessionId;
797
+ logger.info("Agent run completed", {
798
+ chatId,
799
+ sessionId: result.sessionId,
800
+ });
801
+ }
802
+ catch (err) {
803
+ if (err instanceof agentClient_1.AgentAbortError || state.stoppedByUser) {
804
+ logger.info("Agent run stopped by user", {
805
+ chatId,
806
+ sessionId: state.sessionId,
807
+ });
808
+ await notify(logger, `🛑 Agent run stopped by user (session \`${state.sessionId}\`).`, { chatId });
809
+ }
810
+ else {
811
+ logger.error("Agent run failed", { error: err.message });
812
+ await notify(logger, `❌ Agent run failed: ${err.message}`, {
813
+ chatId,
814
+ });
815
+ }
816
+ }
817
+ finally {
818
+ runningSessions.delete(chatId);
819
+ pendingVerbose.delete(chatId);
820
+ }
821
+ }
822
+ async function handleStopCommand(logger, chatId) {
823
+ const running = runningSessions.get(chatId);
824
+ if (!running) {
825
+ await notify(logger, "🛑 No agent session is currently running.", {
826
+ chatId,
827
+ });
828
+ return;
829
+ }
830
+ if (running.abortController.signal.aborted) {
831
+ await notify(logger, "⏳ Stop already requested — waiting for shutdown…", {
832
+ chatId,
833
+ });
834
+ return;
835
+ }
836
+ running.stoppedByUser = true;
837
+ running.abortController.abort();
838
+ logger.info("User requested agent stop", {
839
+ chatId,
840
+ sessionId: running.sessionId,
841
+ });
842
+ await notify(logger, `🛑 Stop requested for session \`${running.sessionId}\`.`, { chatId });
843
+ }
844
+ function setupMessageListener(logger, bot) {
845
+ bot.on("callback_query", (q) => {
846
+ void handleCallbackQuery(logger, q).catch((err) => {
847
+ logger.error("callback_query handler crashed", {
848
+ error: err.message,
849
+ });
850
+ });
851
+ });
852
+ bot.on("message", async (msg) => {
853
+ const chatId = msg.chat.id;
854
+ if (!isAuthorizedChat(chatId)) {
855
+ logger.warn("Received message from unauthorized chat ID:", chatId);
856
+ return;
857
+ }
858
+ const text = (msg.text || "").trim();
859
+ logger.info(`Received message from chat ID ${chatId}: ${text}`);
860
+ const lower = text.toLowerCase();
861
+ if (lower === "/cmd" || lower.startsWith("/cmd ")) {
862
+ const args = text
863
+ .slice("/cmd".length)
864
+ .trim()
865
+ .split(/\s+/)
866
+ .filter(Boolean);
867
+ const verbose = args.some((a) => a === "--verbose" || a === "-v" || a === "--verbos");
868
+ await handleCmdCommand(logger, chatId, verbose);
869
+ return;
870
+ }
871
+ if (lower === "/task") {
872
+ await handleTaskCommand(logger, chatId);
873
+ return;
874
+ }
875
+ if (lower === "/stop") {
876
+ await handleStopCommand(logger, chatId);
877
+ return;
878
+ }
879
+ // If we are awaiting a prompt for a /cmd selection, treat this message as the prompt.
880
+ const pending = pendingPrompts.get(chatId);
881
+ if (pending && text && !text.startsWith("/")) {
882
+ if (pending.phase !== "awaitPrompt") {
883
+ await notify(logger, "👉 Pick the remaining options on the wizard above first.", { chatId });
884
+ return;
885
+ }
886
+ if (pending.sessionId) {
887
+ const exists = (0, db_1.getSessionById)(pending.sessionId);
888
+ if (!exists) {
889
+ pendingPrompts.delete(chatId);
890
+ await notify(logger, "❌ Selected session no longer exists.", {
891
+ chatId,
892
+ });
893
+ return;
894
+ }
895
+ }
896
+ await runAgentForChat(logger, chatId, pending, text);
897
+ return;
898
+ }
899
+ logger.info("Ignoring unknown message");
900
+ });
901
+ }