grok-telegram-bot 2.3.1 → 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 +64 -2
- package/CHANGELOG.md +156 -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/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- 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/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- 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 +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- 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 +242 -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 +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- package/src/tasks/runner.ts +4 -3
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session "comment" shown on Running / Sessions cards:
|
|
3
|
+
* • always — last user prompt (≤ COMMENT_MAX)
|
|
4
|
+
* • while busy — also last AI agent thinking (≤ COMMENT_MAX), second line
|
|
5
|
+
*
|
|
6
|
+
* Built only from data already in the turn — no extra agent prompt (those
|
|
7
|
+
* leaked into chat/history). Display-only; never truncates agent context.
|
|
8
|
+
*/
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import type { SessionUpdate } from "../grok/types.js";
|
|
11
|
+
import { extractProgress } from "./progress.js";
|
|
12
|
+
import {
|
|
13
|
+
extractCommand,
|
|
14
|
+
extractPath,
|
|
15
|
+
extractSearchQuery,
|
|
16
|
+
extractUrl,
|
|
17
|
+
resolveToolIdentity,
|
|
18
|
+
} from "./tool-call-detail.js";
|
|
19
|
+
import type { FileOp } from "./file-summary.js";
|
|
20
|
+
|
|
21
|
+
/** Max length of each card comment line (user prompt or thinking). */
|
|
22
|
+
export const COMMENT_MAX = 250;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Legacy marker for the removed silent AI card-summary prompt. Kept only so
|
|
26
|
+
* history / previews can strip it if an old session log still contains it.
|
|
27
|
+
*/
|
|
28
|
+
export const COMMENT_SUMMARY_PROMPT_PREFIX = "Session status update (meta only).";
|
|
29
|
+
|
|
30
|
+
/** Collapse whitespace and clamp to card width. */
|
|
31
|
+
export function cleanCommentLine(raw: string, max = COMMENT_MAX): string {
|
|
32
|
+
// Drop leaked meta-summary prompts (should never be a card comment).
|
|
33
|
+
if (raw.includes(COMMENT_SUMMARY_PROMPT_PREFIX) || /^Session status update\b/i.test(raw.trim())) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
let t = raw
|
|
37
|
+
.replace(/\{[\s]*progress[\s]*:[\s]*\d{1,3}\s*%?[\s]*\}/gi, "")
|
|
38
|
+
.replace(/\r\n/g, "\n")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
// Prefer the first non-empty line if multi-line junk remains.
|
|
42
|
+
const first = t.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
43
|
+
t = (first ?? t).replace(/^["'`]+|["'`]+$/g, "").trim();
|
|
44
|
+
if (!t) return "";
|
|
45
|
+
if (t.length <= max) return t;
|
|
46
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip bot-injected wrappers (complexity directive, reply markers) so recheck
|
|
51
|
+
* prompts and card previews see the user's real request text.
|
|
52
|
+
*/
|
|
53
|
+
export function stripDirectiveWrappers(raw: string): string {
|
|
54
|
+
let t = raw.trim().replace(/^\([^)]*\)\s*/, "");
|
|
55
|
+
const marker = "User's new message:";
|
|
56
|
+
const i = t.lastIndexOf(marker);
|
|
57
|
+
if (i !== -1) t = t.slice(i + marker.length);
|
|
58
|
+
// Continued marker first — it contains the substring "User task:".
|
|
59
|
+
const cont = "User task (continued):";
|
|
60
|
+
const ci = t.lastIndexOf(cont);
|
|
61
|
+
if (ci !== -1) {
|
|
62
|
+
t = t.slice(ci + cont.length);
|
|
63
|
+
} else {
|
|
64
|
+
t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
|
|
65
|
+
t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
|
|
66
|
+
if (/TELEGRAM BRIDGE \(how to work/i.test(t)) {
|
|
67
|
+
t = t.replace(/TELEGRAM BRIDGE \(how to work[\s\S]*?(?=\n\n[A-Za-z]|$)/i, "");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t.trim())) t = "";
|
|
71
|
+
return t.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Strip bot directives so a user prompt is card-friendly. */
|
|
75
|
+
export function cleanUserPreview(raw: string, max = COMMENT_MAX): string {
|
|
76
|
+
let t = stripDirectiveWrappers(raw);
|
|
77
|
+
// Import confirm prompts are noise on cards.
|
|
78
|
+
if (/session import complete/i.test(t)) return "";
|
|
79
|
+
// Self-recheck / quiet meta prompts should not appear as user "Working:" text.
|
|
80
|
+
if (/^SELF-RECHECK \(automatic quality pass/i.test(t)) return "Self-recheck";
|
|
81
|
+
if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t)) return "";
|
|
82
|
+
if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t)) return "";
|
|
83
|
+
if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t)) return "Telegram bridge";
|
|
84
|
+
return cleanCommentLine(t, max);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format the session-card comment body:
|
|
89
|
+
* idle → last user prompt (≤ max)
|
|
90
|
+
* busy → user prompt + last agent thinking on the next line (each ≤ max)
|
|
91
|
+
*
|
|
92
|
+
* Returns "" when neither line has content. Caller adds icons per line.
|
|
93
|
+
*/
|
|
94
|
+
export function buildSessionCardComment(opts: {
|
|
95
|
+
userPrompt?: string;
|
|
96
|
+
thinking?: string;
|
|
97
|
+
busy?: boolean;
|
|
98
|
+
max?: number;
|
|
99
|
+
}): string {
|
|
100
|
+
const max = opts.max ?? COMMENT_MAX;
|
|
101
|
+
const user = opts.userPrompt?.trim()
|
|
102
|
+
? cleanUserPreview(opts.userPrompt, max)
|
|
103
|
+
: "";
|
|
104
|
+
const thinkRaw = opts.busy && opts.thinking?.trim() ? opts.thinking.trim() : "";
|
|
105
|
+
const thinking = thinkRaw ? clampThinking(thinkRaw, max) : "";
|
|
106
|
+
|
|
107
|
+
if (user && thinking) return `${user}\n${thinking}`;
|
|
108
|
+
if (user) return user;
|
|
109
|
+
if (thinking) return thinking;
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prefer the latest portion of accumulated thought text (streaming chunks
|
|
115
|
+
* append; conclusions land at the end). Collapse whitespace and clamp.
|
|
116
|
+
*/
|
|
117
|
+
export function clampThinking(raw: string, max = COMMENT_MAX): string {
|
|
118
|
+
let t = extractProgress(raw).cleaned;
|
|
119
|
+
t = t.replace(/```[\s\S]*?```/g, " ");
|
|
120
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
121
|
+
if (!t) return "";
|
|
122
|
+
if (t.length <= max) return t;
|
|
123
|
+
// Prefer the ending (most recent thinking).
|
|
124
|
+
if (t.length > max + 40) {
|
|
125
|
+
const tail = t.slice(-(max - 1));
|
|
126
|
+
const sp = tail.indexOf(" ");
|
|
127
|
+
return "\u2026" + (sp > 0 && sp < 40 ? tail.slice(sp + 1) : tail);
|
|
128
|
+
}
|
|
129
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pull a human-useful outcome snippet from the assistant's streamed reply.
|
|
134
|
+
* Prefers the closing sentences (where conclusions land) over mid-turn "I'll…".
|
|
135
|
+
*/
|
|
136
|
+
export function extractResultSnippet(assistantText: string | undefined, max = 160): string {
|
|
137
|
+
if (!assistantText?.trim()) return "";
|
|
138
|
+
let t = extractProgress(assistantText).cleaned;
|
|
139
|
+
// Drop fenced code / diffs / tool-looking blocks — keep prose.
|
|
140
|
+
t = t.replace(/```[\s\S]*?```/g, " ");
|
|
141
|
+
t = t.replace(/^>\s?.*$/gm, " "); // quoted thinking
|
|
142
|
+
// Drop lines that look like tool-call headers (emoji + bold title).
|
|
143
|
+
t = t.replace(/^[^\nA-Za-z0-9]*\*\*[^*\n]+\*\*[^\n]*$/gm, " ");
|
|
144
|
+
t = t.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
145
|
+
t = t.replace(/`([^`]+)`/g, "$1");
|
|
146
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
147
|
+
if (!t) return "";
|
|
148
|
+
// Filter pure meta / plumbing lines.
|
|
149
|
+
if (/^Session status update\b/i.test(t)) return "";
|
|
150
|
+
if (/^COMPLEXITY \(decide yourself/i.test(t)) return "";
|
|
151
|
+
|
|
152
|
+
// Prefer last 1–2 substantial sentences (what was solved).
|
|
153
|
+
const sentences = t
|
|
154
|
+
.split(/(?<=[.!?])\s+/)
|
|
155
|
+
.map((s) => s.trim())
|
|
156
|
+
.filter((s) => s.length >= 24 && !isWeakOpener(s));
|
|
157
|
+
if (sentences.length >= 2) {
|
|
158
|
+
const tail = sentences.slice(-2).join(" ");
|
|
159
|
+
return cleanCommentLine(tail, max);
|
|
160
|
+
}
|
|
161
|
+
if (sentences.length === 1) return cleanCommentLine(sentences[0]!, max);
|
|
162
|
+
|
|
163
|
+
// Fallback: whole cleaned text, clamped.
|
|
164
|
+
return cleanCommentLine(t, max);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Mid-turn openers that are not useful as a "what was solved" line. */
|
|
168
|
+
function isWeakOpener(s: string): boolean {
|
|
169
|
+
return /^(i'?ll |i will |let me |looking |investigat|tracing |checking |reading |searching )/i.test(
|
|
170
|
+
s,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compact file-change phrase: "~3: tool-call.ts, running.ts +1". */
|
|
175
|
+
export function formatFilesPhrase(fileOps: Map<string, FileOp>, maxNames = 3): string {
|
|
176
|
+
const n = fileOps.size;
|
|
177
|
+
if (n === 0) return "";
|
|
178
|
+
const names = [...fileOps.keys()]
|
|
179
|
+
.slice(0, maxNames)
|
|
180
|
+
.map((p) => basename(p.replace(/\\/g, "/")));
|
|
181
|
+
const more = n > maxNames ? ` +${n - maxNames}` : "";
|
|
182
|
+
const counts = countsShort(fileOps);
|
|
183
|
+
return `${counts} ${names.join(", ")}${more}`.trim();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function countsShort(ops: Map<string, FileOp>): string {
|
|
187
|
+
let c = 0,
|
|
188
|
+
e = 0,
|
|
189
|
+
d = 0,
|
|
190
|
+
m = 0;
|
|
191
|
+
for (const op of ops.values()) {
|
|
192
|
+
if (op === "created") c++;
|
|
193
|
+
else if (op === "edited") e++;
|
|
194
|
+
else if (op === "deleted") d++;
|
|
195
|
+
else if (op === "moved") m++;
|
|
196
|
+
}
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
if (c) parts.push(`+${c}`);
|
|
199
|
+
if (e) parts.push(`~${e}`);
|
|
200
|
+
if (d) parts.push(`\u2212${d}`);
|
|
201
|
+
if (m) parts.push(`\u2192${m}`);
|
|
202
|
+
return parts.length ? parts.join("") : `${ops.size} files`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Card summary for a finished turn: what was solved (assistant result) + files.
|
|
207
|
+
* Never uses a follow-up model call.
|
|
208
|
+
*/
|
|
209
|
+
export function buildLastTurnSummary(opts: {
|
|
210
|
+
userText?: string;
|
|
211
|
+
assistantText?: string;
|
|
212
|
+
fileOps: Map<string, FileOp>;
|
|
213
|
+
stopReason?: string;
|
|
214
|
+
cancelled?: boolean;
|
|
215
|
+
error?: string;
|
|
216
|
+
max?: number;
|
|
217
|
+
}): string {
|
|
218
|
+
const max = opts.max ?? COMMENT_MAX;
|
|
219
|
+
if (opts.cancelled) return "Stopped by user";
|
|
220
|
+
if (opts.error) return cleanCommentLine(`Error: ${opts.error}`, max);
|
|
221
|
+
|
|
222
|
+
const result = extractResultSnippet(opts.assistantText, Math.min(160, max - 20));
|
|
223
|
+
const files = formatFilesPhrase(opts.fileOps);
|
|
224
|
+
const intent = cleanUserPreview(opts.userText || "", 55);
|
|
225
|
+
|
|
226
|
+
// Prefer outcome prose (what was solved) over the user's ask.
|
|
227
|
+
if (result && files) {
|
|
228
|
+
const combined = `${result} \u00B7 ${files}`;
|
|
229
|
+
return cleanCommentLine(combined, max);
|
|
230
|
+
}
|
|
231
|
+
if (result) return cleanCommentLine(result, max);
|
|
232
|
+
if (files && intent) return cleanCommentLine(`${intent} \u2192 ${files}`, max);
|
|
233
|
+
if (files) return cleanCommentLine(`Changed ${files}`, max);
|
|
234
|
+
if (intent) return cleanCommentLine(intent, max);
|
|
235
|
+
|
|
236
|
+
if (opts.stopReason && opts.stopReason !== "end_turn" && opts.stopReason !== "cancelled") {
|
|
237
|
+
return cleanCommentLine(`Done (${opts.stopReason})`, max);
|
|
238
|
+
}
|
|
239
|
+
return "Turn complete";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @deprecated Use {@link buildLastTurnSummary}. */
|
|
243
|
+
export function buildLocalTurnComment(opts: {
|
|
244
|
+
userText?: string;
|
|
245
|
+
assistantText?: string;
|
|
246
|
+
fileOps: Map<string, FileOp>;
|
|
247
|
+
stopReason?: string;
|
|
248
|
+
cancelled?: boolean;
|
|
249
|
+
error?: string;
|
|
250
|
+
}): string {
|
|
251
|
+
return buildLastTurnSummary(opts);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Derive a live "current step" line from an ACP tool update. */
|
|
255
|
+
export function stepFromToolUpdate(u: SessionUpdate): string | undefined {
|
|
256
|
+
const raw = { ...((u.rawInput || {}) as Record<string, unknown>) };
|
|
257
|
+
const id = resolveToolIdentity(u, raw);
|
|
258
|
+
const kind = id.kind;
|
|
259
|
+
const path = extractPath(raw);
|
|
260
|
+
const short = path ? basename(path.replace(/\\/g, "/")) : "";
|
|
261
|
+
const status = (u.status || "").toLowerCase();
|
|
262
|
+
const done = status === "completed" || status === "failed";
|
|
263
|
+
const fail = status === "failed" ? " failed" : done ? " done" : "";
|
|
264
|
+
|
|
265
|
+
switch (kind) {
|
|
266
|
+
case "execute": {
|
|
267
|
+
const cmd = extractCommand(raw);
|
|
268
|
+
if (cmd) return cleanCommentLine(`Run: ${cmd}${fail}`, COMMENT_MAX);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "edit":
|
|
272
|
+
return cleanCommentLine(`Edit ${short || path || "file"}${fail}`, COMMENT_MAX);
|
|
273
|
+
case "write":
|
|
274
|
+
case "create":
|
|
275
|
+
return cleanCommentLine(`${kind === "create" ? "Create" : "Write"} ${short || path || "file"}${fail}`, COMMENT_MAX);
|
|
276
|
+
case "read":
|
|
277
|
+
return cleanCommentLine(`Read ${short || path || id.toolName || "file"}${fail}`, COMMENT_MAX);
|
|
278
|
+
case "list":
|
|
279
|
+
return cleanCommentLine(`List ${short || path || "."}${fail}`, COMMENT_MAX);
|
|
280
|
+
case "search": {
|
|
281
|
+
const q = extractSearchQuery(raw);
|
|
282
|
+
return cleanCommentLine(`Search${q ? `: ${q}` : ""}${fail}`, COMMENT_MAX);
|
|
283
|
+
}
|
|
284
|
+
case "delete":
|
|
285
|
+
return cleanCommentLine(`Delete ${short || path || "file"}${fail}`, COMMENT_MAX);
|
|
286
|
+
case "move":
|
|
287
|
+
case "rename":
|
|
288
|
+
return cleanCommentLine(`${kind === "rename" ? "Rename" : "Move"} ${short || path || "file"}${fail}`, COMMENT_MAX);
|
|
289
|
+
case "fetch":
|
|
290
|
+
case "web_fetch": {
|
|
291
|
+
const url = extractUrl(raw);
|
|
292
|
+
return cleanCommentLine(`Fetch ${url || "URL"}${fail}`, COMMENT_MAX);
|
|
293
|
+
}
|
|
294
|
+
case "web_search": {
|
|
295
|
+
const q = extractSearchQuery(raw) || extractUrl(raw);
|
|
296
|
+
return cleanCommentLine(`Web search${q ? `: ${q}` : ""}${fail}`, COMMENT_MAX);
|
|
297
|
+
}
|
|
298
|
+
case "mcp":
|
|
299
|
+
return cleanCommentLine(
|
|
300
|
+
`MCP ${id.mcpServer ? id.mcpServer + ": " : ""}${id.mcpMethod || id.toolName}${fail}`,
|
|
301
|
+
COMMENT_MAX,
|
|
302
|
+
);
|
|
303
|
+
default:
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
if (id.toolName) return cleanCommentLine(`${id.toolName}${fail}`, COMMENT_MAX);
|
|
307
|
+
if (u.title?.trim() && !/^other$/i.test(u.title.trim())) {
|
|
308
|
+
return cleanCommentLine(u.title.trim() + fail, COMMENT_MAX);
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Thinking step line from a thought chunk (display only). */
|
|
314
|
+
export function stepFromThought(text: string): string {
|
|
315
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
316
|
+
if (!t) return "Thinking\u2026";
|
|
317
|
+
return cleanCommentLine(`Thinking: ${t}`, COMMENT_MAX);
|
|
318
|
+
}
|
|
@@ -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
|
+
}
|