grok-telegram-bot 2.4.0 → 2.6.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 (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Session "comment" shown on Running / Sessions cards:
3
- * • while busy — current step (tool / thinking / working on …)
4
- * • when idle — what the last turn solved (assistant result + files)
3
+ * • always — last user prompt (≤ COMMENT_MAX)
4
+ * • while busy — also last AI agent thinking (≤ COMMENT_MAX), second line
5
5
  *
6
6
  * Built only from data already in the turn — no extra agent prompt (those
7
7
  * leaked into chat/history). Display-only; never truncates agent context.
@@ -18,8 +18,8 @@ import {
18
18
  } from "./tool-call-detail.js";
19
19
  import type { FileOp } from "./file-summary.js";
20
20
 
21
- /** Max length of a card comment line (room for a real one-sentence outcome). */
22
- export const COMMENT_MAX = 200;
21
+ /** Max length of each card comment line (user prompt or thinking). */
22
+ export const COMMENT_MAX = 250;
23
23
 
24
24
  /**
25
25
  * Legacy marker for the removed silent AI card-summary prompt. Kept only so
@@ -52,16 +52,36 @@ export function cleanCommentLine(raw: string, max = COMMENT_MAX): string {
52
52
  */
53
53
  export function stripDirectiveWrappers(raw: string): string {
54
54
  let t = raw.trim().replace(/^\([^)]*\)\s*/, "");
55
+ // Manager context + work reports (meta).
56
+ if (/^MANAGER WORK REPORT \(system/i.test(t)) t = "";
57
+ if (/MANAGER CONTEXT \(auto/i.test(t)) {
58
+ t = t.replace(/MANAGER CONTEXT \(auto[\s\S]*?\n---\n\n/i, "");
59
+ }
60
+ if (/^MANAGER MODE \(General topic/i.test(t)) {
61
+ t = t.replace(/^MANAGER MODE \(General topic[\s\S]*?User message:\s*/i, "");
62
+ }
55
63
  const marker = "User's new message:";
56
64
  const i = t.lastIndexOf(marker);
57
65
  if (i !== -1) t = t.slice(i + marker.length);
58
- t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
59
- t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
66
+ // Continued marker first — it contains the substring "User task:".
67
+ const cont = "User task (continued):";
68
+ const ci = t.lastIndexOf(cont);
69
+ if (ci !== -1) {
70
+ t = t.slice(ci + cont.length);
71
+ } else {
72
+ t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
73
+ t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
74
+ if (/TELEGRAM BRIDGE \(how to work/i.test(t)) {
75
+ t = t.replace(/TELEGRAM BRIDGE \(how to work[\s\S]*?(?=\n\n[A-Za-z]|$)/i, "");
76
+ }
77
+ }
78
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t.trim())) t = "";
79
+ if (/^MANAGER WORK REPORT \(system/i.test(t.trim())) t = "";
60
80
  return t.trim();
61
81
  }
62
82
 
63
83
  /** Strip bot directives so a user prompt is card-friendly. */
64
- export function cleanUserPreview(raw: string, max = 80): string {
84
+ export function cleanUserPreview(raw: string, max = COMMENT_MAX): string {
65
85
  let t = stripDirectiveWrappers(raw);
66
86
  // Import confirm prompts are noise on cards.
67
87
  if (/session import complete/i.test(t)) return "";
@@ -69,9 +89,56 @@ export function cleanUserPreview(raw: string, max = 80): string {
69
89
  if (/^SELF-RECHECK \(automatic quality pass/i.test(t)) return "Self-recheck";
70
90
  if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t)) return "";
71
91
  if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t)) return "";
92
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t)) return "Telegram bridge";
93
+ if (/^MANAGER WORK REPORT \(system/i.test(t)) return "Manager work report";
72
94
  return cleanCommentLine(t, max);
73
95
  }
74
96
 
97
+ /**
98
+ * Format the session-card comment body:
99
+ * idle → last user prompt (≤ max)
100
+ * busy → user prompt + last agent thinking on the next line (each ≤ max)
101
+ *
102
+ * Returns "" when neither line has content. Caller adds icons per line.
103
+ */
104
+ export function buildSessionCardComment(opts: {
105
+ userPrompt?: string;
106
+ thinking?: string;
107
+ busy?: boolean;
108
+ max?: number;
109
+ }): string {
110
+ const max = opts.max ?? COMMENT_MAX;
111
+ const user = opts.userPrompt?.trim()
112
+ ? cleanUserPreview(opts.userPrompt, max)
113
+ : "";
114
+ const thinkRaw = opts.busy && opts.thinking?.trim() ? opts.thinking.trim() : "";
115
+ const thinking = thinkRaw ? clampThinking(thinkRaw, max) : "";
116
+
117
+ if (user && thinking) return `${user}\n${thinking}`;
118
+ if (user) return user;
119
+ if (thinking) return thinking;
120
+ return "";
121
+ }
122
+
123
+ /**
124
+ * Prefer the latest portion of accumulated thought text (streaming chunks
125
+ * append; conclusions land at the end). Collapse whitespace and clamp.
126
+ */
127
+ export function clampThinking(raw: string, max = COMMENT_MAX): string {
128
+ let t = extractProgress(raw).cleaned;
129
+ t = t.replace(/```[\s\S]*?```/g, " ");
130
+ t = t.replace(/\s+/g, " ").trim();
131
+ if (!t) return "";
132
+ if (t.length <= max) return t;
133
+ // Prefer the ending (most recent thinking).
134
+ if (t.length > max + 40) {
135
+ const tail = t.slice(-(max - 1));
136
+ const sp = tail.indexOf(" ");
137
+ return "\u2026" + (sp > 0 && sp < 40 ? tail.slice(sp + 1) : tail);
138
+ }
139
+ return t.slice(0, max - 1) + "\u2026";
140
+ }
141
+
75
142
  /**
76
143
  * Pull a human-useful outcome snippet from the assistant's streamed reply.
77
144
  * Prefers the closing sentences (where conclusions land) over mid-turn "I'll…".
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Telegram bridge protocol: first-prompt directive + parse/strip of agent
3
+ * JSON action blocks (`{"telegram":[...]}`) from assistant responses.
4
+ *
5
+ * Keep TELEGRAM_BRIDGE_DIRECTIVE tidy-idempotent (no trailing spaces / 3+ blank
6
+ * lines, no digit `{progress:…}` tokens) so history cleaners can strip by
7
+ * exact match after extractProgress/tidy.
8
+ */
9
+ import type { PromptInput } from "../app/types.js";
10
+
11
+ /** Marker prefix for first-prompt teaching block (used by strip / history). */
12
+ export const TELEGRAM_BRIDGE_MARKER = "TELEGRAM BRIDGE (how to work in this chat):";
13
+
14
+ /** Marker for results injected back into the agent after actions run. */
15
+ export const TELEGRAM_BRIDGE_RESULTS_MARKER =
16
+ "TELEGRAM BRIDGE RESULTS (system — use these facts; do not re-emit the same request unless needed):";
17
+
18
+ /** Max actions accepted from one agent turn (create + path + several prompts). */
19
+ export const TELEGRAM_ACTION_MAX = 9;
20
+ /** Max bot_command actions per turn. */
21
+ export const TELEGRAM_BOT_COMMAND_MAX = 2;
22
+ /** Max send_prompt actions per turn (cross-topic work). */
23
+ export const TELEGRAM_SEND_PROMPT_MAX = 5;
24
+
25
+ export type TelegramAction =
26
+ | { action: "create_topic"; name: string; path?: string }
27
+ | { action: "set_path"; topic: string; path: string }
28
+ | {
29
+ action: "send_prompt";
30
+ topic: string;
31
+ prompt: string;
32
+ newSession?: boolean;
33
+ /** Resume this exact Grok session in the topic (full id or short prefix). */
34
+ sessionId?: string;
35
+ }
36
+ /** User-facing message in the current chat (General: only way to talk to the user). */
37
+ | { action: "notify"; text: string; important?: boolean }
38
+ | { action: "search_memory"; query: string; limit?: number }
39
+ | { action: "list_topics" }
40
+ | { action: "list_jobs" }
41
+ | { action: "list_bots" }
42
+ | { action: "bot_command"; bot: string; command: string; args?: string };
43
+
44
+ /** Max user-facing notify messages per turn (anti-spam). */
45
+ export const TELEGRAM_NOTIFY_MAX = 3;
46
+
47
+ export interface TelegramActionExtract {
48
+ actions: TelegramAction[];
49
+ /** Text with telegram action fences removed. */
50
+ cleaned: string;
51
+ }
52
+
53
+ /**
54
+ * Static teaching block. Dynamic capability lines are appended by
55
+ * {@link buildTelegramBridgeDirective}.
56
+ */
57
+ export const TELEGRAM_BRIDGE_DIRECTIVE_BASE = [
58
+ TELEGRAM_BRIDGE_MARKER,
59
+ "You are driven by the Grok Telegram Bot bridge over ACP. Beyond normal coding tools you may request Telegram-side actions by putting a fenced JSON block in your response (language json preferred), BEFORE the final progress marker line.",
60
+ "",
61
+ "Format (one block; multiple actions allowed, run in order — up to 9):",
62
+ "```json",
63
+ '{ "telegram": [',
64
+ ' { "action": "create_topic", "name": "Topic title", "path": "optional absolute project path or exact catalog name" },',
65
+ ' { "action": "set_path", "topic": "Topic title or #threadId", "path": "absolute path or exact catalog name" },',
66
+ ' { "action": "send_prompt", "topic": "ExactTopicTitle or #threadId", "prompt": "work for that topic", "new_session": false, "session_id": "optional-full-or-short-session-id" },',
67
+ ' { "action": "notify", "text": "short message for the user", "important": false },',
68
+ ' { "action": "search_memory", "query": "keywords about past work", "limit": 8 },',
69
+ ' { "action": "list_topics" },',
70
+ ' { "action": "list_jobs" },',
71
+ ' { "action": "list_bots" },',
72
+ ' { "action": "bot_command", "bot": "username_without_at", "command": "status", "args": "optional" }',
73
+ "] }",
74
+ "```",
75
+ "",
76
+ "Actions:",
77
+ "- create_topic — new forum topic; optional path binds the project immediately.",
78
+ " Absolute paths that do not exist yet are created on disk (new project flow).",
79
+ "- set_path — bind/rebind an existing topic to a project path (absolute dir or exact catalog name).",
80
+ " Absolute paths that do not exist yet are created on disk.",
81
+ "- send_prompt — start or queue a prompt in another topic's session (does not wait for that turn to finish).",
82
+ " From General (manager) you MUST create/set path then send_prompt — never code in General.",
83
+ " Child sessions started from General auto report back with a MANAGER WORK REPORT when done.",
84
+ " topic = EXACT forum title (from list_topics / memory), #threadId, \"general\", or \"ai chat\".",
85
+ " NEVER use placeholders like \"…\", \"...\", \"topic\", \"there\" — call list_topics if unsure.",
86
+ " Optional new_session=true starts a FRESH session in that topic.",
87
+ " Optional session_id (full UUID or short prefix like 019fc9ec) RESUMES that exact session.",
88
+ " When session_id is set, topic may be omitted — the bridge maps session path → topic.",
89
+ " session_id is REQUIRED when memory found a related session and the user wants a follow-up there.",
90
+ " Without session_id the bridge uses the topic's current foreground session (often wrong).",
91
+ "- notify — optional extra short message. In General the user ALSO sees your free-form prose,",
92
+ " so always write a brief chat reply; do not stay silent after search/dispatch.",
93
+ " Max 3 notify per turn. Never notify dispatch chatter, job tables, or \"sending to…\" spam.",
94
+ "- search_memory — search indexed forum topics + session titles/comments/history (memory-first).",
95
+ "- list_topics — list all mapped forum topics (name, #id, path, kind).",
96
+ "- list_jobs — list recent manager dispatches from General (status, target topic).",
97
+ "- list_bots — list allowlisted sibling Telegram bots and command catalogs.",
98
+ "- bot_command — /command@bot; waits for that bot to settle. NOT a Done; timeouts return ok=false.",
99
+ "",
100
+ "Example (General — short prose plus dispatch):",
101
+ "```json",
102
+ '{ "telegram": [',
103
+ ' { "action": "send_prompt", "topic": "WindowsStoreListingGenerator", "session_id": "019fc9ec", "prompt": "Continue ship gate." },',
104
+ ' { "action": "notify", "text": "On it — continuing the ship gate in WindowsStoreListingGenerator." }',
105
+ "] }",
106
+ "```",
107
+ "",
108
+ "Example (session_id only — topic inferred from session path):",
109
+ "```json",
110
+ '{ "telegram": [',
111
+ ' { "action": "send_prompt", "session_id": "019fc9ec", "prompt": "Continue the related work." }',
112
+ "] }",
113
+ "```",
114
+ "",
115
+ "Example (from General): create project topic + path + kick off work there:",
116
+ "```json",
117
+ '{ "telegram": [',
118
+ ' { "action": "create_topic", "name": "MyApp", "path": "H:\\\\Projects\\\\MyApp" },',
119
+ ' { "action": "send_prompt", "topic": "MyApp", "new_session": true, "prompt": "1) scaffold\\n2) tests\\n3) README" },',
120
+ ' { "action": "notify", "text": "Created **MyApp** and started scaffolding there." }',
121
+ "] }",
122
+ "```",
123
+ "",
124
+ "After you emit these actions, the bridge runs them and may send TELEGRAM BRIDGE RESULTS. Use those facts; do not invent replies.",
125
+ "The bridge strips the JSON fence from display. In General write a short prose answer the user can see.",
126
+ "Do not spam actions. Prefer one orchestration block per turn.",
127
+ ].join("\n");
128
+
129
+ export interface TelegramBridgeCaps {
130
+ forumReady: boolean;
131
+ topicGroupId?: number;
132
+ allowedBots: string[];
133
+ /** username → command list for first-prompt teaching */
134
+ botCommands?: Record<string, Array<{ command: string; description?: string }>>;
135
+ /** When true, General manager wording is appended. */
136
+ managerMode?: boolean;
137
+ }
138
+
139
+ /** Full first-prompt directive including live capabilities. */
140
+ export function buildTelegramBridgeDirective(caps: TelegramBridgeCaps): string {
141
+ const lines = [TELEGRAM_BRIDGE_DIRECTIVE_BASE, "", "Capabilities right now:"];
142
+ if (caps.forumReady && caps.topicGroupId !== undefined) {
143
+ lines.push(
144
+ `- Forum topics: READY (group ${caps.topicGroupId}). You may create_topic, set_path, and send_prompt.`,
145
+ `- General is the manager topic (orchestrates only). AI Chat uses GROK_WORKSPACE for coding there; project topics use their bound path.`,
146
+ );
147
+ } else if (caps.topicGroupId !== undefined) {
148
+ lines.push(
149
+ `- Forum topics: NOT READY (group ${caps.topicGroupId} configured but setup failed or bot is not admin). Do not rely on create_topic / set_path / send_prompt.`,
150
+ );
151
+ } else {
152
+ lines.push("- Forum topics: OFF (TOPIC_GROUP_ID unset). create_topic / set_path / send_prompt will fail.");
153
+ }
154
+ if (caps.allowedBots.length > 0) {
155
+ lines.push(
156
+ `- Sibling bots (allowlist): ${caps.allowedBots.map((b) => "@" + b).join(", ")}. Use list_bots / bot_command like MCP.`,
157
+ );
158
+ for (const u of caps.allowedBots) {
159
+ const cmds = caps.botCommands?.[u];
160
+ if (cmds && cmds.length > 0) {
161
+ lines.push(
162
+ ` - @${u}: ${cmds
163
+ .map((c) => (c.description ? `/${c.command} (${c.description})` : `/${c.command}`))
164
+ .join(", ")}`,
165
+ );
166
+ }
167
+ }
168
+ } else {
169
+ lines.push(
170
+ "- Sibling bots: none configured (ALLOWED_TELEGRAM_BOTS empty). list_bots returns empty; bot_command disabled.",
171
+ );
172
+ }
173
+ lines.push("- search_memory / list_topics / list_jobs: available against bot-owned indexes.");
174
+ if (caps.managerMode) {
175
+ lines.push(
176
+ "- You are in General manager mode: chat-like replies only; memory-first; dispatch via send_prompt; child work auto-reports back.",
177
+ );
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+
182
+ /**
183
+ * Prepend the telegram bridge teaching block (idempotent if already present).
184
+ * Call after complexity wrapping so it sits between complexity and user task body.
185
+ */
186
+ export function wrapTelegramBridgePrompt(input: PromptInput, directive: string): PromptInput {
187
+ const body = input.text.trim() || "(see attached media / files)";
188
+ if (body.includes(TELEGRAM_BRIDGE_MARKER)) return input;
189
+ // Prefer inserting after "User task:\n" when complexity wrapper is present.
190
+ const marker = "User task:";
191
+ const idx = body.indexOf(marker);
192
+ if (idx !== -1) {
193
+ const before = body.slice(0, idx + marker.length);
194
+ const after = body.slice(idx + marker.length);
195
+ return {
196
+ ...input,
197
+ text: `${before}\n\n${directive}\n\nUser task (continued):\n${after.trimStart()}`,
198
+ };
199
+ }
200
+ return {
201
+ ...input,
202
+ text: `${directive}\n\n${body}`,
203
+ };
204
+ }
205
+
206
+ /** True when text is the bridge results follow-up (meta; skip recheck). */
207
+ export function isTelegramBridgeResultsPrompt(text: string): boolean {
208
+ return text.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER);
209
+ }
210
+
211
+ /** Build the meta prompt that feeds action results back to the agent. */
212
+ export function buildTelegramBridgeResultsPrompt(results: unknown[]): string {
213
+ const payload = JSON.stringify({ results }, null, 2);
214
+ return [
215
+ TELEGRAM_BRIDGE_RESULTS_MARKER,
216
+ "```json",
217
+ payload,
218
+ "```",
219
+ "Continue the user's task with this information. Do not ask the user to paste results. Do not re-emit the same telegram actions unless something failed and a retry is useful.",
220
+ "If actions succeeded, continue with a short prose answer — never dump job tables or dispatch narration.",
221
+ ].join("\n");
222
+ }
223
+
224
+ /**
225
+ * Extract telegram actions from fenced JSON blocks and strip those fences from
226
+ * the visible text. Non-telegram fences are left intact.
227
+ */
228
+ export function extractTelegramActions(text: string): TelegramActionExtract {
229
+ if (!text) return { actions: [], cleaned: text };
230
+ const actions: TelegramAction[] = [];
231
+ // Match complete fenced blocks (``` or ```json etc.).
232
+ const fenceRe = /```(?:json|JSON)?\s*\r?\n([\s\S]*?)```/g;
233
+ let cleaned = text.replace(fenceRe, (full, body: string) => {
234
+ const parsed = tryParseTelegramFence(body);
235
+ if (!parsed) return full;
236
+ for (const a of parsed) {
237
+ if (actions.length >= TELEGRAM_ACTION_MAX) break;
238
+ actions.push(a);
239
+ }
240
+ return "";
241
+ });
242
+ // Hide a trailing incomplete ```json … telegram block mid-stream.
243
+ cleaned = cleaned.replace(/```(?:json|JSON)?\s*\r?\n[\s\S]*$/i, (tail) => {
244
+ if (
245
+ /"telegram"\s*:/i.test(tail) ||
246
+ /"action"\s*:\s*"(?:create_topic|set_path|send_prompt|notify|search_memory|list_topics|list_jobs|list_bots|bot_command)"/i.test(
247
+ tail,
248
+ )
249
+ ) {
250
+ return "";
251
+ }
252
+ return tail;
253
+ });
254
+ cleaned = tidy(cleaned);
255
+ return { actions: capBotCommands(actions), cleaned };
256
+ }
257
+
258
+ /** Strip only (no need for actions) — streamer path. */
259
+ export function stripTelegramActionFences(text: string): string {
260
+ return extractTelegramActions(text).cleaned;
261
+ }
262
+
263
+ function tryParseTelegramFence(body: string): TelegramAction[] | undefined {
264
+ const raw = body.trim();
265
+ if (!raw) return undefined;
266
+ let parsed: unknown;
267
+ try {
268
+ parsed = JSON.parse(raw);
269
+ } catch {
270
+ return undefined;
271
+ }
272
+ const list = coerceActionList(parsed);
273
+ if (!list) return undefined;
274
+ const out: TelegramAction[] = [];
275
+ for (const item of list) {
276
+ const a = normalizeAction(item);
277
+ if (a) out.push(a);
278
+ }
279
+ return out.length > 0 ? out : undefined;
280
+ }
281
+
282
+ function coerceActionList(parsed: unknown): unknown[] | undefined {
283
+ if (Array.isArray(parsed)) {
284
+ if (parsed.length === 0) return undefined;
285
+ // Bare array only if every element looks like an action.
286
+ if (parsed.every((x) => x && typeof x === "object" && "action" in (x as object))) {
287
+ return parsed;
288
+ }
289
+ return undefined;
290
+ }
291
+ if (!parsed || typeof parsed !== "object") return undefined;
292
+ const rec = parsed as Record<string, unknown>;
293
+ if ("telegram" in rec) {
294
+ const t = rec.telegram;
295
+ if (Array.isArray(t)) return t;
296
+ if (t && typeof t === "object") return [t];
297
+ return undefined;
298
+ }
299
+ // Single action object at top level.
300
+ if (typeof rec.action === "string") return [rec];
301
+ return undefined;
302
+ }
303
+
304
+ function normalizeAction(item: unknown): TelegramAction | undefined {
305
+ if (!item || typeof item !== "object") return undefined;
306
+ const rec = item as Record<string, unknown>;
307
+ const action = String(rec.action ?? rec.type ?? "")
308
+ .trim()
309
+ .toLowerCase()
310
+ .replace(/-/g, "_");
311
+
312
+ switch (action) {
313
+ case "create_topic": {
314
+ const name = String(rec.name ?? rec.title ?? "").trim();
315
+ if (!name) return undefined;
316
+ const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
317
+ return path
318
+ ? { action: "create_topic", name: name.slice(0, 128), path: path.slice(0, 500) }
319
+ : { action: "create_topic", name: name.slice(0, 128) };
320
+ }
321
+ case "set_path":
322
+ case "bind_path":
323
+ case "bind_topic": {
324
+ const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
325
+ const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
326
+ if (!topic || !path) return undefined;
327
+ return {
328
+ action: "set_path",
329
+ topic: topic.slice(0, 128),
330
+ path: path.slice(0, 500),
331
+ };
332
+ }
333
+ case "send_prompt":
334
+ case "topic_prompt":
335
+ case "prompt_topic": {
336
+ let topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
337
+ const prompt = String(rec.prompt ?? rec.text ?? rec.message ?? "").trim();
338
+ const newSessionRaw = rec.new_session ?? rec.newSession ?? rec.fresh;
339
+ const newSession =
340
+ newSessionRaw === true ||
341
+ newSessionRaw === 1 ||
342
+ /^(1|true|yes|y)$/i.test(String(newSessionRaw ?? "").trim());
343
+ const sessionIdRaw = String(
344
+ rec.session_id ?? rec.sessionId ?? rec.sess ?? rec.session ?? "",
345
+ ).trim();
346
+ const sessionId = sessionIdRaw
347
+ ? sessionIdRaw.replace(/^#?sess[_-]?/i, "").slice(0, 64)
348
+ : undefined;
349
+ // Drop placeholder topics ("…") so resolve can use session_id / memory inference.
350
+ if (
351
+ !topic ||
352
+ /^[\s.…·•⋯︙\-–—_*~`'"“”‘’\u2026\u22ef\u3002]+$/u.test(topic) ||
353
+ /^(topic|there|here|same|related|todo|tbd|none|null|target)$/i.test(topic)
354
+ ) {
355
+ topic = "";
356
+ }
357
+ // topic optional when session_id is present OR prompt alone (memory may infer topic).
358
+ if (!prompt) return undefined;
359
+ // Allow prompt-only send_prompt: bridge may infer topic from memory + user ask.
360
+ // Still require at least something actionable (prompt is enough).
361
+ // Full prompt for the target agent — do NOT hard-crop to Telegram size.
362
+ // Long prompts are split only in the human-visible announce message.
363
+ const PROMPT_MAX = 100_000;
364
+ return {
365
+ action: "send_prompt",
366
+ topic: topic.slice(0, 128),
367
+ prompt: prompt.length > PROMPT_MAX ? prompt.slice(0, PROMPT_MAX) : prompt,
368
+ newSession: newSession || undefined,
369
+ // session_id wins over new_session when both are set (resume is explicit).
370
+ sessionId: sessionId || undefined,
371
+ };
372
+ }
373
+ case "notify":
374
+ case "message_user":
375
+ case "say":
376
+ case "tell_user": {
377
+ const text = String(rec.text ?? rec.message ?? rec.body ?? rec.prompt ?? "").trim();
378
+ if (!text) return undefined;
379
+ const impRaw = rec.important ?? rec.priority ?? rec.loud;
380
+ const important =
381
+ impRaw === true ||
382
+ impRaw === 1 ||
383
+ /^(1|true|yes|y|high|urgent|important)$/i.test(String(impRaw ?? "").trim());
384
+ return {
385
+ action: "notify",
386
+ text: text.slice(0, 3500),
387
+ important: important || undefined,
388
+ };
389
+ }
390
+ case "search_memory":
391
+ case "search":
392
+ case "memory_search": {
393
+ const query = String(rec.query ?? rec.q ?? rec.text ?? "").trim();
394
+ if (!query) return undefined;
395
+ let limit = Number(rec.limit ?? rec.max ?? 8);
396
+ if (!Number.isFinite(limit)) limit = 8;
397
+ limit = Math.max(1, Math.min(20, Math.round(limit)));
398
+ return { action: "search_memory", query: query.slice(0, 300), limit };
399
+ }
400
+ case "list_topics":
401
+ case "topics":
402
+ return { action: "list_topics" };
403
+ case "list_jobs":
404
+ case "jobs":
405
+ case "manager_jobs":
406
+ return { action: "list_jobs" };
407
+ case "list_bots":
408
+ case "bots":
409
+ return { action: "list_bots" };
410
+ case "bot_command":
411
+ case "call_bot":
412
+ case "invoke_bot": {
413
+ const bot = normalizeUsername(String(rec.bot ?? rec.username ?? ""));
414
+ const command = String(rec.command ?? rec.cmd ?? "")
415
+ .trim()
416
+ .replace(/^\//, "");
417
+ if (!bot || !command) return undefined;
418
+ const args = String(rec.args ?? rec.arguments ?? rec.text ?? "").trim();
419
+ return {
420
+ action: "bot_command",
421
+ bot,
422
+ command: command.slice(0, 64),
423
+ args: args ? args.slice(0, 500) : undefined,
424
+ };
425
+ }
426
+ default:
427
+ return undefined;
428
+ }
429
+ }
430
+
431
+ function capBotCommands(actions: TelegramAction[]): TelegramAction[] {
432
+ let botCmds = 0;
433
+ let sendPrompts = 0;
434
+ let notifies = 0;
435
+ const out: TelegramAction[] = [];
436
+ for (const a of actions) {
437
+ if (a.action === "bot_command") {
438
+ if (botCmds >= TELEGRAM_BOT_COMMAND_MAX) continue;
439
+ botCmds++;
440
+ }
441
+ if (a.action === "send_prompt") {
442
+ if (sendPrompts >= TELEGRAM_SEND_PROMPT_MAX) continue;
443
+ sendPrompts++;
444
+ }
445
+ if (a.action === "notify") {
446
+ if (notifies >= TELEGRAM_NOTIFY_MAX) continue;
447
+ notifies++;
448
+ }
449
+ out.push(a);
450
+ if (out.length >= TELEGRAM_ACTION_MAX) break;
451
+ }
452
+ return out;
453
+ }
454
+
455
+ export function normalizeUsername(raw: string): string {
456
+ return raw.trim().replace(/^@/, "").toLowerCase();
457
+ }
458
+
459
+ function tidy(s: string): string {
460
+ return s
461
+ .replace(/[ \t]+\n/g, "\n")
462
+ .replace(/\n{3,}/g, "\n\n")
463
+ .replace(/\s+$/g, "");
464
+ }