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
@@ -0,0 +1,1130 @@
1
+ /**
2
+ * Execute agent-requested Telegram bridge actions and produce results +
3
+ * user-facing status lines.
4
+ */
5
+ import type { Api } from "grammy";
6
+ import type { AppConfig } from "../config.js";
7
+ import type { ForumManager } from "../forum/manager.js";
8
+ import type { ForumTopicBinding } from "../forum/types.js";
9
+ import {
10
+ FORUM_GENERAL_THREAD_ID,
11
+ isGeneralThread,
12
+ outboundThreadExtra,
13
+ } from "../forum/thread.js";
14
+ import type { SessionStore } from "../sessions/store.js";
15
+ import { createLogger } from "../logger.js";
16
+ import { searchGroupMemory } from "./group-memory.js";
17
+ import {
18
+ bindJobSession,
19
+ listRecentManagerJobs,
20
+ registerManagerJob,
21
+ updateManagerJob,
22
+ type ReportBackMeta,
23
+ } from "./manager-jobs.js";
24
+ import type { TelegramBotService } from "./telegram-bots.js";
25
+ import type { TelegramAction } from "../render/telegram-bridge.js";
26
+
27
+ const log = createLogger("telegram-actions");
28
+
29
+ /** Cross-topic prompt injection (implemented by bot.ts via registry). */
30
+ export type SubmitTopicPromptFn = (opts: {
31
+ threadId: number;
32
+ cwd: string;
33
+ projectName: string;
34
+ prompt: string;
35
+ newSession?: boolean;
36
+ /**
37
+ * Resume this exact Grok session in the target topic (full UUID or short
38
+ * prefix from memory hits). When set, overrides newSession / foreground.
39
+ */
40
+ sessionId?: string;
41
+ /** When set, child session reports completion back to General. */
42
+ reportBack?: ReportBackMeta;
43
+ }) => Promise<{ outcome: "ran" | "queued"; sessionId?: string }>;
44
+
45
+ export interface TelegramActionContext {
46
+ api: Api;
47
+ cfg: AppConfig;
48
+ chatId: number;
49
+ messageThreadId?: number;
50
+ /** Reply-to for notify (usually the user's message that started the turn). */
51
+ replyToMessageId?: number;
52
+ forum?: ForumManager;
53
+ store: SessionStore;
54
+ bots: TelegramBotService;
55
+ /** Dispatch a prompt into another forum topic's session. */
56
+ submitTopicPrompt?: SubmitTopicPromptFn;
57
+ /**
58
+ * When actions originate from General manager, register jobs + report-back
59
+ * and suppress chat spam notes for durable actions.
60
+ */
61
+ managerMode?: boolean;
62
+ /** Short preview of the user ask that triggered this manager turn. */
63
+ managerUserAskPreview?: string;
64
+ }
65
+
66
+ export interface TelegramActionResult {
67
+ action: string;
68
+ ok: boolean;
69
+ /** Machine-readable payload for the agent. */
70
+ data?: unknown;
71
+ error?: string;
72
+ /** Short line to show the user (optional). */
73
+ userNote?: string;
74
+ }
75
+
76
+ /** Run actions sequentially; bot_command may await a sibling bot. */
77
+ export async function executeTelegramActions(
78
+ actions: TelegramAction[],
79
+ ctx: TelegramActionContext,
80
+ ): Promise<TelegramActionResult[]> {
81
+ const results: TelegramActionResult[] = [];
82
+ for (const action of actions) {
83
+ try {
84
+ results.push(await runOne(action, ctx));
85
+ } catch (e) {
86
+ const msg = (e as Error).message ?? String(e);
87
+ log.warn(`action ${action.action} failed: ${msg}`);
88
+ results.push({ action: action.action, ok: false, error: msg });
89
+ }
90
+ }
91
+ return results;
92
+ }
93
+
94
+ async function runOne(
95
+ action: TelegramAction,
96
+ ctx: TelegramActionContext,
97
+ ): Promise<TelegramActionResult> {
98
+ switch (action.action) {
99
+ case "create_topic":
100
+ return createTopic(action, ctx);
101
+ case "set_path":
102
+ return setPath(action, ctx);
103
+ case "send_prompt":
104
+ return sendPrompt(action, ctx);
105
+ case "notify":
106
+ return notifyUser(action, ctx);
107
+ case "search_memory":
108
+ return searchMemory(action, ctx);
109
+ case "list_topics":
110
+ return listTopics(ctx);
111
+ case "list_jobs":
112
+ return listJobs();
113
+ case "list_bots":
114
+ return listBots(ctx);
115
+ case "bot_command":
116
+ return botCommand(action, ctx);
117
+ default:
118
+ return { action: "unknown", ok: false, error: "unsupported action" };
119
+ }
120
+ }
121
+
122
+ async function createTopic(
123
+ action: Extract<TelegramAction, { action: "create_topic" }>,
124
+ ctx: TelegramActionContext,
125
+ ): Promise<TelegramActionResult> {
126
+ const forum = ctx.forum;
127
+ if (!forum?.isReady) {
128
+ return {
129
+ action: "create_topic",
130
+ ok: false,
131
+ error:
132
+ "Forum topics not available (TOPIC_GROUP_ID unset, bot not admin, or Topics disabled)",
133
+ };
134
+ }
135
+ const created = await forum.createBoundTopic(action.name, action.path);
136
+ if (!created.ok) {
137
+ return { action: "create_topic", ok: false, error: created.error };
138
+ }
139
+ const b = created.binding;
140
+ return {
141
+ action: "create_topic",
142
+ ok: true,
143
+ data: {
144
+ threadId: b.threadId,
145
+ name: b.name,
146
+ projectPath: b.projectPath,
147
+ kind: b.kind,
148
+ },
149
+ userNote: ctx.managerMode
150
+ ? undefined
151
+ : b.projectPath
152
+ ? `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\``
153
+ : `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) (unbound — use set_path)`,
154
+ };
155
+ }
156
+
157
+ function setPath(
158
+ action: Extract<TelegramAction, { action: "set_path" }>,
159
+ ctx: TelegramActionContext,
160
+ ): TelegramActionResult {
161
+ const forum = ctx.forum;
162
+ if (!forum?.isReady) {
163
+ return {
164
+ action: "set_path",
165
+ ok: false,
166
+ error: "Forum topics not available",
167
+ };
168
+ }
169
+ const resolved = resolveTopicRef(forum, action.topic, ctx.cfg);
170
+ if (!resolved.ok) {
171
+ return { action: "set_path", ok: false, error: resolved.error };
172
+ }
173
+ const { binding } = resolved;
174
+ if (binding.threadId === FORUM_GENERAL_THREAD_ID) {
175
+ return {
176
+ action: "set_path",
177
+ ok: false,
178
+ error: "Cannot rebind the General topic (always GROK_WORKSPACE)",
179
+ };
180
+ }
181
+ if (binding.kind === "ai_chat") {
182
+ return {
183
+ action: "set_path",
184
+ ok: false,
185
+ error: "Cannot rebind the AI Chat topic (always GROK_WORKSPACE)",
186
+ };
187
+ }
188
+
189
+ // Agent set_path: create missing absolute folders (new project flow).
190
+ const result = forum.tryBindPath(binding.threadId, action.path, { createIfMissing: true });
191
+ if (!result.ok) {
192
+ return { action: "set_path", ok: false, error: result.error };
193
+ }
194
+ const b = result.binding;
195
+ const createdNote = result.created ? " (folder created)" : "";
196
+ return {
197
+ action: "set_path",
198
+ ok: true,
199
+ data: {
200
+ threadId: b.threadId,
201
+ name: b.name,
202
+ projectPath: b.projectPath,
203
+ kind: b.kind,
204
+ created: !!result.created,
205
+ },
206
+ userNote: ctx.managerMode
207
+ ? undefined
208
+ : `\u{1F4C1} Topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\`${createdNote}`,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Explicit user-facing message. In General this is the only chat surface —
214
+ * free-form agent prose is not streamed.
215
+ */
216
+ async function notifyUser(
217
+ action: Extract<TelegramAction, { action: "notify" }>,
218
+ ctx: TelegramActionContext,
219
+ ): Promise<TelegramActionResult> {
220
+ const text = action.text.trim();
221
+ if (!text) {
222
+ return { action: "notify", ok: false, error: "Empty notify text" };
223
+ }
224
+ try {
225
+ const extra: Record<string, unknown> = {
226
+ disable_notification: !action.important,
227
+ // General: never pass message_thread_id=1 (Telegram rejects it).
228
+ ...outboundThreadExtra(ctx.messageThreadId),
229
+ };
230
+ if (ctx.replyToMessageId !== undefined) {
231
+ extra.reply_parameters = {
232
+ message_id: ctx.replyToMessageId,
233
+ allow_sending_without_reply: true,
234
+ };
235
+ }
236
+ const msg = await ctx.api.sendMessage(ctx.chatId, text, extra);
237
+ return {
238
+ action: "notify",
239
+ ok: true,
240
+ data: {
241
+ messageId: msg.message_id,
242
+ chars: text.length,
243
+ important: !!action.important,
244
+ },
245
+ // Never double-post via userNote — the message already went out.
246
+ userNote: undefined,
247
+ };
248
+ } catch (e) {
249
+ return {
250
+ action: "notify",
251
+ ok: false,
252
+ error: (e as Error).message ?? String(e),
253
+ };
254
+ }
255
+ }
256
+
257
+ async function sendPrompt(
258
+ action: Extract<TelegramAction, { action: "send_prompt" }>,
259
+ ctx: TelegramActionContext,
260
+ ): Promise<TelegramActionResult> {
261
+ const forum = ctx.forum;
262
+ if (!forum?.isReady) {
263
+ return {
264
+ action: "send_prompt",
265
+ ok: false,
266
+ error: "Forum topics not available",
267
+ };
268
+ }
269
+ if (!ctx.submitTopicPrompt) {
270
+ return {
271
+ action: "send_prompt",
272
+ ok: false,
273
+ error: "Cross-topic prompt dispatch is not wired",
274
+ };
275
+ }
276
+
277
+ // Resolve session_id first when present. A real session can recover a missing
278
+ // or placeholder topic (e.g. topic: "…") by matching session.cwd → forum binding.
279
+ let resumeSessionId: string | undefined;
280
+ let resumeCwd: string | undefined;
281
+ let inferredFromMemory = false;
282
+ if (action.sessionId) {
283
+ const resolvedSess = resolveSessionRef(ctx.store, action.sessionId);
284
+ if (!resolvedSess.ok) {
285
+ return {
286
+ action: "send_prompt",
287
+ ok: false,
288
+ error: resolvedSess.error,
289
+ data: { topic: action.topic, sessionRef: action.sessionId },
290
+ };
291
+ }
292
+ resumeSessionId = resolvedSess.sessionId;
293
+ if (resolvedSess.cwd) resumeCwd = resolvedSess.cwd;
294
+ }
295
+
296
+ let binding: ForumTopicBinding | undefined;
297
+ let topicError: string | undefined;
298
+ const topicRef = (action.topic || "").trim();
299
+ const topicIsPlaceholder = isPlaceholderTopicRef(topicRef);
300
+
301
+ if (topicRef && !topicIsPlaceholder) {
302
+ const resolved = resolveTopicRef(forum, topicRef, ctx.cfg);
303
+ if (resolved.ok) {
304
+ binding = resolved.binding;
305
+ } else {
306
+ topicError = resolved.error;
307
+ }
308
+ }
309
+
310
+ // Fallback: map session cwd → topic when topic missing, placeholder, or not found.
311
+ if (!binding && resumeCwd) {
312
+ const fromPath = resolveTopicFromPath(forum, resumeCwd);
313
+ if (fromPath) binding = fromPath;
314
+ }
315
+
316
+ // Last resort: model used topic "…" / wrong name without session_id — infer from
317
+ // memory using the user ask + prompt text, then map path → forum topic.
318
+ if (!binding) {
319
+ const inferred = inferDispatchTarget(ctx, forum, [
320
+ ctx.managerUserAskPreview || "",
321
+ action.prompt.slice(0, 400),
322
+ topicIsPlaceholder ? "" : topicRef,
323
+ ]);
324
+ if (inferred) {
325
+ binding = inferred.binding;
326
+ if (!resumeSessionId && inferred.sessionId) {
327
+ resumeSessionId = inferred.sessionId;
328
+ resumeCwd = inferred.cwd || inferred.binding.projectPath || resumeCwd;
329
+ inferredFromMemory = true;
330
+ } else if (!resumeCwd && inferred.cwd) {
331
+ resumeCwd = inferred.cwd;
332
+ inferredFromMemory = true;
333
+ } else {
334
+ inferredFromMemory = true;
335
+ }
336
+ }
337
+ }
338
+
339
+ if (!binding) {
340
+ const hint = listTopicHints(forum);
341
+ const topics = safeListTopics(forum);
342
+ if (topicIsPlaceholder || !topicRef) {
343
+ return {
344
+ action: "send_prompt",
345
+ ok: false,
346
+ error:
347
+ (topicIsPlaceholder
348
+ ? `Topic is a placeholder ("${topicRef || "…"}"). Pass exact title, #threadId, or session_id (memory could not auto-infer a project topic).`
349
+ : "Topic missing. Pass exact title, #threadId, or session_id.") +
350
+ (hint ? ` Available: ${hint}` : " Call list_topics first."),
351
+ data: {
352
+ topic: action.topic,
353
+ sessionRef: action.sessionId,
354
+ resumeSessionId,
355
+ availableTopics: topics,
356
+ },
357
+ };
358
+ }
359
+ return {
360
+ action: "send_prompt",
361
+ ok: false,
362
+ error: (topicError || `Topic not found: "${topicRef}".`) + (hint ? ` Available: ${hint}` : ""),
363
+ data: {
364
+ topic: action.topic,
365
+ sessionRef: action.sessionId,
366
+ resumeSessionId,
367
+ availableTopics: topics,
368
+ },
369
+ };
370
+ }
371
+
372
+ const cwd = binding.projectPath || resumeCwd;
373
+ if (!cwd) {
374
+ return {
375
+ action: "send_prompt",
376
+ ok: false,
377
+ error: `Topic **${binding.name}** (#${binding.threadId}) has no project path — use set_path or create_topic with path first`,
378
+ };
379
+ }
380
+ if (!resumeCwd) resumeCwd = cwd;
381
+
382
+ // If session was resolved without path filter, re-prefer under this topic when ambiguous.
383
+ if (action.sessionId && resumeSessionId) {
384
+ const refined = resolveSessionRef(ctx.store, action.sessionId, cwd);
385
+ if (refined.ok) {
386
+ resumeSessionId = refined.sessionId;
387
+ if (refined.cwd) resumeCwd = refined.cwd;
388
+ }
389
+ }
390
+
391
+ const projectName =
392
+ binding.kind === "ai_chat"
393
+ ? ctx.cfg.topicAiChatName
394
+ : binding.kind === "general"
395
+ ? "General"
396
+ : binding.name;
397
+
398
+ // Human-visible announce in the *target* topic (may be multi-message).
399
+ // The full `action.prompt` is always submitted to the agent below — never crop it here.
400
+ const sessNote = resumeSessionId ? ` → session ${resumeSessionId.slice(0, 8)}` : "";
401
+ await announceBridgePrompt(
402
+ ctx.api,
403
+ forum.groupId,
404
+ binding.threadId,
405
+ action.prompt,
406
+ !!action.newSession && !resumeSessionId,
407
+ sessNote,
408
+ );
409
+
410
+ // Manager → project: register job and ask the child runtime to report back.
411
+ let reportBack: ReportBackMeta | undefined;
412
+ if (ctx.managerMode && isGeneralThread(ctx.messageThreadId)) {
413
+ // Never report-back into the same general loop for self-prompts.
414
+ if (binding.threadId !== FORUM_GENERAL_THREAD_ID) {
415
+ const job = registerManagerJob({
416
+ originChatId: ctx.chatId,
417
+ originThreadId: FORUM_GENERAL_THREAD_ID,
418
+ targetThreadId: binding.threadId,
419
+ targetName: binding.name,
420
+ targetPath: resumeCwd,
421
+ dispatchPrompt: action.prompt,
422
+ userAskPreview: (ctx.managerUserAskPreview || action.prompt).slice(0, 400),
423
+ });
424
+ reportBack = {
425
+ jobId: job.id,
426
+ originChatId: job.originChatId,
427
+ originThreadId: job.originThreadId,
428
+ userAskPreview: job.userAskPreview,
429
+ targetName: job.targetName,
430
+ targetPath: job.targetPath,
431
+ dispatchPrompt: job.dispatchPrompt,
432
+ };
433
+ }
434
+ }
435
+
436
+ try {
437
+ const res = await ctx.submitTopicPrompt({
438
+ threadId: binding.threadId,
439
+ cwd: resumeCwd,
440
+ projectName,
441
+ prompt: action.prompt,
442
+ newSession: !!action.newSession && !resumeSessionId,
443
+ sessionId: resumeSessionId,
444
+ reportBack,
445
+ });
446
+ if (reportBack && res.sessionId) {
447
+ bindJobSession(reportBack.jobId, res.sessionId);
448
+ }
449
+ return {
450
+ action: "send_prompt",
451
+ ok: true,
452
+ data: {
453
+ threadId: binding.threadId,
454
+ name: binding.name,
455
+ projectPath: cwd,
456
+ outcome: res.outcome,
457
+ sessionId: res.sessionId,
458
+ resumedSessionId: resumeSessionId,
459
+ inferredFromMemory: inferredFromMemory || undefined,
460
+ newSession: !!action.newSession && !resumeSessionId,
461
+ promptPreview: action.prompt.slice(0, 200),
462
+ jobId: reportBack?.jobId,
463
+ reportBack: !!reportBack,
464
+ },
465
+ // Manager mode: keep General quiet — agent prose confirms dispatch.
466
+ userNote: ctx.managerMode
467
+ ? undefined
468
+ : `\u{1F4E8} Prompt ${res.outcome} in **${binding.name}** (#${binding.threadId})` +
469
+ (resumeSessionId ? ` session ${resumeSessionId.slice(0, 8)}` : ""),
470
+ };
471
+ } catch (e) {
472
+ if (reportBack) {
473
+ updateManagerJob(reportBack.jobId, {
474
+ status: "failed",
475
+ resultSummary: `dispatch failed: ${(e as Error).message ?? String(e)}`.slice(0, 400),
476
+ });
477
+ }
478
+ return {
479
+ action: "send_prompt",
480
+ ok: false,
481
+ error: (e as Error).message ?? String(e),
482
+ data: {
483
+ threadId: binding.threadId,
484
+ name: binding.name,
485
+ projectPath: cwd,
486
+ jobId: reportBack?.jobId,
487
+ sessionRef: action.sessionId,
488
+ },
489
+ };
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Resolve a session ref from memory (full UUID or short prefix like 019fc9ec)
495
+ * to a concrete on-disk session. Prefer sessions under topicCwd when multiple match.
496
+ */
497
+ export function resolveSessionRef(
498
+ store: SessionStore,
499
+ ref: string,
500
+ topicCwd?: string,
501
+ ): { ok: true; sessionId: string; cwd?: string } | { ok: false; error: string } {
502
+ const raw = ref.trim().replace(/^#?sess[_-]?/i, "");
503
+ if (!raw || raw.length < 4) {
504
+ return { ok: false, error: `Invalid session_id "${ref}"` };
505
+ }
506
+ const compact = raw.replace(/-/g, "").toLowerCase();
507
+ const topicKey = topicCwd
508
+ ? topicCwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
509
+ : "";
510
+
511
+ // Exact get first.
512
+ const exact = store.get(raw) ?? store.get(raw.toLowerCase());
513
+ if (exact) return { ok: true, sessionId: exact.sessionId, cwd: exact.cwd || undefined };
514
+
515
+ let metas;
516
+ try {
517
+ metas = store.list(200);
518
+ } catch {
519
+ return { ok: false, error: `Session store unreadable for "${ref}"` };
520
+ }
521
+
522
+ const matches = metas.filter((m) => {
523
+ const id = m.sessionId.toLowerCase();
524
+ const idCompact = id.replace(/-/g, "");
525
+ return (
526
+ id === raw.toLowerCase() ||
527
+ id.startsWith(raw.toLowerCase()) ||
528
+ idCompact.startsWith(compact) ||
529
+ idCompact.includes(compact)
530
+ );
531
+ });
532
+ if (matches.length === 0) {
533
+ return {
534
+ ok: false,
535
+ error: `No session found for session_id "${ref}". Use a full id or longer prefix from memory hits.`,
536
+ };
537
+ }
538
+ // Prefer path under the topic project, then most recently updated.
539
+ matches.sort((a, b) => {
540
+ const aPath = (a.cwd || "").replace(/\\/g, "/").toLowerCase();
541
+ const bPath = (b.cwd || "").replace(/\\/g, "/").toLowerCase();
542
+ const aIn =
543
+ topicKey && (aPath === topicKey || aPath.startsWith(topicKey + "/")) ? 1 : 0;
544
+ const bIn =
545
+ topicKey && (bPath === topicKey || bPath.startsWith(topicKey + "/")) ? 1 : 0;
546
+ if (bIn !== aIn) return bIn - aIn;
547
+ return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
548
+ });
549
+ const best = matches[0]!;
550
+ return { ok: true, sessionId: best.sessionId, cwd: best.cwd || undefined };
551
+ }
552
+
553
+ /** True when the model used a non-topic placeholder like "…" or "TODO". */
554
+ export function isPlaceholderTopicRef(ref: string): boolean {
555
+ const t = ref.trim().toLowerCase();
556
+ if (!t) return true;
557
+ // Any pure punctuation / ellipsis run (incl. multi-char "……" and fullwidth).
558
+ if (/^[\s.…·•⋯︙\-–—_*~`'"“”‘’\u2026\u22ef\u3002]+$/u.test(t)) return true;
559
+ if (t.includes("…") && t.replace(/[.…\s]/g, "").length === 0) return true;
560
+ if (t === "..." || t === "…" || t === "topic" || t === "name" || t === "project") return true;
561
+ if (t === "todo" || t === "tbd" || t === "null" || t === "undefined" || t === "none") return true;
562
+ if (t === "here" || t === "there" || t === "same" || t === "related" || t === "target") return true;
563
+ if (t === "the topic" || t === "that topic" || t === "this topic") return true;
564
+ return false;
565
+ }
566
+
567
+ function safeListTopics(forum: ForumManager): Array<{
568
+ threadId: number;
569
+ name: string;
570
+ kind: string;
571
+ projectPath?: string;
572
+ }> {
573
+ try {
574
+ return forum.store.all().map((t) => ({
575
+ threadId: t.threadId,
576
+ name: t.name,
577
+ kind: t.kind,
578
+ projectPath: t.projectPath ?? undefined,
579
+ }));
580
+ } catch {
581
+ return [];
582
+ }
583
+ }
584
+
585
+ /**
586
+ * When the model omits topic / uses "…" / wrong name, pick the best forum topic
587
+ * (+ optional session) from group memory using the user ask and prompt body.
588
+ */
589
+ export function inferDispatchTarget(
590
+ ctx: Pick<TelegramActionContext, "store" | "cfg" | "managerUserAskPreview">,
591
+ forum: ForumManager,
592
+ queryParts: string[],
593
+ ): { binding: ForumTopicBinding; sessionId?: string; cwd?: string } | undefined {
594
+ const query = queryParts
595
+ .map((s) => (s || "").trim())
596
+ .filter(Boolean)
597
+ .join(" ")
598
+ .replace(/\s+/g, " ")
599
+ .slice(0, 400);
600
+ if (query.length < 3) return undefined;
601
+
602
+ let topics: ForumTopicBinding[] = [];
603
+ try {
604
+ topics = forum.store.all();
605
+ } catch {
606
+ return undefined;
607
+ }
608
+ if (topics.length === 0) return undefined;
609
+
610
+ const preferPaths = topics
611
+ .filter((t) => t.kind === "project" && t.projectPath)
612
+ .map((t) => t.projectPath!)
613
+ .slice(0, 30);
614
+
615
+ let hits;
616
+ try {
617
+ hits = searchGroupMemory({
618
+ query,
619
+ limit: 16,
620
+ sessionsDir: ctx.cfg.sessionsDir,
621
+ store: ctx.store,
622
+ topics,
623
+ preferPaths,
624
+ preferGeneral: false,
625
+ maxSessions: 80,
626
+ });
627
+ } catch {
628
+ return undefined;
629
+ }
630
+ if (!hits.length) return undefined;
631
+
632
+ // 1) Session / history hit → path → topic (prefer resume for follow-ups).
633
+ for (const h of hits) {
634
+ if (!h.sessionId) continue;
635
+ let path = h.path;
636
+ if (!path) {
637
+ try {
638
+ path = ctx.store.get(h.sessionId)?.cwd;
639
+ } catch {
640
+ path = undefined;
641
+ }
642
+ }
643
+ if (!path) continue;
644
+ const b = resolveTopicFromPath(forum, path);
645
+ if (!b?.projectPath || b.kind === "general") continue;
646
+ const sess = resolveSessionRef(ctx.store, h.sessionId, path);
647
+ return {
648
+ binding: b,
649
+ sessionId: sess.ok ? sess.sessionId : h.sessionId,
650
+ cwd: (sess.ok && sess.cwd) || path,
651
+ };
652
+ }
653
+
654
+ // 2) Direct topic hit (project preferred), then attach newest session under path.
655
+ for (const h of hits) {
656
+ if (h.kind !== "topic" || h.threadId === undefined) continue;
657
+ const b = forum.store.get(h.threadId);
658
+ if (!b?.projectPath) continue;
659
+ if (b.kind === "general") continue;
660
+ const under = newestSessionUnderPath(ctx.store, b.projectPath);
661
+ if (under) {
662
+ return { binding: b, sessionId: under.sessionId, cwd: under.cwd || b.projectPath };
663
+ }
664
+ return { binding: b };
665
+ }
666
+
667
+ // 3) Unique project-topic name token match from query.
668
+ const qn = normalizeTopicKey(query);
669
+ const nameHits = topics.filter((t) => {
670
+ if (t.kind === "general" || !t.projectPath) return false;
671
+ const tn = normalizeTopicKey(t.name);
672
+ return tn.length >= 4 && (qn.includes(tn) || tn.includes(qn.slice(0, Math.min(12, qn.length))));
673
+ });
674
+ if (nameHits.length === 1) {
675
+ const b = nameHits[0]!;
676
+ const under = newestSessionUnderPath(ctx.store, b.projectPath!);
677
+ if (under) {
678
+ return { binding: b, sessionId: under.sessionId, cwd: under.cwd || b.projectPath || undefined };
679
+ }
680
+ return { binding: b };
681
+ }
682
+
683
+ return undefined;
684
+ }
685
+
686
+ function newestSessionUnderPath(
687
+ store: SessionStore,
688
+ projectPath: string,
689
+ ): { sessionId: string; cwd?: string } | undefined {
690
+ const key = normPath(projectPath);
691
+ if (!key) return undefined;
692
+ let metas;
693
+ try {
694
+ metas = store.list(80);
695
+ } catch {
696
+ return undefined;
697
+ }
698
+ const under = metas
699
+ .filter((m) => {
700
+ const p = normPath(m.cwd || "");
701
+ return p === key || p.startsWith(key + "/");
702
+ })
703
+ .sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
704
+ const best = under[0];
705
+ if (!best) return undefined;
706
+ return { sessionId: best.sessionId, cwd: best.cwd || undefined };
707
+ }
708
+
709
+ function normalizeTopicKey(s: string): string {
710
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "");
711
+ }
712
+
713
+ function normPath(p: string): string {
714
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
715
+ }
716
+
717
+ /** Map a session/project path to a forum topic binding (exact path, then parent). */
718
+ export function resolveTopicFromPath(
719
+ forum: ForumManager,
720
+ cwd: string,
721
+ ): ForumTopicBinding | undefined {
722
+ const key = normPath(cwd);
723
+ if (!key) return undefined;
724
+ const all = forum.store.all().filter((t) => t.projectPath);
725
+ // Exact path match first.
726
+ const exact = all.filter((t) => normPath(t.projectPath!) === key);
727
+ if (exact.length === 1) return exact[0];
728
+ if (exact.length > 1) {
729
+ // Prefer project kind over general/ai_chat.
730
+ const proj = exact.find((t) => t.kind === "project");
731
+ return proj || exact[0];
732
+ }
733
+ // Session cwd may be a subfolder of the bound project path.
734
+ const parents = all.filter((t) => {
735
+ const tp = normPath(t.projectPath!);
736
+ return key === tp || key.startsWith(tp + "/");
737
+ });
738
+ if (parents.length === 0) {
739
+ // Or topic path is under session cwd (less common).
740
+ const children = all.filter((t) => {
741
+ const tp = normPath(t.projectPath!);
742
+ return tp.startsWith(key + "/");
743
+ });
744
+ if (children.length === 1) return children[0];
745
+ if (children.length > 1) {
746
+ children.sort((a, b) => normPath(a.projectPath!).length - normPath(b.projectPath!).length);
747
+ return children[0];
748
+ }
749
+ return undefined;
750
+ }
751
+ // Longest matching project path wins.
752
+ parents.sort((a, b) => normPath(b.projectPath!).length - normPath(a.projectPath!).length);
753
+ const topLen = normPath(parents[0]!.projectPath!).length;
754
+ const top = parents.filter((t) => normPath(t.projectPath!).length === topLen);
755
+ if (top.length === 1) return top[0];
756
+ return top.find((t) => t.kind === "project") || top[0];
757
+ }
758
+
759
+ function listTopicHints(forum: ForumManager, limit = 12): string {
760
+ try {
761
+ const topics = forum.store.all().slice(0, limit);
762
+ if (topics.length === 0) return "";
763
+ return topics.map((t) => `«${t.name}» #${t.threadId}`).join(", ");
764
+ } catch {
765
+ return "";
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Resolve a topic ref: numeric / #id, "general", "ai chat", exact title,
771
+ * or fuzzy (prefix / contains / normalized) when unique.
772
+ */
773
+ export function resolveTopicRef(
774
+ forum: ForumManager,
775
+ ref: string,
776
+ cfg: AppConfig,
777
+ ): { ok: true; binding: ForumTopicBinding } | { ok: false; error: string } {
778
+ const raw = ref.trim();
779
+ if (!raw || isPlaceholderTopicRef(raw)) {
780
+ return {
781
+ ok: false,
782
+ error: `Empty or placeholder topic "${raw || "…"}". Use exact title, #threadId, "general", or "ai chat".`,
783
+ };
784
+ }
785
+
786
+ // #123 or plain digits
787
+ const idMatch = /^#?(\d+)$/.exec(raw);
788
+ if (idMatch) {
789
+ const threadId = Number(idMatch[1]);
790
+ const b = forum.store.get(threadId);
791
+ if (!b) {
792
+ // General (1) may not be in store yet — synthesize workspace bind.
793
+ if (threadId === FORUM_GENERAL_THREAD_ID) {
794
+ const bound = forum.store.bindProject(threadId, cfg.workspace, "General", "general");
795
+ return { ok: true, binding: bound };
796
+ }
797
+ return { ok: false, error: `No topic mapped for thread #${threadId}` };
798
+ }
799
+ return { ok: true, binding: b };
800
+ }
801
+
802
+ const key = raw.toLowerCase();
803
+ if (key === "general" || key === "general topic") {
804
+ let b = forum.store.get(FORUM_GENERAL_THREAD_ID);
805
+ if (!b?.projectPath) {
806
+ b = forum.store.bindProject(FORUM_GENERAL_THREAD_ID, cfg.workspace, "General", "general");
807
+ }
808
+ return { ok: true, binding: b };
809
+ }
810
+
811
+ const aiName = (cfg.topicAiChatName || "AI Chat").toLowerCase();
812
+ if (key === "ai chat" || key === "ai_chat" || key === aiName) {
813
+ const ai = forum.store.findAiChat();
814
+ if (ai) return { ok: true, binding: ai };
815
+ // Fallback: ensure workspace topic by name match
816
+ const byName = forum.store.all().find((t) => t.name.toLowerCase() === aiName);
817
+ if (byName) return { ok: true, binding: byName };
818
+ return { ok: false, error: "AI Chat topic not found — run /forum_setup" };
819
+ }
820
+
821
+ const all = forum.store.all();
822
+
823
+ // Exact title match (case-insensitive)
824
+ const hits = all.filter((t) => t.name.toLowerCase() === key);
825
+ if (hits.length === 1) return { ok: true, binding: hits[0]! };
826
+ if (hits.length > 1) {
827
+ return {
828
+ ok: false,
829
+ error: `Multiple topics named "${raw}" (${hits.map((h) => "#" + h.threadId).join(", ")}). Use #threadId.`,
830
+ };
831
+ }
832
+
833
+ // Fuzzy: starts-with / includes / normalized alphanumeric.
834
+ // Require at least 3 useful chars to avoid accidental matches.
835
+ const keyN = normalizeTopicKey(raw);
836
+ if (keyN.length >= 3 || key.length >= 3) {
837
+ const scored = all
838
+ .map((t) => {
839
+ const n = t.name.toLowerCase();
840
+ const nn = normalizeTopicKey(t.name);
841
+ let score = 0;
842
+ if (n === key || nn === keyN) score = 100;
843
+ else if (n.startsWith(key) || nn.startsWith(keyN)) score = 80;
844
+ else if (key.startsWith(n) && n.length >= 3) score = 70;
845
+ else if (n.includes(key) || (keyN.length >= 4 && nn.includes(keyN))) score = 50;
846
+ else if (keyN.length >= 4 && keyN.includes(nn) && nn.length >= 4) score = 40;
847
+ return { t, score };
848
+ })
849
+ .filter((x) => x.score > 0)
850
+ .sort((a, b) => b.score - a.score || a.t.name.length - b.t.name.length);
851
+
852
+ if (scored.length === 1 && scored[0]!.score >= 40) {
853
+ return { ok: true, binding: scored[0]!.t };
854
+ }
855
+ if (scored.length > 1) {
856
+ const best = scored[0]!.score;
857
+ const top = scored.filter((x) => x.score === best);
858
+ if (top.length === 1 && best >= 70) {
859
+ return { ok: true, binding: top[0]!.t };
860
+ }
861
+ return {
862
+ ok: false,
863
+ error:
864
+ `Ambiguous topic "${raw}" — matches: ` +
865
+ top
866
+ .slice(0, 6)
867
+ .map((x) => `«${x.t.name}» #${x.t.threadId}`)
868
+ .join(", ") +
869
+ ". Use exact title or #threadId.",
870
+ };
871
+ }
872
+ }
873
+
874
+ const hint = listTopicHints(forum);
875
+ return {
876
+ ok: false,
877
+ error:
878
+ `Topic not found: "${raw}". Use exact title, #threadId, "general", or "ai chat".` +
879
+ (hint ? ` Available: ${hint}` : ""),
880
+ };
881
+ }
882
+
883
+ function searchMemory(
884
+ action: Extract<TelegramAction, { action: "search_memory" }>,
885
+ ctx: TelegramActionContext,
886
+ ): TelegramActionResult {
887
+ const topics = ctx.forum?.isReady ? ctx.forum.store.all() : undefined;
888
+ const workspace =
889
+ topics?.find((t) => t.kind === "general")?.projectPath ||
890
+ topics?.find((t) => t.kind === "ai_chat")?.projectPath ||
891
+ ctx.cfg.workspace;
892
+ const preferPaths = [
893
+ workspace,
894
+ ...(topics ?? [])
895
+ .filter((t) => t.kind === "project" && t.projectPath)
896
+ .map((t) => t.projectPath!)
897
+ .slice(0, 20),
898
+ ];
899
+ const hits = searchGroupMemory({
900
+ query: action.query,
901
+ limit: action.limit ?? 14,
902
+ sessionsDir: ctx.cfg.sessionsDir,
903
+ store: ctx.store,
904
+ topics,
905
+ preferPaths,
906
+ preferGeneral: !!ctx.managerMode,
907
+ maxSessions: ctx.managerMode ? 80 : 50,
908
+ });
909
+ return {
910
+ action: "search_memory",
911
+ ok: true,
912
+ data: {
913
+ query: action.query,
914
+ hits,
915
+ note:
916
+ "Hits ranked by relevance + recency (newest sessions/history first). " +
917
+ "Snippets may include [age]. Prefer the newest session for a project path. " +
918
+ "When following up on a hit, pass its sessionId as send_prompt.session_id " +
919
+ "(full id or first 8 chars) so the bridge resumes that session, not the topic foreground. " +
920
+ (ctx.managerMode ? "Use before git." : ""),
921
+ },
922
+ // Silent in chat (manager or not) — results go to the agent.
923
+ userNote: undefined,
924
+ };
925
+ }
926
+
927
+ function listTopics(ctx: TelegramActionContext): TelegramActionResult {
928
+ const forum = ctx.forum;
929
+ if (!forum?.isReady) {
930
+ return {
931
+ action: "list_topics",
932
+ ok: true,
933
+ data: { topics: [], note: "Forum not ready" },
934
+ };
935
+ }
936
+ const topics = forum.store.all().map((t) => ({
937
+ threadId: t.threadId,
938
+ name: t.name,
939
+ kind: t.kind,
940
+ projectPath: t.projectPath,
941
+ sessionId: t.sessionId,
942
+ }));
943
+ return {
944
+ action: "list_topics",
945
+ ok: true,
946
+ data: { topics, count: topics.length },
947
+ };
948
+ }
949
+
950
+ function listJobs(): TelegramActionResult {
951
+ const jobs = listRecentManagerJobs(20).map((j) => ({
952
+ id: j.id,
953
+ status: j.status,
954
+ targetName: j.targetName,
955
+ targetThreadId: j.targetThreadId,
956
+ targetPath: j.targetPath,
957
+ childSessionId: j.childSessionId,
958
+ userAskPreview: j.userAskPreview.slice(0, 160),
959
+ createdAt: j.createdAt,
960
+ updatedAt: j.updatedAt,
961
+ resultSummary: j.resultSummary?.slice(0, 200),
962
+ }));
963
+ return {
964
+ action: "list_jobs",
965
+ ok: true,
966
+ data: { jobs, count: jobs.length },
967
+ };
968
+ }
969
+
970
+ async function listBots(ctx: TelegramActionContext): Promise<TelegramActionResult> {
971
+ if (ctx.cfg.allowedTelegramBots.length === 0) {
972
+ return {
973
+ action: "list_bots",
974
+ ok: true,
975
+ data: { bots: [], note: "ALLOWED_TELEGRAM_BOTS is empty" },
976
+ userNote: "\u{1F916} No sibling bots configured (ALLOWED_TELEGRAM_BOTS)",
977
+ };
978
+ }
979
+ const bots = await ctx.bots.listBots(true);
980
+ const catalog = Object.fromEntries(
981
+ bots.map((b) => [
982
+ b.username,
983
+ b.commands.map((c) =>
984
+ c.description ? `/${c.command} — ${c.description}` : `/${c.command}`,
985
+ ),
986
+ ]),
987
+ );
988
+ return {
989
+ action: "list_bots",
990
+ ok: true,
991
+ data: {
992
+ bots,
993
+ commands: catalog,
994
+ usage:
995
+ "Call bot_command with bot=username (no @), command without leading slash, optional args. " +
996
+ "Treat replies like MCP tool results. Unknown/timeout commands return ok=false — continue the session; do not treat them as Done.",
997
+ },
998
+ userNote: `\u{1F916} Sibling bots: ${bots
999
+ .map((b) => {
1000
+ const cmds = b.commands.length
1001
+ ? ` (${b.commands.map((c) => "/" + c.command).join(", ")})`
1002
+ : "";
1003
+ return "@" + b.username + cmds;
1004
+ })
1005
+ .join(", ") || "(none)"}`,
1006
+ };
1007
+ }
1008
+
1009
+ async function botCommand(
1010
+ action: Extract<TelegramAction, { action: "bot_command" }>,
1011
+ ctx: TelegramActionContext,
1012
+ ): Promise<TelegramActionResult> {
1013
+ // Prefer the configured forum group so sibling bots (group-scoped) see the command.
1014
+ const chatId = ctx.cfg.topicGroupId ?? ctx.chatId;
1015
+ const messageThreadId =
1016
+ ctx.cfg.topicGroupId !== undefined && chatId === ctx.cfg.topicGroupId
1017
+ ? ctx.messageThreadId
1018
+ : undefined;
1019
+
1020
+ const res = await ctx.bots.invokeCommand({
1021
+ bot: action.bot,
1022
+ command: action.command,
1023
+ args: action.args,
1024
+ chatId,
1025
+ messageThreadId,
1026
+ });
1027
+
1028
+ if (!res.ok) {
1029
+ // Timeout / dead bot / bad command: session continues via bridge results.
1030
+ // Never surface as a successful Done.
1031
+ const kind = res.kind;
1032
+ const icon = kind === "timeout" ? "\u23F1\uFE0F" : "\u26A0\uFE0F";
1033
+ return {
1034
+ action: "bot_command",
1035
+ ok: false,
1036
+ error: res.error,
1037
+ data: {
1038
+ bot: action.bot,
1039
+ command: action.command,
1040
+ args: action.args,
1041
+ kind,
1042
+ sessionContinues: true,
1043
+ partialReply: res.partialReply,
1044
+ },
1045
+ userNote: `${icon} Waiting for @${action.bot} /${action.command} failed: ${res.error} (session continues)`,
1046
+ };
1047
+ }
1048
+
1049
+ const partialNote = res.partial
1050
+ ? " \u2014 partial (hard timeout; stream may be incomplete)"
1051
+ : "";
1052
+ return {
1053
+ action: "bot_command",
1054
+ ok: true,
1055
+ data: {
1056
+ bot: action.bot,
1057
+ command: action.command,
1058
+ args: action.args,
1059
+ reply: res.reply,
1060
+ partial: res.partial,
1061
+ },
1062
+ userNote: `\u{1F916} @${action.bot} /${action.command} \u2014 reply ready (${res.reply.length} chars)${partialNote}`,
1063
+ };
1064
+ }
1065
+
1066
+ /** Telegram hard cap; leave headroom for UTF-16 / markup surprises. */
1067
+ const TG_MSG_SAFE = 4000;
1068
+
1069
+ /**
1070
+ * Split a bridge prompt into Telegram-safe announce parts (full coverage, no crop).
1071
+ * Exported for tests — agent still receives the unsplit string separately.
1072
+ */
1073
+ export function splitBridgeAnnounceParts(
1074
+ prompt: string,
1075
+ newSession: boolean,
1076
+ sessNote = "",
1077
+ ): string[] {
1078
+ const flag = newSession ? " (new session)" : "";
1079
+ const note = sessNote || "";
1080
+ const singleHeader = `\u{1F4E8} Prompt from bridge${flag}${note} (${prompt.length} chars)\n\n`;
1081
+ if (singleHeader.length + prompt.length <= TG_MSG_SAFE) {
1082
+ return [singleHeader + prompt];
1083
+ }
1084
+ // Conservative body size so part headers never push over TG_MSG_SAFE.
1085
+ const BODY = 3400;
1086
+ const total = Math.max(1, Math.ceil(prompt.length / BODY));
1087
+ const parts: string[] = [];
1088
+ for (let i = 0; i < total; i++) {
1089
+ const chunk = prompt.slice(i * BODY, (i + 1) * BODY);
1090
+ const prefix =
1091
+ i === 0
1092
+ ? `\u{1F4E8} Prompt from bridge${flag}${note} (part 1/${total}, ${prompt.length} chars)\n\n`
1093
+ : `\u{1F4E8} Prompt from bridge (part ${i + 1}/${total})\n\n`;
1094
+ const full = prefix + chunk;
1095
+ if (full.length <= TG_MSG_SAFE) {
1096
+ parts.push(full);
1097
+ } else {
1098
+ const room = Math.max(500, TG_MSG_SAFE - prefix.length);
1099
+ for (let o = 0; o < chunk.length; o += room) {
1100
+ parts.push(prefix + chunk.slice(o, o + room));
1101
+ }
1102
+ }
1103
+ }
1104
+ return parts;
1105
+ }
1106
+
1107
+ /**
1108
+ * Post the bridge prompt into the target topic for humans to read.
1109
+ * Splits across multiple Telegram messages (4096 hard limit) so long
1110
+ * orchestration prompts are not silently cropped in the topic UI.
1111
+ * The agent still receives the full unsplit string via submitTopicPrompt.
1112
+ */
1113
+ async function announceBridgePrompt(
1114
+ api: Api,
1115
+ chatId: number,
1116
+ threadId: number,
1117
+ prompt: string,
1118
+ newSession: boolean,
1119
+ sessNote = "",
1120
+ ): Promise<void> {
1121
+ const thread = outboundThreadExtra(threadId);
1122
+ const parts = splitBridgeAnnounceParts(prompt, newSession, sessNote);
1123
+ try {
1124
+ for (const part of parts) {
1125
+ await api.sendMessage(chatId, part, thread);
1126
+ }
1127
+ } catch (e) {
1128
+ log.debug(`send_prompt announce failed: ${(e as Error).message}`);
1129
+ }
1130
+ }