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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bridge protocol: first-prompt directive + parse/strip of agent
|
|
3
|
+
* JSON action blocks (`{"telegram":[...]}`) from assistant responses.
|
|
4
|
+
*
|
|
5
|
+
* Keep TELEGRAM_BRIDGE_DIRECTIVE tidy-idempotent (no trailing spaces / 3+ blank
|
|
6
|
+
* lines, no digit `{progress:…}` tokens) so history cleaners can strip by
|
|
7
|
+
* exact match after extractProgress/tidy.
|
|
8
|
+
*/
|
|
9
|
+
import type { PromptInput } from "../app/types.js";
|
|
10
|
+
|
|
11
|
+
/** Marker prefix for first-prompt teaching block (used by strip / history). */
|
|
12
|
+
export const TELEGRAM_BRIDGE_MARKER = "TELEGRAM BRIDGE (how to work in this chat):";
|
|
13
|
+
|
|
14
|
+
/** Marker for results injected back into the agent after actions run. */
|
|
15
|
+
export const TELEGRAM_BRIDGE_RESULTS_MARKER =
|
|
16
|
+
"TELEGRAM BRIDGE RESULTS (system — use these facts; do not re-emit the same request unless needed):";
|
|
17
|
+
|
|
18
|
+
/** Max actions accepted from one agent turn (create + path + several prompts). */
|
|
19
|
+
export const TELEGRAM_ACTION_MAX = 9;
|
|
20
|
+
/** Max bot_command actions per turn. */
|
|
21
|
+
export const TELEGRAM_BOT_COMMAND_MAX = 2;
|
|
22
|
+
/** Max send_prompt actions per turn (cross-topic work). */
|
|
23
|
+
export const TELEGRAM_SEND_PROMPT_MAX = 5;
|
|
24
|
+
|
|
25
|
+
export type TelegramAction =
|
|
26
|
+
| { action: "create_topic"; name: string; path?: string }
|
|
27
|
+
| { action: "set_path"; topic: string; path: string }
|
|
28
|
+
| { action: "send_prompt"; topic: string; prompt: string; newSession?: boolean }
|
|
29
|
+
| { action: "search_memory"; query: string; limit?: number }
|
|
30
|
+
| { action: "list_bots" }
|
|
31
|
+
| { action: "bot_command"; bot: string; command: string; args?: string };
|
|
32
|
+
|
|
33
|
+
export interface TelegramActionExtract {
|
|
34
|
+
actions: TelegramAction[];
|
|
35
|
+
/** Text with telegram action fences removed. */
|
|
36
|
+
cleaned: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Static teaching block. Dynamic capability lines are appended by
|
|
41
|
+
* {@link buildTelegramBridgeDirective}.
|
|
42
|
+
*/
|
|
43
|
+
export const TELEGRAM_BRIDGE_DIRECTIVE_BASE = [
|
|
44
|
+
TELEGRAM_BRIDGE_MARKER,
|
|
45
|
+
"You are driven by the Grok Telegram Bot bridge over ACP. Beyond normal coding tools you may request Telegram-side actions by putting a fenced JSON block in your response (language json preferred), BEFORE the final progress marker line.",
|
|
46
|
+
"",
|
|
47
|
+
"Format (one block; multiple actions allowed, run in order — up to 9):",
|
|
48
|
+
"```json",
|
|
49
|
+
'{ "telegram": [',
|
|
50
|
+
' { "action": "create_topic", "name": "Topic title", "path": "optional absolute project path or exact catalog name" },',
|
|
51
|
+
' { "action": "set_path", "topic": "Topic title or #threadId", "path": "absolute path or exact catalog name" },',
|
|
52
|
+
' { "action": "send_prompt", "topic": "Topic title or #threadId", "prompt": "work for that topic", "new_session": false },',
|
|
53
|
+
' { "action": "search_memory", "query": "keywords about past work", "limit": 8 },',
|
|
54
|
+
' { "action": "list_bots" },',
|
|
55
|
+
' { "action": "bot_command", "bot": "username_without_at", "command": "status", "args": "optional" }',
|
|
56
|
+
"] }",
|
|
57
|
+
"```",
|
|
58
|
+
"",
|
|
59
|
+
"Actions:",
|
|
60
|
+
"- create_topic — new forum topic; optional path binds the project immediately.",
|
|
61
|
+
" Absolute paths that do not exist yet are created on disk (new project flow).",
|
|
62
|
+
"- set_path — bind/rebind an existing topic to a project path (absolute dir or exact catalog name).",
|
|
63
|
+
" Absolute paths that do not exist yet are created on disk.",
|
|
64
|
+
"- send_prompt — start or queue a prompt in another topic's session (does not wait for that turn to finish).",
|
|
65
|
+
" From General/AI Chat (GROK_WORKSPACE) you can create a project topic, set_path, then send_prompt with multi-step work.",
|
|
66
|
+
" topic = exact topic title, #threadId, \"general\", or \"ai chat\". Optional new_session=true starts a fresh session there.",
|
|
67
|
+
"- search_memory — search indexed forum topics + session titles/comments/history.",
|
|
68
|
+
"- list_bots — list allowlisted sibling Telegram bots and command catalogs.",
|
|
69
|
+
"- bot_command — /command@bot; waits for that bot to settle. NOT a Done; timeouts return ok=false.",
|
|
70
|
+
"",
|
|
71
|
+
"Example (from General): create project topic + path + kick off work there:",
|
|
72
|
+
'```json',
|
|
73
|
+
'{ "telegram": [',
|
|
74
|
+
' { "action": "create_topic", "name": "MyApp", "path": "H:\\\\Projects\\\\MyApp" },',
|
|
75
|
+
' { "action": "send_prompt", "topic": "MyApp", "prompt": "1) scaffold\\n2) tests\\n3) README" }',
|
|
76
|
+
"] }",
|
|
77
|
+
"```",
|
|
78
|
+
"",
|
|
79
|
+
"After you emit these actions, the bridge runs them and may send TELEGRAM BRIDGE RESULTS. Use those facts; do not invent replies.",
|
|
80
|
+
"The bridge strips the JSON fence from the user-visible message. Prefer plain prose for the user; put protocol only in the fence.",
|
|
81
|
+
"Do not spam actions. Prefer one orchestration block per turn.",
|
|
82
|
+
].join("\n");
|
|
83
|
+
|
|
84
|
+
export interface TelegramBridgeCaps {
|
|
85
|
+
forumReady: boolean;
|
|
86
|
+
topicGroupId?: number;
|
|
87
|
+
allowedBots: string[];
|
|
88
|
+
/** username → command list for first-prompt teaching */
|
|
89
|
+
botCommands?: Record<string, Array<{ command: string; description?: string }>>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Full first-prompt directive including live capabilities. */
|
|
93
|
+
export function buildTelegramBridgeDirective(caps: TelegramBridgeCaps): string {
|
|
94
|
+
const lines = [TELEGRAM_BRIDGE_DIRECTIVE_BASE, "", "Capabilities right now:"];
|
|
95
|
+
if (caps.forumReady && caps.topicGroupId !== undefined) {
|
|
96
|
+
lines.push(
|
|
97
|
+
`- Forum topics: READY (group ${caps.topicGroupId}). You may create_topic, set_path, and send_prompt.`,
|
|
98
|
+
`- General / AI Chat sessions use GROK_WORKSPACE; other topics use their bound project path.`,
|
|
99
|
+
);
|
|
100
|
+
} else if (caps.topicGroupId !== undefined) {
|
|
101
|
+
lines.push(
|
|
102
|
+
`- Forum topics: NOT READY (group ${caps.topicGroupId} configured but setup failed or bot is not admin). Do not rely on create_topic / set_path / send_prompt.`,
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
lines.push("- Forum topics: OFF (TOPIC_GROUP_ID unset). create_topic / set_path / send_prompt will fail.");
|
|
106
|
+
}
|
|
107
|
+
if (caps.allowedBots.length > 0) {
|
|
108
|
+
lines.push(
|
|
109
|
+
`- Sibling bots (allowlist): ${caps.allowedBots.map((b) => "@" + b).join(", ")}. Use list_bots / bot_command like MCP.`,
|
|
110
|
+
);
|
|
111
|
+
for (const u of caps.allowedBots) {
|
|
112
|
+
const cmds = caps.botCommands?.[u];
|
|
113
|
+
if (cmds && cmds.length > 0) {
|
|
114
|
+
lines.push(
|
|
115
|
+
` - @${u}: ${cmds
|
|
116
|
+
.map((c) => (c.description ? `/${c.command} (${c.description})` : `/${c.command}`))
|
|
117
|
+
.join(", ")}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(
|
|
123
|
+
"- Sibling bots: none configured (ALLOWED_TELEGRAM_BOTS empty). list_bots returns empty; bot_command disabled.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
lines.push("- search_memory: always available against bot-owned session/topic indexes.");
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Prepend the telegram bridge teaching block (idempotent if already present).
|
|
132
|
+
* Call after complexity wrapping so it sits between complexity and user task body.
|
|
133
|
+
*/
|
|
134
|
+
export function wrapTelegramBridgePrompt(input: PromptInput, directive: string): PromptInput {
|
|
135
|
+
const body = input.text.trim() || "(see attached media / files)";
|
|
136
|
+
if (body.includes(TELEGRAM_BRIDGE_MARKER)) return input;
|
|
137
|
+
// Prefer inserting after "User task:\n" when complexity wrapper is present.
|
|
138
|
+
const marker = "User task:";
|
|
139
|
+
const idx = body.indexOf(marker);
|
|
140
|
+
if (idx !== -1) {
|
|
141
|
+
const before = body.slice(0, idx + marker.length);
|
|
142
|
+
const after = body.slice(idx + marker.length);
|
|
143
|
+
return {
|
|
144
|
+
...input,
|
|
145
|
+
text: `${before}\n\n${directive}\n\nUser task (continued):\n${after.trimStart()}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...input,
|
|
150
|
+
text: `${directive}\n\n${body}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when text is the bridge results follow-up (meta; skip recheck). */
|
|
155
|
+
export function isTelegramBridgeResultsPrompt(text: string): boolean {
|
|
156
|
+
return text.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Build the meta prompt that feeds action results back to the agent. */
|
|
160
|
+
export function buildTelegramBridgeResultsPrompt(results: unknown[]): string {
|
|
161
|
+
const payload = JSON.stringify({ results }, null, 2);
|
|
162
|
+
return [
|
|
163
|
+
TELEGRAM_BRIDGE_RESULTS_MARKER,
|
|
164
|
+
"```json",
|
|
165
|
+
payload,
|
|
166
|
+
"```",
|
|
167
|
+
"Continue the user's task with this information. Do not ask the user to paste results. Do not re-emit the same telegram actions unless something failed and a retry is useful.",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract telegram actions from fenced JSON blocks and strip those fences from
|
|
173
|
+
* the visible text. Non-telegram fences are left intact.
|
|
174
|
+
*/
|
|
175
|
+
export function extractTelegramActions(text: string): TelegramActionExtract {
|
|
176
|
+
if (!text) return { actions: [], cleaned: text };
|
|
177
|
+
const actions: TelegramAction[] = [];
|
|
178
|
+
// Match complete fenced blocks (``` or ```json etc.).
|
|
179
|
+
const fenceRe = /```(?:json|JSON)?\s*\r?\n([\s\S]*?)```/g;
|
|
180
|
+
let cleaned = text.replace(fenceRe, (full, body: string) => {
|
|
181
|
+
const parsed = tryParseTelegramFence(body);
|
|
182
|
+
if (!parsed) return full;
|
|
183
|
+
for (const a of parsed) {
|
|
184
|
+
if (actions.length >= TELEGRAM_ACTION_MAX) break;
|
|
185
|
+
actions.push(a);
|
|
186
|
+
}
|
|
187
|
+
return "";
|
|
188
|
+
});
|
|
189
|
+
// Hide a trailing incomplete ```json … telegram block mid-stream.
|
|
190
|
+
cleaned = cleaned.replace(/```(?:json|JSON)?\s*\r?\n[\s\S]*$/i, (tail) => {
|
|
191
|
+
if (
|
|
192
|
+
/"telegram"\s*:/i.test(tail) ||
|
|
193
|
+
/"action"\s*:\s*"(?:create_topic|set_path|send_prompt|search_memory|list_bots|bot_command)"/i.test(
|
|
194
|
+
tail,
|
|
195
|
+
)
|
|
196
|
+
) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
return tail;
|
|
200
|
+
});
|
|
201
|
+
cleaned = tidy(cleaned);
|
|
202
|
+
return { actions: capBotCommands(actions), cleaned };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Strip only (no need for actions) — streamer path. */
|
|
206
|
+
export function stripTelegramActionFences(text: string): string {
|
|
207
|
+
return extractTelegramActions(text).cleaned;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function tryParseTelegramFence(body: string): TelegramAction[] | undefined {
|
|
211
|
+
const raw = body.trim();
|
|
212
|
+
if (!raw) return undefined;
|
|
213
|
+
let parsed: unknown;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(raw);
|
|
216
|
+
} catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const list = coerceActionList(parsed);
|
|
220
|
+
if (!list) return undefined;
|
|
221
|
+
const out: TelegramAction[] = [];
|
|
222
|
+
for (const item of list) {
|
|
223
|
+
const a = normalizeAction(item);
|
|
224
|
+
if (a) out.push(a);
|
|
225
|
+
}
|
|
226
|
+
return out.length > 0 ? out : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function coerceActionList(parsed: unknown): unknown[] | undefined {
|
|
230
|
+
if (Array.isArray(parsed)) {
|
|
231
|
+
if (parsed.length === 0) return undefined;
|
|
232
|
+
// Bare array only if every element looks like an action.
|
|
233
|
+
if (parsed.every((x) => x && typeof x === "object" && "action" in (x as object))) {
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
239
|
+
const rec = parsed as Record<string, unknown>;
|
|
240
|
+
if ("telegram" in rec) {
|
|
241
|
+
const t = rec.telegram;
|
|
242
|
+
if (Array.isArray(t)) return t;
|
|
243
|
+
if (t && typeof t === "object") return [t];
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
// Single action object at top level.
|
|
247
|
+
if (typeof rec.action === "string") return [rec];
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeAction(item: unknown): TelegramAction | undefined {
|
|
252
|
+
if (!item || typeof item !== "object") return undefined;
|
|
253
|
+
const rec = item as Record<string, unknown>;
|
|
254
|
+
const action = String(rec.action ?? rec.type ?? "")
|
|
255
|
+
.trim()
|
|
256
|
+
.toLowerCase()
|
|
257
|
+
.replace(/-/g, "_");
|
|
258
|
+
|
|
259
|
+
switch (action) {
|
|
260
|
+
case "create_topic": {
|
|
261
|
+
const name = String(rec.name ?? rec.title ?? "").trim();
|
|
262
|
+
if (!name) return undefined;
|
|
263
|
+
const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
|
|
264
|
+
return path
|
|
265
|
+
? { action: "create_topic", name: name.slice(0, 128), path: path.slice(0, 500) }
|
|
266
|
+
: { action: "create_topic", name: name.slice(0, 128) };
|
|
267
|
+
}
|
|
268
|
+
case "set_path":
|
|
269
|
+
case "bind_path":
|
|
270
|
+
case "bind_topic": {
|
|
271
|
+
const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
|
|
272
|
+
const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
|
|
273
|
+
if (!topic || !path) return undefined;
|
|
274
|
+
return {
|
|
275
|
+
action: "set_path",
|
|
276
|
+
topic: topic.slice(0, 128),
|
|
277
|
+
path: path.slice(0, 500),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
case "send_prompt":
|
|
281
|
+
case "topic_prompt":
|
|
282
|
+
case "prompt_topic": {
|
|
283
|
+
const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
|
|
284
|
+
const prompt = String(rec.prompt ?? rec.text ?? rec.message ?? "").trim();
|
|
285
|
+
if (!topic || !prompt) return undefined;
|
|
286
|
+
const newSessionRaw = rec.new_session ?? rec.newSession ?? rec.fresh;
|
|
287
|
+
const newSession =
|
|
288
|
+
newSessionRaw === true ||
|
|
289
|
+
newSessionRaw === 1 ||
|
|
290
|
+
/^(1|true|yes|y)$/i.test(String(newSessionRaw ?? "").trim());
|
|
291
|
+
return {
|
|
292
|
+
action: "send_prompt",
|
|
293
|
+
topic: topic.slice(0, 128),
|
|
294
|
+
prompt: prompt.slice(0, 4000),
|
|
295
|
+
newSession: newSession || undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
case "search_memory":
|
|
299
|
+
case "search":
|
|
300
|
+
case "memory_search": {
|
|
301
|
+
const query = String(rec.query ?? rec.q ?? rec.text ?? "").trim();
|
|
302
|
+
if (!query) return undefined;
|
|
303
|
+
let limit = Number(rec.limit ?? rec.max ?? 8);
|
|
304
|
+
if (!Number.isFinite(limit)) limit = 8;
|
|
305
|
+
limit = Math.max(1, Math.min(20, Math.round(limit)));
|
|
306
|
+
return { action: "search_memory", query: query.slice(0, 300), limit };
|
|
307
|
+
}
|
|
308
|
+
case "list_bots":
|
|
309
|
+
case "bots":
|
|
310
|
+
return { action: "list_bots" };
|
|
311
|
+
case "bot_command":
|
|
312
|
+
case "call_bot":
|
|
313
|
+
case "invoke_bot": {
|
|
314
|
+
const bot = normalizeUsername(String(rec.bot ?? rec.username ?? ""));
|
|
315
|
+
const command = String(rec.command ?? rec.cmd ?? "")
|
|
316
|
+
.trim()
|
|
317
|
+
.replace(/^\//, "");
|
|
318
|
+
if (!bot || !command) return undefined;
|
|
319
|
+
const args = String(rec.args ?? rec.arguments ?? rec.text ?? "").trim();
|
|
320
|
+
return {
|
|
321
|
+
action: "bot_command",
|
|
322
|
+
bot,
|
|
323
|
+
command: command.slice(0, 64),
|
|
324
|
+
args: args ? args.slice(0, 500) : undefined,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
default:
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function capBotCommands(actions: TelegramAction[]): TelegramAction[] {
|
|
333
|
+
let botCmds = 0;
|
|
334
|
+
let sendPrompts = 0;
|
|
335
|
+
const out: TelegramAction[] = [];
|
|
336
|
+
for (const a of actions) {
|
|
337
|
+
if (a.action === "bot_command") {
|
|
338
|
+
if (botCmds >= TELEGRAM_BOT_COMMAND_MAX) continue;
|
|
339
|
+
botCmds++;
|
|
340
|
+
}
|
|
341
|
+
if (a.action === "send_prompt") {
|
|
342
|
+
if (sendPrompts >= TELEGRAM_SEND_PROMPT_MAX) continue;
|
|
343
|
+
sendPrompts++;
|
|
344
|
+
}
|
|
345
|
+
out.push(a);
|
|
346
|
+
if (out.length >= TELEGRAM_ACTION_MAX) break;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function normalizeUsername(raw: string): string {
|
|
352
|
+
return raw.trim().replace(/^@/, "").toLowerCase();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function tidy(s: string): string {
|
|
356
|
+
return s
|
|
357
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
358
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
359
|
+
.replace(/\s+$/g, "");
|
|
360
|
+
}
|
package/src/render/tool-call.ts
CHANGED
|
@@ -159,7 +159,24 @@ function formatByKind(
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// ---- helpers for code fences ----
|
|
162
|
+
// ---- helpers for code fences / path labels ----
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* File paths in **bold** break Telegram MarkdownV2 (Windows `\`, dots, long
|
|
166
|
+
* session paths). Always put paths in inline code; keep only the verb bold.
|
|
167
|
+
* ✏️ **Edit** `C:\Users\…\plan.md`
|
|
168
|
+
*/
|
|
169
|
+
function pathCode(path: string | undefined | null, fallback = "file"): string {
|
|
170
|
+
const p = (path || "").trim() || fallback;
|
|
171
|
+
// Inline code cannot contain raw backticks unescaped in our MD pipeline;
|
|
172
|
+
// replace rare ` in paths so the span stays closed.
|
|
173
|
+
return "`" + p.replace(/`/g, "'") + "`";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `**Edit** \`path\`` style header fragment (no leading emoji). */
|
|
177
|
+
function boldVerbPath(verb: string, path: string | undefined | null, fallback = "file"): string {
|
|
178
|
+
return "**" + verb + "** " + pathCode(path, fallback);
|
|
179
|
+
}
|
|
163
180
|
|
|
164
181
|
/** Fence that lengthens itself when the body contains backticks (avoids MD break). */
|
|
165
182
|
function fence(text: string, lang?: string): string {
|
|
@@ -197,8 +214,8 @@ function formatExecute(
|
|
|
197
214
|
): string {
|
|
198
215
|
const cmd = extractCommand(raw);
|
|
199
216
|
const cwd = strOf(raw.cwd) || strOf(raw.working_directory) || strOf(raw.workingDirectory);
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
let out = "\u{1F4BB} **Run command**" + tail;
|
|
218
|
+
if (cwd) out += " in " + pathCode(truncate(cwd, 80));
|
|
202
219
|
if (toolName && toolName !== "execute" && toolName !== "shell" && toolName !== "bash") {
|
|
203
220
|
out += `\n tool: \`${toolName}\``;
|
|
204
221
|
}
|
|
@@ -226,8 +243,7 @@ function formatEdit(
|
|
|
226
243
|
toolName: string,
|
|
227
244
|
): string {
|
|
228
245
|
const path = extractPath(raw);
|
|
229
|
-
|
|
230
|
-
let out = "\u270F\uFE0F **" + title + "**" + tail;
|
|
246
|
+
let out = "\u270F\uFE0F " + boldVerbPath("Edit", path) + tail;
|
|
231
247
|
if (toolName && !/edit|replace/i.test(toolName)) out += `\n tool: \`${toolName}\``;
|
|
232
248
|
if (opts.showDiffs) {
|
|
233
249
|
const diff = buildEditDiff(u, raw, opts.diffMaxLines);
|
|
@@ -242,7 +258,7 @@ function formatEdit(
|
|
|
242
258
|
function formatWrite(u: SessionUpdate, kind: string, raw: Record<string, unknown>, tail: string): string {
|
|
243
259
|
const path = extractPath(raw);
|
|
244
260
|
const verb = kind === "create" ? "Create" : "Write";
|
|
245
|
-
let out = "\u{1F4DD}
|
|
261
|
+
let out = "\u{1F4DD} " + boldVerbPath(verb, path) + tail;
|
|
246
262
|
const content = extractContent(raw) || extractToolOutput(u);
|
|
247
263
|
if (content) {
|
|
248
264
|
out += "\n" + fence(truncateMiddleLines(content, OUTPUT_PREVIEW_LINES, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
|
|
@@ -258,14 +274,13 @@ function formatRead(
|
|
|
258
274
|
): string {
|
|
259
275
|
const path = extractPath(raw, u);
|
|
260
276
|
const range = extractReadRange(raw);
|
|
261
|
-
let title = "Read " + (path || "file");
|
|
262
277
|
const parts: string[] = [];
|
|
263
278
|
if (range.startLine) parts.push("line " + range.startLine);
|
|
264
279
|
if (range.offset) parts.push("offset " + range.offset);
|
|
265
280
|
if (range.limit) parts.push("limit " + range.limit);
|
|
266
281
|
if (range.pages) parts.push("pages " + range.pages);
|
|
267
|
-
|
|
268
|
-
|
|
282
|
+
let out = "\u{1F4D6} " + boldVerbPath("Read", path) + tail;
|
|
283
|
+
if (parts.length) out += " (" + parts.join(", ") + ")";
|
|
269
284
|
if (toolName && toolName !== "read" && toolName !== "read_file") {
|
|
270
285
|
out += `\n tool: \`${toolName}\``;
|
|
271
286
|
}
|
|
@@ -292,11 +307,11 @@ function formatList(
|
|
|
292
307
|
strOf(raw.target_directory) ||
|
|
293
308
|
strOf(raw.targetDirectory) ||
|
|
294
309
|
".";
|
|
295
|
-
let out = "\u{1F4C1}
|
|
310
|
+
let out = "\u{1F4C1} " + boldVerbPath("List", truncate(path, 120), ".") + tail;
|
|
296
311
|
if (toolName) out += `\n tool: \`${toolName}\``;
|
|
297
312
|
const filters = extractFilters(raw);
|
|
298
|
-
if (filters.include) out += "\n include: " + filters.include;
|
|
299
|
-
if (filters.exclude) out += "\n exclude: " + filters.exclude;
|
|
313
|
+
if (filters.include) out += "\n include: `" + filters.include.replace(/`/g, "'") + "`";
|
|
314
|
+
if (filters.exclude) out += "\n exclude: `" + filters.exclude.replace(/`/g, "'") + "`";
|
|
300
315
|
const body = extractToolOutput(u);
|
|
301
316
|
if (body) {
|
|
302
317
|
out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
|
|
@@ -314,16 +329,16 @@ function formatSearch(
|
|
|
314
329
|
const path = extractSearchPath(raw);
|
|
315
330
|
const filters = extractFilters(raw);
|
|
316
331
|
const isGlob = /glob/i.test(toolName) || (!!filters.include && !query);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
332
|
+
const verb = isGlob ? "Glob" : "Search";
|
|
333
|
+
let out = "\u{1F50E} **" + verb + "**" + tail;
|
|
334
|
+
if (query) out += " " + pathCode(truncate(query, 120), "query");
|
|
335
|
+
else if (path) out += " " + pathCode(truncate(path, 120));
|
|
321
336
|
if (toolName && toolName !== "search" && toolName !== "grep") {
|
|
322
337
|
out += `\n tool: \`${toolName}\``;
|
|
323
338
|
}
|
|
324
|
-
if (path &&
|
|
325
|
-
if (filters.include) out += "\n \u{1F4C1} include: " + filters.include;
|
|
326
|
-
if (filters.exclude) out += "\n \u{1F6AB} exclude: " + filters.exclude;
|
|
339
|
+
if (path && query) out += "\n \u{1F4C2} in: " + pathCode(truncate(path, 100));
|
|
340
|
+
if (filters.include) out += "\n \u{1F4C1} include: " + pathCode(filters.include);
|
|
341
|
+
if (filters.exclude) out += "\n \u{1F6AB} exclude: " + pathCode(filters.exclude);
|
|
327
342
|
if (raw.case_sensitive !== undefined) {
|
|
328
343
|
out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
|
|
329
344
|
}
|
|
@@ -338,7 +353,7 @@ function formatSearch(
|
|
|
338
353
|
|
|
339
354
|
function formatDelete(raw: Record<string, unknown>, tail: string): string {
|
|
340
355
|
const path = extractPath(raw);
|
|
341
|
-
return "\u{1F5D1}\uFE0F
|
|
356
|
+
return "\u{1F5D1}\uFE0F " + boldVerbPath("Delete", path) + tail;
|
|
342
357
|
}
|
|
343
358
|
|
|
344
359
|
function formatMove(kind: string, raw: Record<string, unknown>, tail: string): string {
|
|
@@ -352,20 +367,20 @@ function formatMove(kind: string, raw: Record<string, unknown>, tail: string): s
|
|
|
352
367
|
"**" +
|
|
353
368
|
tail +
|
|
354
369
|
"\n \u{1F4C4} " +
|
|
355
|
-
truncate(src, 100) +
|
|
370
|
+
pathCode(truncate(src, 100)) +
|
|
356
371
|
"\n \u27A1\uFE0F " +
|
|
357
|
-
truncate(dst, 100)
|
|
372
|
+
pathCode(truncate(dst, 100))
|
|
358
373
|
);
|
|
359
374
|
}
|
|
360
|
-
return "\u{1F4E6}
|
|
375
|
+
return "\u{1F4E6} " + boldVerbPath(verb, src || dst) + tail;
|
|
361
376
|
}
|
|
362
377
|
|
|
363
378
|
function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
|
|
364
379
|
const url = extractUrl(raw);
|
|
365
380
|
const method = strOf(raw.method) || strOf(raw.verb) || "GET";
|
|
366
|
-
let
|
|
367
|
-
|
|
368
|
-
|
|
381
|
+
let out = url
|
|
382
|
+
? "\u{1F310} **Fetch** " + pathCode(truncate(url, 200), "URL") + tail
|
|
383
|
+
: "\u{1F310} **Fetch URL**" + tail;
|
|
369
384
|
if (method && method !== "GET") out += "\n method: " + method;
|
|
370
385
|
const headers = raw.headers;
|
|
371
386
|
if (headers && typeof headers === "object") {
|
|
@@ -384,9 +399,8 @@ function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: strin
|
|
|
384
399
|
function formatWebSearch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
|
|
385
400
|
const query = extractSearchQuery(raw) || extractUrl(raw);
|
|
386
401
|
const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results) || numStr(raw.num_results);
|
|
387
|
-
let
|
|
388
|
-
if (query)
|
|
389
|
-
let out = "\u{1F310} **" + title + "**" + tail;
|
|
402
|
+
let out = "\u{1F310} **Web search**" + tail;
|
|
403
|
+
if (query) out += " " + pathCode(truncate(query, 150), "query");
|
|
390
404
|
if (count) out += "\n results: " + count;
|
|
391
405
|
const result = extractToolOutput(u);
|
|
392
406
|
if (result) {
|
|
@@ -428,7 +442,7 @@ function formatImage(raw: Record<string, unknown>, tail: string, toolName: strin
|
|
|
428
442
|
const path = extractPath(raw) || strOf(raw.image) || strOf(raw.output);
|
|
429
443
|
let out = "\u{1F5BC}\uFE0F **Image**" + tail;
|
|
430
444
|
if (toolName) out += `\n tool: \`${toolName}\``;
|
|
431
|
-
if (path) out += "\n " + truncate(path, 200);
|
|
445
|
+
if (path) out += "\n " + pathCode(truncate(path, 200));
|
|
432
446
|
const prompt = strOf(raw.prompt);
|
|
433
447
|
if (prompt) out += "\n prompt: " + truncate(prompt, 200);
|
|
434
448
|
return out;
|
|
@@ -482,27 +496,32 @@ function formatGeneric(
|
|
|
482
496
|
const path = extractPath(raw);
|
|
483
497
|
const cmd = extractCommand(raw);
|
|
484
498
|
const query = extractSearchQuery(raw);
|
|
485
|
-
// Never show a bare "Other" / "Tool call" — prefer real tool name /
|
|
499
|
+
// Never show a bare "Other" / "Tool call" — prefer real tool name. Paths/queries
|
|
500
|
+
// go in inline code (not bold) so Windows paths cannot break MarkdownV2.
|
|
486
501
|
let label =
|
|
487
502
|
(toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "") ||
|
|
488
503
|
(u.title && !/^other$/i.test(u.title.trim()) && !/^tool[_ ]?call$/i.test(u.title.trim())
|
|
489
504
|
? u.title.trim()
|
|
490
505
|
: "") ||
|
|
491
|
-
(path ? capitalize(kind !== "other" ? kind : "use")
|
|
506
|
+
(path ? capitalize(kind !== "other" ? kind : "use") : "") ||
|
|
492
507
|
(cmd ? "Run command" : "") ||
|
|
493
|
-
(query ? "Search
|
|
508
|
+
(query ? "Search" : "") ||
|
|
494
509
|
(kind && kind !== "other" ? capitalize(kind) : "") ||
|
|
495
510
|
(toolName || "Tool");
|
|
496
511
|
if (/^other$/i.test(label) || /^tool[_ ]?call$/i.test(label)) {
|
|
497
|
-
label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName :
|
|
512
|
+
label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "Tool";
|
|
513
|
+
}
|
|
514
|
+
// Titles that accidentally embed a path stay short: drop path from bold label.
|
|
515
|
+
if (path && label.includes(path)) {
|
|
516
|
+
label = label.replace(path, "").replace(/\s+/g, " ").trim() || capitalize(kind !== "other" ? kind : "Tool");
|
|
498
517
|
}
|
|
499
518
|
|
|
500
519
|
let out = icon + " **" + label + "**" + tail;
|
|
501
520
|
if (toolName && toolName !== label) out += `\n tool: \`${toolName}\``;
|
|
502
|
-
if (path
|
|
521
|
+
if (path) out += "\n \u{1F4C4} " + pathCode(truncate(path, 120));
|
|
503
522
|
if (cmd) out += "\n" + fence(truncateMiddle(cmd, PREVIEW_MAX), "bash") + "\n";
|
|
504
|
-
else if (query
|
|
505
|
-
else {
|
|
523
|
+
else if (query) out += "\n query: " + pathCode(truncate(query, 150), "query");
|
|
524
|
+
else if (!path && !cmd) {
|
|
506
525
|
const args = formatArgLines(raw);
|
|
507
526
|
if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
|
|
508
527
|
}
|
package/src/service/platform.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Platform detection, launch-spec construction, and a small command runner
|
|
3
3
|
* shared by the per-OS service controllers.
|
|
4
4
|
*/
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { PROJECT_ROOT, INSTANCE_DIR } from "../config.js";
|
|
8
8
|
|
|
@@ -47,18 +47,55 @@ export function buildLaunchSpec(): LaunchSpec {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
/** Default max wait for schtasks/systemctl/powershell helpers — never hang the CLI. */
|
|
51
|
+
const RUN_TIMEOUT_MS = 45_000;
|
|
52
|
+
|
|
53
|
+
/** Run a command, returning combined output. Throws on non-zero exit or timeout. */
|
|
54
|
+
export function run(cmd: string, args: string[], timeoutMs = RUN_TIMEOUT_MS): string {
|
|
55
|
+
return execFileSync(cmd, args, {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
timeout: timeoutMs,
|
|
59
|
+
});
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/** Run a command, swallowing errors and returning { ok, out }. */
|
|
56
|
-
export function runSafe(cmd: string, args: string[]): { ok: boolean; out: string } {
|
|
63
|
+
export function runSafe(cmd: string, args: string[], timeoutMs = RUN_TIMEOUT_MS): { ok: boolean; out: string } {
|
|
57
64
|
try {
|
|
58
|
-
return { ok: true, out: run(cmd, args) };
|
|
65
|
+
return { ok: true, out: run(cmd, args, timeoutMs) };
|
|
59
66
|
} catch (e) {
|
|
60
|
-
const err = e as {
|
|
67
|
+
const err = e as {
|
|
68
|
+
stdout?: Buffer | string;
|
|
69
|
+
stderr?: Buffer | string;
|
|
70
|
+
message?: string;
|
|
71
|
+
killed?: boolean;
|
|
72
|
+
code?: string;
|
|
73
|
+
};
|
|
74
|
+
// Node sets killed=true when the timeout option aborts the child.
|
|
75
|
+
if (err.killed || err.code === "ETIMEDOUT") {
|
|
76
|
+
return { ok: false, out: `timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}` };
|
|
77
|
+
}
|
|
61
78
|
const out = String(err.stdout ?? "") + String(err.stderr ?? "") || err.message || "failed";
|
|
62
79
|
return { ok: false, out };
|
|
63
80
|
}
|
|
64
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fire-and-forget process (detached). Use for forever-restart supervisors
|
|
85
|
+
* (Windows VBS loop) so `grok-tg install|start|restart` does not hang waiting
|
|
86
|
+
* for a process that never exits.
|
|
87
|
+
*/
|
|
88
|
+
export function launchDetached(cmd: string, args: string[]): { ok: boolean; out: string } {
|
|
89
|
+
try {
|
|
90
|
+
const child = spawn(cmd, args, {
|
|
91
|
+
detached: true,
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
});
|
|
95
|
+
child.unref();
|
|
96
|
+
if (child.pid == null) return { ok: false, out: "spawn produced no pid" };
|
|
97
|
+
return { ok: true, out: `pid ${child.pid}` };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { ok: false, out: (e as Error).message || "spawn failed" };
|
|
100
|
+
}
|
|
101
|
+
}
|