grok-telegram-bot 2.3.0 → 2.4.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 +26 -0
- package/CHANGELOG.md +55 -0
- package/package.json +1 -1
- package/scripts/analyze-jsonl.ts +33 -0
- package/scripts/delayed-restart.ps1 +29 -0
- package/scripts/probe-exit-response-shape.py +77 -0
- package/scripts/probe-plan-exit.py +60 -0
- package/scripts/probe-plan-exit2.py +48 -0
- package/scripts/probe-plan-fields.py +41 -0
- package/scripts/probe-plan-fields2.py +58 -0
- package/scripts/probe-plan-response-path.py +48 -0
- package/scripts/sample-claude-tooluse.ts +21 -0
- package/scripts/sample-kiro-events.ts +31 -0
- package/scripts/smoke-exit-plan.ts +274 -0
- package/scripts/smoke-exit-shapes.ts +252 -0
- package/scripts/smoke-import.mjs +82 -0
- package/scripts/smoke-import.ts +73 -0
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/types.ts +19 -2
- package/src/app/updater.ts +17 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +71 -2
- package/src/bot/bot.ts +36 -0
- package/src/bot/chat-controller.ts +35 -0
- package/src/bot/commands.ts +2 -0
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +19 -0
- package/src/bot/handlers/accounts.ts +55 -5
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +17 -38
- package/src/bot/handlers/message.ts +1 -0
- package/src/bot/handlers/running.ts +35 -5
- package/src/bot/handlers/session-card.ts +12 -0
- package/src/bot/handlers/sessions.ts +14 -3
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/menu/keyboard.ts +5 -4
- package/src/bot/menu/status-panel.ts +19 -6
- package/src/bot/prompt-content.ts +4 -0
- package/src/bot/reauth-controller.ts +2 -2
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +831 -64
- package/src/bot/suggestions.ts +429 -0
- package/src/config.ts +41 -0
- package/src/grok/client.ts +106 -20
- 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 +179 -24
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +261 -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 +405 -142
- package/src/render/truncate.ts +85 -0
- package/src/service/windows.ts +14 -2
- package/src/sessions/history.ts +57 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +73 -9
- package/src/tasks/runner.ts +4 -3
package/src/render/markdown.ts
CHANGED
|
@@ -2,38 +2,156 @@
|
|
|
2
2
|
* Convert standard Markdown (as produced by the agent) into Telegram
|
|
3
3
|
* MarkdownV2, with correct escaping and graceful handling of code blocks,
|
|
4
4
|
* headings, lists, quotes, links and inline styles.
|
|
5
|
+
*
|
|
6
|
+
* Design goals:
|
|
7
|
+
* - Nothing meaningful is dropped: unbalanced fences, partial markup, and
|
|
8
|
+
* orphan backticks are escaped as literal text rather than deleted.
|
|
9
|
+
* - Code fences use a dynamic fence length so bodies that contain ``` never
|
|
10
|
+
* break the outer block (common when displaying source of fence helpers).
|
|
11
|
+
* - Closing fences must match open length (CommonMark-style ≥ open ticks) and
|
|
12
|
+
* are recognized on the first body line (empty code blocks).
|
|
13
|
+
* - Inline styles nest safely; unclosed markers fall through as escaped text.
|
|
14
|
+
* - Quote / thinking lines use a safer inline subset to avoid mid-stream
|
|
15
|
+
* breakage from half-open ** or nested fences.
|
|
5
16
|
*/
|
|
6
17
|
import { escapeCode, escapeMdV2, escapeUrl } from "./escape.js";
|
|
7
18
|
|
|
8
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Match a fenced code block: opening run of 3+ backticks, optional lang,
|
|
21
|
+
* optional trailing newline. `^` is only applied at the start of the remaining
|
|
22
|
+
* slice (we never search with multiline ^).
|
|
23
|
+
*/
|
|
24
|
+
const FENCE_OPEN = /^(```+)([^\n`]*)\n?/;
|
|
9
25
|
|
|
10
26
|
/** Main entry: returns a MarkdownV2-safe string. */
|
|
11
27
|
export function toTelegramMarkdown(src: string): string {
|
|
28
|
+
if (!src) return "";
|
|
29
|
+
// Normalize newlines; strip zero-width / bidi junk that Telegram chokes on.
|
|
30
|
+
const normalized = src
|
|
31
|
+
.replace(/\r\n/g, "\n")
|
|
32
|
+
.replace(/\r/g, "\n")
|
|
33
|
+
.replace(/[\u200B-\u200D\uFEFF\u202A-\u202E]/g, "");
|
|
34
|
+
|
|
12
35
|
let out = "";
|
|
13
|
-
let
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
let i = 0;
|
|
37
|
+
const n = normalized.length;
|
|
38
|
+
|
|
39
|
+
while (i < n) {
|
|
40
|
+
const slice = normalized.slice(i);
|
|
41
|
+
const open = FENCE_OPEN.exec(slice);
|
|
42
|
+
if (open && open.index === 0) {
|
|
43
|
+
const ticks = open[1]!;
|
|
44
|
+
const minTicks = ticks.length;
|
|
45
|
+
const langRaw = (open[2] ?? "").trim();
|
|
46
|
+
const bodyStart = i + open[0].length;
|
|
47
|
+
const close = findClosingFence(normalized, bodyStart, minTicks);
|
|
48
|
+
if (close) {
|
|
49
|
+
const code = normalized.slice(bodyStart, close.bodyEnd);
|
|
50
|
+
out += fenceOut(code, sanitizeFenceLang(langRaw));
|
|
51
|
+
i = close.afterClose;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// Unclosed fence → treat rest as code (streaming-safe).
|
|
55
|
+
const code = normalized.slice(bodyStart);
|
|
56
|
+
out += fenceOut(code, sanitizeFenceLang(langRaw));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find next fence start (line-start ``` only).
|
|
61
|
+
const nextFence = findNextFenceStart(normalized, i);
|
|
62
|
+
const end = nextFence === -1 ? n : nextFence;
|
|
63
|
+
out += renderTextBlock(normalized.slice(i, end));
|
|
64
|
+
i = end;
|
|
65
|
+
if (nextFence === -1) break;
|
|
23
66
|
}
|
|
24
|
-
out += renderTextBlock(src.slice(last));
|
|
25
67
|
|
|
26
|
-
return out.replace(/\n{
|
|
68
|
+
return out.replace(/\n{4,}/g, "\n\n\n").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Emit a Telegram-safe fenced block; fence length adapts to body content. */
|
|
72
|
+
function fenceOut(code: string, lang: string): string {
|
|
73
|
+
// Drop a single trailing newline so we don't pad every closed fence with a
|
|
74
|
+
// blank line inside the code block; keep internal newlines intact.
|
|
75
|
+
const body = code.endsWith("\n") ? code.slice(0, -1) : code;
|
|
76
|
+
let tickLen = 3;
|
|
77
|
+
const runs = body.match(/`+/g);
|
|
78
|
+
if (runs) {
|
|
79
|
+
const max = Math.max(...runs.map((r) => r.length));
|
|
80
|
+
if (max >= tickLen) tickLen = max + 1;
|
|
81
|
+
}
|
|
82
|
+
const marker = "`".repeat(tickLen);
|
|
83
|
+
return marker + lang + "\n" + escapeCode(body) + "\n" + marker + "\n";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find a closing fence starting at `from` (body start).
|
|
88
|
+
* Closing fence: a line that is only ≥ minTicks backticks (optional trailing
|
|
89
|
+
* spaces / lang-like junk), CommonMark-style.
|
|
90
|
+
*
|
|
91
|
+
* Returns bodyEnd (exclusive, before the closing fence line's leading newline
|
|
92
|
+
* if any) and afterClose (index past the closing fence line + its newline).
|
|
93
|
+
*/
|
|
94
|
+
function findClosingFence(
|
|
95
|
+
src: string,
|
|
96
|
+
from: number,
|
|
97
|
+
minTicks: number,
|
|
98
|
+
): { bodyEnd: number; afterClose: number } | undefined {
|
|
99
|
+
let lineStart = from;
|
|
100
|
+
while (lineStart <= src.length) {
|
|
101
|
+
// Measure leading backticks on this line.
|
|
102
|
+
let j = lineStart;
|
|
103
|
+
while (j < src.length && src[j] === "`") j++;
|
|
104
|
+
const tickCount = j - lineStart;
|
|
105
|
+
if (tickCount >= minTicks) {
|
|
106
|
+
const nl = src.indexOf("\n", j);
|
|
107
|
+
const lineEnd = nl === -1 ? src.length : nl;
|
|
108
|
+
const rest = src.slice(j, lineEnd);
|
|
109
|
+
// Closing fence: rest empty/whitespace, or only a simple info string.
|
|
110
|
+
if (/^\s*$/.test(rest) || /^[A-Za-z0-9_+\-#./]*\s*$/.test(rest)) {
|
|
111
|
+
// bodyEnd: exclude the newline that precedes this line when the close
|
|
112
|
+
// is not the first character of the body (so code doesn't keep a
|
|
113
|
+
// trailing blank). When close is on the first body line (empty block),
|
|
114
|
+
// bodyEnd == from.
|
|
115
|
+
let bodyEnd = lineStart;
|
|
116
|
+
if (lineStart > from && src[lineStart - 1] === "\n") {
|
|
117
|
+
bodyEnd = lineStart - 1;
|
|
118
|
+
}
|
|
119
|
+
const afterClose = nl === -1 ? src.length : nl + 1;
|
|
120
|
+
return { bodyEnd, afterClose };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Advance to next line.
|
|
124
|
+
const nl = src.indexOf("\n", lineStart);
|
|
125
|
+
if (nl === -1) return undefined;
|
|
126
|
+
lineStart = nl + 1;
|
|
127
|
+
if (lineStart > src.length) return undefined;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function findNextFenceStart(src: string, from: number): number {
|
|
133
|
+
if (from === 0 && src.startsWith("```")) return 0;
|
|
134
|
+
let idx = from;
|
|
135
|
+
while (idx < src.length) {
|
|
136
|
+
const nl = src.indexOf("\n", idx);
|
|
137
|
+
if (nl === -1) return -1;
|
|
138
|
+
if (src.startsWith("```", nl + 1)) return nl + 1;
|
|
139
|
+
idx = nl + 1;
|
|
140
|
+
}
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sanitizeFenceLang(raw: string): string {
|
|
145
|
+
const t = raw.trim();
|
|
146
|
+
if (!t) return "";
|
|
147
|
+
// Telegram fence info is free-form but we keep it ASCII-safe.
|
|
148
|
+
return /^[A-Za-z0-9_+\-#./]+$/.test(t) ? t : "";
|
|
27
149
|
}
|
|
28
150
|
|
|
29
151
|
function renderTextBlock(text: string): string {
|
|
30
152
|
if (!text) return "";
|
|
31
153
|
return text
|
|
32
154
|
.split("\n")
|
|
33
|
-
// Drop stray orphan backtick lines (` or ``) left by an unbalanced/partial
|
|
34
|
-
// code fence — they otherwise render as a broken-looking lone "`". A real
|
|
35
|
-
// fence is ``` (3+) and is handled by the FENCE pass, so it's never seen here.
|
|
36
|
-
.filter((line) => !/^\s*`{1,2}\s*$/.test(line))
|
|
37
155
|
.map((line) => renderLine(line))
|
|
38
156
|
.join("\n");
|
|
39
157
|
}
|
|
@@ -46,9 +164,14 @@ function renderLine(line: string): string {
|
|
|
46
164
|
// Horizontal rule
|
|
47
165
|
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return "\u2014\u2014\u2014\u2014\u2014\u2014\u2014\u2014";
|
|
48
166
|
|
|
49
|
-
// Blockquote
|
|
50
|
-
|
|
51
|
-
|
|
167
|
+
// Blockquote — process body with lighter inline rules to avoid * / ` breakage
|
|
168
|
+
// inside long thinking dumps.
|
|
169
|
+
const quote = /^(>+)\s?(.*)$/.exec(line);
|
|
170
|
+
if (quote) {
|
|
171
|
+
const depth = (quote[1] ?? ">").length;
|
|
172
|
+
const prefix = ">".repeat(depth);
|
|
173
|
+
return prefix + renderQuoteInline(quote[2] ?? "");
|
|
174
|
+
}
|
|
52
175
|
|
|
53
176
|
// Unordered list
|
|
54
177
|
const ul = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
|
@@ -61,6 +184,42 @@ function renderLine(line: string): string {
|
|
|
61
184
|
return renderInline(line);
|
|
62
185
|
}
|
|
63
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Quote bodies (thinking): prefer plain escaping + simple `code` only.
|
|
189
|
+
* Nested **bold** and triple-backtick fences often break mid-stream thoughts.
|
|
190
|
+
*/
|
|
191
|
+
function renderQuoteInline(text: string): string {
|
|
192
|
+
let out = "";
|
|
193
|
+
let i = 0;
|
|
194
|
+
const n = text.length;
|
|
195
|
+
while (i < n) {
|
|
196
|
+
const c = text[i]!;
|
|
197
|
+
if (c === "`") {
|
|
198
|
+
let ticks = 1;
|
|
199
|
+
while (i + ticks < n && text[i + ticks] === "`") ticks++;
|
|
200
|
+
if (ticks >= 3) {
|
|
201
|
+
// Triple+ ticks inside a quote → escape as literals (never open a fence).
|
|
202
|
+
out += escapeMdV2("`".repeat(ticks));
|
|
203
|
+
i += ticks;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// Prefer matching same-length close; skip empty ``.
|
|
207
|
+
const end = findCloseTicks(text, i + ticks, ticks);
|
|
208
|
+
if (end !== -1 && end > i + ticks) {
|
|
209
|
+
out += "`" + escapeCode(text.slice(i + ticks, end)) + "`";
|
|
210
|
+
i = end + ticks;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
out += escapeMdV2("`".repeat(ticks));
|
|
214
|
+
i += ticks;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
out += escapeMdV2(c);
|
|
218
|
+
i += 1;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
64
223
|
/** Render inline markdown spans into MarkdownV2. */
|
|
65
224
|
function renderInline(text: string): string {
|
|
66
225
|
let out = "";
|
|
@@ -71,21 +230,32 @@ function renderInline(text: string): string {
|
|
|
71
230
|
const c = text[i]!;
|
|
72
231
|
const next = text[i + 1];
|
|
73
232
|
|
|
74
|
-
// Inline code
|
|
233
|
+
// Inline code — count backticks; never treat ``` as inline.
|
|
75
234
|
if (c === "`") {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
235
|
+
let ticks = 1;
|
|
236
|
+
while (i + ticks < n && text[i + ticks] === "`") ticks++;
|
|
237
|
+
if (ticks >= 3) {
|
|
238
|
+
out += escapeMdV2("`".repeat(ticks));
|
|
239
|
+
i += ticks;
|
|
80
240
|
continue;
|
|
81
241
|
}
|
|
242
|
+
const close = findCloseTicks(text, i + ticks, ticks);
|
|
243
|
+
if (close !== -1 && close > i + ticks) {
|
|
244
|
+
const body = text.slice(i + ticks, close);
|
|
245
|
+
out += "`" + escapeCode(body) + "`";
|
|
246
|
+
i = close + ticks;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
out += escapeMdV2("`".repeat(ticks));
|
|
250
|
+
i += ticks;
|
|
251
|
+
continue;
|
|
82
252
|
}
|
|
83
253
|
|
|
84
254
|
// Bold ** ** or __ __
|
|
85
255
|
if ((c === "*" && next === "*") || (c === "_" && next === "_")) {
|
|
86
256
|
const marker = c + c;
|
|
87
|
-
const end = text
|
|
88
|
-
if (end !== -1
|
|
257
|
+
const end = findBalanced(text, i + 2, marker);
|
|
258
|
+
if (end !== -1) {
|
|
89
259
|
out += "*" + renderInline(text.slice(i + 2, end)) + "*";
|
|
90
260
|
i = end + 2;
|
|
91
261
|
continue;
|
|
@@ -94,8 +264,8 @@ function renderInline(text: string): string {
|
|
|
94
264
|
|
|
95
265
|
// Strikethrough ~~ ~~
|
|
96
266
|
if (c === "~" && next === "~") {
|
|
97
|
-
const end = text
|
|
98
|
-
if (end !== -1
|
|
267
|
+
const end = findBalanced(text, i + 2, "~~");
|
|
268
|
+
if (end !== -1) {
|
|
99
269
|
out += "~" + renderInline(text.slice(i + 2, end)) + "~";
|
|
100
270
|
i = end + 2;
|
|
101
271
|
continue;
|
|
@@ -103,21 +273,34 @@ function renderInline(text: string): string {
|
|
|
103
273
|
}
|
|
104
274
|
|
|
105
275
|
// Italic * * (single)
|
|
106
|
-
if (c === "*") {
|
|
107
|
-
const end = text
|
|
108
|
-
if (end !== -1
|
|
276
|
+
if (c === "*" && next !== "*") {
|
|
277
|
+
const end = findSingleStar(text, i + 1);
|
|
278
|
+
if (end !== -1) {
|
|
109
279
|
out += "_" + renderInline(text.slice(i + 1, end)) + "_";
|
|
110
280
|
i = end + 1;
|
|
111
281
|
continue;
|
|
112
282
|
}
|
|
113
283
|
}
|
|
114
284
|
|
|
285
|
+
// Italic _ _ (single) — skip snake_case
|
|
286
|
+
if (c === "_" && next !== "_") {
|
|
287
|
+
const prev = i > 0 ? text[i - 1]! : " ";
|
|
288
|
+
if (!/\w/.test(prev)) {
|
|
289
|
+
const end = findSingleUnderscore(text, i + 1);
|
|
290
|
+
if (end !== -1) {
|
|
291
|
+
out += "_" + renderInline(text.slice(i + 1, end)) + "_";
|
|
292
|
+
i = end + 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
115
298
|
// Link [text](url)
|
|
116
299
|
if (c === "[") {
|
|
117
|
-
const link =
|
|
300
|
+
const link = parseLink(text, i);
|
|
118
301
|
if (link) {
|
|
119
|
-
out += "[" + renderInline(link
|
|
120
|
-
i
|
|
302
|
+
out += "[" + renderInline(link.text) + "](" + escapeUrl(link.url) + ")";
|
|
303
|
+
i = link.end;
|
|
121
304
|
continue;
|
|
122
305
|
}
|
|
123
306
|
}
|
|
@@ -128,3 +311,78 @@ function renderInline(text: string): string {
|
|
|
128
311
|
|
|
129
312
|
return out;
|
|
130
313
|
}
|
|
314
|
+
|
|
315
|
+
function findCloseTicks(text: string, from: number, ticks: number): number {
|
|
316
|
+
const needle = "`".repeat(ticks);
|
|
317
|
+
let idx = from;
|
|
318
|
+
while (idx < text.length) {
|
|
319
|
+
const at = text.indexOf(needle, idx);
|
|
320
|
+
if (at === -1) return -1;
|
|
321
|
+
// Don't stop on a longer run (e.g. looking for ` but hit ```).
|
|
322
|
+
const after = at + ticks;
|
|
323
|
+
if (after < text.length && text[after] === "`") {
|
|
324
|
+
// Skip the whole run.
|
|
325
|
+
let j = after;
|
|
326
|
+
while (j < text.length && text[j] === "`") j++;
|
|
327
|
+
idx = j;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
return at;
|
|
331
|
+
}
|
|
332
|
+
return -1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function findBalanced(text: string, from: number, marker: string): number {
|
|
336
|
+
// Don't span blank lines — keeps half-open ** from swallowing the rest of
|
|
337
|
+
// the message when the agent streams incomplete emphasis.
|
|
338
|
+
const end = text.indexOf(marker, from);
|
|
339
|
+
if (end === -1 || end <= from) return -1;
|
|
340
|
+
const mid = text.slice(from, end);
|
|
341
|
+
if (mid.includes("\n\n")) return -1;
|
|
342
|
+
return end;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function findSingleStar(text: string, from: number): number {
|
|
346
|
+
for (let i = from; i < text.length; i++) {
|
|
347
|
+
if (text[i] === "*" && text[i + 1] !== "*") {
|
|
348
|
+
if (text.slice(from, i).includes("\n")) return -1;
|
|
349
|
+
if (i > from) return i;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return -1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function findSingleUnderscore(text: string, from: number): number {
|
|
356
|
+
for (let i = from; i < text.length; i++) {
|
|
357
|
+
if (text[i] === "_" && text[i + 1] !== "_") {
|
|
358
|
+
if (text.slice(from, i).includes("\n")) return -1;
|
|
359
|
+
const next = text[i + 1] ?? " ";
|
|
360
|
+
if (/\w/.test(next)) continue;
|
|
361
|
+
if (i > from) return i;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return -1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseLink(text: string, start: number): { text: string; url: string; end: number } | undefined {
|
|
368
|
+
if (text[start] !== "[") return undefined;
|
|
369
|
+
let depth = 0;
|
|
370
|
+
let i = start;
|
|
371
|
+
for (; i < text.length; i++) {
|
|
372
|
+
if (text[i] === "[") depth++;
|
|
373
|
+
else if (text[i] === "]") {
|
|
374
|
+
depth--;
|
|
375
|
+
if (depth === 0) {
|
|
376
|
+
i++;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (depth !== 0 || text[i] !== "(") return undefined;
|
|
382
|
+
const linkText = text.slice(start + 1, i - 1);
|
|
383
|
+
const close = text.indexOf(")", i + 1);
|
|
384
|
+
if (close === -1) return undefined;
|
|
385
|
+
const url = text.slice(i + 1, close).trim();
|
|
386
|
+
if (!url || /\s/.test(url)) return undefined;
|
|
387
|
+
return { text: linkText, url, end: close + 1 };
|
|
388
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP plan updates → compact, professional status lines for Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Plan steps stay visible on the live stream (above the progress bar) so the
|
|
5
|
+
* user always sees what's done / in progress / pending.
|
|
6
|
+
*/
|
|
7
|
+
import type { SessionUpdate } from "../grok/types.js";
|
|
8
|
+
|
|
9
|
+
export type PlanStatus = "pending" | "in_progress" | "completed" | "cancelled" | string;
|
|
10
|
+
|
|
11
|
+
export interface PlanEntry {
|
|
12
|
+
content: string;
|
|
13
|
+
status: PlanStatus;
|
|
14
|
+
priority?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ICON: Record<string, string> = {
|
|
18
|
+
completed: "\u2705", // ✅
|
|
19
|
+
done: "\u2705",
|
|
20
|
+
finished: "\u2705",
|
|
21
|
+
in_progress: "\u25B6\uFE0F", // ▶️
|
|
22
|
+
inprogress: "\u25B6\uFE0F",
|
|
23
|
+
active: "\u25B6\uFE0F",
|
|
24
|
+
running: "\u25B6\uFE0F",
|
|
25
|
+
pending: "\u25CB", // ○
|
|
26
|
+
todo: "\u25CB",
|
|
27
|
+
cancelled: "\u2716", // ✖
|
|
28
|
+
canceled: "\u2716",
|
|
29
|
+
skipped: "\u2716",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a session/update plan payload into entries.
|
|
34
|
+
* Supports common ACP shapes: entries[], plan[], or steps[].
|
|
35
|
+
*/
|
|
36
|
+
export function parsePlanUpdate(u: SessionUpdate): PlanEntry[] | undefined {
|
|
37
|
+
const raw =
|
|
38
|
+
(u as { entries?: unknown }).entries ??
|
|
39
|
+
(u as { plan?: unknown }).plan ??
|
|
40
|
+
(u as { steps?: unknown }).steps ??
|
|
41
|
+
(u as { items?: unknown }).items;
|
|
42
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
43
|
+
|
|
44
|
+
const out: PlanEntry[] = [];
|
|
45
|
+
for (const item of raw) {
|
|
46
|
+
if (!item || typeof item !== "object") continue;
|
|
47
|
+
const rec = item as Record<string, unknown>;
|
|
48
|
+
const content = String(
|
|
49
|
+
rec.content ?? rec.text ?? rec.title ?? rec.description ?? rec.step ?? "",
|
|
50
|
+
)
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
if (!content) continue;
|
|
54
|
+
const status = String(rec.status ?? rec.state ?? "pending")
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/[\s-]+/g, "_");
|
|
58
|
+
const priority =
|
|
59
|
+
typeof rec.priority === "string"
|
|
60
|
+
? rec.priority
|
|
61
|
+
: typeof rec.priority === "number"
|
|
62
|
+
? String(rec.priority)
|
|
63
|
+
: undefined;
|
|
64
|
+
out.push({ content: content.slice(0, 200), status, priority });
|
|
65
|
+
}
|
|
66
|
+
return out.length ? out : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compact plan board for the live stream and status panel (always above the
|
|
71
|
+
* progress bar). Plain text + emoji — safe for both MarkdownV2 and plain API.
|
|
72
|
+
*/
|
|
73
|
+
export function renderPlanMarkdown(entries: PlanEntry[]): string {
|
|
74
|
+
if (!entries.length) return "";
|
|
75
|
+
const done = entries.filter((e) => isDone(e.status)).length;
|
|
76
|
+
const active = entries.filter((e) => isActive(e.status)).length;
|
|
77
|
+
const total = entries.length;
|
|
78
|
+
// Header: clipboard · done/total · optional "N active"
|
|
79
|
+
const head =
|
|
80
|
+
active > 0
|
|
81
|
+
? `\u{1F4CB} Plan \u00B7 ${done}/${total} \u00B7 ${active} active`
|
|
82
|
+
: `\u{1F4CB} Plan \u00B7 ${done}/${total}`;
|
|
83
|
+
const lines: string[] = [head];
|
|
84
|
+
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const icon = ICON[e.status] ?? "\u25CB";
|
|
87
|
+
// Minimalist: icon + step text (status is encoded in the icon)
|
|
88
|
+
const suffix = statusSuffix(e.status);
|
|
89
|
+
lines.push(`${icon}${suffix} ${e.content}`);
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One-line summary for status panel / cards. */
|
|
95
|
+
export function renderPlanOneLine(entries: PlanEntry[]): string {
|
|
96
|
+
if (!entries.length) return "";
|
|
97
|
+
const current = entries.find((e) => isActive(e.status));
|
|
98
|
+
const done = entries.filter((e) => isDone(e.status)).length;
|
|
99
|
+
const total = entries.length;
|
|
100
|
+
if (current) {
|
|
101
|
+
return `\u{1F4CB} ${done}/${total} \u00B7 \u25B6\uFE0F ${truncate(current.content, 80)}`;
|
|
102
|
+
}
|
|
103
|
+
if (done === total) return `\u{1F4CB} ${done}/${total} complete`;
|
|
104
|
+
const next = entries.find((e) => isPending(e.status));
|
|
105
|
+
if (next) return `\u{1F4CB} ${done}/${total} \u00B7 next: ${truncate(next.content, 70)}`;
|
|
106
|
+
return `\u{1F4CB} ${done}/${total}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isDone(s: string): boolean {
|
|
110
|
+
return s === "completed" || s === "done" || s === "finished";
|
|
111
|
+
}
|
|
112
|
+
function isActive(s: string): boolean {
|
|
113
|
+
return s === "in_progress" || s === "inprogress" || s === "active" || s === "running";
|
|
114
|
+
}
|
|
115
|
+
function isPending(s: string): boolean {
|
|
116
|
+
return s === "pending" || s === "todo" || s === "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Optional short tag after the icon for cancelled/skipped only. */
|
|
120
|
+
function statusSuffix(s: string): string {
|
|
121
|
+
if (s === "cancelled" || s === "canceled" || s === "skipped") return " skip";
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function truncate(s: string, n: number): string {
|
|
126
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
127
|
+
}
|