grok-telegram-bot 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/CHANGELOG.md +106 -0
- package/README.md +20 -5
- package/docs/GROUP.md +39 -4
- package/docs/INSTALL.md +2 -0
- package/package.json +4 -4
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +34 -1
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +105 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -1
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/image-return.ts +4 -1
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +4 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +665 -55
- package/src/bot/telegram-actions.ts +728 -38
- package/src/bot/telegram-bots.ts +2 -1
- package/src/bot/telegram-io.ts +4 -1
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -0
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +10 -0
- package/src/render/telegram-bridge.ts +118 -14
- package/src/service/linux.ts +21 -15
- package/src/service/macos.ts +20 -15
- package/src/service/platform.ts +12 -3
- package/src/service/types.ts +6 -0
- package/src/service/windows.ts +31 -22
- package/src/sessions/history.ts +18 -0
- package/src/stream/streamer.ts +46 -10
|
@@ -6,10 +6,21 @@ import type { Api } from "grammy";
|
|
|
6
6
|
import type { AppConfig } from "../config.js";
|
|
7
7
|
import type { ForumManager } from "../forum/manager.js";
|
|
8
8
|
import type { ForumTopicBinding } from "../forum/types.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FORUM_GENERAL_THREAD_ID,
|
|
11
|
+
isGeneralThread,
|
|
12
|
+
outboundThreadExtra,
|
|
13
|
+
} from "../forum/thread.js";
|
|
10
14
|
import type { SessionStore } from "../sessions/store.js";
|
|
11
15
|
import { createLogger } from "../logger.js";
|
|
12
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";
|
|
13
24
|
import type { TelegramBotService } from "./telegram-bots.js";
|
|
14
25
|
import type { TelegramAction } from "../render/telegram-bridge.js";
|
|
15
26
|
|
|
@@ -22,6 +33,13 @@ export type SubmitTopicPromptFn = (opts: {
|
|
|
22
33
|
projectName: string;
|
|
23
34
|
prompt: string;
|
|
24
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;
|
|
25
43
|
}) => Promise<{ outcome: "ran" | "queued"; sessionId?: string }>;
|
|
26
44
|
|
|
27
45
|
export interface TelegramActionContext {
|
|
@@ -29,11 +47,20 @@ export interface TelegramActionContext {
|
|
|
29
47
|
cfg: AppConfig;
|
|
30
48
|
chatId: number;
|
|
31
49
|
messageThreadId?: number;
|
|
50
|
+
/** Reply-to for notify (usually the user's message that started the turn). */
|
|
51
|
+
replyToMessageId?: number;
|
|
32
52
|
forum?: ForumManager;
|
|
33
53
|
store: SessionStore;
|
|
34
54
|
bots: TelegramBotService;
|
|
35
55
|
/** Dispatch a prompt into another forum topic's session. */
|
|
36
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;
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
export interface TelegramActionResult {
|
|
@@ -75,8 +102,14 @@ async function runOne(
|
|
|
75
102
|
return setPath(action, ctx);
|
|
76
103
|
case "send_prompt":
|
|
77
104
|
return sendPrompt(action, ctx);
|
|
105
|
+
case "notify":
|
|
106
|
+
return notifyUser(action, ctx);
|
|
78
107
|
case "search_memory":
|
|
79
108
|
return searchMemory(action, ctx);
|
|
109
|
+
case "list_topics":
|
|
110
|
+
return listTopics(ctx);
|
|
111
|
+
case "list_jobs":
|
|
112
|
+
return listJobs();
|
|
80
113
|
case "list_bots":
|
|
81
114
|
return listBots(ctx);
|
|
82
115
|
case "bot_command":
|
|
@@ -113,9 +146,11 @@ async function createTopic(
|
|
|
113
146
|
projectPath: b.projectPath,
|
|
114
147
|
kind: b.kind,
|
|
115
148
|
},
|
|
116
|
-
userNote:
|
|
117
|
-
?
|
|
118
|
-
:
|
|
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)`,
|
|
119
154
|
};
|
|
120
155
|
}
|
|
121
156
|
|
|
@@ -168,10 +203,57 @@ function setPath(
|
|
|
168
203
|
kind: b.kind,
|
|
169
204
|
created: !!result.created,
|
|
170
205
|
},
|
|
171
|
-
userNote:
|
|
206
|
+
userNote: ctx.managerMode
|
|
207
|
+
? undefined
|
|
208
|
+
: `\u{1F4C1} Topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\`${createdNote}`,
|
|
172
209
|
};
|
|
173
210
|
}
|
|
174
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
|
+
|
|
175
257
|
async function sendPrompt(
|
|
176
258
|
action: Extract<TelegramAction, { action: "send_prompt" }>,
|
|
177
259
|
ctx: TelegramActionContext,
|
|
@@ -192,12 +274,102 @@ async function sendPrompt(
|
|
|
192
274
|
};
|
|
193
275
|
}
|
|
194
276
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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;
|
|
198
294
|
}
|
|
199
|
-
|
|
200
|
-
|
|
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;
|
|
201
373
|
if (!cwd) {
|
|
202
374
|
return {
|
|
203
375
|
action: "send_prompt",
|
|
@@ -205,6 +377,16 @@ async function sendPrompt(
|
|
|
205
377
|
error: `Topic **${binding.name}** (#${binding.threadId}) has no project path — use set_path or create_topic with path first`,
|
|
206
378
|
};
|
|
207
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
|
+
}
|
|
208
390
|
|
|
209
391
|
const projectName =
|
|
210
392
|
binding.kind === "ai_chat"
|
|
@@ -213,28 +395,57 @@ async function sendPrompt(
|
|
|
213
395
|
? "General"
|
|
214
396
|
: binding.name;
|
|
215
397
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
435
|
|
|
230
436
|
try {
|
|
231
437
|
const res = await ctx.submitTopicPrompt({
|
|
232
438
|
threadId: binding.threadId,
|
|
233
|
-
cwd,
|
|
439
|
+
cwd: resumeCwd,
|
|
234
440
|
projectName,
|
|
235
441
|
prompt: action.prompt,
|
|
236
|
-
newSession: action.newSession,
|
|
442
|
+
newSession: !!action.newSession && !resumeSessionId,
|
|
443
|
+
sessionId: resumeSessionId,
|
|
444
|
+
reportBack,
|
|
237
445
|
});
|
|
446
|
+
if (reportBack && res.sessionId) {
|
|
447
|
+
bindJobSession(reportBack.jobId, res.sessionId);
|
|
448
|
+
}
|
|
238
449
|
return {
|
|
239
450
|
action: "send_prompt",
|
|
240
451
|
ok: true,
|
|
@@ -244,23 +455,320 @@ async function sendPrompt(
|
|
|
244
455
|
projectPath: cwd,
|
|
245
456
|
outcome: res.outcome,
|
|
246
457
|
sessionId: res.sessionId,
|
|
247
|
-
|
|
458
|
+
resumedSessionId: resumeSessionId,
|
|
459
|
+
inferredFromMemory: inferredFromMemory || undefined,
|
|
460
|
+
newSession: !!action.newSession && !resumeSessionId,
|
|
248
461
|
promptPreview: action.prompt.slice(0, 200),
|
|
462
|
+
jobId: reportBack?.jobId,
|
|
463
|
+
reportBack: !!reportBack,
|
|
249
464
|
},
|
|
250
|
-
|
|
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)}` : ""),
|
|
251
470
|
};
|
|
252
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
|
+
}
|
|
253
478
|
return {
|
|
254
479
|
action: "send_prompt",
|
|
255
480
|
ok: false,
|
|
256
481
|
error: (e as Error).message ?? String(e),
|
|
257
|
-
data: {
|
|
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,
|
|
258
651
|
};
|
|
259
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
|
+
}
|
|
260
767
|
}
|
|
261
768
|
|
|
262
769
|
/**
|
|
263
|
-
* Resolve a topic ref: numeric / #id, "general", "ai chat",
|
|
770
|
+
* Resolve a topic ref: numeric / #id, "general", "ai chat", exact title,
|
|
771
|
+
* or fuzzy (prefix / contains / normalized) when unique.
|
|
264
772
|
*/
|
|
265
773
|
export function resolveTopicRef(
|
|
266
774
|
forum: ForumManager,
|
|
@@ -268,7 +776,12 @@ export function resolveTopicRef(
|
|
|
268
776
|
cfg: AppConfig,
|
|
269
777
|
): { ok: true; binding: ForumTopicBinding } | { ok: false; error: string } {
|
|
270
778
|
const raw = ref.trim();
|
|
271
|
-
if (!raw)
|
|
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
|
+
}
|
|
272
785
|
|
|
273
786
|
// #123 or plain digits
|
|
274
787
|
const idMatch = /^#?(\d+)$/.exec(raw);
|
|
@@ -305,8 +818,10 @@ export function resolveTopicRef(
|
|
|
305
818
|
return { ok: false, error: "AI Chat topic not found — run /forum_setup" };
|
|
306
819
|
}
|
|
307
820
|
|
|
821
|
+
const all = forum.store.all();
|
|
822
|
+
|
|
308
823
|
// Exact title match (case-insensitive)
|
|
309
|
-
const hits =
|
|
824
|
+
const hits = all.filter((t) => t.name.toLowerCase() === key);
|
|
310
825
|
if (hits.length === 1) return { ok: true, binding: hits[0]! };
|
|
311
826
|
if (hits.length > 1) {
|
|
312
827
|
return {
|
|
@@ -314,9 +829,54 @@ export function resolveTopicRef(
|
|
|
314
829
|
error: `Multiple topics named "${raw}" (${hits.map((h) => "#" + h.threadId).join(", ")}). Use #threadId.`,
|
|
315
830
|
};
|
|
316
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);
|
|
317
875
|
return {
|
|
318
876
|
ok: false,
|
|
319
|
-
error:
|
|
877
|
+
error:
|
|
878
|
+
`Topic not found: "${raw}". Use exact title, #threadId, "general", or "ai chat".` +
|
|
879
|
+
(hint ? ` Available: ${hint}` : ""),
|
|
320
880
|
};
|
|
321
881
|
}
|
|
322
882
|
|
|
@@ -325,21 +885,85 @@ function searchMemory(
|
|
|
325
885
|
ctx: TelegramActionContext,
|
|
326
886
|
): TelegramActionResult {
|
|
327
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
|
+
];
|
|
328
899
|
const hits = searchGroupMemory({
|
|
329
900
|
query: action.query,
|
|
330
|
-
limit: action.limit,
|
|
901
|
+
limit: action.limit ?? 14,
|
|
331
902
|
sessionsDir: ctx.cfg.sessionsDir,
|
|
332
903
|
store: ctx.store,
|
|
333
904
|
topics,
|
|
905
|
+
preferPaths,
|
|
906
|
+
preferGeneral: !!ctx.managerMode,
|
|
907
|
+
maxSessions: ctx.managerMode ? 80 : 50,
|
|
334
908
|
});
|
|
335
909
|
return {
|
|
336
910
|
action: "search_memory",
|
|
337
911
|
ok: true,
|
|
338
|
-
data: {
|
|
339
|
-
|
|
340
|
-
hits
|
|
341
|
-
|
|
342
|
-
|
|
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 },
|
|
343
967
|
};
|
|
344
968
|
}
|
|
345
969
|
|
|
@@ -438,3 +1062,69 @@ async function botCommand(
|
|
|
438
1062
|
userNote: `\u{1F916} @${action.bot} /${action.command} \u2014 reply ready (${res.reply.length} chars)${partialNote}`,
|
|
439
1063
|
};
|
|
440
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
|
+
}
|