kiro-telegram-bot 1.5.1

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.
Files changed (83) hide show
  1. package/.env.example +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +517 -0
  4. package/bin/kiro-tg.mjs +21 -0
  5. package/docs/INSTALL.md +143 -0
  6. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  7. package/package.json +70 -0
  8. package/scripts/mq.ts +25 -0
  9. package/scripts/setup.mjs +78 -0
  10. package/src/acp/client.ts +456 -0
  11. package/src/acp/server-handlers.ts +85 -0
  12. package/src/acp/transport.ts +50 -0
  13. package/src/acp/types.ts +136 -0
  14. package/src/agents/catalog.ts +44 -0
  15. package/src/app/json-store.ts +54 -0
  16. package/src/app/reasoning.ts +30 -0
  17. package/src/app/settings-store.ts +31 -0
  18. package/src/app/stt.ts +53 -0
  19. package/src/app/types.ts +48 -0
  20. package/src/app/usage.ts +32 -0
  21. package/src/bot/auth.ts +27 -0
  22. package/src/bot/bot.ts +154 -0
  23. package/src/bot/chat-controller.ts +251 -0
  24. package/src/bot/commands.ts +48 -0
  25. package/src/bot/deps.ts +47 -0
  26. package/src/bot/handlers/control.ts +94 -0
  27. package/src/bot/handlers/history.ts +58 -0
  28. package/src/bot/handlers/kill.ts +69 -0
  29. package/src/bot/handlers/mcp.ts +205 -0
  30. package/src/bot/handlers/menu.ts +204 -0
  31. package/src/bot/handlers/message.ts +93 -0
  32. package/src/bot/handlers/photo.ts +108 -0
  33. package/src/bot/handlers/projects.ts +83 -0
  34. package/src/bot/handlers/running.ts +104 -0
  35. package/src/bot/handlers/session-card.ts +65 -0
  36. package/src/bot/handlers/sessions.ts +131 -0
  37. package/src/bot/handlers/system.ts +51 -0
  38. package/src/bot/handlers/tasks.ts +223 -0
  39. package/src/bot/handlers/usage.ts +33 -0
  40. package/src/bot/handlers/voice.ts +53 -0
  41. package/src/bot/image-return.ts +69 -0
  42. package/src/bot/menu/keyboard.ts +47 -0
  43. package/src/bot/menu/refresh.ts +13 -0
  44. package/src/bot/menu/status-panel.ts +78 -0
  45. package/src/bot/permission-service.ts +149 -0
  46. package/src/bot/prompt-content.ts +49 -0
  47. package/src/bot/prompt-retry.ts +70 -0
  48. package/src/bot/registry.ts +178 -0
  49. package/src/bot/session-runtime.ts +670 -0
  50. package/src/bot/telegram-io.ts +109 -0
  51. package/src/bot/typing.ts +35 -0
  52. package/src/bot/wizard/task-wizard.ts +214 -0
  53. package/src/cli.ts +125 -0
  54. package/src/config.ts +190 -0
  55. package/src/index.ts +74 -0
  56. package/src/logger.ts +78 -0
  57. package/src/mcp/config.ts +103 -0
  58. package/src/mcp/probe.ts +218 -0
  59. package/src/mcp/types.ts +68 -0
  60. package/src/projects/manager.ts +88 -0
  61. package/src/render/chunk.ts +57 -0
  62. package/src/render/diff.ts +48 -0
  63. package/src/render/escape.ts +22 -0
  64. package/src/render/markdown.ts +126 -0
  65. package/src/render/subagent.ts +75 -0
  66. package/src/render/tool-call.ts +102 -0
  67. package/src/service/index.ts +24 -0
  68. package/src/service/linux.ts +83 -0
  69. package/src/service/macos.ts +91 -0
  70. package/src/service/platform.ts +59 -0
  71. package/src/service/types.ts +34 -0
  72. package/src/service/windows.ts +103 -0
  73. package/src/sessions/history.ts +181 -0
  74. package/src/sessions/store.ts +133 -0
  75. package/src/sessions/tail.ts +86 -0
  76. package/src/sessions/types.ts +26 -0
  77. package/src/stream/streamer.ts +167 -0
  78. package/src/tasks/runner.ts +82 -0
  79. package/src/tasks/schedule.ts +142 -0
  80. package/src/tasks/scheduler.ts +53 -0
  81. package/src/tasks/store.ts +80 -0
  82. package/src/tasks/types.ts +33 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Windows service controller — runs the bot at logon via a hidden Scheduled
