grok-telegram-bot 2.2.3 → 2.3.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 +7 -4
- package/CHANGELOG.md +51 -0
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/app/accounts.ts +35 -0
- package/src/app/stt.ts +5 -2
- package/src/app/types.ts +16 -2
- package/src/bot/account-rotator.ts +22 -1
- package/src/bot/bot.ts +10 -0
- package/src/bot/callback.ts +74 -0
- package/src/bot/chat-controller.ts +90 -8
- package/src/bot/handlers/accounts.ts +15 -4
- package/src/bot/handlers/menu.ts +22 -5
- package/src/bot/handlers/projects.ts +11 -5
- package/src/bot/handlers/voice.ts +21 -4
- package/src/bot/image-return.ts +118 -14
- package/src/bot/prompt-content.ts +24 -5
- package/src/bot/session-runtime.ts +56 -10
- package/src/config.ts +8 -4
- package/src/grok/client.ts +39 -3
- package/src/grok/types.ts +18 -3
- package/src/render/image-output.ts +12 -0
- package/src/sessions/history.ts +2 -0
- package/scripts/_tmp-release.mjs +0 -16
|
@@ -60,8 +60,11 @@ export async function sendProjectMenu(
|
|
|
60
60
|
* "last used" is the latest of its directory mtime and the newest session
|
|
61
61
|
* opened in it, so the project you worked in most recently floats to the top. */
|
|
62
62
|
function sortByRecency(entries: ProjectEntry[], deps: BotDeps): ProjectEntry[] {
|
|
63
|
+
if (entries.length === 0) return entries;
|
|
64
|
+
// Cap the session scan — full directory walks get expensive as history grows,
|
|
65
|
+
// and only the freshest sessions matter for ranking.
|
|
63
66
|
const recencyByCwd = new Map<string, number>();
|
|
64
|
-
for (const s of deps.store.list(
|
|
67
|
+
for (const s of deps.store.list(80)) {
|
|
65
68
|
const key = normCwd(s.cwd);
|
|
66
69
|
if (!key) continue;
|
|
67
70
|
const ms = Date.parse(s.updatedAt);
|
|
@@ -87,7 +90,8 @@ export async function showProjects(ctx: Context, deps: BotDeps, query?: string):
|
|
|
87
90
|
if (create) {
|
|
88
91
|
try {
|
|
89
92
|
const entry = deps.projects.create(create[1]!);
|
|
90
|
-
|
|
93
|
+
// Instant switch — ACP session is created on the first message.
|
|
94
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
|
|
91
95
|
await refreshMenu(ctx, deps, `\u2705 Created and opened ${entry.name}\n${entry.path} \u2014 send a message.`);
|
|
92
96
|
} catch (e) {
|
|
93
97
|
await deps.ephemeral.open(ctx);
|
|
@@ -132,7 +136,7 @@ async function openProjectPath(ctx: Context, deps: BotDeps, raw: string): Promis
|
|
|
132
136
|
await deps.ephemeral.open(ctx);
|
|
133
137
|
const name = basename(dir) || dir;
|
|
134
138
|
try {
|
|
135
|
-
await deps.registry.controller(ctx.chat!.id).
|
|
139
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(dir, name);
|
|
136
140
|
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${name}\n${dir} \u2014 send a message.`);
|
|
137
141
|
} catch (e) {
|
|
138
142
|
await deps.ephemeral.reply(ctx, `\u274C Could not open ${dir}: ${(e as Error).message}`);
|
|
@@ -171,10 +175,12 @@ export function registerProjects(bot: Bot, deps: BotDeps): void {
|
|
|
171
175
|
await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
|
|
172
176
|
return;
|
|
173
177
|
}
|
|
174
|
-
|
|
178
|
+
// Answer immediately (before any work) so Telegram never times out the query.
|
|
179
|
+
await ctx.answerCallbackQuery({ text: `Opening ${entry.name}\u2026` });
|
|
175
180
|
await deps.ephemeral.clear(ctx.chat!.id); // remove the project picker
|
|
176
181
|
try {
|
|
177
|
-
|
|
182
|
+
// Instant: no ACP session/new — live session is created on first message.
|
|
183
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
|
|
178
184
|
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name} \u2014 send a message.`);
|
|
179
185
|
} catch (err) {
|
|
180
186
|
await ctx.reply(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Voice & audio handler — transcribes Telegram voice notes / audio files to
|
|
3
3
|
* text (any language) and submits them as prompts.
|
|
4
|
+
*
|
|
5
|
+
* Requires STT_API_URL (and optionally STT_API_KEY). Grok Build CLI over ACP
|
|
6
|
+
* does not accept audio content blocks, so without an STT endpoint voice is
|
|
7
|
+
* disabled rather than attaching raw audio the agent cannot hear.
|
|
4
8
|
*/
|
|
5
9
|
import type { Bot, Context } from "grammy";
|
|
6
10
|
import { textPrompt } from "../../app/types.js";
|
|
@@ -18,7 +22,9 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
18
22
|
return;
|
|
19
23
|
}
|
|
20
24
|
if (!deps.stt.enabled) {
|
|
21
|
-
await ctx.reply(
|
|
25
|
+
await ctx.reply(
|
|
26
|
+
"\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY if needed) in .env.",
|
|
27
|
+
);
|
|
22
28
|
return;
|
|
23
29
|
}
|
|
24
30
|
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
@@ -41,9 +47,20 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
41
47
|
}
|
|
42
48
|
};
|
|
43
49
|
|
|
44
|
-
bot.on("message:voice", (ctx) =>
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
bot.on("message:voice", (ctx) =>
|
|
51
|
+
handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
|
|
52
|
+
);
|
|
53
|
+
bot.on("message:audio", (ctx) =>
|
|
54
|
+
handle(
|
|
55
|
+
ctx,
|
|
56
|
+
ctx.message.audio.file_id,
|
|
57
|
+
ctx.message.audio.mime_type || "audio/mpeg",
|
|
58
|
+
ctx.message.audio.file_name || "audio.mp3",
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
bot.on("message:video_note", (ctx) =>
|
|
62
|
+
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
|
|
63
|
+
);
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
|
package/src/bot/image-return.ts
CHANGED
|
@@ -1,30 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent image return —
|
|
3
|
-
* (screenshots, diagrams…)
|
|
4
|
-
* back to Telegram
|
|
2
|
+
* Agent image return — finds image files the agent produced this turn
|
|
3
|
+
* (Imagine `image_gen` / `image_edit`, screenshots, diagrams…) and sends them
|
|
4
|
+
* back to Telegram as **downloadable files** (`sendDocument`).
|
|
5
|
+
*
|
|
6
|
+
* Discovery sources:
|
|
7
|
+
* 1. Paths mentioned in agent text / tool inputs (project-relative or absolute)
|
|
8
|
+
* 2. Fresh files under the Grok session media dirs
|
|
9
|
+
* (`~/.grok/sessions/<encoded-cwd>/<sessionId>/images/` and `…/assets/`)
|
|
10
|
+
* 3. Fresh files under `<cwd>/images/` (common short path reported by tools)
|
|
5
11
|
*/
|
|
6
12
|
import { type Api, InputFile } from "grammy";
|
|
7
|
-
import { existsSync, statSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
8
15
|
import { basename, isAbsolute, join } from "node:path";
|
|
9
16
|
import { createLogger } from "../logger.js";
|
|
10
17
|
|
|
18
|
+
// Re-export so existing import paths (`./image-return.js`) keep working.
|
|
19
|
+
export { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
20
|
+
|
|
11
21
|
const log = createLogger("image-return");
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
23
|
+
/** Absolute / relative image path tokens in free text (Unix + Windows). */
|
|
24
|
+
const PATH_RE =
|
|
25
|
+
/(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])?[^\s"'`<>|()*\[\]{}]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
|
|
26
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
|
16
27
|
const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
17
28
|
|
|
18
29
|
/** Pull candidate image paths out of arbitrary text, resolved against cwd. */
|
|
19
30
|
export function extractImagePaths(text: string, cwd: string): string[] {
|
|
20
31
|
const out = new Set<string>();
|
|
21
32
|
for (const m of text.matchAll(PATH_RE)) {
|
|
22
|
-
|
|
33
|
+
let raw = m[0].replace(/[).,;:]+$/, "");
|
|
34
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
35
|
+
raw = join(homedir(), raw.slice(2));
|
|
36
|
+
}
|
|
23
37
|
out.add(isAbsolute(raw) ? raw : join(cwd, raw));
|
|
24
38
|
}
|
|
25
39
|
return [...out];
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Grok stores per-session media under:
|
|
44
|
+
* ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/{images,assets}/
|
|
45
|
+
* Imagine `image_gen` currently prefers `images/`; older runs used `assets/`.
|
|
46
|
+
*/
|
|
47
|
+
export function grokSessionMediaRoot(cwd: string, sessionId: string): string {
|
|
48
|
+
return join(homedir(), ".grok", "sessions", encodeURIComponent(cwd), sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @deprecated Prefer grokSessionMediaDirs — kept for callers/tests that used assets. */
|
|
52
|
+
export function grokSessionAssetsDir(cwd: string, sessionId: string): string {
|
|
53
|
+
return join(grokSessionMediaRoot(cwd, sessionId), "assets");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Session folders where Imagine / tools drop generated images. */
|
|
57
|
+
export function grokSessionMediaDirs(cwd: string, sessionId: string): string[] {
|
|
58
|
+
const root = grokSessionMediaRoot(cwd, sessionId);
|
|
59
|
+
return [join(root, "images"), join(root, "assets")];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** List image files under `dir` modified at/after `since` (non-recursive). */
|
|
63
|
+
export function listFreshImagesInDir(dir: string, since: number): string[] {
|
|
64
|
+
if (!existsSync(dir)) return [];
|
|
65
|
+
let names: string[];
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(dir);
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const path = join(dir, name);
|
|
74
|
+
const ext = name.toLowerCase().split(".").pop() ?? "";
|
|
75
|
+
if (!IMAGE_EXT.has(ext)) continue;
|
|
76
|
+
try {
|
|
77
|
+
const st = statSync(path);
|
|
78
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
79
|
+
// 2s slack for clock skew / write completion.
|
|
80
|
+
if (st.mtimeMs < since - 2000) continue;
|
|
81
|
+
out.push(path);
|
|
82
|
+
} catch {
|
|
83
|
+
/* skip */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Newest first so max-cap still keeps the latest gens.
|
|
87
|
+
return out.sort((a, b) => {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(b).mtimeMs - statSync(a).mtimeMs;
|
|
90
|
+
} catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Collect all image candidates for a turn from text + known asset locations. */
|
|
97
|
+
export function collectTurnImagePaths(opts: {
|
|
98
|
+
scanText: string;
|
|
99
|
+
cwd: string;
|
|
100
|
+
sessionId?: string;
|
|
101
|
+
since: number;
|
|
102
|
+
}): string[] {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const add = (paths: string[]) => {
|
|
106
|
+
for (const p of paths) {
|
|
107
|
+
if (seen.has(p)) continue;
|
|
108
|
+
seen.add(p);
|
|
109
|
+
out.push(p);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
add(extractImagePaths(opts.scanText, opts.cwd));
|
|
114
|
+
add(listFreshImagesInDir(join(opts.cwd, "images"), opts.since));
|
|
115
|
+
if (opts.sessionId) {
|
|
116
|
+
for (const dir of grokSessionMediaDirs(opts.cwd, opts.sessionId)) {
|
|
117
|
+
add(listFreshImagesInDir(dir, opts.since));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
28
123
|
export interface SendImagesOptions {
|
|
29
124
|
/** Only send files modified at/after this epoch ms (fresh this turn). */
|
|
30
125
|
since: number;
|
|
@@ -32,9 +127,11 @@ export interface SendImagesOptions {
|
|
|
32
127
|
already: Set<string>;
|
|
33
128
|
/** Max images to send in this call. */
|
|
34
129
|
max: number;
|
|
130
|
+
/** Optional Telegram message id to thread replies under. */
|
|
131
|
+
replyTo?: number;
|
|
35
132
|
}
|
|
36
133
|
|
|
37
|
-
/** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
|
|
134
|
+
/** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
|
|
38
135
|
export async function sendImages(
|
|
39
136
|
api: Api,
|
|
40
137
|
chatId: number,
|
|
@@ -42,6 +139,10 @@ export async function sendImages(
|
|
|
42
139
|
opts: SendImagesOptions,
|
|
43
140
|
): Promise<number> {
|
|
44
141
|
let sent = 0;
|
|
142
|
+
const replyExtra =
|
|
143
|
+
opts.replyTo !== undefined
|
|
144
|
+
? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
|
|
145
|
+
: {};
|
|
45
146
|
for (const path of paths) {
|
|
46
147
|
if (sent >= opts.max) break;
|
|
47
148
|
if (opts.already.has(path)) continue;
|
|
@@ -55,12 +156,15 @@ export async function sendImages(
|
|
|
55
156
|
if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
|
|
56
157
|
opts.already.add(path);
|
|
57
158
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const file = new InputFile(path);
|
|
61
|
-
|
|
62
|
-
|
|
159
|
+
// Always send as a document so Telegram delivers a downloadable file
|
|
160
|
+
// (not a compressed photo bubble).
|
|
161
|
+
const file = new InputFile(path, basename(path));
|
|
162
|
+
await api.sendDocument(chatId, file, {
|
|
163
|
+
caption: basename(path),
|
|
164
|
+
...replyExtra,
|
|
165
|
+
});
|
|
63
166
|
sent++;
|
|
167
|
+
log.debug(`sent document ${path}`);
|
|
64
168
|
} catch (e) {
|
|
65
169
|
log.debug(`failed to send ${path}:`, (e as Error).message);
|
|
66
170
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build ACP prompt content blocks from a PromptInput (text + images
|
|
3
|
-
* the reasoning directive and any fork-priming
|
|
4
|
-
* queued inputs into one.
|
|
2
|
+
* Build ACP prompt content blocks from a PromptInput (text + images + optional
|
|
3
|
+
* resource links), applying the reasoning directive and any fork-priming
|
|
4
|
+
* context. Also merges multiple queued inputs into one.
|
|
5
5
|
*/
|
|
6
6
|
import type { ContentBlock } from "../grok/types.js";
|
|
7
7
|
import type { PromptInput } from "../app/types.js";
|
|
@@ -9,8 +9,10 @@ import type { PromptInput } from "../app/types.js";
|
|
|
9
9
|
export interface ContentOptions {
|
|
10
10
|
reasoning?: string;
|
|
11
11
|
priming?: string;
|
|
12
|
-
/** Appended
|
|
12
|
+
/** Appended so the agent emits a `{progress: N%}` marker. */
|
|
13
13
|
progress?: string;
|
|
14
|
+
/** Appended so the agent keeps generated images in the session media folder. */
|
|
15
|
+
imageOutput?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
|
|
@@ -19,11 +21,23 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
19
21
|
for (const img of input.images) {
|
|
20
22
|
blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
21
23
|
}
|
|
24
|
+
for (const link of input.resourceLinks ?? []) {
|
|
25
|
+
blocks.push({
|
|
26
|
+
type: "resource_link",
|
|
27
|
+
uri: link.uri,
|
|
28
|
+
name: link.name,
|
|
29
|
+
mimeType: link.mimeType,
|
|
30
|
+
size: link.size,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
let text = input.text.trim();
|
|
24
35
|
if (!text && input.images.length > 0) {
|
|
25
36
|
text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
|
|
26
37
|
}
|
|
38
|
+
if (!text && (input.resourceLinks?.length ?? 0) > 0) {
|
|
39
|
+
text = "Please process the attached file(s).";
|
|
40
|
+
}
|
|
27
41
|
if (input.quotedText?.trim()) {
|
|
28
42
|
const quoted = input.quotedText.trim();
|
|
29
43
|
const body = text || "(the user's reply carried no additional text)";
|
|
@@ -35,7 +49,11 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
35
49
|
if (opts.reasoning) {
|
|
36
50
|
text = `(${opts.reasoning})\n\n${text}`;
|
|
37
51
|
}
|
|
52
|
+
if (opts.imageOutput) {
|
|
53
|
+
text = `${text}\n\n${opts.imageOutput}`;
|
|
54
|
+
}
|
|
38
55
|
if (opts.progress) {
|
|
56
|
+
// Progress last so its "marker is the final line" rule stays true.
|
|
39
57
|
text = `${text}\n\n${opts.progress}`;
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -43,7 +61,7 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
43
61
|
return blocks;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
/** Merge queued inputs into a single prompt (concatenated text, all images). */
|
|
64
|
+
/** Merge queued inputs into a single prompt (concatenated text, all images/links). */
|
|
47
65
|
export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
48
66
|
const quotes = inputs
|
|
49
67
|
.map((i) => i.quotedText?.trim())
|
|
@@ -54,6 +72,7 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
54
72
|
.filter((t) => t.trim().length > 0)
|
|
55
73
|
.join("\n\n"),
|
|
56
74
|
images: inputs.flatMap((i) => i.images),
|
|
75
|
+
resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
|
|
57
76
|
replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
|
|
58
77
|
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
59
78
|
};
|
|
@@ -8,7 +8,7 @@ import { basename } from "node:path";
|
|
|
8
8
|
import { type Api, InlineKeyboard } from "grammy";
|
|
9
9
|
import {
|
|
10
10
|
type GrokClient,
|
|
11
|
-
|
|
11
|
+
isAccountRotationError,
|
|
12
12
|
isContextExhaustedError,
|
|
13
13
|
isTransientError,
|
|
14
14
|
type SessionMetadata,
|
|
@@ -31,7 +31,8 @@ import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarize
|
|
|
31
31
|
import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
|
|
32
32
|
import type { PendingStage, SubagentInfo } from "../grok/types.js";
|
|
33
33
|
import { ResponseStreamer } from "../stream/streamer.js";
|
|
34
|
-
import {
|
|
34
|
+
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
35
|
+
import { collectTurnImagePaths, sendImages } from "./image-return.js";
|
|
35
36
|
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
36
37
|
import {
|
|
37
38
|
backoffSchedule,
|
|
@@ -101,6 +102,8 @@ export class SessionRuntime {
|
|
|
101
102
|
private turnReplyTo: number | undefined;
|
|
102
103
|
private imageScanText = "";
|
|
103
104
|
private sentImagesThisTurn = new Set<string>();
|
|
105
|
+
/** Monotonic count used to reject ACP "success" responses with no turn updates. */
|
|
106
|
+
private sessionUpdateCount = 0;
|
|
104
107
|
private readonly listener: (sessionId: string, update: SessionUpdate) => void;
|
|
105
108
|
private primingContext: string | undefined;
|
|
106
109
|
private watcher: TailWatcher | undefined;
|
|
@@ -214,8 +217,11 @@ export class SessionRuntime {
|
|
|
214
217
|
this.typing.stop();
|
|
215
218
|
this.stopWatch();
|
|
216
219
|
if (this.streamer) {
|
|
217
|
-
|
|
220
|
+
// Finalize off the critical path so project/session switches never wait
|
|
221
|
+
// on Telegram edits of the previous live stream.
|
|
222
|
+
const prev = this.streamer;
|
|
218
223
|
this.streamer = undefined;
|
|
224
|
+
void prev.finalize().catch(() => {});
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
227
|
this.changed();
|
|
@@ -512,6 +518,7 @@ export class SessionRuntime {
|
|
|
512
518
|
const content = buildContentBlocks(input, {
|
|
513
519
|
reasoning: reasoningDirective(this.reasoning),
|
|
514
520
|
priming: this.primingContext,
|
|
521
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
515
522
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
516
523
|
});
|
|
517
524
|
this.primingContext = undefined;
|
|
@@ -668,6 +675,7 @@ export class SessionRuntime {
|
|
|
668
675
|
const forkContent = buildContentBlocks(input, {
|
|
669
676
|
reasoning: reasoningDirective(this.reasoning),
|
|
670
677
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
678
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
671
679
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
672
680
|
});
|
|
673
681
|
return this.runPromptWithRetries(forkContent);
|
|
@@ -692,6 +700,14 @@ export class SessionRuntime {
|
|
|
692
700
|
): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
|
|
693
701
|
const rotator = this.accountRotator;
|
|
694
702
|
if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
|
|
703
|
+
// A quota-exhausted or access-denied response cannot be recovered by retrying
|
|
704
|
+
// this login. Quarantine it before choosing targets, so later rotations do not
|
|
705
|
+
// cycle back to a known-bad account. This intentionally happens before the
|
|
706
|
+
// partial-stream guard: we must not retry/rotate a partial reply, but its
|
|
707
|
+
// account still needs to be skipped during a future rotation.
|
|
708
|
+
if (isAccountRotationError(final.error)) {
|
|
709
|
+
await rotator.markFailed(undefined, final.error.message);
|
|
710
|
+
}
|
|
695
711
|
if (this.streamer?.hasOutput ?? false) return undefined;
|
|
696
712
|
const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
|
|
697
713
|
if (targets.length === 0) return undefined;
|
|
@@ -726,11 +742,12 @@ export class SessionRuntime {
|
|
|
726
742
|
const content = buildContentBlocks(input, {
|
|
727
743
|
reasoning: reasoningDirective(this.reasoning),
|
|
728
744
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
729
|
-
|
|
745
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
746
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
730
747
|
});
|
|
731
748
|
log.info(
|
|
732
749
|
`chat ${this.chatId} auto-rotating to account ${t.label}` +
|
|
733
|
-
(
|
|
750
|
+
(isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
|
|
734
751
|
);
|
|
735
752
|
// runPromptWithRetries already skips backoff for 402 / balance exhausted.
|
|
736
753
|
last = await this.runPromptWithRetries(content);
|
|
@@ -740,6 +757,9 @@ export class SessionRuntime {
|
|
|
740
757
|
}
|
|
741
758
|
return last;
|
|
742
759
|
}
|
|
760
|
+
if (last.error && isAccountRotationError(last.error)) {
|
|
761
|
+
await rotator.markFailed(t.id, last.error.message);
|
|
762
|
+
}
|
|
743
763
|
if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
|
|
744
764
|
errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
|
|
745
765
|
}
|
|
@@ -767,7 +787,16 @@ export class SessionRuntime {
|
|
|
767
787
|
for (;;) {
|
|
768
788
|
attempt++;
|
|
769
789
|
try {
|
|
790
|
+
const updatesBeforePrompt = this.sessionUpdateCount;
|
|
770
791
|
const result = await this.acp.prompt(this.sessionId!, content);
|
|
792
|
+
// A healthy ACP turn emits at least one session/update (text, thought,
|
|
793
|
+
// or tool event) before resolving session/prompt. Grok can otherwise
|
|
794
|
+
// report a successful end-turn after an upstream model failure; never
|
|
795
|
+
// present that as a completed user request.
|
|
796
|
+
await sleep(0);
|
|
797
|
+
if (this.sessionUpdateCount === updatesBeforePrompt) {
|
|
798
|
+
throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
|
|
799
|
+
}
|
|
771
800
|
return { result, attempts: attempt };
|
|
772
801
|
} catch (err) {
|
|
773
802
|
const error = err as Error;
|
|
@@ -781,7 +810,7 @@ export class SessionRuntime {
|
|
|
781
810
|
attempt <= delays.length &&
|
|
782
811
|
canRecover &&
|
|
783
812
|
!forkInstead &&
|
|
784
|
-
!
|
|
813
|
+
!isAccountRotationError(error) &&
|
|
785
814
|
isTransientError(error);
|
|
786
815
|
if (!willRetry) return { error, attempts: attempt };
|
|
787
816
|
const waitMs = delays[attempt - 1]!;
|
|
@@ -837,6 +866,7 @@ export class SessionRuntime {
|
|
|
837
866
|
const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [RETRY_BASE_MS];
|
|
838
867
|
const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
|
|
839
868
|
reasoning: reasoningDirective(this.reasoning),
|
|
869
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
840
870
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
841
871
|
});
|
|
842
872
|
|
|
@@ -866,17 +896,26 @@ export class SessionRuntime {
|
|
|
866
896
|
return last;
|
|
867
897
|
}
|
|
868
898
|
|
|
869
|
-
/** Send any fresh images the agent produced this turn (
|
|
899
|
+
/** Send any fresh images the agent produced this turn (Imagine, screenshots…). */
|
|
870
900
|
private async sendTurnImages(): Promise<void> {
|
|
871
|
-
if (!this.cfg.sendAgentImages
|
|
872
|
-
|
|
901
|
+
if (!this.cfg.sendAgentImages) return;
|
|
902
|
+
// Always check session images/ + assets/ even when the agent never named a
|
|
903
|
+
// path in text — image_gen writes under ~/.grok/sessions/.../images/.
|
|
904
|
+
const paths = collectTurnImagePaths({
|
|
905
|
+
scanText: this.imageScanText,
|
|
906
|
+
cwd: this.cwd,
|
|
907
|
+
sessionId: this.sessionId,
|
|
908
|
+
since: this.turnStartedAt,
|
|
909
|
+
});
|
|
873
910
|
if (paths.length === 0) return;
|
|
874
911
|
try {
|
|
875
|
-
await sendImages(this.api, this.chatId, paths, {
|
|
912
|
+
const n = await sendImages(this.api, this.chatId, paths, {
|
|
876
913
|
since: this.turnStartedAt,
|
|
877
914
|
already: this.sentImagesThisTurn,
|
|
878
915
|
max: this.cfg.agentImagesMax,
|
|
916
|
+
replyTo: this.turnReplyTo,
|
|
879
917
|
});
|
|
918
|
+
if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
|
|
880
919
|
} catch {
|
|
881
920
|
/* non-fatal */
|
|
882
921
|
}
|
|
@@ -960,6 +999,7 @@ export class SessionRuntime {
|
|
|
960
999
|
|
|
961
1000
|
private onUpdate(sessionId: string, update: SessionUpdate): void {
|
|
962
1001
|
if (!this.busy || sessionId !== this.sessionId) return;
|
|
1002
|
+
this.sessionUpdateCount++;
|
|
963
1003
|
const kind = update.sessionUpdate;
|
|
964
1004
|
|
|
965
1005
|
// Accumulate the turn's file-change summary + image-scan text even when this
|
|
@@ -968,6 +1008,12 @@ export class SessionRuntime {
|
|
|
968
1008
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
969
1009
|
if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
|
|
970
1010
|
if (update.title) this.imageScanText += " " + update.title;
|
|
1011
|
+
// Tool results often carry the saved path only in content_blocks (Imagine).
|
|
1012
|
+
if (Array.isArray(update.content_blocks)) {
|
|
1013
|
+
this.imageScanText += " " + JSON.stringify(update.content_blocks);
|
|
1014
|
+
}
|
|
1015
|
+
// Some agents put free-form result text on `content`.
|
|
1016
|
+
if (update.content?.text) this.imageScanText += " " + update.content.text;
|
|
971
1017
|
const fo = fileOpFromUpdate(update);
|
|
972
1018
|
if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
|
|
973
1019
|
} else if (kind === "agent_message_chunk") {
|
package/src/config.ts
CHANGED
|
@@ -44,9 +44,10 @@ function resolveInstanceDir(): string {
|
|
|
44
44
|
return CANONICAL_DIR;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Load .env from the resolved instance directory.
|
|
48
|
-
//
|
|
49
|
-
|
|
47
|
+
// Load .env from the resolved instance directory. Keep the parsed values as
|
|
48
|
+
// well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex,
|
|
49
|
+
// Kiro, etc.) and must never override this Grok instance's identity.
|
|
50
|
+
const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {};
|
|
50
51
|
|
|
51
52
|
function expandHome(p: string): string {
|
|
52
53
|
if (p === "~") return homedir();
|
|
@@ -146,7 +147,10 @@ export interface AppConfig {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
export function loadConfig(): AppConfig {
|
|
149
|
-
|
|
150
|
+
// Telegram long polling permits one consumer per token. Prefer the token in
|
|
151
|
+
// this bot's own instance file over a globally inherited environment value,
|
|
152
|
+
// otherwise a Grok process can accidentally poll as a sibling bot.
|
|
153
|
+
const token = (instanceEnv.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || "").trim();
|
|
150
154
|
if (!token) {
|
|
151
155
|
throw new Error(
|
|
152
156
|
"TELEGRAM_BOT_TOKEN is missing. Copy .env.example to .env and set it (run `npm run setup`).",
|
package/src/grok/client.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { EventEmitter } from "node:events";
|
|
|
15
15
|
import { createLogger } from "../logger.js";
|
|
16
16
|
import { hasLogin } from "../app/grok-credentials.js";
|
|
17
17
|
import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
|
|
18
|
+
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
18
19
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
19
20
|
import { SessionLog } from "./session-log.js";
|
|
20
21
|
import { JsonRpcTransport } from "./transport.js";
|
|
@@ -43,7 +44,7 @@ export interface SessionMetadata {
|
|
|
43
44
|
|
|
44
45
|
const TRANSIENT_CODES = new Set([-32603, -32500, -32000, 500, 502, 503, 504, 429]);
|
|
45
46
|
const TRANSIENT_RE =
|
|
46
|
-
/internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
|
|
47
|
+
/internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|empty agent response|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
|
|
47
48
|
const CONTEXT_EXHAUSTED_RE =
|
|
48
49
|
/context (?:length|window|limit|size|overflow)|maximum context|input (?:is )?too long|prompt (?:is )?too long|too many (?:input )?tokens|token limit|exceeds? (?:the )?(?:maximum|context|token)|reduce the (?:length|size)|context.{0,24}exhaust/i;
|
|
49
50
|
/**
|
|
@@ -54,6 +55,10 @@ const CONTEXT_EXHAUSTED_RE =
|
|
|
54
55
|
*/
|
|
55
56
|
const ACCOUNT_EXHAUSTED_RE =
|
|
56
57
|
/\b402\b|payment required|balance exhausted|usage balance|out of (?:credits|quota|balance)|insufficient (?:credits|balance|quota)|quota exceeded|no (?:remaining )?credits/i;
|
|
58
|
+
/** Account-level authorization failures from the Grok CLI proxy. A different
|
|
59
|
+
* saved login may be permitted, while same-account retries cannot help. */
|
|
60
|
+
const ACCOUNT_ACCESS_DENIED_RE =
|
|
61
|
+
/\b403\b|forbidden|access denied/i;
|
|
57
62
|
|
|
58
63
|
export class GrokError extends Error {
|
|
59
64
|
constructor(
|
|
@@ -92,9 +97,36 @@ export function isAccountExhaustedError(err: Error): boolean {
|
|
|
92
97
|
return false;
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
/**
|
|
101
|
+
* True when the active saved login cannot serve the request: either its Grok
|
|
102
|
+
* Build quota is exhausted (402), or the proxy rejects it as unauthorized
|
|
103
|
+
* (403 / Forbidden / Access denied). Both must skip same-account backoff and
|
|
104
|
+
* trigger account rotation when enabled.
|
|
105
|
+
*/
|
|
106
|
+
export function isAccountRotationError(err: Error): boolean {
|
|
107
|
+
if (isAccountExhaustedError(err) || ACCOUNT_ACCESS_DENIED_RE.test(err.message)) return true;
|
|
108
|
+
const data = (err as GrokError).data;
|
|
109
|
+
if (!data || typeof data !== "object") return false;
|
|
110
|
+
const d = data as Record<string, unknown>;
|
|
111
|
+
const status = d.http_status ?? d.status ?? d.statusCode;
|
|
112
|
+
if (status === 403 || status === "403") return true;
|
|
113
|
+
if (typeof d.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(d.message)) return true;
|
|
114
|
+
for (const value of Object.values(d)) {
|
|
115
|
+
if (typeof value === "string" && ACCOUNT_ACCESS_DENIED_RE.test(value)) return true;
|
|
116
|
+
if (value && typeof value === "object") {
|
|
117
|
+
const nested = value as Record<string, unknown>;
|
|
118
|
+
const nestedStatus = nested.http_status ?? nested.status ?? nested.statusCode;
|
|
119
|
+
if (nestedStatus === 403 || nestedStatus === "403") return true;
|
|
120
|
+
if (typeof nested.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(nested.message)) return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
95
126
|
export function isTransientError(err: Error): boolean {
|
|
96
|
-
//
|
|
97
|
-
|
|
127
|
+
// Quota exhaustion and access denial are permanent for this login — rotate,
|
|
128
|
+
// never back off and retry the same credentials.
|
|
129
|
+
if (isAccountRotationError(err)) return false;
|
|
98
130
|
const code = (err as GrokError).code;
|
|
99
131
|
if (typeof code === "number" && TRANSIENT_CODES.has(code)) return true;
|
|
100
132
|
return TRANSIENT_RE.test(err.message);
|
|
@@ -697,8 +729,12 @@ export class GrokClient extends EventEmitter {
|
|
|
697
729
|
* leading reasoning directive, fork/priming preamble) removed, for a clean log. */
|
|
698
730
|
private cleanUserText(content: ContentBlock[]): string {
|
|
699
731
|
let t = this.visibleText(content);
|
|
732
|
+
// Strip bot-injected appendices (image rules first, then progress — progress
|
|
733
|
+
// is always last when both are present).
|
|
700
734
|
const pi = t.indexOf(PROGRESS_DIRECTIVE);
|
|
701
735
|
if (pi !== -1) t = t.slice(0, pi).trimEnd();
|
|
736
|
+
const ii = t.indexOf(IMAGE_OUTPUT_DIRECTIVE);
|
|
737
|
+
if (ii !== -1) t = t.slice(0, ii).trimEnd();
|
|
702
738
|
const marker = "User's new message:\n";
|
|
703
739
|
const mi = t.lastIndexOf(marker);
|
|
704
740
|
if (mi !== -1) t = t.slice(mi + marker.length);
|
package/src/grok/types.ts
CHANGED
|
@@ -26,12 +26,23 @@ export interface JsonRpcNotification {
|
|
|
26
26
|
|
|
27
27
|
export type JsonRpcMessage = JsonRpcResponse & JsonRpcNotification & { method?: string };
|
|
28
28
|
|
|
29
|
-
/** A content block in a prompt or message. */
|
|
29
|
+
/** A content block in a prompt or message (ACP ContentBlock subset). */
|
|
30
30
|
export interface ContentBlock {
|
|
31
|
-
type: "text" | "image" | "resource";
|
|
31
|
+
type: "text" | "image" | "audio" | "resource" | "resource_link";
|
|
32
32
|
text?: string;
|
|
33
33
|
data?: string;
|
|
34
34
|
mimeType?: string;
|
|
35
|
+
/** resource_link */
|
|
36
|
+
uri?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
size?: number;
|
|
39
|
+
/** embedded resource */
|
|
40
|
+
resource?: {
|
|
41
|
+
uri: string;
|
|
42
|
+
mimeType?: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
blob?: string;
|
|
45
|
+
};
|
|
35
46
|
[k: string]: unknown;
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -47,7 +58,11 @@ export interface InitializeResult {
|
|
|
47
58
|
authMethods?: AuthMethod[];
|
|
48
59
|
agentCapabilities?: {
|
|
49
60
|
loadSession?: boolean;
|
|
50
|
-
promptCapabilities?: {
|
|
61
|
+
promptCapabilities?: {
|
|
62
|
+
image?: boolean;
|
|
63
|
+
audio?: boolean;
|
|
64
|
+
embeddedContext?: boolean;
|
|
65
|
+
};
|
|
51
66
|
};
|
|
52
67
|
agentInfo?: { name?: string; version?: string };
|
|
53
68
|
}
|