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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only truncation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Long tool outputs, diffs, and reasoning are shortened for Telegram so messages
|
|
5
|
+
* stay readable and under size limits. Session history / agent context is
|
|
6
|
+
* unaffected — these helpers never touch what is stored on disk for context.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Ellipsis used in truncated regions (single character). */
|
|
10
|
+
const ELLIPSIS = "\u2026";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keep the start of `text` when longer than `max` (classic head truncate).
|
|
14
|
+
* Prefer {@link truncateMiddle} for long command/diff/reasoning bodies.
|
|
15
|
+
*/
|
|
16
|
+
export function truncateHead(text: string, max: number): string {
|
|
17
|
+
if (max <= 0) return "";
|
|
18
|
+
if (text.length <= max) return text;
|
|
19
|
+
if (max === 1) return ELLIPSIS;
|
|
20
|
+
return text.slice(0, max - 1) + ELLIPSIS;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Keep both ends of a long string, cutting the middle with an explicit note.
|
|
25
|
+
* Head ~40% / tail ~60% of the budget so recent terminal output stays visible.
|
|
26
|
+
*/
|
|
27
|
+
export function truncateMiddle(text: string, max: number): string {
|
|
28
|
+
if (max <= 0) return "";
|
|
29
|
+
if (text.length <= max) return text;
|
|
30
|
+
if (max <= 24) return truncateHead(text, max);
|
|
31
|
+
|
|
32
|
+
const omitted = text.length;
|
|
33
|
+
const marker = `\n${ELLIPSIS} (${omitted.toLocaleString("en-US")} chars total; middle omitted) ${ELLIPSIS}\n`;
|
|
34
|
+
const budget = max - marker.length;
|
|
35
|
+
if (budget < 8) return truncateHead(text, max);
|
|
36
|
+
|
|
37
|
+
const headLen = Math.max(4, Math.floor(budget * 0.4));
|
|
38
|
+
const tailLen = Math.max(4, budget - headLen);
|
|
39
|
+
// Avoid overlapping when max is only slightly under length.
|
|
40
|
+
if (headLen + tailLen >= text.length) return text;
|
|
41
|
+
return text.slice(0, headLen) + marker + text.slice(text.length - tailLen);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Middle-truncate a multi-line block by whole lines when possible (better for
|
|
46
|
+
* diffs and command output). Falls back to character middle-truncate.
|
|
47
|
+
*/
|
|
48
|
+
export function truncateMiddleLines(text: string, maxLines: number, maxChars = 12_000): string {
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
let body: string;
|
|
51
|
+
if (lines.length <= maxLines) {
|
|
52
|
+
body = text;
|
|
53
|
+
} else {
|
|
54
|
+
const headN = Math.max(1, Math.floor(maxLines * 0.45));
|
|
55
|
+
const tailN = Math.max(1, maxLines - headN);
|
|
56
|
+
const omitted = lines.length - headN - tailN;
|
|
57
|
+
body = [
|
|
58
|
+
...lines.slice(0, headN),
|
|
59
|
+
`${ELLIPSIS} (${omitted} lines omitted) ${ELLIPSIS}`,
|
|
60
|
+
...lines.slice(lines.length - tailN),
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
return truncateMiddle(body, maxChars);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Live terminal display: keep the first output line + the last `tailLines`
|
|
68
|
+
* lines so long command runs update in place without spamming new blocks.
|
|
69
|
+
* Display-only — full text stays in the agent session / tool snapshot.
|
|
70
|
+
*/
|
|
71
|
+
export function formatLiveTerminalOutput(full: string, tailLines = 12, maxChars = 3500): string {
|
|
72
|
+
const normalized = full.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
73
|
+
const lines = normalized.split("\n");
|
|
74
|
+
// Drop trailing empty lines for a cleaner tail.
|
|
75
|
+
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
76
|
+
if (lines.length === 0) return "";
|
|
77
|
+
if (lines.length <= tailLines + 1) {
|
|
78
|
+
return truncateMiddle(lines.join("\n"), maxChars);
|
|
79
|
+
}
|
|
80
|
+
const first = lines[0]!;
|
|
81
|
+
const tail = lines.slice(-tailLines);
|
|
82
|
+
const omitted = lines.length - 1 - tailLines;
|
|
83
|
+
const body = [first, `${ELLIPSIS} (${omitted} lines omitted; live tail) ${ELLIPSIS}`, ...tail].join("\n");
|
|
84
|
+
return truncateMiddle(body, maxChars);
|
|
85
|
+
}
|
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
|
+
}
|
package/src/service/windows.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { runSafe } from "./platform.js";
|
|
11
|
+
import { launchDetached, runSafe } from "./platform.js";
|
|
12
12
|
import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
|
|
13
13
|
|
|
14
14
|
const TASK = "GrokTelegramBot";
|
|
@@ -105,7 +105,11 @@ export const windowsController: ServiceController = {
|
|
|
105
105
|
} catch (e) {
|
|
106
106
|
return fail(`Startup-folder install failed: ${(e as Error).message}`);
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
// Detached: the VBS is a forever-restart loop — never wait on it (hangs install/start/restart).
|
|
109
|
+
if (!isRunning(spec)) {
|
|
110
|
+
const launched = launchDetached("wscript.exe", [startupVbs]);
|
|
111
|
+
if (!launched.ok) return fail(`Installed launcher but failed to start: ${launched.out}`);
|
|
112
|
+
}
|
|
109
113
|
return ok(
|
|
110
114
|
`Installed via the Startup folder — starts hidden at logon, no admin needed — and launched it.\n` +
|
|
111
115
|
`(Tip: run "grok-tg install" from an elevated terminal to use a hidden Scheduled Task instead.)`,
|
|
@@ -124,13 +128,21 @@ export const windowsController: ServiceController = {
|
|
|
124
128
|
async start(spec) {
|
|
125
129
|
if (isRunning(spec)) return ok("Already running.");
|
|
126
130
|
if (taskInstalled()) {
|
|
131
|
+
// schtasks /Run returns once the task is queued (does not wait for the bot).
|
|
127
132
|
const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
|
|
128
133
|
return res.ok ? ok("Started.") : fail(res.out);
|
|
129
134
|
}
|
|
130
135
|
const startupVbs = startupVbsPath();
|
|
131
136
|
if (startupVbs && existsSync(startupVbs)) {
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
// Forever-restart VBS — must be detached or this CLI never returns.
|
|
138
|
+
const launched = launchDetached("wscript.exe", [startupVbs]);
|
|
139
|
+
return launched.ok ? ok("Started.") : fail(launched.out);
|
|
140
|
+
}
|
|
141
|
+
// Local run-service.vbs in the package dir (task points here when elevated).
|
|
142
|
+
const local = vbsPath(spec);
|
|
143
|
+
if (existsSync(local)) {
|
|
144
|
+
const launched = launchDetached("wscript.exe", [local]);
|
|
145
|
+
return launched.ok ? ok("Started.") : fail(launched.out);
|
|
134
146
|
}
|
|
135
147
|
return fail(`Not installed. Run "grok-tg install" first.`);
|
|
136
148
|
},
|
|
@@ -168,20 +180,32 @@ function entryOf(spec: LaunchSpec): string {
|
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
function vbsLauncher(spec: LaunchSpec): string {
|
|
183
|
+
// Forever-restart loop: wait for the bot process to exit, pause, relaunch.
|
|
184
|
+
// Prevents silent death when the node process crashes. GROK_TG_SUPERVISED=1
|
|
185
|
+
// tells the bot a supervisor will relaunch it (auto-update exits cleanly).
|
|
171
186
|
const cmd = `""${spec.nodePath}"" ${spec.args.map((a) => `""${a}""`).join(" ")}`;
|
|
172
187
|
return [
|
|
173
188
|
'Set sh = CreateObject("WScript.Shell")',
|
|
174
189
|
`sh.CurrentDirectory = "${spec.cwd}"`,
|
|
175
|
-
|
|
190
|
+
'Set env = sh.Environment("PROCESS")',
|
|
191
|
+
'env("GROK_TG_SUPERVISED") = "1"',
|
|
192
|
+
"Do",
|
|
193
|
+
` sh.Run "${cmd}", 0, True`,
|
|
194
|
+
" WScript.Sleep 3000",
|
|
195
|
+
"Loop",
|
|
176
196
|
].join("\r\n");
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
function killScript(entry: string): string {
|
|
180
200
|
const safe = entry.replace(/'/g, "''");
|
|
201
|
+
// Kill the forever-restart VBS host *before* node, otherwise the 3s loop
|
|
202
|
+
// respawns the bot right after "stop". Targets our service / Startup launchers.
|
|
181
203
|
return [
|
|
204
|
+
`$vbs = Get-CimInstance Win32_Process -Filter "Name='wscript.exe' OR Name='cscript.exe'" | Where-Object { $_.CommandLine -like '*GrokTelegramBot.vbs*' -or $_.CommandLine -like '*run-service.vbs*' };`,
|
|
205
|
+
`$vbs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
|
|
182
206
|
`$p = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' };`,
|
|
183
207
|
`$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
|
|
184
|
-
`"killed " + (@($p).Count)`,
|
|
208
|
+
`"killed " + (@($p).Count) + " node, " + (@($vbs).Count) + " vbs"`,
|
|
185
209
|
].join(" ");
|
|
186
210
|
}
|
|
187
211
|
|
package/src/sessions/history.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
6
6
|
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
7
7
|
import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
8
|
+
import {
|
|
9
|
+
extractTelegramActions,
|
|
10
|
+
TELEGRAM_BRIDGE_MARKER,
|
|
11
|
+
TELEGRAM_BRIDGE_RESULTS_MARKER,
|
|
12
|
+
} from "../render/telegram-bridge.js";
|
|
8
13
|
import type { HistoryEntry, HistoryRole } from "./types.js";
|
|
9
14
|
|
|
10
15
|
const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
|
|
@@ -46,6 +51,66 @@ export function jsonlMtimeMs(jsonlPath: string): number {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Best-effort card blurb from the tail of a session log: last assistant prose
|
|
56
|
+
* (what was solved), else last user prompt. Skips import-confirm noise.
|
|
57
|
+
* @deprecated Prefer {@link readLastUserPrompt} for session card comments.
|
|
58
|
+
*/
|
|
59
|
+
export function readLastCardSummary(jsonlPath: string, maxEntries = 30): string {
|
|
60
|
+
const entries = readHistory(jsonlPath, maxEntries);
|
|
61
|
+
if (entries.length === 0) return "";
|
|
62
|
+
// Walk newest → oldest for a useful assistant conclusion.
|
|
63
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
64
|
+
const e = entries[i]!;
|
|
65
|
+
if (e.role === "assistant" && e.text.trim()) {
|
|
66
|
+
const t = cleanCardProse(e.text);
|
|
67
|
+
if (t.length >= 20) return t;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
71
|
+
const e = entries[i]!;
|
|
72
|
+
if (e.role === "user" && e.text.trim()) {
|
|
73
|
+
const t = cleanCardProse(e.text);
|
|
74
|
+
if (t && !/session import complete/i.test(t)) return t;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Last user prompt from the session log for card comments (newest → oldest).
|
|
82
|
+
* Strips complexity wrappers / import-confirm noise. Empty when none found.
|
|
83
|
+
*/
|
|
84
|
+
export function readLastUserPrompt(jsonlPath: string, maxEntries = 40, maxLen = 250): string {
|
|
85
|
+
const entries = readHistory(jsonlPath, maxEntries);
|
|
86
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
87
|
+
const e = entries[i]!;
|
|
88
|
+
if (e.role !== "user" || !e.text.trim()) continue;
|
|
89
|
+
const t = cleanCardProse(e.text, maxLen);
|
|
90
|
+
if (!t) continue;
|
|
91
|
+
if (/session import complete/i.test(t)) continue;
|
|
92
|
+
return t;
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cleanCardProse(raw: string, max = 250): string {
|
|
98
|
+
let t = extractProgress(raw).cleaned;
|
|
99
|
+
t = t.replace(/```[\s\S]*?```/g, " ");
|
|
100
|
+
t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
|
|
101
|
+
t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
|
|
102
|
+
if (/^Session status update \(meta only\)/i.test(t.trim())) return "";
|
|
103
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
104
|
+
if (t.length <= max) return t;
|
|
105
|
+
// Prefer the ending (conclusions).
|
|
106
|
+
if (t.length > max + 40) {
|
|
107
|
+
const tail = t.slice(-max + 1);
|
|
108
|
+
const sp = tail.indexOf(" ");
|
|
109
|
+
return "\u2026" + (sp > 0 && sp < 30 ? tail.slice(sp + 1) : tail);
|
|
110
|
+
}
|
|
111
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
112
|
+
}
|
|
113
|
+
|
|
49
114
|
/** The first user prompt in a session log (read from the start), or "". */
|
|
50
115
|
export function readFirstPrompt(jsonlPath: string, maxBytes = 256 * 1024): string {
|
|
51
116
|
let size: number;
|
|
@@ -161,8 +226,41 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
|
|
|
161
226
|
function cleanStoredText(text: string): string {
|
|
162
227
|
if (!text) return text;
|
|
163
228
|
let t = extractProgress(text).cleaned;
|
|
229
|
+
t = extractTelegramActions(t).cleaned;
|
|
164
230
|
if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
|
|
165
231
|
if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
|
|
232
|
+
// Prefer "User task (continued):" BEFORE plain "User task:" — the continued
|
|
233
|
+
// marker contains the substring "User task:", so lastIndexOf("User task:")
|
|
234
|
+
// would slice into "(continued):…" and leak bridge teaching into cards/logs.
|
|
235
|
+
const cont = "User task (continued):";
|
|
236
|
+
const ci = t.lastIndexOf(cont);
|
|
237
|
+
if (ci !== -1) {
|
|
238
|
+
t = t.slice(ci + cont.length).trim();
|
|
239
|
+
} else if (
|
|
240
|
+
/^COMPLEXITY \(decide yourself/i.test(t) ||
|
|
241
|
+
/^TASK COMPLEXITY:/i.test(t)
|
|
242
|
+
) {
|
|
243
|
+
const taskMarker = "User task:";
|
|
244
|
+
const ti = t.indexOf(taskMarker);
|
|
245
|
+
if (ti !== -1) t = t.slice(ti + taskMarker.length).trim();
|
|
246
|
+
}
|
|
247
|
+
// Strip leftover telegram bridge teaching if still present (directive-only wrap).
|
|
248
|
+
if (t.includes(TELEGRAM_BRIDGE_MARKER)) {
|
|
249
|
+
const mi = t.indexOf(TELEGRAM_BRIDGE_MARKER);
|
|
250
|
+
if (mi === 0) {
|
|
251
|
+
const after = t.slice(TELEGRAM_BRIDGE_MARKER.length);
|
|
252
|
+
const dbl = after.search(/\n\n(?![-*`])/);
|
|
253
|
+
t = dbl !== -1 ? after.slice(dbl).trim() : "";
|
|
254
|
+
} else {
|
|
255
|
+
t = t.slice(0, mi).trim();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Drop removed/quiet meta-prompts if they landed in history.
|
|
259
|
+
if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
|
|
260
|
+
if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
|
|
261
|
+
if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
|
|
262
|
+
if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
|
|
263
|
+
if (t.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER)) t = "";
|
|
166
264
|
return t;
|
|
167
265
|
}
|
|
168
266
|
|
package/src/sessions/process.ts
CHANGED
|
@@ -16,6 +16,13 @@ const log = createLogger("sessions:process");
|
|
|
16
16
|
*/
|
|
17
17
|
export function killPid(pid: number): boolean {
|
|
18
18
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
// Never kill this bot process (or a mistaken self-target). Session locks for
|
|
20
|
+
// multiplexed ACP turns store the child agent pid, not node — but a recycled
|
|
21
|
+
// or mis-attributed lock must not take the Telegram poller down.
|
|
22
|
+
if (pid === process.pid) {
|
|
23
|
+
log.warn(`refusing to kill pid ${pid} (this bot process)`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
19
26
|
try {
|
|
20
27
|
if (process.platform === "win32") {
|
|
21
28
|
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
package/src/sessions/store.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface RawSessionJson {
|
|
|
17
17
|
created_at?: string;
|
|
18
18
|
updated_at?: string;
|
|
19
19
|
session_created_reason?: string;
|
|
20
|
+
comment?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface RawLock {
|
|
@@ -93,6 +94,7 @@ export class SessionStore {
|
|
|
93
94
|
/* no history yet */
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const comment = (raw.comment || "").trim();
|
|
96
98
|
return {
|
|
97
99
|
sessionId,
|
|
98
100
|
cwd: raw.cwd || "",
|
|
@@ -103,6 +105,7 @@ export class SessionStore {
|
|
|
103
105
|
lockPid,
|
|
104
106
|
active,
|
|
105
107
|
historyBytes,
|
|
108
|
+
comment: comment || undefined,
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
|
package/src/sessions/types.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface SessionMeta {
|
|
|
13
13
|
active: boolean;
|
|
14
14
|
/** Size of the .jsonl history in bytes (proxy for conversation length). */
|
|
15
15
|
historyBytes: number;
|
|
16
|
+
/**
|
|
17
|
+
* Short status for cards: last user prompt (persisted by the bot). While a
|
|
18
|
+
* turn is live, runtime may append last agent thinking as a second line.
|
|
19
|
+
*/
|
|
20
|
+
comment?: string;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
export type HistoryRole = "user" | "assistant" | "tool" | "system";
|
package/src/stream/streamer.ts
CHANGED
|
@@ -15,15 +15,20 @@ import { chunkMarkdown } from "../render/chunk.js";
|
|
|
15
15
|
import { toTelegramMarkdown } from "../render/markdown.js";
|
|
16
16
|
import { extractProgress, progressBar } from "../render/progress.js";
|
|
17
17
|
import { estimateProgress } from "../render/progress-estimate.js";
|
|
18
|
+
import { stripTelegramActionFences } from "../render/telegram-bridge.js";
|
|
19
|
+
import { truncateMiddle } from "../render/truncate.js";
|
|
18
20
|
import { safeEdit, safeSend } from "../bot/telegram-io.js";
|
|
19
21
|
|
|
20
22
|
const SOFT_LIMIT = 3500;
|
|
21
|
-
|
|
23
|
+
/** Display budget for a thinking block (middle-truncated; session context keeps all). */
|
|
24
|
+
const THINK_DISPLAY_MAX = 2800;
|
|
22
25
|
|
|
23
26
|
type SegKind = "out" | "think" | "tool";
|
|
24
27
|
interface Seg {
|
|
25
28
|
kind: SegKind;
|
|
26
29
|
text: string;
|
|
30
|
+
/** When set, later tool updates replace this segment instead of appending. */
|
|
31
|
+
toolId?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export class ResponseStreamer {
|
|
@@ -44,6 +49,11 @@ export class ResponseStreamer {
|
|
|
44
49
|
private toolCalls = 0;
|
|
45
50
|
private outChars = 0;
|
|
46
51
|
private thoughtChars = 0;
|
|
52
|
+
/**
|
|
53
|
+
* Active plan board (ACP sessionUpdate "plan"). Always rendered just above
|
|
54
|
+
* the progress bar when set — done / in-progress / pending steps.
|
|
55
|
+
*/
|
|
56
|
+
private planMarkdown: string | undefined;
|
|
47
57
|
|
|
48
58
|
constructor(
|
|
49
59
|
private readonly api: Api,
|
|
@@ -56,6 +66,8 @@ export class ResponseStreamer {
|
|
|
56
66
|
private readonly fallbackEnabled = false,
|
|
57
67
|
/** Turn start time, used by the fallback's elapsed-time signal. */
|
|
58
68
|
private readonly turnStartedAt = Date.now(),
|
|
69
|
+
/** Forum topic thread — required so stream edits land in the right topic. */
|
|
70
|
+
private readonly messageThreadId?: number,
|
|
59
71
|
) {}
|
|
60
72
|
|
|
61
73
|
/** Replace the hashtag footer (used after a logical fork swaps the session id
|
|
@@ -69,10 +81,11 @@ export class ResponseStreamer {
|
|
|
69
81
|
return this.footer ? `\n\n${this.footer}` : "";
|
|
70
82
|
}
|
|
71
83
|
|
|
72
|
-
/** Strip `{progress: N%}` markers
|
|
73
|
-
*
|
|
84
|
+
/** Strip `{progress: N%}` markers and telegram action JSON fences from
|
|
85
|
+
* rendered text, remembering the latest progress value. */
|
|
74
86
|
private captureProgress(text: string): string {
|
|
75
|
-
const
|
|
87
|
+
const withoutTg = stripTelegramActionFences(text);
|
|
88
|
+
const { value, cleaned } = extractProgress(withoutTg);
|
|
76
89
|
if (value !== undefined) this.setProgressValue(value, true);
|
|
77
90
|
return cleaned;
|
|
78
91
|
}
|
|
@@ -112,12 +125,19 @@ export class ResponseStreamer {
|
|
|
112
125
|
this.setProgressValue(100, false);
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
private threadExtra(): Record<string, unknown> {
|
|
129
|
+
return this.messageThreadId !== undefined ? { message_thread_id: this.messageThreadId } : {};
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
/** reply_parameters threading EVERY message of the turn to the user's prompt,
|
|
116
133
|
* so the whole response (all bubbles, tool calls and continuations) stays in
|
|
117
|
-
* one thread — not just the first message. */
|
|
134
|
+
* one thread — not just the first message. Also carries forum topic id. */
|
|
118
135
|
private replyExtra(): Record<string, unknown> {
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
const extra: Record<string, unknown> = { ...this.threadExtra() };
|
|
137
|
+
if (this.replyTo !== undefined) {
|
|
138
|
+
extra.reply_parameters = { message_id: this.replyTo, allow_sending_without_reply: true };
|
|
139
|
+
}
|
|
140
|
+
return extra;
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
appendOutput(text: string): void {
|
|
@@ -134,6 +154,10 @@ export class ResponseStreamer {
|
|
|
134
154
|
this.schedule();
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Append a one-shot tool card (no live updates). Prefer {@link upsertTool}
|
|
159
|
+
* for ACP tool calls that stream progress/output under a stable toolCallId.
|
|
160
|
+
*/
|
|
137
161
|
addTool(rawMarkdown: string): void {
|
|
138
162
|
if (!rawMarkdown) return;
|
|
139
163
|
this.toolCalls += 1;
|
|
@@ -141,6 +165,45 @@ export class ResponseStreamer {
|
|
|
141
165
|
this.schedule();
|
|
142
166
|
}
|
|
143
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Insert or replace a tool card keyed by toolCallId so one command/edit stays
|
|
170
|
+
* a single Telegram block that auto-updates (no spam of new code sections).
|
|
171
|
+
* Full tool results remain in the agent session; this is display-only.
|
|
172
|
+
*/
|
|
173
|
+
/** Replace the live plan board (or clear with empty/undefined). */
|
|
174
|
+
setPlan(markdown: string | undefined): void {
|
|
175
|
+
const next = markdown?.trim() ? markdown.trim() : undefined;
|
|
176
|
+
if (next === this.planMarkdown) return;
|
|
177
|
+
this.planMarkdown = next;
|
|
178
|
+
this.schedule();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
upsertTool(toolId: string | undefined, rawMarkdown: string): void {
|
|
182
|
+
if (!rawMarkdown) return;
|
|
183
|
+
const id = (toolId || "").trim();
|
|
184
|
+
if (id) {
|
|
185
|
+
// Replace any existing segment with this id (newest first; includes rare
|
|
186
|
+
// sealed-region matches so we don't keep stale text in the segs model).
|
|
187
|
+
for (let i = this.segs.length - 1; i >= 0; i--) {
|
|
188
|
+
const s = this.segs[i]!;
|
|
189
|
+
if (s.kind === "tool" && s.toolId === id) {
|
|
190
|
+
if (s.text === rawMarkdown) return;
|
|
191
|
+
s.text = rawMarkdown;
|
|
192
|
+
// If the card lives only in a sealed bubble, also ensure a live copy
|
|
193
|
+
// so the user sees the latest output on the current message.
|
|
194
|
+
if (i < this.sealedIdx) {
|
|
195
|
+
this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id });
|
|
196
|
+
}
|
|
197
|
+
this.schedule();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.toolCalls += 1;
|
|
203
|
+
this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id || undefined });
|
|
204
|
+
this.schedule();
|
|
205
|
+
}
|
|
206
|
+
|
|
144
207
|
get hasOutput(): boolean {
|
|
145
208
|
return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
|
|
146
209
|
}
|
|
@@ -182,13 +245,15 @@ export class ResponseStreamer {
|
|
|
182
245
|
await this.sealOverflow();
|
|
183
246
|
const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
|
|
184
247
|
this.applyFallback();
|
|
185
|
-
// Never send an empty / progress-only bubble.
|
|
186
|
-
//
|
|
187
|
-
if (!base.trim()) return;
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
248
|
+
// Never send an empty / progress-only bubble. Plan alone is allowed so the
|
|
249
|
+
// board is visible as soon as the agent publishes steps.
|
|
250
|
+
if (!base.trim() && !this.planMarkdown) return;
|
|
251
|
+
// Live bubble: body → plan (always above progress) → progress bar → footer.
|
|
252
|
+
const parts: string[] = [];
|
|
253
|
+
if (base.trim()) parts.push(base);
|
|
254
|
+
if (this.planMarkdown) parts.push(this.planMarkdown);
|
|
191
255
|
if (this.progress !== undefined) parts.push(progressBar(this.progress));
|
|
256
|
+
if (parts.length === 0) return;
|
|
192
257
|
const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
|
|
193
258
|
const rendered = toTelegramMarkdown(src);
|
|
194
259
|
const chunks = chunkMarkdown(rendered);
|
|
@@ -255,7 +320,17 @@ function renderSegs(segs: Seg[]): string {
|
|
|
255
320
|
function quoteThought(text: string): string {
|
|
256
321
|
const t = text.trim();
|
|
257
322
|
if (!t) return "";
|
|
258
|
-
|
|
323
|
+
// Keep both ends of long reasoning so early investigation is not lost in the UI.
|
|
324
|
+
// Truncation is display-only — the agent session retains every thought token.
|
|
325
|
+
// Neutralize fence markers and half-open emphasis so thinking never breaks
|
|
326
|
+
// MarkdownV2 parsing of the surrounding live message.
|
|
327
|
+
const safe = t
|
|
328
|
+
.replace(/```+/g, "'''")
|
|
329
|
+
.replace(/\*\*/g, "")
|
|
330
|
+
.replace(/__/g, "")
|
|
331
|
+
.replace(/~~/g, "");
|
|
332
|
+
const short = truncateMiddle(safe, THINK_DISPLAY_MAX);
|
|
259
333
|
const lines = short.split("\n");
|
|
260
|
-
|
|
334
|
+
// Plain "thinking:" (no nested *bold*) — nested markers break mid-stream.
|
|
335
|
+
return lines.map((l, i) => (i === 0 ? `> \u{1F4AD} thinking: ${l}` : `> ${l}`)).join("\n");
|
|
261
336
|
}
|
package/src/tasks/runner.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { Api } from "grammy";
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import type { GrokClient } from "../grok/client.js";
|
|
9
|
-
import type
|
|
9
|
+
import { contentText, type SessionUpdate } from "../grok/types.js";
|
|
10
10
|
import { createLogger } from "../logger.js";
|
|
11
11
|
import { sendMarkdownDoc } from "../bot/telegram-io.js";
|
|
12
12
|
import type { Task } from "./types.js";
|
|
@@ -29,8 +29,9 @@ export class TaskRunner {
|
|
|
29
29
|
|
|
30
30
|
const listener = (sid: string, u: SessionUpdate): void => {
|
|
31
31
|
if (sid !== sessionId) return;
|
|
32
|
-
if (u.sessionUpdate === "agent_message_chunk"
|
|
33
|
-
|
|
32
|
+
if (u.sessionUpdate === "agent_message_chunk") {
|
|
33
|
+
const t = contentText(u.content);
|
|
34
|
+
if (t) text += t;
|
|
34
35
|
} else if (u.sessionUpdate === "tool_call") {
|
|
35
36
|
const id = u.toolCallId || u.title || String(tools);
|
|
36
37
|
if (!seen.has(id)) {
|