3
+ * Task. A small .vbs launcher starts node with no console window; the app logs
4
+ * to a file. Stop precisely targets our node process by command line.
5
+ */
6
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { runSafe } from "./platform.js";
9
+ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
10
+
11
+ const TASK = "KiroTelegramBot";
12
+
13
+ export const windowsController: ServiceController = {
14
+ platform: "windows",
15
+
16
+ async install(spec) {
17
+ mkdirSync(spec.logsDir, { recursive: true });
18
+ const vbs = join(spec.cwd, "run-service.vbs");
19
+ writeFileSync(vbs, vbsLauncher(spec), "utf-8");
20
+
21
+ runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // replace if present
22
+ const res = runSafe("schtasks", [
23
+ "/Create",
24
+ "/F",
25
+ "/SC",
26
+ "ONLOGON",
27
+ "/TN",
28
+ TASK,
29
+ "/TR",
30
+ `wscript.exe "${vbs}"`,
31
+ ]);
32
+ if (!res.ok) return fail(`schtasks create failed: ${res.out}`);
33
+ runSafe("schtasks", ["/Run", "/TN", TASK]);
34
+ return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
35
+ },
36
+
37
+ async uninstall(spec) {
38
+ await this.stop(spec);
39
+ const res = runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]);
40
+ rmSync(join(spec.cwd, "run-service.vbs"), { force: true });
41
+ return res.ok ? ok(`Removed scheduled task "${TASK}".`) : fail(res.out);
42
+ },
43
+
44
+ async start() {
45
+ const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
46
+ return res.ok ? ok("Started.") : fail(res.out);
47
+ },
48
+
49
+ async stop(spec) {
50
+ runSafe("schtasks", ["/End", "/TN", TASK]);
51
+ const res = runSafe("powershell", ["-NoProfile", "-Command", killScript(entryOf(spec))]);
52
+ return ok(`Stopped. ${res.out.trim()}`);
53
+ },
54
+
55
+ async status(spec) {
56
+ const task = runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]);
57
+ const proc = runSafe("powershell", ["-NoProfile", "-Command", countScript(entryOf(spec))]);
58
+ const running = proc.ok && /[1-9]\d*/.test(proc.out.trim());
59
+ const installed = task.ok;
60
+ return ok(
61
+ `Installed: ${installed ? "yes" : "no"} | Running: ${running ? "yes" : "no"}\n` +
62
+ (installed ? task.out.trim() : "Task not found."),
63
+ );
64
+ },
65
+ };
66
+
67
+ /** The bot entry file — unique enough to identify the bot process. It may be
68
+ * followed by trailing args (e.g. `--instance <dir>`), so find it explicitly. */
69
+ function entryOf(spec: LaunchSpec): string {
70
+ return (
71
+ spec.args.find((a) => a.endsWith("index.ts")) ?? spec.args[spec.args.length - 1] ?? spec.cwd
72
+ );
73
+ }
74
+
75
+ function vbsLauncher(spec: LaunchSpec): string {
76
+ const cmd = `""${spec.nodePath}"" ${spec.args.map((a) => `""${a}""`).join(" ")}`;
77
+ return [
78
+ 'Set sh = CreateObject("WScript.Shell")',
79
+ `sh.CurrentDirectory = "${spec.cwd}"`,
80
+ `sh.Run "${cmd}", 0, False`,
81
+ ].join("\r\n");
82
+ }
83
+
84
+ function killScript(entry: string): string {
85
+ const safe = entry.replace(/'/g, "''");
86
+ return [
87
+ `$p = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' };`,
88
+ `$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
89
+ `"killed " + (@($p).Count)`,
90
+ ].join(" ");
91
+ }
92
+
93
+ function countScript(entry: string): string {
94
+ const safe = entry.replace(/'/g, "''");
95
+ return `@(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' }).Count`;
96
+ }
97
+
98
+ function ok(message: string): ServiceResult {
99
+ return { ok: true, message };
100
+ }
101
+ function fail(message: string): ServiceResult {
102
+ return { ok: false, message };
103
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * History parser — turns a session's .jsonl event log into readable entries.
3
+ * Reads only the tail of large logs to stay fast.
4
+ */
5
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
6
+ import type { HistoryEntry, HistoryRole } from "./types.js";
7
+
8
+ const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
9
+
10
+ interface RawEvent {
11
+ kind?: string;
12
+ data?: {
13
+ content?: Array<{ kind?: string; data?: unknown; text?: unknown }>;
14
+ meta?: { timestamp?: number };
15
+ name?: string;
16
+ tool_name?: string;
17
+ };
18
+ }
19
+
20
+ /** Parse the most recent `maxEntries` history entries from a session log. */
21
+ export function readHistory(jsonlPath: string, maxEntries = 20): HistoryEntry[] {
22
+ for (const window of TAIL_WINDOWS) {
23
+ const entries = parseTail(jsonlPath, window, maxEntries);
24
+ if (entries.length > 0) return entries;
25
+ }
26
+ return [];
27
+ }
28
+
29
+ /** Current byte size of a session log (0 if missing). */
30
+ export function jsonlSize(jsonlPath: string): number {
31
+ try {
32
+ return statSync(jsonlPath).size;
33
+ } catch {
34
+ return 0;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Read the entries appended after `fromByte` (the "unread" since last seen).
40
+ * Returns the parsed entries and the new end-of-file byte offset. Kiro appends
41
+ * whole newline-terminated JSON objects, so `fromByte` is always a line boundary.
42
+ */
43
+ export function readEntriesFrom(jsonlPath: string, fromByte: number): { entries: HistoryEntry[]; size: number } {
44
+ const size = jsonlSize(jsonlPath);
45
+ if (size <= fromByte || size === 0) return { entries: [], size };
46
+ const length = size - fromByte;
47
+ const fd = openSync(jsonlPath, "r");
48
+ try {
49
+ const buf = Buffer.alloc(length);
50
+ readSync(fd, buf, 0, length, fromByte);
51
+ const lines = buf.toString("utf-8").split("\n").filter((l) => l.trim().length > 0);
52
+ const entries: HistoryEntry[] = [];
53
+ for (const line of lines) {
54
+ const e = parseEventLine(line);
55
+ if (e) entries.push(e);
56
+ }
57
+ return { entries, size };
58
+ } finally {
59
+ closeSync(fd);
60
+ }
61
+ }
62
+
63
+ function parseTail(jsonlPath: string, window: number, maxEntries: number): HistoryEntry[] {
64
+ const text = readTail(jsonlPath, window);
65
+ if (!text) return [];
66
+
67
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
68
+ const entries: HistoryEntry[] = [];
69
+
70
+ for (const line of lines) {
71
+ const entry = parseEventLine(line);
72
+ if (entry) entries.push(entry);
73
+ }
74
+
75
+ return entries.slice(-maxEntries);
76
+ }
77
+
78
+ /** Parse a single .jsonl event line into a history entry (or undefined). */
79
+ export function parseEventLine(line: string): HistoryEntry | undefined {
80
+ const trimmed = line.trim();
81
+ if (!trimmed) return undefined;
82
+ let ev: RawEvent;
83
+ try {
84
+ ev = JSON.parse(trimmed) as RawEvent;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ return toEntry(ev);
89
+ }
90
+
91
+ /** Build a compact plain-text transcript from history entries (for priming). */
92
+ export function buildTranscript(entries: HistoryEntry[], perEntryMax = 600): string {
93
+ const label: Record<string, string> = {
94
+ user: "User",
95
+ assistant: "Assistant",
96
+ tool: "Tool",
97
+ system: "System",
98
+ };
99
+ return entries
100
+ .map((e) => {
101
+ const text = e.text.length > perEntryMax ? e.text.slice(0, perEntryMax) + " …" : e.text;
102
+ return `${label[e.role] ?? e.role}: ${text}`;
103
+ })
104
+ .join("\n");
105
+ }
106
+
107
+ function toEntry(ev: RawEvent): HistoryEntry | undefined {
108
+ const role = roleOf(ev.kind);
109
+ if (!role) return undefined;
110
+
111
+ const text = extractText(ev.data?.content);
112
+ const tool = ev.data?.tool_name || ev.data?.name;
113
+ if (!text && !tool) return undefined;
114
+
115
+ return {
116
+ role,
117
+ text: text || (tool ? `(${tool})` : ""),
118
+ tool,
119
+ timestamp: ev.data?.meta?.timestamp,
120
+ };
121
+ }
122
+
123
+ function roleOf(kind?: string): HistoryRole | undefined {
124
+ switch (kind) {
125
+ case "Prompt":
126
+ case "UserMessage":
127
+ return "user";
128
+ case "AssistantMessage":
129
+ case "Response":
130
+ return "assistant";
131
+ case "ToolUse":
132
+ case "ToolUseResults":
133
+ return "tool";
134
+ default:
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ function extractText(content?: Array<{ kind?: string; data?: unknown; text?: unknown }>): string {
140
+ if (!Array.isArray(content)) return "";
141
+ const parts: string[] = [];
142
+ for (const block of content) {
143
+ if (block.kind === "text") {
144
+ if (typeof block.data === "string") parts.push(block.data);
145
+ else if (block.data && typeof (block.data as { text?: unknown }).text === "string") {
146
+ parts.push((block.data as { text: string }).text);
147
+ }
148
+ } else if (typeof block.text === "string") {
149
+ parts.push(block.text);
150
+ }
151
+ }
152
+ return parts.join("").trim();
153
+ }
154
+
155
+ /** Read up to `maxBytes` from the end of a file as UTF-8 text. */
156
+ function readTail(path: string, maxBytes: number): string {
157
+ let size: number;
158
+ try {
159
+ size = statSync(path).size;
160
+ } catch {
161
+ return "";
162
+ }
163
+ if (size === 0) return "";
164
+
165
+ const start = Math.max(0, size - maxBytes);
166
+ const length = size - start;
167
+ const fd = openSync(path, "r");
168
+ try {
169
+ const buf = Buffer.alloc(length);
170
+ readSync(fd, buf, 0, length, start);
171
+ let text = buf.toString("utf-8");
172
+ // If we started mid-file, drop the partial first line.
173
+ if (start > 0) {
174
+ const nl = text.indexOf("\n");
175
+ if (nl !== -1) text = text.slice(nl + 1);
176
+ }
177
+ return text;
178
+ } finally {
179
+ closeSync(fd);
180
+ }
181
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Session store — reads ~/.kiro/sessions/cli to discover existing Kiro 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 Kiro 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,167 @@
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 { safeEdit, safeSend } from "../bot/telegram-io.js";
17
+
18
+ const SOFT_LIMIT = 3500;
19
+ const THINK_TAIL = 500;
20
+
21
+ type SegKind = "out" | "think" | "tool";
22
+ interface Seg {
23
+ kind: SegKind;
24
+ text: string;
25
+ }
26
+
27
+ export class ResponseStreamer {
28
+ private readonly segs: Seg[] = [];
29
+ private sealedIdx = 0;
30
+ private liveId: number | undefined;
31
+ private timer: NodeJS.Timeout | undefined;
32
+ private dirty = false;
33
+ private flushing = false;
34
+ private closed = false;
35
+
36
+ constructor(
37
+ private readonly api: Api,
38
+ private readonly chatId: number,
39
+ private readonly throttleMs: number,
40
+ ) {}
41
+
42
+ appendOutput(text: string): void {
43
+ if (!text) return;
44
+ this.merge("out", text);
45
+ this.schedule();
46
+ }
47
+
48
+ appendThought(text: string): void {
49
+ if (!text) return;
50
+ this.merge("think", text);
51
+ this.schedule();
52
+ }
53
+
54
+ addTool(rawMarkdown: string): void {
55
+ if (!rawMarkdown) return;
56
+ this.segs.push({ kind: "tool", text: rawMarkdown });
57
+ this.schedule();
58
+ }
59
+
60
+ get hasOutput(): boolean {
61
+ return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
62
+ }
63
+
64
+ async finalize(): Promise<void> {
65
+ this.closed = true;
66
+ if (this.timer) clearTimeout(this.timer);
67
+ this.timer = undefined;
68
+ await this.flush(true);
69
+ }
70
+
71
+ // ── internals ──────────────────────────────────────────────────────────────
72
+
73
+ private merge(kind: SegKind, text: string): void {
74
+ const last = this.segs.at(-1);
75
+ if (last && last.kind === kind) last.text += text;
76
+ else this.segs.push({ kind, text });
77
+ }
78
+
79
+ private schedule(): void {
80
+ if (this.closed) return;
81
+ this.dirty = true;
82
+ if (this.timer) return;
83
+ this.timer = setTimeout(() => {
84
+ this.timer = undefined;
85
+ void this.flush(false);
86
+ }, this.throttleMs);
87
+ }
88
+
89
+ private async flush(final: boolean): Promise<void> {
90
+ if (this.flushing) {
91
+ if (!final) this.schedule();
92
+ return;
93
+ }
94
+ if (!this.dirty && !final) return;
95
+ this.flushing = true;
96
+ this.dirty = false;
97
+ try {
98
+ await this.sealOverflow();
99
+ const src = renderSegs(this.segs.slice(this.sealedIdx));
100
+ if (!src.trim()) return;
101
+ const rendered = toTelegramMarkdown(src);
102
+ const chunks = chunkMarkdown(rendered);
103
+ const plain = chunkMarkdown(src);
104
+ if (chunks.length <= 1) {
105
+ const mdv2 = chunks[0] ?? rendered;
106
+ if (this.liveId === undefined) this.liveId = await safeSend(this.api, this.chatId, mdv2, src);
107
+ else await safeEdit(this.api, this.chatId, this.liveId, mdv2, src);
108
+ } else {
109
+ // Remainder no longer fits one message: flush all, last stays live.
110
+ for (let i = 0; i < chunks.length; i++) {
111
+ const mdv2 = chunks[i]!;
112
+ const p = plain[i] ?? mdv2;
113
+ if (i === 0 && this.liveId !== undefined) await safeEdit(this.api, this.chatId, this.liveId, mdv2, p);
114
+ else if (i < chunks.length - 1) await safeSend(this.api, this.chatId, mdv2, p);
115
+ else this.liveId = await safeSend(this.api, this.chatId, mdv2, p);
116
+ }
117
+ this.sealedIdx = this.segs.length; // everything before the live tail is sealed
118
+ }
119
+ } finally {
120
+ this.flushing = false;
121
+ }
122
+ }
123
+
124
+ /** Seal leading segments into finalized messages while the live view is too big. */
125
+ private async sealOverflow(): Promise<void> {
126
+ let live = this.segs.slice(this.sealedIdx);
127
+ while (live.length > 1 && toTelegramMarkdown(renderSegs(live)).length > SOFT_LIMIT) {
128
+ const headCount = live.length - 1;
129
+ await this.seal(this.sealedIdx, this.sealedIdx + headCount);
130
+ this.sealedIdx += headCount;
131
+ this.liveId = undefined;
132
+ live = this.segs.slice(this.sealedIdx);
133
+ }
134
+ }
135
+
136
+ private async seal(from: number, to: number): Promise<void> {
137
+ const src = renderSegs(this.segs.slice(from, to));
138
+ if (!src.trim()) return;
139
+ const chunks = chunkMarkdown(toTelegramMarkdown(src));
140
+ const plain = chunkMarkdown(src);
141
+ for (let i = 0; i < chunks.length; i++) {
142
+ const mdv2 = chunks[i]!;
143
+ const p = plain[i] ?? mdv2;
144
+ if (i === 0 && this.liveId !== undefined) await safeEdit(this.api, this.chatId, this.liveId, mdv2, p);
145
+ else await safeSend(this.api, this.chatId, mdv2, p);
146
+ }
147
+ }
148
+ }
149
+
150
+ function renderSegs(segs: Seg[]): string {
151
+ return segs
152
+ .map((s) => {
153
+ if (s.kind === "out") return s.text.trim();
154
+ if (s.kind === "think") return quoteThought(s.text);
155
+ return s.text.trim();
156
+ })
157
+ .filter((x) => x.length > 0)
158
+ .join("\n\n");
159
+ }
160
+
161
+ function quoteThought(text: string): string {
162
+ const t = text.trim();
163
+ if (!t) return "";
164
+ const short = t.length > THINK_TAIL ? "…" + t.slice(-THINK_TAIL) : t;
165
+ const lines = short.split("\n");
166
+ return lines.map((l, i) => (i === 0 ? `> 💭 *thinking:* ${l}` : `> ${l}`)).join("\n");
167
+ }