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.
Files changed (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. 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 { FORUM_GENERAL_THREAD_ID } from "../forum/thread.js";
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: b.projectPath
117
- ? `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\``
118
- : `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) (unbound — use set_path)`,
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: `\u{1F4C1} Topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\`${createdNote}`,
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
- const resolved = resolveTopicRef(forum, action.topic, ctx.cfg);
196
- if (!resolved.ok) {
197
- return { action: "send_prompt", ok: false, error: resolved.error };
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
- const { binding } = resolved;
200
- const cwd = binding.projectPath;
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
- // Visible injection in the *target* topic so humans see the orchestrated prompt.
217
- // Plain text (no Markdown) so agent prompts with * _ ` cannot break the send.
218
- const preview =
219
- action.prompt.length > 1500 ? action.prompt.slice(0, 1499) + "\u2026" : action.prompt;
220
- await ctx.api
221
- .sendMessage(
222
- forum.groupId,
223
- `\u{1F4E8} Prompt from bridge${action.newSession ? " (new session)" : ""}\n\n${preview}`,
224
- { message_thread_id: binding.threadId },
225
- )
226
- .catch((e) => {
227
- log.debug(`send_prompt announce failed: ${(e as Error).message}`);
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
- newSession: !!action.newSession,
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
- userNote: `\u{1F4E8} Prompt ${res.outcome} in **${binding.name}** (#${binding.threadId})`,
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: { threadId: binding.threadId, name: binding.name, projectPath: cwd },
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", or exact title.
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) return { ok: false, error: "Empty topic reference" };
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 = forum.store.all().filter((t) => t.name.toLowerCase() === key);
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: `Topic not found: "${raw}". Use exact title, #threadId, "general", or "ai chat".`,
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: { query: action.query, hits },
339
- userNote:
340
- hits.length > 0
341
- ? `\u{1F50D} Memory search: ${hits.length} hit(s) for \u201c${action.query.slice(0, 60)}\u201d`
342
- : `\u{1F50D} Memory search: no hits for \u201c${action.query.slice(0, 60)}\u201d`,
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
+ }