grok-telegram-bot 2.0.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 +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session store — reads ~/.grok/sessions/cli to discover existing Grok CLI
|
|
3
|
+
* sessions, sorts them by recency, and detects which ones are currently
|
|
4
|
+
* running on this PC (a .lock file whose PID is alive).
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { createLogger } from "../logger.js";
|
|
9
|
+
import type { SessionMeta } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("sessions:store");
|
|
12
|
+
|
|
13
|
+
interface RawSessionJson {
|
|
14
|
+
session_id?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
created_at?: string;
|
|
18
|
+
updated_at?: string;
|
|
19
|
+
session_created_reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RawLock {
|
|
23
|
+
pid?: number;
|
|
24
|
+
started_at?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SessionStore {
|
|
28
|
+
constructor(private readonly dir: string) {}
|
|
29
|
+
|
|
30
|
+
/** Returns true once the sessions directory exists. */
|
|
31
|
+
available(): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return statSync(this.dir).isDirectory();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** List all sessions, most recently updated first. */
|
|
40
|
+
list(limit = 50): SessionMeta[] {
|
|
41
|
+
if (!this.available()) return [];
|
|
42
|
+
let files: string[];
|
|
43
|
+
try {
|
|
44
|
+
files = readdirSync(this.dir).filter((f) => f.endsWith(".json"));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
log.warn("cannot read sessions dir:", (e as Error).message);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const metas: SessionMeta[] = [];
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const meta = this.readMeta(file);
|
|
53
|
+
if (meta) metas.push(meta);
|
|
54
|
+
}
|
|
55
|
+
// Active sessions first, then most-recently-updated.
|
|
56
|
+
metas.sort(
|
|
57
|
+
(a, b) => Number(b.active) - Number(a.active) || b.updatedAt.localeCompare(a.updatedAt),
|
|
58
|
+
);
|
|
59
|
+
return metas.slice(0, limit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** List only sessions currently running on this PC. */
|
|
63
|
+
listActive(): SessionMeta[] {
|
|
64
|
+
return this.list(200).filter((s) => s.active);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get(sessionId: string): SessionMeta | undefined {
|
|
68
|
+
return this.readMeta(`${sessionId}.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
jsonlPath(sessionId: string): string {
|
|
72
|
+
return join(this.dir, `${sessionId}.jsonl`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private readMeta(file: string): SessionMeta | undefined {
|
|
76
|
+
const full = join(this.dir, file);
|
|
77
|
+
let raw: RawSessionJson;
|
|
78
|
+
let mtime = new Date(0).toISOString();
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(readFileSync(full, "utf-8")) as RawSessionJson;
|
|
81
|
+
mtime = statSync(full).mtime.toISOString();
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const sessionId = raw.session_id || file.replace(/\.json$/, "");
|
|
86
|
+
const base = sessionId;
|
|
87
|
+
|
|
88
|
+
const { lockPid, active } = this.checkLock(base);
|
|
89
|
+
let historyBytes = 0;
|
|
90
|
+
try {
|
|
91
|
+
historyBytes = statSync(join(this.dir, `${base}.jsonl`)).size;
|
|
92
|
+
} catch {
|
|
93
|
+
/* no history yet */
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
sessionId,
|
|
98
|
+
cwd: raw.cwd || "",
|
|
99
|
+
title: (raw.title || "").trim() || "(untitled)",
|
|
100
|
+
createdAt: raw.created_at || mtime,
|
|
101
|
+
updatedAt: raw.updated_at || mtime,
|
|
102
|
+
reason: raw.session_created_reason,
|
|
103
|
+
lockPid,
|
|
104
|
+
active,
|
|
105
|
+
historyBytes,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private checkLock(base: string): { lockPid?: number; active: boolean } {
|
|
110
|
+
try {
|
|
111
|
+
const lock = JSON.parse(readFileSync(join(this.dir, `${base}.lock`), "utf-8")) as RawLock;
|
|
112
|
+
if (typeof lock.pid === "number") {
|
|
113
|
+
return { lockPid: lock.pid, active: isPidAlive(lock.pid) };
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
/* no lock => not active */
|
|
117
|
+
}
|
|
118
|
+
return { active: false };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Cross-platform "is this process still running?" check. */
|
|
123
|
+
export function isPidAlive(pid: number): boolean {
|
|
124
|
+
if (!pid || pid <= 0) return false;
|
|
125
|
+
try {
|
|
126
|
+
// Signal 0 does not kill; it only checks for existence/permission.
|
|
127
|
+
process.kill(pid, 0);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// EPERM means the process exists but we can't signal it => still alive.
|
|
131
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TailWatcher — follows a session's .jsonl event log and emits newly appended
|
|
3
|
+
* entries. Polling (not fs.watch) is used because it is reliable for appends
|
|
4
|
+
* across platforms and network drives.
|
|
5
|
+
*/
|
|
6
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
import { parseEventLine } from "./history.js";
|
|
9
|
+
import type { HistoryEntry } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("sessions:tail");
|
|
12
|
+
|
|
13
|
+
export class TailWatcher {
|
|
14
|
+
private pos = 0;
|
|
15
|
+
private remainder = "";
|
|
16
|
+
private timer: NodeJS.Timeout | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly path: string,
|
|
20
|
+
private readonly onEntries: (entries: HistoryEntry[]) => void,
|
|
21
|
+
private readonly intervalMs = 1500,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/** Start watching. From the current end of file by default (only new events). */
|
|
25
|
+
start(fromEnd = true): void {
|
|
26
|
+
if (this.timer) return;
|
|
27
|
+
try {
|
|
28
|
+
this.pos = fromEnd ? statSync(this.path).size : 0;
|
|
29
|
+
} catch {
|
|
30
|
+
this.pos = 0;
|
|
31
|
+
}
|
|
32
|
+
this.timer = setInterval(() => this.poll(), this.intervalMs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop(): void {
|
|
36
|
+
if (this.timer) {
|
|
37
|
+
clearInterval(this.timer);
|
|
38
|
+
this.timer = undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get running(): boolean {
|
|
43
|
+
return this.timer !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private poll(): void {
|
|
47
|
+
let size: number;
|
|
48
|
+
try {
|
|
49
|
+
size = statSync(this.path).size;
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (size === this.pos) return;
|
|
54
|
+
if (size < this.pos) {
|
|
55
|
+
// File rotated/truncated — restart from the beginning.
|
|
56
|
+
this.pos = 0;
|
|
57
|
+
this.remainder = "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const length = size - this.pos;
|
|
61
|
+
let chunk = "";
|
|
62
|
+
const fd = openSync(this.path, "r");
|
|
63
|
+
try {
|
|
64
|
+
const buf = Buffer.alloc(length);
|
|
65
|
+
readSync(fd, buf, 0, length, this.pos);
|
|
66
|
+
chunk = buf.toString("utf-8");
|
|
67
|
+
} catch (e) {
|
|
68
|
+
log.debug("tail read failed:", (e as Error).message);
|
|
69
|
+
return;
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
this.pos = size;
|
|
74
|
+
|
|
75
|
+
const text = this.remainder + chunk;
|
|
76
|
+
const lines = text.split("\n");
|
|
77
|
+
this.remainder = lines.pop() ?? ""; // keep last partial line
|
|
78
|
+
|
|
79
|
+
const entries: HistoryEntry[] = [];
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const entry = parseEventLine(line);
|
|
82
|
+
if (entry) entries.push(entry);
|
|
83
|
+
}
|
|
84
|
+
if (entries.length > 0) this.onEntries(entries);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Types for discovered Grok CLI sessions on disk. */
|
|
2
|
+
|
|
3
|
+
export interface SessionMeta {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
title: string;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
reason?: string;
|
|
10
|
+
/** PID holding the .lock file, if any. */
|
|
11
|
+
lockPid?: number;
|
|
12
|
+
/** True when lockPid refers to a live process => running on this PC. */
|
|
13
|
+
active: boolean;
|
|
14
|
+
/** Size of the .jsonl history in bytes (proxy for conversation length). */
|
|
15
|
+
historyBytes: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type HistoryRole = "user" | "assistant" | "tool" | "system";
|
|
19
|
+
|
|
20
|
+
export interface HistoryEntry {
|
|
21
|
+
role: HistoryRole;
|
|
22
|
+
text: string;
|
|
23
|
+
/** Optional tool name for tool entries. */
|
|
24
|
+
tool?: string;
|
|
25
|
+
timestamp?: number;
|
|
26
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResponseStreamer — renders a whole agent turn into as FEW Telegram messages as
|
|
3
|
+
* possible, edited at most once per throttle window (anti-spam, avoids 429s).
|
|
4
|
+
*
|
|
5
|
+
* The turn is modelled as ordered segments so the transcript reads clearly:
|
|
6
|
+
* • plain prose = the agent talking to you
|
|
7
|
+
* • > 💭 quoted block = the agent's thinking
|
|
8
|
+
* • 🔧 + code block = tool calls / terminal commands / diffs
|
|
9
|
+
*
|
|
10
|
+
* A single "live" message is edited as content grows; only when it would exceed
|
|
11
|
+
* Telegram's size limit is it sealed and a new live message started.
|
|
12
|
+
*/
|
|
13
|
+
import type { Api } from "grammy";
|
|
14
|
+
import { chunkMarkdown } from "../render/chunk.js";
|
|
15
|
+
import { toTelegramMarkdown } from "../render/markdown.js";
|
|
16
|
+
import { extractProgress, progressBar } from "../render/progress.js";
|
|
17
|
+
import { estimateProgress } from "../render/progress-estimate.js";
|
|
18
|
+
import { safeEdit, safeSend } from "../bot/telegram-io.js";
|
|
19
|
+
|
|
20
|
+
const SOFT_LIMIT = 3500;
|
|
21
|
+
const THINK_TAIL = 500;
|
|
22
|
+
|
|
23
|
+
type SegKind = "out" | "think" | "tool";
|
|
24
|
+
interface Seg {
|
|
25
|
+
kind: SegKind;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ResponseStreamer {
|
|
30
|
+
private readonly segs: Seg[] = [];
|
|
31
|
+
private sealedIdx = 0;
|
|
32
|
+
private liveId: number | undefined;
|
|
33
|
+
private timer: NodeJS.Timeout | undefined;
|
|
34
|
+
private dirty = false;
|
|
35
|
+
private flushing = false;
|
|
36
|
+
private closed = false;
|
|
37
|
+
/** Latest task-progress % parsed from the agent's `{progress: N%}` markers
|
|
38
|
+
* (sticky across flushes; rendered as a bar on the live message). */
|
|
39
|
+
private progress: number | undefined;
|
|
40
|
+
/** True once the agent emitted a real `{progress}` marker — from then on its
|
|
41
|
+
* values are authoritative and the bot fallback stops contributing. */
|
|
42
|
+
private agentReported = false;
|
|
43
|
+
/** Real work signals for the fallback estimate (monotonic within a turn). */
|
|
44
|
+
private toolCalls = 0;
|
|
45
|
+
private outChars = 0;
|
|
46
|
+
private thoughtChars = 0;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly api: Api,
|
|
50
|
+
private readonly chatId: number,
|
|
51
|
+
private readonly throttleMs: number,
|
|
52
|
+
private replyTo?: number,
|
|
53
|
+
private footer?: string,
|
|
54
|
+
private readonly onProgress?: (pct: number) => void,
|
|
55
|
+
/** Show a bot-computed bar when the agent emits no marker. */
|
|
56
|
+
private readonly fallbackEnabled = false,
|
|
57
|
+
/** Turn start time, used by the fallback's elapsed-time signal. */
|
|
58
|
+
private readonly turnStartedAt = Date.now(),
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
/** Replace the hashtag footer (used after a logical fork swaps the session id
|
|
62
|
+
* mid-turn, so the streamed response carries the NEW session's tags). */
|
|
63
|
+
setFooter(footer: string): void {
|
|
64
|
+
this.footer = footer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** "\n\n<footer>" appended to every finished message bubble (e.g. hashtags). */
|
|
68
|
+
private footerSuffix(): string {
|
|
69
|
+
return this.footer ? `\n\n${this.footer}` : "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Strip `{progress: N%}` markers from rendered text, remembering the latest
|
|
73
|
+
* value (sticky across flushes) and notifying the owner when it changes. */
|
|
74
|
+
private captureProgress(text: string): string {
|
|
75
|
+
const { value, cleaned } = extractProgress(text);
|
|
76
|
+
if (value !== undefined) this.setProgressValue(value, true);
|
|
77
|
+
return cleaned;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Record a progress value, enforcing global monotonicity (never decreases)
|
|
81
|
+
* and notifying the owner on change. Agent markers are authoritative: once
|
|
82
|
+
* one arrives, the bot fallback stops contributing. */
|
|
83
|
+
private setProgressValue(pct: number, fromAgent: boolean): void {
|
|
84
|
+
if (fromAgent) this.agentReported = true;
|
|
85
|
+
const next = Math.max(this.progress ?? 0, Math.round(pct));
|
|
86
|
+
if (next === this.progress) return;
|
|
87
|
+
this.progress = next;
|
|
88
|
+
try {
|
|
89
|
+
this.onProgress?.(next);
|
|
90
|
+
} catch {
|
|
91
|
+
/* non-fatal */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Advance the fallback estimate from real activity signals, but only while
|
|
96
|
+
* the agent itself hasn't reported a value. No-op when fallback is off. */
|
|
97
|
+
private applyFallback(): void {
|
|
98
|
+
if (!this.fallbackEnabled || this.agentReported) return;
|
|
99
|
+
const est = estimateProgress({
|
|
100
|
+
toolCalls: this.toolCalls,
|
|
101
|
+
outputChars: this.outChars,
|
|
102
|
+
thoughtChars: this.thoughtChars,
|
|
103
|
+
elapsedMs: Date.now() - this.turnStartedAt,
|
|
104
|
+
});
|
|
105
|
+
if (est > 0) this.setProgressValue(est, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Called when the turn finishes successfully: if the agent never reported
|
|
109
|
+
* its own progress, fill the fallback bar to 100. No-op otherwise. */
|
|
110
|
+
completeFallback(): void {
|
|
111
|
+
if (!this.fallbackEnabled || this.agentReported) return;
|
|
112
|
+
this.setProgressValue(100, false);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** reply_parameters threading EVERY message of the turn to the user's prompt,
|
|
116
|
+
* so the whole response (all bubbles, tool calls and continuations) stays in
|
|
117
|
+
* one thread — not just the first message. */
|
|
118
|
+
private replyExtra(): Record<string, unknown> {
|
|
119
|
+
if (this.replyTo === undefined) return {};
|
|
120
|
+
return { reply_parameters: { message_id: this.replyTo, allow_sending_without_reply: true } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
appendOutput(text: string): void {
|
|
124
|
+
if (!text) return;
|
|
125
|
+
this.outChars += text.length;
|
|
126
|
+
this.merge("out", text);
|
|
127
|
+
this.schedule();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
appendThought(text: string): void {
|
|
131
|
+
if (!text) return;
|
|
132
|
+
this.thoughtChars += text.length;
|
|
133
|
+
this.merge("think", text);
|
|
134
|
+
this.schedule();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
addTool(rawMarkdown: string): void {
|
|
138
|
+
if (!rawMarkdown) return;
|
|
139
|
+
this.toolCalls += 1;
|
|
140
|
+
this.segs.push({ kind: "tool", text: rawMarkdown });
|
|
141
|
+
this.schedule();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get hasOutput(): boolean {
|
|
145
|
+
return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async finalize(): Promise<void> {
|
|
149
|
+
this.closed = true;
|
|
150
|
+
if (this.timer) clearTimeout(this.timer);
|
|
151
|
+
this.timer = undefined;
|
|
152
|
+
await this.flush(true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
private merge(kind: SegKind, text: string): void {
|
|
158
|
+
const last = this.segs.at(-1);
|
|
159
|
+
if (last && last.kind === kind) last.text += text;
|
|
160
|
+
else this.segs.push({ kind, text });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private schedule(): void {
|
|
164
|
+
if (this.closed) return;
|
|
165
|
+
this.dirty = true;
|
|
166
|
+
if (this.timer) return;
|
|
167
|
+
this.timer = setTimeout(() => {
|
|
168
|
+
this.timer = undefined;
|
|
169
|
+
void this.flush(false);
|
|
170
|
+
}, this.throttleMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async flush(final: boolean): Promise<void> {
|
|
174
|
+
if (this.flushing) {
|
|
175
|
+
if (!final) this.schedule();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!this.dirty && !final) return;
|
|
179
|
+
this.flushing = true;
|
|
180
|
+
this.dirty = false;
|
|
181
|
+
try {
|
|
182
|
+
await this.sealOverflow();
|
|
183
|
+
const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
|
|
184
|
+
this.applyFallback();
|
|
185
|
+
// Never send an empty / progress-only bubble. The bar is appended only to
|
|
186
|
+
// real streamed content; the live status panel shows the standalone bar.
|
|
187
|
+
if (!base.trim()) return;
|
|
188
|
+
// The live (still-streaming) bubble carries the hashtag footer AND a fresh
|
|
189
|
+
// progress bar at the bottom (sealed bubbles below get neither bar).
|
|
190
|
+
const parts: string[] = [base];
|
|
191
|
+
if (this.progress !== undefined) parts.push(progressBar(this.progress));
|
|
192
|
+
const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
|
|
193
|
+
const rendered = toTelegramMarkdown(src);
|
|
194
|
+
const chunks = chunkMarkdown(rendered);
|
|
195
|
+
const plain = chunkMarkdown(src);
|
|
196
|
+
if (chunks.length <= 1) {
|
|
197
|
+
const mdv2 = chunks[0] ?? rendered;
|
|
198
|
+
if (this.liveId === undefined) this.liveId = await safeSend(this.api, this.chatId, mdv2, src, this.replyExtra());
|
|
199
|
+
else await safeEdit(this.api, this.chatId, this.liveId, mdv2, src);
|
|
200
|
+
} else {
|
|
201
|
+
// Remainder no longer fits one message: flush all, last stays live.
|
|
202
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
203
|
+
const mdv2 = chunks[i]!;
|
|
204
|
+
const p = plain[i] ?? mdv2;
|
|
205
|
+
if (i === 0 && this.liveId !== undefined) await safeEdit(this.api, this.chatId, this.liveId, mdv2, p);
|
|
206
|
+
else if (i < chunks.length - 1) await safeSend(this.api, this.chatId, mdv2, p, this.replyExtra());
|
|
207
|
+
else this.liveId = await safeSend(this.api, this.chatId, mdv2, p, this.replyExtra());
|
|
208
|
+
}
|
|
209
|
+
this.sealedIdx = this.segs.length; // everything before the live tail is sealed
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
this.flushing = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Seal leading segments into finalized messages while the live view is too big. */
|
|
217
|
+
private async sealOverflow(): Promise<void> {
|
|
218
|
+
let live = this.segs.slice(this.sealedIdx);
|
|
219
|
+
while (live.length > 1 && toTelegramMarkdown(renderSegs(live)).length > SOFT_LIMIT) {
|
|
220
|
+
const headCount = live.length - 1;
|
|
221
|
+
await this.seal(this.sealedIdx, this.sealedIdx + headCount);
|
|
222
|
+
this.sealedIdx += headCount;
|
|
223
|
+
this.liveId = undefined;
|
|
224
|
+
live = this.segs.slice(this.sealedIdx);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async seal(from: number, to: number): Promise<void> {
|
|
229
|
+
const base = this.captureProgress(renderSegs(this.segs.slice(from, to)));
|
|
230
|
+
if (!base.trim()) return;
|
|
231
|
+
// A sealed bubble is finished, so it carries the footer (hashtags).
|
|
232
|
+
const src = `${base}${this.footerSuffix()}`;
|
|
233
|
+
const chunks = chunkMarkdown(toTelegramMarkdown(src));
|
|
234
|
+
const plain = chunkMarkdown(src);
|
|
235
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
236
|
+
const mdv2 = chunks[i]!;
|
|
237
|
+
const p = plain[i] ?? mdv2;
|
|
238
|
+
if (i === 0 && this.liveId !== undefined) await safeEdit(this.api, this.chatId, this.liveId, mdv2, p);
|
|
239
|
+
else await safeSend(this.api, this.chatId, mdv2, p, this.replyExtra());
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderSegs(segs: Seg[]): string {
|
|
245
|
+
return segs
|
|
246
|
+
.map((s) => {
|
|
247
|
+
if (s.kind === "out") return s.text.trim();
|
|
248
|
+
if (s.kind === "think") return quoteThought(s.text);
|
|
249
|
+
return s.text.trim();
|
|
250
|
+
})
|
|
251
|
+
.filter((x) => x.length > 0)
|
|
252
|
+
.join("\n\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function quoteThought(text: string): string {
|
|
256
|
+
const t = text.trim();
|
|
257
|
+
if (!t) return "";
|
|
258
|
+
const short = t.length > THINK_TAIL ? "…" + t.slice(-THINK_TAIL) : t;
|
|
259
|
+
const lines = short.split("\n");
|
|
260
|
+
return lines.map((l, i) => (i === 0 ? `> 💭 *thinking:* ${l}` : `> ${l}`)).join("\n");
|
|
261
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executes a scheduled task: opens a fresh session in the task's project,
|
|
3
|
+
* sends the prompt, collects the response, and delivers it to the chat.
|
|
4
|
+
* Runs independently of the user's interactive session.
|
|
5
|
+
*/
|
|
6
|
+
import type { Api } from "grammy";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import type { GrokClient } from "../grok/client.js";
|
|
9
|
+
import type { SessionUpdate } from "../grok/types.js";
|
|
10
|
+
import { createLogger } from "../logger.js";
|
|
11
|
+
import { sendMarkdownDoc } from "../bot/telegram-io.js";
|
|
12
|
+
import type { Task } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("task-runner");
|
|
15
|
+
|
|
16
|
+
export class TaskRunner {
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly api: Api,
|
|
19
|
+
private readonly acp: GrokClient,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** Run a task; resolves true on success, false on error. */
|
|
23
|
+
async run(task: Task): Promise<boolean> {
|
|
24
|
+
log.info(`running task "${task.name}" in ${task.projectPath}`);
|
|
25
|
+
let sessionId = "";
|
|
26
|
+
let text = "";
|
|
27
|
+
let tools = 0;
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
|
|
30
|
+
const listener = (sid: string, u: SessionUpdate): void => {
|
|
31
|
+
if (sid !== sessionId) return;
|
|
32
|
+
if (u.sessionUpdate === "agent_message_chunk" && typeof u.content?.text === "string") {
|
|
33
|
+
text += u.content.text;
|
|
34
|
+
} else if (u.sessionUpdate === "tool_call") {
|
|
35
|
+
const id = u.toolCallId || u.title || String(tools);
|
|
36
|
+
if (!seen.has(id)) {
|
|
37
|
+
seen.add(id);
|
|
38
|
+
tools++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
sessionId = await this.acp.newSession(task.projectPath);
|
|
45
|
+
if (task.agent) {
|
|
46
|
+
try {
|
|
47
|
+
await this.acp.setMode(sessionId, task.agent);
|
|
48
|
+
} catch {
|
|
49
|
+
/* best-effort */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.acp.on("session-update", listener);
|
|
53
|
+
await this.acp.prompt(sessionId, [{ type: "text", text: task.prompt }]);
|
|
54
|
+
this.acp.off("session-update", listener);
|
|
55
|
+
await this.deliver(task, text, tools);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
this.acp.off("session-update", listener);
|
|
59
|
+
await this.deliverError(task, (err as Error).message);
|
|
60
|
+
log.error(`task "${task.name}" failed:`, (err as Error).message);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async deliver(task: Task, text: string, tools: number): Promise<void> {
|
|
66
|
+
const project = task.projectName || basename(task.projectPath);
|
|
67
|
+
const body = text.trim() || "_(no text output)_";
|
|
68
|
+
const footer = tools > 0 ? `\n\n\u{1F527} ${tools} tool call(s)` : "";
|
|
69
|
+
const header = `\u23F0 **Task: ${task.name}** \u00B7 ${project}`;
|
|
70
|
+
await sendMarkdownDoc(this.api, task.chatId, `${header}\n\n${body}${footer}`, { loud: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async deliverError(task: Task, message: string): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
await this.api.sendMessage(task.chatId, `\u274C Task "${task.name}" failed: ${message}`, {
|
|
76
|
+
disable_notification: false,
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
/* non-fatal */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|