grok-telegram-bot 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -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 +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -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 +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -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 +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -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 +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -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/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -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 +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- 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
|
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
|
7
7
|
import type { RunningSession, SwitchResult } from "../chat-controller.js";
|
|
8
8
|
import type { BotDeps } from "../deps.js";
|
|
9
9
|
import type { HistoryEntry } from "../../sessions/types.js";
|
|
10
|
-
import { jsonlMtimeMs, readFirstPrompt,
|
|
10
|
+
import { jsonlMtimeMs, readFirstPrompt, readLastUserPrompt } from "../../sessions/history.js";
|
|
11
11
|
import { progressBar } from "../../render/progress.js";
|
|
12
12
|
import { refreshMenu } from "../menu/refresh.js";
|
|
13
13
|
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
@@ -51,47 +51,49 @@ function cleanPrompt(raw: string): string {
|
|
|
51
51
|
/** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
|
|
52
52
|
* session: Switch / History / Close.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Comment:
|
|
55
|
+
* always — last user prompt (≤250)
|
|
56
|
+
* busy — plus last AI agent thinking on the next line (≤250)
|
|
57
57
|
*/
|
|
58
58
|
function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
|
|
59
59
|
const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
|
|
60
60
|
const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
|
|
61
61
|
|
|
62
62
|
let when = "new";
|
|
63
|
-
let
|
|
63
|
+
let lastUser = "";
|
|
64
64
|
let firstPrompt = "";
|
|
65
65
|
if (s.sessionId) {
|
|
66
66
|
const path = deps.store.jsonlPath(s.sessionId);
|
|
67
67
|
const mtime = jsonlMtimeMs(path);
|
|
68
68
|
if (mtime) when = timeAgo(now - mtime);
|
|
69
|
-
|
|
70
|
-
historySummary = readLastCardSummary(path);
|
|
69
|
+
lastUser = readLastUserPrompt(path, 40, 250);
|
|
71
70
|
firstPrompt = cleanPrompt(readFirstPrompt(path));
|
|
72
71
|
if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
|
|
76
|
-
// Order: live runtime
|
|
75
|
+
// Order: live runtime (user + thinking) → last user from history → disk → first prompt.
|
|
77
76
|
const comment =
|
|
78
77
|
(s.comment && s.comment.trim()) ||
|
|
78
|
+
lastUser ||
|
|
79
79
|
diskComment ||
|
|
80
|
-
historySummary ||
|
|
81
80
|
firstPrompt;
|
|
82
81
|
|
|
83
82
|
const meta = [when, state];
|
|
84
83
|
if (s.busy) meta.push("\u23F3");
|
|
85
84
|
if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
|
|
86
85
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
?
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
const lines = [`${dot} ${s.projectName}`];
|
|
87
|
+
if (comment) {
|
|
88
|
+
const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
89
|
+
parts.forEach((part, i) => {
|
|
90
|
+
const icon = i === 0 ? (s.busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
|
|
91
|
+
lines.push(`${icon} ${trunc(part, 250)}`);
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
lines.push("\u{1F4AC} (no messages yet)");
|
|
95
|
+
}
|
|
96
|
+
lines.push(`\u{1F552} ${meta.join(" \u00B7 ")}`);
|
|
95
97
|
if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
|
|
96
98
|
if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
|
|
97
99
|
|
|
@@ -108,14 +110,55 @@ function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text
|
|
|
108
110
|
|
|
109
111
|
export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
|
|
110
112
|
await deps.ephemeral.open(ctx);
|
|
111
|
-
const
|
|
113
|
+
const { resolveScope } = await import("../scope.js");
|
|
114
|
+
const scope = resolveScope(ctx, deps);
|
|
115
|
+
// Topic: only this topic's controlled sessions. Private: this chat + same-project forum sessions.
|
|
116
|
+
let list = dedupeBySession(scope.controller.list());
|
|
117
|
+
if (!scope.isForum) {
|
|
118
|
+
const path = scope.rt.cwd;
|
|
119
|
+
for (const fc of deps.registry.allForumControllers()) {
|
|
120
|
+
if (fc.fixedCwd && samePath(fc.fixedCwd, path)) {
|
|
121
|
+
list = dedupeBySession([...list, ...fc.list().map((s) => ({ ...s, projectName: `${s.projectName} \u00B7 topic` }))]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Also surface private-bot sessions for the same project path.
|
|
126
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
127
|
+
if (chatId === scope.chatId) continue;
|
|
128
|
+
try {
|
|
129
|
+
const priv = deps.registry.controller(chatId);
|
|
130
|
+
for (const s of priv.list()) {
|
|
131
|
+
// Match by session cwd via store or name — use store meta if available.
|
|
132
|
+
if (s.sessionId) {
|
|
133
|
+
const meta = deps.store.get(s.sessionId);
|
|
134
|
+
if (meta?.cwd && samePath(meta.cwd, scope.rt.cwd)) {
|
|
135
|
+
list = dedupeBySession([
|
|
136
|
+
...list,
|
|
137
|
+
{ ...s, projectName: `${s.projectName} \u00B7 DM`, foreground: false },
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* skip */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
112
147
|
if (list.length === 0) {
|
|
113
|
-
await deps.ephemeral.reply(
|
|
148
|
+
await deps.ephemeral.reply(
|
|
149
|
+
ctx,
|
|
150
|
+
scope.isForum
|
|
151
|
+
? "No sessions in this topic yet. Send a message or tap \u{1F195} New."
|
|
152
|
+
: "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.",
|
|
153
|
+
);
|
|
114
154
|
return;
|
|
115
155
|
}
|
|
116
156
|
const now = Date.now();
|
|
117
157
|
const shown = list.slice(0, CARD_LIMIT);
|
|
118
|
-
|
|
158
|
+
const header = scope.isForum
|
|
159
|
+
? `\u{1F9ED} Running in topic **${scope.projectName ?? "topic"}** (${list.length})`
|
|
160
|
+
: `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`;
|
|
161
|
+
await deps.ephemeral.reply(ctx, header);
|
|
119
162
|
for (const s of shown) {
|
|
120
163
|
const { text, kb } = buildRunningCard(s, deps, now);
|
|
121
164
|
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
@@ -125,6 +168,11 @@ export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
|
|
|
125
168
|
}
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
function samePath(a: string, b: string): boolean {
|
|
172
|
+
return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() ===
|
|
173
|
+
b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
|
|
128
176
|
/** Collapse any cards that share a session id (defensive — the controller
|
|
129
177
|
* already prunes duplicate runtimes, but never show the same session twice). */
|
|
130
178
|
function dedupeBySession(list: RunningSession[]): RunningSession[] {
|
|
@@ -139,14 +187,76 @@ function dedupeBySession(list: RunningSession[]): RunningSession[] {
|
|
|
139
187
|
|
|
140
188
|
/** Switch the chat to a session and show its summary + unread. */
|
|
141
189
|
export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
|
|
142
|
-
const
|
|
190
|
+
const { resolveScope } = await import("../scope.js");
|
|
191
|
+
const scope = resolveScope(ctx, deps);
|
|
192
|
+
// Always bring the session into *this* scope's controller (never switch FG on
|
|
193
|
+
// another surface — that would dual-own one ACP session across controllers).
|
|
194
|
+
let res = await scope.controller.switchTo(sessionId);
|
|
195
|
+
if (!res) {
|
|
196
|
+
const meta = deps.store.get(sessionId);
|
|
197
|
+
let cwd = meta?.cwd;
|
|
198
|
+
let name = meta?.title || (cwd ? basenameSafe(cwd) : undefined);
|
|
199
|
+
if (!cwd) {
|
|
200
|
+
// Discover cwd from whatever controller currently lists it.
|
|
201
|
+
for (const c of [scope.controller, ...deps.registry.allForumControllers()]) {
|
|
202
|
+
const hit = c.list().find((s) => s.sessionId === sessionId);
|
|
203
|
+
if (hit) {
|
|
204
|
+
cwd = (c as { fixedCwd?: string }).fixedCwd || scope.rt.cwd;
|
|
205
|
+
name = hit.projectName || name;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (cwd) {
|
|
211
|
+
// Topic controllers are fixed-path — refuse foreign projects.
|
|
212
|
+
if (scope.controller.fixedCwd && !samePath(scope.controller.fixedCwd, cwd)) {
|
|
213
|
+
await ctx.reply(
|
|
214
|
+
"That session belongs to a different project than this topic.",
|
|
215
|
+
scope.threadExtra,
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Drop dual ownership: release from any other controller first.
|
|
220
|
+
await releaseSessionElsewhere(deps, scope.controller, sessionId);
|
|
221
|
+
const hist = (await import("../../sessions/history.js")).readHistory(
|
|
222
|
+
deps.store.jsonlPath(sessionId),
|
|
223
|
+
);
|
|
224
|
+
await scope.controller.addAttach(sessionId, cwd, name || basenameSafe(cwd), hist);
|
|
225
|
+
res = await scope.controller.switchTo(sessionId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
143
228
|
if (!res) {
|
|
144
|
-
await ctx.reply("Session not found (it may have been closed).");
|
|
229
|
+
await ctx.reply("Session not found (it may have been closed).", scope.threadExtra);
|
|
145
230
|
return;
|
|
146
231
|
}
|
|
147
232
|
await deliverSwitch(ctx, deps, res);
|
|
148
233
|
}
|
|
149
234
|
|
|
235
|
+
/** Stop controlling a session on every controller except `keep`. */
|
|
236
|
+
async function releaseSessionElsewhere(
|
|
237
|
+
deps: BotDeps,
|
|
238
|
+
keep: import("../chat-controller.js").ChatController,
|
|
239
|
+
sessionId: string,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
for (const c of deps.registry.allForumControllers()) {
|
|
242
|
+
if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
|
|
243
|
+
}
|
|
244
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
245
|
+
try {
|
|
246
|
+
const c = deps.registry.controller(chatId);
|
|
247
|
+
if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
|
|
248
|
+
} catch {
|
|
249
|
+
/* skip */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function basenameSafe(p: string): string {
|
|
255
|
+
const n = p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
256
|
+
const i = n.lastIndexOf("/");
|
|
257
|
+
return i >= 0 ? n.slice(i + 1) : n;
|
|
258
|
+
}
|
|
259
|
+
|
|
150
260
|
export function registerRunning(bot: Bot, deps: BotDeps): void {
|
|
151
261
|
bot.command("running", (ctx) => showRunning(ctx, deps));
|
|
152
262
|
|
|
@@ -160,8 +270,24 @@ export function registerRunning(bot: Bot, deps: BotDeps): void {
|
|
|
160
270
|
|
|
161
271
|
bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
|
|
162
272
|
const id = ctx.match![1]!;
|
|
163
|
-
await
|
|
164
|
-
|
|
273
|
+
const { resolveScope } = await import("../scope.js");
|
|
274
|
+
const scope = resolveScope(ctx, deps);
|
|
275
|
+
let closed = await scope.controller.close(id);
|
|
276
|
+
// Card may show a session owned by the other surface — close on owner.
|
|
277
|
+
if (!closed) {
|
|
278
|
+
const fc = deps.registry.forumControllerForSession(id);
|
|
279
|
+
if (fc) closed = await fc.close(id);
|
|
280
|
+
}
|
|
281
|
+
if (!closed) {
|
|
282
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
283
|
+
const c = deps.registry.controller(chatId);
|
|
284
|
+
if (c.findBySession(id)) {
|
|
285
|
+
closed = await c.close(id);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await ctx.answerCallbackQuery({ text: closed ? "Closed" : "Not found" });
|
|
165
291
|
await ctx.deleteMessage().catch(() => {}); // remove just this card
|
|
166
292
|
});
|
|
167
293
|
}
|
|
@@ -24,8 +24,8 @@ export interface SessionCardExtras {
|
|
|
24
24
|
/** Latest task-completion % (0–100) for this session, if this chat runs it. */
|
|
25
25
|
progress?: number;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* `m.comment` when provided by the controlling chat runtime.
|
|
27
|
+
* Last user prompt (and, when busy, last AI thinking on a second line).
|
|
28
|
+
* Overrides `m.comment` when provided by the controlling chat runtime.
|
|
29
29
|
*/
|
|
30
30
|
comment?: string;
|
|
31
31
|
}
|
|
@@ -35,6 +35,8 @@ export interface SessionCard {
|
|
|
35
35
|
keyboard: InlineKeyboard;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const COMMENT_LINE_MAX = 250;
|
|
39
|
+
|
|
38
40
|
/** Build the card body + buttons for one session. */
|
|
39
41
|
export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}): SessionCard {
|
|
40
42
|
const dot = m.active ? "\u{1F7E2}" : "\u26AA";
|
|
@@ -44,10 +46,16 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
|
|
|
44
46
|
|
|
45
47
|
const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
|
|
46
48
|
if (m.cwd) lines.push(` ${m.cwd}`);
|
|
47
|
-
//
|
|
49
|
+
// Last user prompt always; second line = thinking while running.
|
|
48
50
|
if (comment) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
+
const busy = m.active || typeof extra.progress === "number";
|
|
52
|
+
const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
53
|
+
parts.forEach((part, i) => {
|
|
54
|
+
const clipped = part.length > COMMENT_LINE_MAX ? part.slice(0, COMMENT_LINE_MAX - 1) + "\u2026" : part;
|
|
55
|
+
// First line: user prompt; later lines (thinking): hourglass when busy.
|
|
56
|
+
const icon = i === 0 ? (busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
|
|
57
|
+
lines.push(`${icon} ${clipped}`);
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
|
|
53
61
|
const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
11
11
|
import { basename } from "node:path";
|
|
12
12
|
import type { BotDeps } from "../deps.js";
|
|
13
|
-
import { readHistory,
|
|
13
|
+
import { readHistory, readLastUserPrompt } from "../../sessions/history.js";
|
|
14
14
|
import type { SessionMeta } from "../../sessions/types.js";
|
|
15
15
|
import { refreshMenu } from "../menu/refresh.js";
|
|
16
16
|
import { showHistory } from "./history.js";
|
|
@@ -22,16 +22,35 @@ const UUID = "([0-9a-fA-F-]{36})";
|
|
|
22
22
|
|
|
23
23
|
export async function showSessions(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
|
|
24
24
|
const q = (query ?? "").trim().toLowerCase();
|
|
25
|
+
const { resolveScope } = await import("../scope.js");
|
|
26
|
+
const scope = resolveScope(ctx, deps);
|
|
25
27
|
let metas = deps.store.list(q ? 400 : 200);
|
|
28
|
+
// In a forum topic, only show sessions for this project path (includes bot DMs).
|
|
29
|
+
if (scope.isForum && scope.projectPath) {
|
|
30
|
+
const want = scope.projectPath.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
31
|
+
metas = metas.filter((m) => (m.cwd || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === want);
|
|
32
|
+
}
|
|
26
33
|
if (q) {
|
|
27
34
|
metas = metas.filter((m) => `${m.title} ${m.cwd} ${m.sessionId}`.toLowerCase().includes(q));
|
|
28
35
|
}
|
|
29
36
|
if (metas.length === 0) {
|
|
30
37
|
await deps.ephemeral.open(ctx);
|
|
31
|
-
await deps.ephemeral.reply(
|
|
38
|
+
await deps.ephemeral.reply(
|
|
39
|
+
ctx,
|
|
40
|
+
q
|
|
41
|
+
? `No sessions match "${q}".`
|
|
42
|
+
: scope.isForum
|
|
43
|
+
? `No saved sessions for project **${scope.projectName}** yet.`
|
|
44
|
+
: "No saved sessions found in ~/.grok/sessions/cli.",
|
|
45
|
+
);
|
|
32
46
|
return;
|
|
33
47
|
}
|
|
34
|
-
|
|
48
|
+
const heading = scope.isForum
|
|
49
|
+
? `Sessions \u00B7 ${scope.projectName ?? "topic"}`
|
|
50
|
+
: q
|
|
51
|
+
? `Sessions matching "${q}"`
|
|
52
|
+
: "Recent sessions";
|
|
53
|
+
deps.menuCache.setSessions(ctx.chat!.id, metas, heading);
|
|
35
54
|
await renderSessionPage(ctx, deps, 0);
|
|
36
55
|
}
|
|
37
56
|
|
|
@@ -50,15 +69,20 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
|
|
|
50
69
|
const pageStr = totalPages > 1 ? ` \u00B7 page ${p + 1}/${totalPages}` : "";
|
|
51
70
|
await deps.ephemeral.reply(ctx, `\u{1F5C2} ${heading} \u2014 ${metas.length} total${liveStr}${pageStr}`);
|
|
52
71
|
|
|
72
|
+
const { resolveScope } = await import("../scope.js");
|
|
73
|
+
const scope = resolveScope(ctx, deps);
|
|
53
74
|
for (const m of slice) {
|
|
54
75
|
const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
|
|
55
|
-
const ctrl =
|
|
56
|
-
const progress =
|
|
57
|
-
|
|
76
|
+
const ctrl = scope.controller;
|
|
77
|
+
const progress =
|
|
78
|
+
ctrl.progressFor(m.sessionId) ??
|
|
79
|
+
deps.registry.forumControllerForSession(m.sessionId)?.progressFor(m.sessionId);
|
|
80
|
+
// Live runtime (user + thinking) → last user from history → disk comment.
|
|
58
81
|
const comment =
|
|
59
82
|
ctrl.commentFor(m.sessionId) ||
|
|
60
|
-
m.
|
|
61
|
-
|
|
83
|
+
deps.registry.forumControllerForSession(m.sessionId)?.commentFor(m.sessionId) ||
|
|
84
|
+
readLastUserPrompt(deps.store.jsonlPath(m.sessionId)) ||
|
|
85
|
+
m.comment;
|
|
62
86
|
const { text, keyboard } = buildSessionCard(m, {
|
|
63
87
|
contextPct,
|
|
64
88
|
selfPid: deps.acp.pid,
|
|
@@ -97,8 +121,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
|
|
|
97
121
|
});
|
|
98
122
|
|
|
99
123
|
bot.command("unwatch", async (ctx) => {
|
|
100
|
-
const
|
|
101
|
-
|
|
124
|
+
const { resolveScope } = await import("../scope.js");
|
|
125
|
+
const scope = resolveScope(ctx, deps);
|
|
126
|
+
await ctx.reply(
|
|
127
|
+
scope.rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.",
|
|
128
|
+
scope.threadExtra,
|
|
129
|
+
);
|
|
102
130
|
});
|
|
103
131
|
|
|
104
132
|
bot.callbackQuery(new RegExp(`^sess:${UUID}$`), async (ctx) => {
|
|
@@ -110,19 +138,39 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
|
|
|
110
138
|
}
|
|
111
139
|
await ctx.answerCallbackQuery();
|
|
112
140
|
await deps.ephemeral.clear(ctx.chat!.id); // remove the session cards
|
|
113
|
-
const
|
|
141
|
+
const { resolveScope } = await import("../scope.js");
|
|
142
|
+
const scope = resolveScope(ctx, deps);
|
|
143
|
+
const fgCwd = scope.rt.cwd;
|
|
114
144
|
const cwd = meta.cwd || fgCwd;
|
|
115
145
|
const projectName = basename(meta.cwd || fgCwd) || "session";
|
|
146
|
+
if (scope.controller.fixedCwd) {
|
|
147
|
+
const a = scope.controller.fixedCwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
148
|
+
const b = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
149
|
+
if (a !== b) {
|
|
150
|
+
await ctx.reply("That session is for a different project than this topic.", scope.threadExtra);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
116
154
|
const prior = readHistory(deps.store.jsonlPath(id), 24);
|
|
117
155
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.
|
|
121
|
-
|
|
156
|
+
// Avoid dual ownership across DM ↔ topic controllers.
|
|
157
|
+
for (const c of deps.registry.allForumControllers()) {
|
|
158
|
+
if (c !== scope.controller && c.findBySession(id)) await c.close(id);
|
|
159
|
+
}
|
|
160
|
+
const { result, alreadyControlled } = await scope.controller.addAttach(
|
|
161
|
+
id,
|
|
162
|
+
cwd,
|
|
163
|
+
projectName,
|
|
164
|
+
prior,
|
|
165
|
+
);
|
|
166
|
+
await ctx.reply(
|
|
167
|
+
alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta),
|
|
168
|
+
scope.threadExtra,
|
|
169
|
+
);
|
|
122
170
|
await refreshMenu(ctx, deps, `\u{1F4C2} ${meta.title}`);
|
|
123
171
|
await showHistory(deps, ctx.chat!.id, id, meta);
|
|
124
172
|
} catch (err) {
|
|
125
|
-
await ctx.reply(`\u274C Could not connect: ${(err as Error).message}
|
|
173
|
+
await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`, scope.threadExtra);
|
|
126
174
|
}
|
|
127
175
|
});
|
|
128
176
|
|
|
@@ -137,10 +185,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
|
|
|
137
185
|
const id = ctx.match![1]!;
|
|
138
186
|
await ctx.answerCallbackQuery();
|
|
139
187
|
const meta = deps.store.get(id);
|
|
140
|
-
const
|
|
141
|
-
|
|
188
|
+
const { resolveScope } = await import("../scope.js");
|
|
189
|
+
const scope = resolveScope(ctx, deps);
|
|
190
|
+
scope.rt.startWatch(deps.store.jsonlPath(id));
|
|
142
191
|
await ctx.reply(
|
|
143
192
|
`\u{1F4E1} Watching live: ${meta?.title ?? id.slice(0, 8)}\nNew activity streams here. Send /unwatch to stop.`,
|
|
193
|
+
scope.threadExtra,
|
|
144
194
|
);
|
|
145
195
|
});
|
|
146
196
|
}
|
|
@@ -14,8 +14,16 @@ import { extractReplyContext } from "../reply-context.js";
|
|
|
14
14
|
|
|
15
15
|
const log = createLogger("voice");
|
|
16
16
|
|
|
17
|
+
type VoiceMediaKind = "voice" | "audio" | "video_note";
|
|
18
|
+
|
|
17
19
|
export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
18
|
-
const handle = async (
|
|
20
|
+
const handle = async (
|
|
21
|
+
ctx: Context,
|
|
22
|
+
fileId: string,
|
|
23
|
+
mime: string,
|
|
24
|
+
name: string,
|
|
25
|
+
mediaKind: VoiceMediaKind,
|
|
26
|
+
): Promise<void> => {
|
|
19
27
|
const chatId = ctx.chat!.id;
|
|
20
28
|
if (deps.wizard.isActive(chatId)) {
|
|
21
29
|
await ctx.reply("Finish or /cancel the task wizard before sending voice.");
|
|
@@ -36,11 +44,41 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
36
44
|
await ctx.reply("\u{1F399} I couldn't make out any speech.");
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
39
|
-
|
|
40
|
-
const
|
|
47
|
+
const { resolveScope } = await import("../scope.js");
|
|
48
|
+
const { adoptUserPrompt } = await import("../prompt-anchor.js");
|
|
49
|
+
const scope = resolveScope(ctx, deps);
|
|
41
50
|
const quoted = extractReplyContext(ctx);
|
|
42
|
-
const
|
|
43
|
-
|
|
51
|
+
const userMsgId = ctx.message?.message_id;
|
|
52
|
+
const media =
|
|
53
|
+
mediaKind === "video_note"
|
|
54
|
+
? [{ type: "video_note" as const, fileId }]
|
|
55
|
+
: mediaKind === "voice"
|
|
56
|
+
? [{ type: "voice" as const, fileId }]
|
|
57
|
+
: [{ type: "audio" as const, fileId, fileName: name }];
|
|
58
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
59
|
+
chatId,
|
|
60
|
+
text: `\u201C${text}\u201D`,
|
|
61
|
+
userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
|
|
62
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
63
|
+
projectName: scope.rt.projectName,
|
|
64
|
+
prefix: "\u{1F399} Voice",
|
|
65
|
+
media,
|
|
66
|
+
});
|
|
67
|
+
const outcome = await scope.rt.submit(
|
|
68
|
+
textPrompt(text, anchor?.replyTo ?? userMsgId, quoted, {
|
|
69
|
+
promptId: anchor?.promptId,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
if (outcome === "queued") {
|
|
73
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
74
|
+
if (anchor?.replyTo !== undefined) {
|
|
75
|
+
extra.reply_parameters = {
|
|
76
|
+
message_id: anchor.replyTo,
|
|
77
|
+
allow_sending_without_reply: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.", extra);
|
|
81
|
+
}
|
|
44
82
|
} catch (e) {
|
|
45
83
|
log.warn("voice failed:", (e as Error).message);
|
|
46
84
|
await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
|
|
@@ -48,7 +86,13 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
48
86
|
};
|
|
49
87
|
|
|
50
88
|
bot.on("message:voice", (ctx) =>
|
|
51
|
-
handle(
|
|
89
|
+
handle(
|
|
90
|
+
ctx,
|
|
91
|
+
ctx.message.voice.file_id,
|
|
92
|
+
ctx.message.voice.mime_type || "audio/ogg",
|
|
93
|
+
"voice.ogg",
|
|
94
|
+
"voice",
|
|
95
|
+
),
|
|
52
96
|
);
|
|
53
97
|
bot.on("message:audio", (ctx) =>
|
|
54
98
|
handle(
|
|
@@ -56,10 +100,11 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
56
100
|
ctx.message.audio.file_id,
|
|
57
101
|
ctx.message.audio.mime_type || "audio/mpeg",
|
|
58
102
|
ctx.message.audio.file_name || "audio.mp3",
|
|
103
|
+
"audio",
|
|
59
104
|
),
|
|
60
105
|
);
|
|
61
106
|
bot.on("message:video_note", (ctx) =>
|
|
62
|
-
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
|
|
107
|
+
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4", "video_note"),
|
|
63
108
|
);
|
|
64
109
|
}
|
|
65
110
|
|
package/src/bot/image-return.ts
CHANGED
|
@@ -129,6 +129,8 @@ export interface SendImagesOptions {
|
|
|
129
129
|
max: number;
|
|
130
130
|
/** Optional Telegram message id to thread replies under. */
|
|
131
131
|
replyTo?: number;
|
|
132
|
+
/** Forum topic — required so agent images land in the right topic. */
|
|
133
|
+
messageThreadId?: number;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
|
|
@@ -139,10 +141,14 @@ export async function sendImages(
|
|
|
139
141
|
opts: SendImagesOptions,
|
|
140
142
|
): Promise<number> {
|
|
141
143
|
let sent = 0;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const extra: Record<string, unknown> = {};
|
|
145
|
+
if (opts.replyTo !== undefined) {
|
|
146
|
+
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
147
|
+
}
|
|
148
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
149
|
+
if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
|
|
150
|
+
extra.message_thread_id = opts.messageThreadId;
|
|
151
|
+
}
|
|
146
152
|
for (const path of paths) {
|
|
147
153
|
if (sent >= opts.max) break;
|
|
148
154
|
if (opts.already.has(path)) continue;
|
|
@@ -161,7 +167,7 @@ export async function sendImages(
|
|
|
161
167
|
const file = new InputFile(path, basename(path));
|
|
162
168
|
await api.sendDocument(chatId, file, {
|
|
163
169
|
caption: basename(path),
|
|
164
|
-
...
|
|
170
|
+
...extra,
|
|
165
171
|
});
|
|
166
172
|
sent++;
|
|
167
173
|
log.debug(`sent document ${path}`);
|