grok-telegram-bot 2.4.0 → 2.5.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 +38 -2
- package/CHANGELOG.md +119 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +12 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +122 -15
- package/src/bot/chat-controller.ts +52 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +101 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +3 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-runtime.ts +647 -158
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/session-comment.ts +64 -7
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +50 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +17 -6
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -23,10 +23,7 @@ export interface RunningSession {
|
|
|
23
23
|
unread: number;
|
|
24
24
|
/** Latest task-completion % (0–100) for this session, if known. */
|
|
25
25
|
progress?: number;
|
|
26
|
-
/**
|
|
27
|
-
* Card comment: live step while working, chat summary when idle
|
|
28
|
-
* ("what is happening / what was done").
|
|
29
|
-
*/
|
|
26
|
+
/** Card comment: last user prompt (+ last AI thinking while busy). */
|
|
30
27
|
comment?: string;
|
|
31
28
|
}
|
|
32
29
|
|
|
@@ -40,11 +37,31 @@ export interface SwitchResult {
|
|
|
40
37
|
alreadyForeground: boolean;
|
|
41
38
|
}
|
|
42
39
|
|
|
40
|
+
export interface ChatControllerOpts {
|
|
41
|
+
/** Forum topic thread — all runtimes post into this topic. */
|
|
42
|
+
messageThreadId?: number;
|
|
43
|
+
/** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
|
|
44
|
+
settingsKey?: string;
|
|
45
|
+
/** Fixed project path (forum topics): never switch away via project picker. */
|
|
46
|
+
fixedCwd?: string;
|
|
47
|
+
fixedProjectName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Optional Telegram bridge services attached to every runtime in this chat. */
|
|
51
|
+
export type ChatBridgeServices = SessionRuntime["bridge"];
|
|
52
|
+
|
|
43
53
|
export class ChatController {
|
|
44
54
|
private readonly runtimes: SessionRuntime[] = [];
|
|
45
55
|
private fg: SessionRuntime | undefined;
|
|
46
56
|
private readonly lastRead = new Map<string, number>();
|
|
47
57
|
private restored = false;
|
|
58
|
+
readonly settingsKey: string;
|
|
59
|
+
readonly messageThreadId: number | undefined;
|
|
60
|
+
readonly fixedCwd: string | undefined;
|
|
61
|
+
readonly fixedProjectName: string | undefined;
|
|
62
|
+
|
|
63
|
+
/** Telegram bridge (forum / memory / sibling bots); set by the registry. */
|
|
64
|
+
bridge?: ChatBridgeServices;
|
|
48
65
|
|
|
49
66
|
constructor(
|
|
50
67
|
private readonly api: Api,
|
|
@@ -56,14 +73,22 @@ export class ChatController {
|
|
|
56
73
|
private readonly refresh: (chatId: number) => void,
|
|
57
74
|
private readonly notifyActivity: (busy: boolean) => void,
|
|
58
75
|
private readonly getRotator?: () => AccountRotator | undefined,
|
|
59
|
-
|
|
76
|
+
opts?: ChatControllerOpts,
|
|
77
|
+
) {
|
|
78
|
+
this.messageThreadId = opts?.messageThreadId;
|
|
79
|
+
this.settingsKey = opts?.settingsKey ?? String(chatId);
|
|
80
|
+
this.fixedCwd = opts?.fixedCwd;
|
|
81
|
+
this.fixedProjectName = opts?.fixedProjectName;
|
|
82
|
+
}
|
|
60
83
|
|
|
61
84
|
/** The current foreground runtime (created/restored lazily). */
|
|
62
85
|
foreground(): SessionRuntime {
|
|
63
86
|
this.ensureRestored();
|
|
64
87
|
if (!this.fg) {
|
|
65
|
-
const s = this.settings.
|
|
66
|
-
const
|
|
88
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
89
|
+
const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
|
|
90
|
+
const name = this.fixedProjectName ?? s.projectName;
|
|
91
|
+
const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
|
|
67
92
|
this.runtimes.push(rt);
|
|
68
93
|
this.fg = rt;
|
|
69
94
|
}
|
|
@@ -279,7 +304,7 @@ export class ChatController {
|
|
|
279
304
|
return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
|
|
280
305
|
}
|
|
281
306
|
|
|
282
|
-
/**
|
|
307
|
+
/** Last user prompt (+ thinking when busy) for a controlled session id. */
|
|
283
308
|
commentFor(sessionId?: string): string | undefined {
|
|
284
309
|
if (!sessionId) return undefined;
|
|
285
310
|
this.ensureRestored();
|
|
@@ -301,27 +326,31 @@ export class ChatController {
|
|
|
301
326
|
private ensureRestored(): void {
|
|
302
327
|
if (this.restored) return;
|
|
303
328
|
this.restored = true;
|
|
304
|
-
const s = this.settings.
|
|
329
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
305
330
|
const seen = new Set<string>();
|
|
306
331
|
for (const cs of s.controlledSessions ?? []) {
|
|
307
332
|
if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
|
|
308
333
|
seen.add(cs.sessionId);
|
|
334
|
+
// Forum topics only restore sessions for the fixed project path.
|
|
335
|
+
if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
|
|
309
336
|
this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
|
|
310
337
|
}
|
|
311
338
|
// Lazy project switches persist projectPath without a sessionId. If the
|
|
312
339
|
// saved project is not among controlled sessions, recreate an unbound FG
|
|
313
340
|
// so a restart lands on the project the user last chose.
|
|
314
|
-
|
|
315
|
-
|
|
341
|
+
const homePath = this.fixedCwd ?? s.projectPath;
|
|
342
|
+
const homeName = this.fixedProjectName ?? s.projectName;
|
|
343
|
+
if (homePath) {
|
|
344
|
+
const key = normPath(homePath);
|
|
316
345
|
const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
|
|
317
346
|
if (!hasProject) {
|
|
318
|
-
this.runtimes.push(this.create({ cwd:
|
|
347
|
+
this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
|
|
319
348
|
}
|
|
320
349
|
}
|
|
321
350
|
if (this.runtimes.length > 0) {
|
|
322
351
|
let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
|
|
323
|
-
if (!fg &&
|
|
324
|
-
const key = normPath(
|
|
352
|
+
if (!fg && homePath) {
|
|
353
|
+
const key = normPath(homePath);
|
|
325
354
|
fg = this.runtimes.find((r) => normPath(r.cwd) === key);
|
|
326
355
|
}
|
|
327
356
|
fg = fg ?? this.runtimes[0]!;
|
|
@@ -365,10 +394,15 @@ export class ChatController {
|
|
|
365
394
|
}
|
|
366
395
|
|
|
367
396
|
private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
|
|
368
|
-
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings,
|
|
397
|
+
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
|
|
398
|
+
...init,
|
|
399
|
+
messageThreadId: this.messageThreadId,
|
|
400
|
+
settingsKey: this.settingsKey,
|
|
401
|
+
});
|
|
369
402
|
rt.onStateChange = () => this.refresh(this.chatId);
|
|
370
403
|
rt.onActivity = (busy) => this.notifyActivity(busy);
|
|
371
404
|
rt.accountRotator = this.getRotator?.();
|
|
405
|
+
rt.bridge = this.bridge;
|
|
372
406
|
// A logical fork (auto-fork-on-error / lost-session recovery) swaps the
|
|
373
407
|
// runtime's session id in place — re-persist the controlled list with the
|
|
374
408
|
// new id and treat the fresh session as already-seen.
|
|
@@ -415,15 +449,15 @@ export class ChatController {
|
|
|
415
449
|
seen.add(r.sessionId);
|
|
416
450
|
controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
|
|
417
451
|
}
|
|
418
|
-
this.settings.
|
|
452
|
+
this.settings.updateKey(this.settingsKey, {
|
|
419
453
|
controlledSessions: controlled,
|
|
420
454
|
foregroundSessionId: this.fg?.sessionId,
|
|
421
455
|
// Keep the single-session restore fields aligned with the foreground so
|
|
422
456
|
// the pinned status panel and a fresh restore never show a project that
|
|
423
457
|
// belongs to a different (previously-foreground) session.
|
|
424
458
|
sessionId: this.fg?.sessionId,
|
|
425
|
-
projectPath: this.fg?.cwd,
|
|
426
|
-
projectName: this.fg?.projectName,
|
|
459
|
+
projectPath: this.fixedCwd ?? this.fg?.cwd,
|
|
460
|
+
projectName: this.fixedProjectName ?? this.fg?.projectName,
|
|
427
461
|
});
|
|
428
462
|
}
|
|
429
463
|
}
|
package/src/bot/commands.ts
CHANGED
|
@@ -1,31 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bot command definitions (for the Telegram command menu) and help text.
|
|
3
|
+
*
|
|
4
|
+
* Private chats get the full list (sorted by workflow). Groups/forum topics get
|
|
5
|
+
* a shorter list with cancel/menu first — reply keyboards are unreliable there.
|
|
3
6
|
*/
|
|
7
|
+
|
|
8
|
+
/** Full command menu (private chats + default). Order = Telegram "/" menu order. */
|
|
4
9
|
export const COMMANDS: { command: string; description: string }[] = [
|
|
10
|
+
// Core control
|
|
5
11
|
{ command: "start", description: "Welcome, menu & status panel" },
|
|
6
|
-
{ command: "menu", description: "
|
|
7
|
-
{ command: "
|
|
8
|
-
{ command: "
|
|
12
|
+
{ command: "menu", description: "Open the menu" },
|
|
13
|
+
{ command: "cancel", description: "Stop the current turn" },
|
|
14
|
+
{ command: "stop", description: "Stop the current turn (alias of /cancel)" },
|
|
15
|
+
{ command: "status", description: "Current session, project & queue" },
|
|
16
|
+
{ command: "new", description: "New session in current project" },
|
|
17
|
+
// Sessions
|
|
18
|
+
{ command: "running", description: "Sessions this chat controls" },
|
|
19
|
+
{ command: "sessions", description: "List/resume sessions · /sessions <q>" },
|
|
9
20
|
{ command: "active", description: "Sessions running now on the PC" },
|
|
10
|
-
{ command: "running", description: "Sessions this chat controls \u2014 switch between them" },
|
|
11
|
-
{ command: "import", description: "Import a Kiro/OpenCode/Claude/Codex /running session" },
|
|
12
|
-
{ command: "killall", description: "Kill all active sessions on the PC" },
|
|
13
|
-
{ command: "mcp", description: "Inspect & toggle MCP servers \u00b7 health-check" },
|
|
14
|
-
{ command: "tasks", description: "Manage scheduled tasks" },
|
|
15
|
-
{ command: "newtask", description: "Create a scheduled task" },
|
|
16
21
|
{ command: "history", description: "Show recent conversation history" },
|
|
17
|
-
{ command: "
|
|
18
|
-
|
|
19
|
-
{ command: "
|
|
20
|
-
|
|
22
|
+
{ command: "import", description: "Import Kiro/OpenCode/Claude/Codex session" },
|
|
23
|
+
// Project
|
|
24
|
+
{ command: "projects", description: "Projects: list / search / open / new" },
|
|
25
|
+
// Queue
|
|
26
|
+
{ command: "btw", description: "Run ASAP: /btw <text>" },
|
|
21
27
|
{ command: "flush", description: "Send queued follow-ups now" },
|
|
22
28
|
{ command: "queue", description: "Show queued follow-ups" },
|
|
23
|
-
|
|
24
|
-
{ command: "
|
|
29
|
+
// Account
|
|
30
|
+
{ command: "accounts", description: "Switch between saved Grok accounts" },
|
|
31
|
+
{ command: "reauth", description: "Sign in to Grok (login or import)" },
|
|
32
|
+
{ command: "usage", description: "Account & context usage" },
|
|
33
|
+
// System / tools
|
|
34
|
+
{ command: "mcp", description: "Inspect & toggle MCP servers" },
|
|
35
|
+
{ command: "tasks", description: "Manage scheduled tasks" },
|
|
36
|
+
{ command: "newtask", description: "Create a scheduled task" },
|
|
37
|
+
{ command: "killall", description: "Kill all active sessions on the PC" },
|
|
25
38
|
{ command: "model", description: "Switch model: /model <id>" },
|
|
26
39
|
{ command: "restart", description: "Restart the Grok agent" },
|
|
27
|
-
{ command: "
|
|
28
|
-
{ command: "
|
|
40
|
+
{ command: "unwatch", description: "Stop following a live session" },
|
|
41
|
+
{ command: "help", description: "Show help" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Group / forum command menu — keep short; cancel & menu first so topics can
|
|
46
|
+
* stop a turn without the private reply-keyboard bar.
|
|
47
|
+
*/
|
|
48
|
+
export const GROUP_COMMANDS: { command: string; description: string }[] = [
|
|
49
|
+
{ command: "cancel", description: "Stop the current turn" },
|
|
50
|
+
{ command: "stop", description: "Stop the current turn" },
|
|
51
|
+
{ command: "menu", description: "Open topic / group menu" },
|
|
52
|
+
{ command: "status", description: "Session, project & queue" },
|
|
53
|
+
{ command: "new", description: "New session in this topic/project" },
|
|
54
|
+
{ command: "running", description: "Sessions this topic controls" },
|
|
55
|
+
{ command: "sessions", description: "List / resume sessions" },
|
|
56
|
+
{ command: "btw", description: "Queue or run: /btw <text>" },
|
|
57
|
+
{ command: "flush", description: "Run queued follow-ups now" },
|
|
58
|
+
{ command: "model", description: "Switch model: /model <id>" },
|
|
29
59
|
{ command: "help", description: "Show help" },
|
|
30
60
|
];
|
|
31
61
|
|
|
@@ -37,18 +67,28 @@ export const HELP_TEXT = [
|
|
|
37
67
|
"\u2022 Just send a message to chat with Grok in the current project.",
|
|
38
68
|
"\u2022 While Grok is working, anything you send is queued and runs",
|
|
39
69
|
" automatically when the current turn finishes.",
|
|
70
|
+
"\u2022 Persistent bar (private): \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop.",
|
|
71
|
+
"\u2022 New session: /new or the bar button (not the inline Menu message).",
|
|
72
|
+
"\u2022 In groups / forum topics: use /cancel or Menu \u2192 \u23F9 Stop",
|
|
73
|
+
" (the persistent bar is unreliable in topics; use /new or topic menu).",
|
|
74
|
+
"",
|
|
75
|
+
"COMMANDS (core)",
|
|
76
|
+
"/menu \u2014 open the menu",
|
|
77
|
+
"/cancel or /stop \u2014 stop the current turn",
|
|
78
|
+
"/status \u2014 session, project and queue",
|
|
79
|
+
"/new \u2014 new session in the current project (also bar: \u{1F195} New session)",
|
|
80
|
+
"",
|
|
81
|
+
"COMMANDS (sessions)",
|
|
82
|
+
"/running \u2014 sessions this chat/topic controls",
|
|
83
|
+
"/sessions \u2014 resume a recent Grok session",
|
|
84
|
+
"/active \u2014 attach to a session running on the PC",
|
|
85
|
+
"/history \u2014 latest messages of the current session",
|
|
86
|
+
"/import \u2014 import a /running session from another agent",
|
|
40
87
|
"",
|
|
41
|
-
"COMMANDS",
|
|
42
|
-
"/projects \u2014 choose which folder Grok works in",
|
|
43
|
-
"/
|
|
44
|
-
"/active \u2014 attach to a session currently running on the PC",
|
|
45
|
-
"/import \u2014 import a /running session from Kiro, OpenCode, Claude, or Codex",
|
|
46
|
-
"/history \u2014 show the latest messages of the current session",
|
|
47
|
-
"/new \u2014 start a brand-new session in the current project",
|
|
48
|
-
"/btw <text> \u2014 run it now if idle, otherwise right after the current task",
|
|
88
|
+
"COMMANDS (more)",
|
|
89
|
+
"/projects \u2014 choose which folder Grok works in (private chats)",
|
|
90
|
+
"/btw <text> \u2014 run now if idle, else right after the current task",
|
|
49
91
|
"/flush \u2014 run queued follow-ups immediately",
|
|
50
|
-
"/
|
|
51
|
-
"/
|
|
52
|
-
"/reauth \u2014 sign in to Grok (grok login, or import an existing login)",
|
|
53
|
-
"/accounts \u2014 switch between saved Grok accounts",
|
|
92
|
+
"/reauth \u2014 sign in to Grok",
|
|
93
|
+
"/accounts \u2014 switch saved Grok accounts",
|
|
54
94
|
].join("\n");
|
package/src/bot/deps.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { TaskRunner } from "../tasks/runner.js";
|
|
|
18
18
|
import type { TaskStore } from "../tasks/store.js";
|
|
19
19
|
import type { StatusPanel } from "./menu/status-panel.js";
|
|
20
20
|
import type { Ephemeral } from "./menu/ephemeral.js";
|
|
21
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
21
22
|
import type { RuntimeRegistry } from "./registry.js";
|
|
22
23
|
import type { TaskWizard } from "./wizard/task-wizard.js";
|
|
23
24
|
|
|
@@ -38,6 +39,8 @@ export interface BotDeps {
|
|
|
38
39
|
stt: SttService;
|
|
39
40
|
usage: UsageService;
|
|
40
41
|
accounts: AccountManager;
|
|
42
|
+
/** Present when TOPIC_GROUP_ID is configured. */
|
|
43
|
+
forum?: ForumManager;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** Caches the last project list shown per chat for callback resolution. */
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local "group memory" search across forum topics, session metadata, and
|
|
3
|
+
* recent session history. Telegram bots have no general message-search API,
|
|
4
|
+
* so this indexes what the bridge already stores on disk.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readHistory } from "../sessions/history.js";
|
|
8
|
+
import type { SessionMeta } from "../sessions/types.js";
|
|
9
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
10
|
+
import type { ForumTopicBinding } from "../forum/types.js";
|
|
11
|
+
|
|
12
|
+
export type MemoryHitKind = "topic" | "session" | "history";
|
|
13
|
+
|
|
14
|
+
export interface MemoryHit {
|
|
15
|
+
kind: MemoryHitKind;
|
|
16
|
+
title: string;
|
|
17
|
+
snippet: string;
|
|
18
|
+
score: number;
|
|
19
|
+
path?: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
threadId?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GroupMemorySearchOpts {
|
|
25
|
+
query: string;
|
|
26
|
+
limit?: number;
|
|
27
|
+
sessionsDir: string;
|
|
28
|
+
store: SessionStore;
|
|
29
|
+
topics?: ForumTopicBinding[];
|
|
30
|
+
/** Max sessions whose JSONL tails are scanned. */
|
|
31
|
+
maxSessions?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tokenize a query into lowercase alphanumeric tokens (min length 2). */
|
|
35
|
+
export function tokenizeQuery(query: string): string[] {
|
|
36
|
+
return query
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.split(/[^a-z0-9_./\\-]+/i)
|
|
39
|
+
.map((t) => t.trim())
|
|
40
|
+
.filter((t) => t.length >= 2)
|
|
41
|
+
.slice(0, 12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Score how well `haystack` matches tokens (0 if no token hits). */
|
|
45
|
+
export function scoreTokens(haystack: string, tokens: string[]): number {
|
|
46
|
+
if (!tokens.length || !haystack) return 0;
|
|
47
|
+
const h = haystack.toLowerCase();
|
|
48
|
+
let score = 0;
|
|
49
|
+
let hits = 0;
|
|
50
|
+
for (const t of tokens) {
|
|
51
|
+
if (!h.includes(t)) continue;
|
|
52
|
+
hits++;
|
|
53
|
+
// Prefer whole-word-ish hits slightly.
|
|
54
|
+
const re = new RegExp(`(?:^|[^a-z0-9])${escapeReg(t)}(?:[^a-z0-9]|$)`, "i");
|
|
55
|
+
score += re.test(h) ? 3 : 1;
|
|
56
|
+
// Density bonus for short fields.
|
|
57
|
+
if (h.length < 120) score += 1;
|
|
58
|
+
}
|
|
59
|
+
if (hits === 0) return 0;
|
|
60
|
+
// All-token bonus.
|
|
61
|
+
if (hits === tokens.length) score += 4;
|
|
62
|
+
return score;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Search topics + session store + recent history. Pure ranking over provided
|
|
67
|
+
* store; safe to call on the main bot thread (capped I/O).
|
|
68
|
+
*/
|
|
69
|
+
export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
|
|
70
|
+
const tokens = tokenizeQuery(opts.query);
|
|
71
|
+
if (tokens.length === 0) return [];
|
|
72
|
+
const limit = Math.max(1, Math.min(20, opts.limit ?? 8));
|
|
73
|
+
const maxSessions = opts.maxSessions ?? 40;
|
|
74
|
+
const hits: MemoryHit[] = [];
|
|
75
|
+
|
|
76
|
+
for (const t of opts.topics ?? []) {
|
|
77
|
+
const hay = [t.name, t.projectPath ?? "", t.kind, t.sessionId ?? ""].join("\n");
|
|
78
|
+
const score = scoreTokens(hay, tokens);
|
|
79
|
+
if (score <= 0) continue;
|
|
80
|
+
hits.push({
|
|
81
|
+
kind: "topic",
|
|
82
|
+
title: t.name,
|
|
83
|
+
snippet: t.projectPath ? `path: ${t.projectPath}` : `kind: ${t.kind}`,
|
|
84
|
+
score: score + 2, // slight boost for topic map
|
|
85
|
+
path: t.projectPath ?? undefined,
|
|
86
|
+
threadId: t.threadId,
|
|
87
|
+
sessionId: t.sessionId,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let metas: SessionMeta[] = [];
|
|
92
|
+
try {
|
|
93
|
+
metas = opts.store.list(maxSessions);
|
|
94
|
+
} catch {
|
|
95
|
+
metas = [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const m of metas) {
|
|
99
|
+
const hay = [m.title, m.comment ?? "", m.cwd, m.sessionId].join("\n");
|
|
100
|
+
const score = scoreTokens(hay, tokens);
|
|
101
|
+
if (score > 0) {
|
|
102
|
+
hits.push({
|
|
103
|
+
kind: "session",
|
|
104
|
+
title: m.title || m.sessionId.slice(0, 8),
|
|
105
|
+
snippet: clamp(
|
|
106
|
+
[m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
|
|
107
|
+
220,
|
|
108
|
+
),
|
|
109
|
+
score,
|
|
110
|
+
path: m.cwd || undefined,
|
|
111
|
+
sessionId: m.sessionId,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// History tail (cheap): few entries, short text.
|
|
116
|
+
if (m.historyBytes <= 0) continue;
|
|
117
|
+
try {
|
|
118
|
+
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
119
|
+
const entries = readHistory(path, 12);
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (!e.text?.trim()) continue;
|
|
122
|
+
const s = scoreTokens(e.text, tokens);
|
|
123
|
+
if (s <= 0) continue;
|
|
124
|
+
hits.push({
|
|
125
|
+
kind: "history",
|
|
126
|
+
title: `${e.role} · ${(m.title || m.sessionId.slice(0, 8)).slice(0, 40)}`,
|
|
127
|
+
snippet: clamp(e.text.replace(/\s+/g, " ").trim(), 220),
|
|
128
|
+
score: s,
|
|
129
|
+
path: m.cwd || undefined,
|
|
130
|
+
sessionId: m.sessionId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
/* ignore unreadable logs */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
hits.sort((a, b) => b.score - a.score || a.kind.localeCompare(b.kind));
|
|
139
|
+
// Dedupe near-identical snippets.
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const out: MemoryHit[] = [];
|
|
142
|
+
for (const h of hits) {
|
|
143
|
+
const key = `${h.kind}|${h.sessionId ?? h.threadId ?? ""}|${h.snippet.slice(0, 80)}`;
|
|
144
|
+
if (seen.has(key)) continue;
|
|
145
|
+
seen.add(key);
|
|
146
|
+
out.push(h);
|
|
147
|
+
if (out.length >= limit) break;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function clamp(s: string, max: number): string {
|
|
153
|
+
if (s.length <= max) return s;
|
|
154
|
+
return s.slice(0, max - 1) + "\u2026";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeReg(s: string): string {
|
|
158
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
|
+
}
|
|
@@ -294,6 +294,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
|
|
|
294
294
|
await deps.acp.start(true).catch(() => {});
|
|
295
295
|
throw e;
|
|
296
296
|
}
|
|
297
|
+
// Remember preferred account for this chat/topic scope (model/reasoning peers).
|
|
298
|
+
try {
|
|
299
|
+
const { resolveScope } = await import("../scope.js");
|
|
300
|
+
resolveScope(ctx, deps).rt.setPreferredAccountId(id);
|
|
301
|
+
} catch {
|
|
302
|
+
/* non-fatal */
|
|
303
|
+
}
|
|
297
304
|
const note = (await deps.usage.isLoggedIn())
|
|
298
305
|
? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
|
|
299
306
|
: `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;
|
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Control commands: /start /help /status /new /cancel /btw /flush.
|
|
2
|
+
* Control commands: /start /help /status /new /cancel /stop /btw /flush /menu.
|
|
3
|
+
*
|
|
4
|
+
* Slash messages are deleted instantly by bot.ts middleware; handlers post
|
|
5
|
+
* bot status messages so the user always sees the bot is alive (CLI can be slow).
|
|
3
6
|
*/
|
|
4
|
-
import type { Bot } from "grammy";
|
|
7
|
+
import type { Bot, Context } from "grammy";
|
|
5
8
|
import { basename } from "node:path";
|
|
6
9
|
import { textPrompt } from "../../app/types.js";
|
|
7
10
|
import type { BotDeps } from "../deps.js";
|
|
8
11
|
import { HELP_TEXT } from "../commands.js";
|
|
9
12
|
import { compactKeyboard } from "../menu/keyboard.js";
|
|
10
13
|
import { refreshMenu } from "../menu/refresh.js";
|
|
14
|
+
import { adoptUserPrompt } from "../prompt-anchor.js";
|
|
11
15
|
import { extractReplyContext } from "../reply-context.js";
|
|
16
|
+
import { resolveScope } from "../scope.js";
|
|
12
17
|
import { openMainMenu } from "./menu.js";
|
|
13
18
|
|
|
14
19
|
export function registerControl(bot: Bot, deps: BotDeps): void {
|
|
15
20
|
bot.command("start", async (ctx) => {
|
|
16
|
-
const
|
|
21
|
+
const scope = resolveScope(ctx, deps);
|
|
17
22
|
const agent = deps.acp.agentInfo;
|
|
23
|
+
const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
|
|
18
24
|
const lines = [
|
|
19
25
|
"\u{1F44B} Welcome! I bridge Telegram to Grok Build over ACP.",
|
|
20
26
|
agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
|
|
21
27
|
"",
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
isGroup || scope.isForum
|
|
29
|
+
? "In groups / topics: /menu for controls, /cancel or /stop to halt a turn."
|
|
30
|
+
: "Bar: \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop. Live status panel while I work",
|
|
31
|
+
isGroup || scope.isForum
|
|
32
|
+
? "Just send a message in this topic to start."
|
|
33
|
+
: "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
|
|
24
34
|
].filter(Boolean);
|
|
25
|
-
await ctx.reply(lines.join("\n"), {
|
|
35
|
+
await ctx.reply(lines.join("\n"), {
|
|
36
|
+
reply_markup: compactKeyboard(),
|
|
37
|
+
...scope.threadExtra,
|
|
38
|
+
});
|
|
26
39
|
await deps.statusPanel.refresh(ctx.chat.id);
|
|
27
40
|
});
|
|
28
41
|
|
|
@@ -32,71 +45,111 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
|
|
|
32
45
|
});
|
|
33
46
|
|
|
34
47
|
bot.command("help", async (ctx) => {
|
|
35
|
-
|
|
48
|
+
const scope = resolveScope(ctx, deps);
|
|
49
|
+
await ctx.reply(HELP_TEXT, scope.threadExtra);
|
|
36
50
|
});
|
|
37
51
|
|
|
38
52
|
bot.command("status", async (ctx) => {
|
|
39
|
-
const
|
|
53
|
+
const scope = resolveScope(ctx, deps);
|
|
54
|
+
const rt = scope.rt;
|
|
40
55
|
const lines = [
|
|
41
56
|
"\u{1F4CA} Status",
|
|
57
|
+
scope.isForum ? `Topic: ${scope.projectName ?? "topic"}` : "",
|
|
42
58
|
`Project: ${rt.projectName ?? (basename(rt.cwd) || rt.cwd)}`,
|
|
43
59
|
`Folder: ${rt.cwd}`,
|
|
44
60
|
`Session: ${rt.sessionId ?? "(none yet)"}`,
|
|
61
|
+
`Model: ${rt.model || "default"}`,
|
|
62
|
+
`Reasoning: ${rt.reasoning}`,
|
|
45
63
|
`State: ${rt.isBusy ? "\u23F3 working" : "\u2705 idle"}`,
|
|
46
64
|
`Queued follow-ups: ${rt.queueLength}`,
|
|
47
|
-
];
|
|
65
|
+
].filter(Boolean);
|
|
48
66
|
const subagents = deps.registry.subagentSummaryForChat(ctx.chat.id);
|
|
49
67
|
if (subagents) lines.push(`Subagents: ${subagents}`);
|
|
50
|
-
await ctx.reply(lines.join("\n"));
|
|
68
|
+
await ctx.reply(lines.join("\n"), scope.threadExtra);
|
|
51
69
|
});
|
|
52
70
|
|
|
53
71
|
bot.command("new", async (ctx) => {
|
|
54
|
-
const
|
|
72
|
+
const scope = resolveScope(ctx, deps);
|
|
73
|
+
// Instant feedback before ACP session/new (can be slow on cold CLI).
|
|
74
|
+
// Same copy as bar 🆕 New and inline m:new.
|
|
75
|
+
await ctx.reply("\u2728 Creating new session\u2026", scope.threadExtra).catch(() => {});
|
|
55
76
|
try {
|
|
56
|
-
await
|
|
57
|
-
await refreshMenu(ctx, deps, `\u2728 New session
|
|
77
|
+
await scope.controller.addNew(scope.rt.cwd, scope.rt.projectName);
|
|
78
|
+
await refreshMenu(ctx, deps, `\u2728 New session in ${scope.rt.projectName ?? scope.rt.cwd}`);
|
|
58
79
|
} catch (err) {
|
|
59
|
-
await ctx.reply(`\u274C
|
|
80
|
+
await ctx.reply(`\u274C ${(err as Error).message}`, scope.threadExtra);
|
|
60
81
|
}
|
|
61
82
|
});
|
|
62
83
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const cancelled = await rt.cancel();
|
|
66
|
-
await ctx.reply(
|
|
67
|
-
|
|
84
|
+
const cancelTurn = async (ctx: Context): Promise<void> => {
|
|
85
|
+
const scope = resolveScope(ctx, deps);
|
|
86
|
+
const cancelled = await scope.rt.cancel();
|
|
87
|
+
await ctx.reply(
|
|
88
|
+
cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
|
|
89
|
+
scope.threadExtra,
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
bot.command("cancel", cancelTurn);
|
|
94
|
+
bot.command("stop", cancelTurn);
|
|
68
95
|
|
|
69
96
|
bot.command("btw", async (ctx) => {
|
|
70
97
|
const text = (ctx.match || "").toString().trim();
|
|
71
98
|
if (!text) {
|
|
72
|
-
|
|
99
|
+
const scope = resolveScope(ctx, deps);
|
|
100
|
+
await ctx.reply(
|
|
101
|
+
"Usage: /btw <something for the agent to do — now if idle, otherwise next>",
|
|
102
|
+
scope.threadExtra,
|
|
103
|
+
);
|
|
73
104
|
return;
|
|
74
105
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
const scope = resolveScope(ctx, deps);
|
|
107
|
+
const userMsgId = ctx.message?.message_id;
|
|
108
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
109
|
+
chatId: ctx.chat.id,
|
|
110
|
+
text,
|
|
111
|
+
// Command middleware already deleted the slash message; re-delete is a no-op.
|
|
112
|
+
userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
|
|
113
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
114
|
+
projectName: scope.rt.projectName,
|
|
115
|
+
prefix: "\u{1F4DD} /btw",
|
|
116
|
+
});
|
|
117
|
+
// Fall back to userMsgId if anchor send failed (message may already be gone).
|
|
118
|
+
const outcome = await scope.rt.submit(
|
|
119
|
+
textPrompt(text, anchor?.replyTo ?? userMsgId, extractReplyContext(ctx), {
|
|
120
|
+
promptId: anchor?.promptId,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
79
123
|
if (outcome === "queued") {
|
|
124
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
125
|
+
if (anchor?.replyTo !== undefined) {
|
|
126
|
+
extra.reply_parameters = {
|
|
127
|
+
message_id: anchor.replyTo,
|
|
128
|
+
allow_sending_without_reply: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
80
131
|
await ctx.reply(
|
|
81
|
-
`\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
|
|
132
|
+
`\u{1F4E5} Queued (position ${scope.rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
|
|
133
|
+
extra,
|
|
82
134
|
);
|
|
83
|
-
} else {
|
|
84
|
-
await ctx.reply("\u25B6\uFE0F On it\u2026");
|
|
85
135
|
}
|
|
86
136
|
});
|
|
87
137
|
|
|
88
138
|
bot.command("flush", async (ctx) => {
|
|
89
|
-
const
|
|
139
|
+
const scope = resolveScope(ctx, deps);
|
|
140
|
+
const rt = scope.rt;
|
|
90
141
|
if (rt.queueLength === 0) {
|
|
91
|
-
await ctx.reply("Queue is empty.");
|
|
142
|
+
await ctx.reply("Queue is empty.", scope.threadExtra);
|
|
92
143
|
return;
|
|
93
144
|
}
|
|
94
145
|
if (rt.isBusy) {
|
|
95
|
-
await ctx.reply(
|
|
146
|
+
await ctx.reply(
|
|
147
|
+
`\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`,
|
|
148
|
+
scope.threadExtra,
|
|
149
|
+
);
|
|
96
150
|
return;
|
|
97
151
|
}
|
|
98
|
-
|
|
99
|
-
await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026");
|
|
152
|
+
await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026", scope.threadExtra);
|
|
100
153
|
const drained = rt.drainQueueToPrompt();
|
|
101
154
|
if (drained) await rt.submit(drained);
|
|
102
155
|
});
|