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
package/src/index.ts
CHANGED
|
@@ -6,35 +6,114 @@
|
|
|
6
6
|
* Lifetime rules (critical):
|
|
7
7
|
* - Prefer staying up over clean-but-dead exits.
|
|
8
8
|
* - Uncaught errors are logged; the process keeps polling Telegram.
|
|
9
|
-
* - grammY long-poll is restarted on transport/network death.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
9
|
+
* - grammY long-poll is restarted on transport/network death, 429, 409, etc.
|
|
10
|
+
* - Never exit without a clear stderr + log line.
|
|
11
|
+
* - Interactive `npm start` stays in-process (no silent detach re-exec).
|
|
12
|
+
* - Supervised launches (GROK_TG_SUPERVISED=1) may exit for external relaunch.
|
|
12
13
|
*/
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import type { Bot } from "grammy";
|
|
16
|
+
import { GrammyError, HttpError } from "grammy";
|
|
16
17
|
import { GrokClient } from "./grok/client.js";
|
|
17
18
|
import { createBot } from "./bot/bot.js";
|
|
18
|
-
import { CANONICAL_DIR,
|
|
19
|
+
import { CANONICAL_DIR, loadConfig } from "./config.js";
|
|
19
20
|
import { InstanceLock } from "./app/instance-lock.js";
|
|
21
|
+
import {
|
|
22
|
+
isIntentionalShutdown,
|
|
23
|
+
markIntentionalShutdown,
|
|
24
|
+
} from "./app/lifetime-flag.js";
|
|
20
25
|
import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
|
|
21
26
|
|
|
27
|
+
/** Last known reason for process end (read by exit/beforeExit handlers). */
|
|
28
|
+
let exitReason = "unknown";
|
|
29
|
+
/** Intentional exit in progress (SIGINT, fatal, lock, supervised relaunch). */
|
|
30
|
+
let shuttingDown = false;
|
|
31
|
+
/** beforeExit keep-alive armed at most once (avoid infinite 60s re-arm spam). */
|
|
32
|
+
let beforeExitKeepalive: ReturnType<typeof setInterval> | undefined;
|
|
33
|
+
/** Set immediately after config load; used by scream/shutdown. */
|
|
34
|
+
let log!: ReturnType<typeof createLogger>;
|
|
35
|
+
|
|
36
|
+
function noteExit(reason: string): void {
|
|
37
|
+
exitReason = reason;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function beginShutdown(reason: string): void {
|
|
41
|
+
shuttingDown = true;
|
|
42
|
+
markIntentionalShutdown(reason);
|
|
43
|
+
noteExit(reason);
|
|
44
|
+
if (beforeExitKeepalive) {
|
|
45
|
+
clearInterval(beforeExitKeepalive);
|
|
46
|
+
beforeExitKeepalive = undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scream(msg: string): void {
|
|
51
|
+
try {
|
|
52
|
+
process.stderr.write(`${msg}\n`);
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
process.stdout.write(`${msg}\n`);
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
log?.error(msg);
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
async function main(): Promise<void> {
|
|
23
69
|
process.stdout.write("\u{1F916} Grok Telegram Bot — starting…\n");
|
|
24
70
|
|
|
25
71
|
const cfg = loadConfig();
|
|
26
72
|
setLogLevel(cfg.logLevel);
|
|
27
73
|
enableFileLogging(cfg.logFile);
|
|
28
|
-
|
|
74
|
+
log = createLogger("main");
|
|
75
|
+
|
|
76
|
+
// Always leave a trail — silent process death was the top reliability bug.
|
|
77
|
+
process.on("exit", (code) => {
|
|
78
|
+
const line = `[main] process exit code=${code} reason=${exitReason}`;
|
|
79
|
+
try {
|
|
80
|
+
// File logger may already be gone; still try console.
|
|
81
|
+
console.error(line);
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
process.on("beforeExit", (code) => {
|
|
87
|
+
// Never fight an intentional shutdown / fatal exit / updater re-exec.
|
|
88
|
+
if (shuttingDown || isIntentionalShutdown()) return;
|
|
89
|
+
if (beforeExitKeepalive) return; // already holding the process open
|
|
90
|
+
// Event loop emptied without an intentional shutdown — keep a handle alive
|
|
91
|
+
// so we don't vanish with code 0 and no explanation.
|
|
92
|
+
scream(
|
|
93
|
+
`\u26A0\uFE0F Event loop emptying (beforeExit code=${code}, reason=${exitReason}). ` +
|
|
94
|
+
`Keeping process alive — this usually means polling stopped unexpectedly.`,
|
|
95
|
+
);
|
|
96
|
+
noteExit(`beforeExit:${exitReason}`);
|
|
97
|
+
// One ref'd interval (not stacking setTimeouts that re-fire beforeExit forever).
|
|
98
|
+
beforeExitKeepalive = setInterval(() => {
|
|
99
|
+
if (shuttingDown || isIntentionalShutdown()) {
|
|
100
|
+
if (beforeExitKeepalive) clearInterval(beforeExitKeepalive);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
log?.warn(`beforeExit keepalive still holding process (reason=${exitReason})`);
|
|
104
|
+
}, 60_000);
|
|
105
|
+
beforeExitKeepalive.ref?.();
|
|
106
|
+
});
|
|
29
107
|
|
|
30
108
|
// Single-instance guard: kill any ghost/duplicate already polling this token.
|
|
31
109
|
const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
|
|
32
110
|
if (cfg.singleInstance && !(await lock.acquire())) {
|
|
33
111
|
const msg =
|
|
34
112
|
"Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.";
|
|
113
|
+
beginShutdown("instance-lock-held");
|
|
35
114
|
log.warn(msg);
|
|
36
|
-
|
|
37
|
-
process.exit(
|
|
115
|
+
scream(`\u26D4 ${msg}`);
|
|
116
|
+
process.exit(1);
|
|
38
117
|
}
|
|
39
118
|
|
|
40
119
|
log.info("starting Grok Telegram Bot");
|
|
@@ -47,13 +126,15 @@ async function main(): Promise<void> {
|
|
|
47
126
|
// partially inconsistent one. Registering these handlers overrides Node's
|
|
48
127
|
// default "print and exit" for uncaughtException.
|
|
49
128
|
process.on("uncaughtException", (err) => {
|
|
129
|
+
noteExit(`uncaughtException:${err.message}`);
|
|
50
130
|
log.error("uncaughtException (process stays up):", err);
|
|
131
|
+
scream(`\u26A0\uFE0F uncaughtException (staying up): ${err.stack || err.message}`);
|
|
51
132
|
});
|
|
52
133
|
process.on("unhandledRejection", (reason) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
);
|
|
134
|
+
const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
|
|
135
|
+
noteExit(`unhandledRejection:${msg.slice(0, 120)}`);
|
|
136
|
+
log.error("unhandledRejection (process stays up):", reason instanceof Error ? reason : String(reason));
|
|
137
|
+
scream(`\u26A0\uFE0F unhandledRejection (staying up): ${msg}`);
|
|
57
138
|
});
|
|
58
139
|
|
|
59
140
|
const grok = new GrokClient({
|
|
@@ -75,6 +156,7 @@ async function main(): Promise<void> {
|
|
|
75
156
|
} catch (e) {
|
|
76
157
|
const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
77
158
|
log.error(`Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
|
|
159
|
+
scream(`\u26A0\uFE0F Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
|
|
78
160
|
await sleep(wait);
|
|
79
161
|
}
|
|
80
162
|
}
|
|
@@ -94,7 +176,9 @@ async function main(): Promise<void> {
|
|
|
94
176
|
break;
|
|
95
177
|
} catch (e) {
|
|
96
178
|
const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
97
|
-
|
|
179
|
+
const detail = formatTelegramError(e);
|
|
180
|
+
log.error(`createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
|
|
181
|
+
scream(`\u26A0\uFE0F createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
|
|
98
182
|
await sleep(wait);
|
|
99
183
|
}
|
|
100
184
|
}
|
|
@@ -102,11 +186,11 @@ async function main(): Promise<void> {
|
|
|
102
186
|
scheduler!.start();
|
|
103
187
|
await updater!.start();
|
|
104
188
|
|
|
105
|
-
|
|
106
|
-
const shutdown = (code: number): void => {
|
|
189
|
+
const shutdown = (code: number, reason: string): void => {
|
|
107
190
|
if (shuttingDown) return;
|
|
108
|
-
|
|
109
|
-
log.info(
|
|
191
|
+
beginShutdown(reason);
|
|
192
|
+
log.info(`shutting down (code=${code}, reason=${reason})…`);
|
|
193
|
+
scream(`\u{1F6D1} Shutting down (code=${code}, reason=${reason})`);
|
|
110
194
|
try {
|
|
111
195
|
scheduler!.stop();
|
|
112
196
|
} catch {
|
|
@@ -130,45 +214,66 @@ async function main(): Promise<void> {
|
|
|
130
214
|
|
|
131
215
|
grok.on("restarted", () => log.info("Grok bridge re-bound; sessions continue on next message."));
|
|
132
216
|
|
|
133
|
-
process.on("SIGINT", () => shutdown(0));
|
|
134
|
-
process.on("SIGTERM", () => shutdown(0));
|
|
217
|
+
process.on("SIGINT", () => shutdown(0, "SIGINT"));
|
|
218
|
+
process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
|
|
135
219
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
220
|
+
// Keepalive + lock heartbeat — REF'd so the process cannot exit with an empty
|
|
221
|
+
// event loop while we still intend to run (unref was a silent-death footgun).
|
|
222
|
+
let botUsername = "?";
|
|
223
|
+
const keepalive = setInterval(() => {
|
|
139
224
|
try {
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
225
|
+
if (shuttingDown || isIntentionalShutdown()) return;
|
|
226
|
+
lock.touch();
|
|
227
|
+
log.info(
|
|
228
|
+
`keepalive: alive as @${botUsername} polling=${bot!.isRunning()} ` +
|
|
229
|
+
`pid=${process.pid} uptime=${Math.floor(process.uptime())}s`,
|
|
230
|
+
);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
log.warn(`keepalive tick failed: ${(e as Error).message}`);
|
|
143
233
|
}
|
|
144
|
-
}, 60_000);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// that
|
|
151
|
-
while (!shuttingDown) {
|
|
152
|
-
await runPollingForever(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
234
|
+
}, 5 * 60_000);
|
|
235
|
+
// Explicitly ref — do not unref.
|
|
236
|
+
keepalive.ref?.();
|
|
237
|
+
|
|
238
|
+
// Keep long-polling forever. On network / 409 / 429 / grammY fatal, wait and
|
|
239
|
+
// restart the poller in-process. Interactive runs never detach-relaunch
|
|
240
|
+
// (that looked like a silent death in the terminal).
|
|
241
|
+
while (!shuttingDown && !isIntentionalShutdown()) {
|
|
242
|
+
await runPollingForever(
|
|
243
|
+
bot!,
|
|
244
|
+
log,
|
|
245
|
+
() => shuttingDown || isIntentionalShutdown(),
|
|
246
|
+
(name) => {
|
|
247
|
+
botUsername = name;
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
if (shuttingDown || isIntentionalShutdown()) break;
|
|
251
|
+
|
|
252
|
+
// runPollingForever should only return when shuttingDown; if not, recover.
|
|
253
|
+
scream("\u26A0\uFE0F Telegram polling loop returned unexpectedly; recovering in-process");
|
|
254
|
+
log.error("Telegram polling loop returned unexpectedly; recovering in-process (no silent exit)");
|
|
255
|
+
noteExit("polling-loop-returned");
|
|
256
|
+
|
|
257
|
+
if (process.env.GROK_TG_SUPERVISED === "1") {
|
|
258
|
+
log.info("supervised mode — exiting for external relaunch");
|
|
259
|
+
noteExit("supervised-relaunch");
|
|
260
|
+
clearInterval(keepalive);
|
|
261
|
+
shutdown(1, "supervised-relaunch");
|
|
159
262
|
return;
|
|
160
263
|
}
|
|
161
|
-
|
|
162
|
-
|
|
264
|
+
|
|
265
|
+
// Stay in this process — re-enter polling after a short pause.
|
|
266
|
+
await sleep(3000);
|
|
163
267
|
}
|
|
164
|
-
clearInterval(
|
|
268
|
+
clearInterval(keepalive);
|
|
165
269
|
}
|
|
166
270
|
|
|
167
|
-
/** grammY start with automatic recovery. */
|
|
271
|
+
/** grammY start with automatic recovery and loud classification of API errors. */
|
|
168
272
|
async function runPollingForever(
|
|
169
273
|
bot: Bot,
|
|
170
274
|
log: ReturnType<typeof createLogger>,
|
|
171
275
|
isShuttingDown: () => boolean,
|
|
276
|
+
onOnline: (username: string) => void,
|
|
172
277
|
): Promise<void> {
|
|
173
278
|
let attempt = 0;
|
|
174
279
|
while (!isShuttingDown()) {
|
|
@@ -176,20 +281,25 @@ async function runPollingForever(
|
|
|
176
281
|
attempt = 0;
|
|
177
282
|
await bot.start({
|
|
178
283
|
onStart: (info) => {
|
|
284
|
+
onOnline(info.username);
|
|
179
285
|
log.info(`bot online as @${info.username}`);
|
|
180
286
|
process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
|
|
181
287
|
},
|
|
182
288
|
});
|
|
183
|
-
// bot.start resolves when polling is stopped (bot.stop).
|
|
289
|
+
// bot.start resolves when polling is stopped (bot.stop) or loop ends.
|
|
184
290
|
if (isShuttingDown()) return;
|
|
185
291
|
log.warn("bot.start resolved without shutdown; restarting polling in 2s");
|
|
292
|
+
scream("\u26A0\uFE0F bot.start resolved without shutdown; restarting polling in 2s");
|
|
186
293
|
await sleep(2000);
|
|
187
294
|
} catch (e) {
|
|
188
295
|
attempt++;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
296
|
+
const classified = classifyPollingError(e);
|
|
297
|
+
const wait = classified.waitMs ?? Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
|
|
298
|
+
const line =
|
|
299
|
+
`Telegram polling failed (attempt ${attempt}, ${classified.kind}): ${classified.message}; ` +
|
|
300
|
+
`retry in ${wait}ms`;
|
|
301
|
+
log.error(line);
|
|
302
|
+
scream(`\u26A0\uFE0F ${line}`);
|
|
193
303
|
try {
|
|
194
304
|
await bot.stop();
|
|
195
305
|
} catch {
|
|
@@ -200,32 +310,49 @@ async function runPollingForever(
|
|
|
200
310
|
}
|
|
201
311
|
}
|
|
202
312
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
313
|
+
function classifyPollingError(e: unknown): { kind: string; message: string; waitMs?: number } {
|
|
314
|
+
const message = formatTelegramError(e);
|
|
315
|
+
if (e instanceof GrammyError) {
|
|
316
|
+
if (e.error_code === 429) {
|
|
317
|
+
const ra = e.parameters?.retry_after;
|
|
318
|
+
const waitMs = typeof ra === "number" ? (ra + 1) * 1000 : 10_000;
|
|
319
|
+
return { kind: "429-rate-limit", message, waitMs };
|
|
320
|
+
}
|
|
321
|
+
if (e.error_code === 409) {
|
|
322
|
+
return {
|
|
323
|
+
kind: "409-conflict",
|
|
324
|
+
message: `${message} (another getUpdates consumer for this token?)`,
|
|
325
|
+
waitMs: 15_000,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (e.error_code === 401) {
|
|
329
|
+
return {
|
|
330
|
+
kind: "401-unauthorized",
|
|
331
|
+
message: `${message} (check TELEGRAM_BOT_TOKEN)`,
|
|
332
|
+
waitMs: 60_000,
|
|
333
|
+
};
|
|
222
334
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
335
|
+
return { kind: `grammy-${e.error_code}`, message };
|
|
336
|
+
}
|
|
337
|
+
if (e instanceof HttpError) {
|
|
338
|
+
return { kind: "http-network", message, waitMs: 5_000 };
|
|
339
|
+
}
|
|
340
|
+
const msg = (e as Error)?.message ?? String(e);
|
|
341
|
+
if (/ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang|fetch failed|network/i.test(msg)) {
|
|
342
|
+
return { kind: "network", message: msg, waitMs: 5_000 };
|
|
343
|
+
}
|
|
344
|
+
return { kind: "unknown", message: msg };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function formatTelegramError(e: unknown): string {
|
|
348
|
+
if (e instanceof GrammyError) {
|
|
349
|
+
const ra = e.parameters?.retry_after;
|
|
350
|
+
const extra = typeof ra === "number" ? ` retry_after=${ra}s` : "";
|
|
351
|
+
return `${e.message} (code ${e.error_code}${extra})`;
|
|
228
352
|
}
|
|
353
|
+
if (e instanceof HttpError) return `HttpError: ${e.message}`;
|
|
354
|
+
if (e instanceof Error) return e.message;
|
|
355
|
+
return String(e);
|
|
229
356
|
}
|
|
230
357
|
|
|
231
358
|
function sleep(ms: number): Promise<void> {
|
|
@@ -233,7 +360,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
233
360
|
}
|
|
234
361
|
|
|
235
362
|
main().catch((err) => {
|
|
236
|
-
|
|
237
|
-
|
|
363
|
+
// Mark intentional exit so beforeExit keep-alive cannot swallow a real fatal.
|
|
364
|
+
beginShutdown(`fatal:${err instanceof Error ? err.message : String(err)}`);
|
|
365
|
+
const text = err instanceof Error ? err.stack || err.message : String(err);
|
|
366
|
+
console.error("Fatal:", text);
|
|
367
|
+
scream(`\u274C Fatal (exiting in 1s): ${text}`);
|
|
238
368
|
setTimeout(() => process.exit(1), 1000);
|
|
239
369
|
});
|
package/src/projects/manager.ts
CHANGED
|
@@ -34,6 +34,13 @@ export class ProjectManager {
|
|
|
34
34
|
|
|
35
35
|
/** List projects, de-duplicated by (case-insensitive) name. */
|
|
36
36
|
list(limit = 100): ProjectEntry[] {
|
|
37
|
+
const out = this.listAll();
|
|
38
|
+
if (!Number.isFinite(limit) || limit <= 0 || limit >= out.length) return out;
|
|
39
|
+
return out.slice(0, limit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Full catalog (no slice). Used by forum topic setup for 1000+ projects. */
|
|
43
|
+
listAll(): ProjectEntry[] {
|
|
37
44
|
const byName = new Map<string, ProjectEntry>();
|
|
38
45
|
|
|
39
46
|
for (const root of this.roots) {
|
|
@@ -62,17 +69,23 @@ export class ProjectManager {
|
|
|
62
69
|
|
|
63
70
|
// Freshest first (directory mtime); callers may refine `lastUsed` with
|
|
64
71
|
// session activity and re-sort. Alphabetical as a stable tiebreak.
|
|
65
|
-
|
|
72
|
+
return [...byName.values()].sort(
|
|
66
73
|
(a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name),
|
|
67
74
|
);
|
|
68
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Exact catalog name match (case-insensitive). */
|
|
78
|
+
findExactByName(name: string): ProjectEntry | undefined {
|
|
79
|
+
const key = name.trim().toLowerCase();
|
|
80
|
+
if (!key) return undefined;
|
|
81
|
+
return this.listAll().find((p) => p.name.toLowerCase() === key);
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
/** Projects whose name contains the query (case-insensitive). */
|
|
72
85
|
search(query: string, limit = 100): ProjectEntry[] {
|
|
73
86
|
const q = query.trim().toLowerCase();
|
|
74
87
|
if (!q) return this.list(limit);
|
|
75
|
-
return this.
|
|
88
|
+
return this.listAll()
|
|
76
89
|
.filter((p) => p.name.toLowerCase().includes(q))
|
|
77
90
|
.slice(0, limit);
|
|
78
91
|
}
|
package/src/render/chunk.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Split a MarkdownV2 string into Telegram-sized chunks (<= 4096 chars) without
|
|
3
|
-
* breaking code fences. If a split happens inside a
|
|
4
|
-
* closed before the boundary and reopened in the next chunk.
|
|
3
|
+
* breaking code fences. If a split happens inside a fenced block, the block is
|
|
4
|
+
* closed before the boundary and reopened in the next chunk (same tick length).
|
|
5
5
|
*/
|
|
6
6
|
const LIMIT = 4000; // headroom under Telegram's 4096 hard limit
|
|
7
7
|
|
|
@@ -12,18 +12,19 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
|
|
|
12
12
|
const chunks: string[] = [];
|
|
13
13
|
let current: string[] = [];
|
|
14
14
|
let size = 0;
|
|
15
|
-
|
|
15
|
+
/** Open fence: tick count + optional lang; null when outside a fence. */
|
|
16
|
+
let openFence: { ticks: number; lang: string } | null = null;
|
|
16
17
|
|
|
17
18
|
const flush = (): void => {
|
|
18
19
|
if (current.length === 0) return;
|
|
19
20
|
let body = current.join("\n");
|
|
20
|
-
if (
|
|
21
|
+
if (openFence) body += "\n" + "`".repeat(openFence.ticks); // close dangling fence
|
|
21
22
|
chunks.push(body);
|
|
22
23
|
current = [];
|
|
23
24
|
size = 0;
|
|
24
|
-
if (
|
|
25
|
-
// Reopen the fence at the top of the next chunk.
|
|
26
|
-
const reopen = "
|
|
25
|
+
if (openFence) {
|
|
26
|
+
// Reopen the fence at the top of the next chunk (preserve tick length).
|
|
27
|
+
const reopen = "`".repeat(openFence.ticks) + openFence.lang;
|
|
27
28
|
current.push(reopen);
|
|
28
29
|
size = reopen.length + 1;
|
|
29
30
|
}
|
|
@@ -31,9 +32,9 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
|
|
|
31
32
|
|
|
32
33
|
for (const rawLine of lines) {
|
|
33
34
|
const line = rawLine;
|
|
34
|
-
const fenceMatch =
|
|
35
|
+
const fenceMatch = /^(```+)(.*)$/.exec(line);
|
|
35
36
|
|
|
36
|
-
// Hard-split a single oversized line.
|
|
37
|
+
// Hard-split a single oversized line (never mid-fence marker line).
|
|
37
38
|
if (line.length + 1 > limit && fenceMatch === null) {
|
|
38
39
|
flush();
|
|
39
40
|
for (let i = 0; i < line.length; i += limit) {
|
|
@@ -48,7 +49,13 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
|
|
|
48
49
|
size += line.length + 1;
|
|
49
50
|
|
|
50
51
|
if (fenceMatch) {
|
|
51
|
-
|
|
52
|
+
const ticks = fenceMatch[1]!.length;
|
|
53
|
+
const lang = (fenceMatch[2] ?? "").trim();
|
|
54
|
+
if (!openFence) {
|
|
55
|
+
openFence = { ticks, lang };
|
|
56
|
+
} else if (ticks >= openFence.ticks) {
|
|
57
|
+
openFence = null;
|
|
58
|
+
}
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
|
package/src/render/hashtags.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface TagInput {
|
|
|
10
10
|
projectName?: string;
|
|
11
11
|
cwd?: string;
|
|
12
12
|
sessionId?: string;
|
|
13
|
+
/** Short id for a single user turn; becomes `#prompt_<id>`. */
|
|
14
|
+
promptId?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/** Sanitise a value into a Telegram-safe hashtag body (letters/digits/_ only). */
|
|
@@ -25,10 +27,12 @@ export function tagSafe(v: string): string {
|
|
|
25
27
|
/**
|
|
26
28
|
* Build the hashtag footer. `#proj_` is always present; `#sess_` is added only
|
|
27
29
|
* when the session id is known, so partial callers (e.g. a static /history view)
|
|
28
|
-
* still tag consistently.
|
|
30
|
+
* still tag consistently. `#prompt_` is added when a turn-level id is known.
|
|
31
|
+
* Order: project · session · prompt.
|
|
29
32
|
*/
|
|
30
33
|
export function sessionHashtags(input: TagInput): string {
|
|
31
34
|
const tags = [`#proj_${tagSafe(input.projectName || basename(input.cwd || "") || "none")}`];
|
|
32
35
|
if (input.sessionId) tags.push(`#sess_${tagSafe(input.sessionId.slice(0, 8))}`);
|
|
36
|
+
if (input.promptId) tags.push(`#prompt_${tagSafe(input.promptId)}`);
|
|
33
37
|
return tags.join(" ");
|
|
34
38
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session "comment" shown on Running / Sessions cards:
|
|
3
|
-
* •
|
|
4
|
-
* •
|
|
3
|
+
* • always — last user prompt (≤ COMMENT_MAX)
|
|
4
|
+
* • while busy — also last AI agent thinking (≤ COMMENT_MAX), second line
|
|
5
5
|
*
|
|
6
6
|
* Built only from data already in the turn — no extra agent prompt (those
|
|
7
7
|
* leaked into chat/history). Display-only; never truncates agent context.
|
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
} from "./tool-call-detail.js";
|
|
19
19
|
import type { FileOp } from "./file-summary.js";
|
|
20
20
|
|
|
21
|
-
/** Max length of
|
|
22
|
-
export const COMMENT_MAX =
|
|
21
|
+
/** Max length of each card comment line (user prompt or thinking). */
|
|
22
|
+
export const COMMENT_MAX = 250;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Legacy marker for the removed silent AI card-summary prompt. Kept only so
|
|
@@ -55,13 +55,24 @@ export function stripDirectiveWrappers(raw: string): string {
|
|
|
55
55
|
const marker = "User's new message:";
|
|
56
56
|
const i = t.lastIndexOf(marker);
|
|
57
57
|
if (i !== -1) t = t.slice(i + marker.length);
|
|
58
|
-
|
|
59
|
-
|
|
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 = "";
|
|
60
71
|
return t.trim();
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/** Strip bot directives so a user prompt is card-friendly. */
|
|
64
|
-
export function cleanUserPreview(raw: string, max =
|
|
75
|
+
export function cleanUserPreview(raw: string, max = COMMENT_MAX): string {
|
|
65
76
|
let t = stripDirectiveWrappers(raw);
|
|
66
77
|
// Import confirm prompts are noise on cards.
|
|
67
78
|
if (/session import complete/i.test(t)) return "";
|
|
@@ -69,9 +80,55 @@ export function cleanUserPreview(raw: string, max = 80): string {
|
|
|
69
80
|
if (/^SELF-RECHECK \(automatic quality pass/i.test(t)) return "Self-recheck";
|
|
70
81
|
if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t)) return "";
|
|
71
82
|
if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t)) return "";
|
|
83
|
+
if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t)) return "Telegram bridge";
|
|
72
84
|
return cleanCommentLine(t, max);
|
|
73
85
|
}
|
|
74
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
|
+
|
|
75
132
|
/**
|
|
76
133
|
* Pull a human-useful outcome snippet from the assistant's streamed reply.
|
|
77
134
|
* Prefers the closing sentences (where conclusions land) over mid-turn "I'll…".
|