@aaroncql/pim-agent 0.0.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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,713 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import { GrammyError, type Api } from "grammy";
3
+ import { basename } from "node:path";
4
+
5
+ import type { SubagentDetails } from "../extensions/subagent/subagent";
6
+ import type { TodoInput } from "../extensions/todo/schema";
7
+ import type { LogsMode } from "./Config";
8
+ import { Markdown } from "./Markdown";
9
+ import type { Session, SessionId } from "./Session";
10
+ import { TypingIndicator } from "./TypingIndicator";
11
+
12
+ export type TurnEndState = "ok" | "cancelled" | "error";
13
+ type TurnState = TurnEndState | "running";
14
+
15
+ type TrackerEntry = {
16
+ readonly key: string;
17
+ readonly kind: "tool" | "todo" | "thinking" | "narration";
18
+ readonly emoji: string;
19
+ label: string;
20
+ state: "running" | "ok" | "error";
21
+ };
22
+
23
+ const TOOL_EMOJI: Record<string, string> = {
24
+ read: "📄",
25
+ edit: "✏️",
26
+ write: "✏️",
27
+ bash: "⚡️",
28
+ grep: "🔎",
29
+ glob: "🔎",
30
+ todo: "📋",
31
+ web_search: "🌐",
32
+ web_fetch: "🌐",
33
+ send_file: "📤",
34
+ task: "⏰",
35
+ subagent: "🤖",
36
+ };
37
+
38
+ const MESSAGE_LIMIT = 4000;
39
+
40
+ export class Renderer {
41
+ private readonly api: Api;
42
+ private readonly sessionId: SessionId;
43
+ private readonly logsMode: LogsMode;
44
+ private readonly entries: TrackerEntry[] = [];
45
+ private readonly toolIndex = new Map<string, number>();
46
+ private readonly subagentBaseById = new Map<string, string>();
47
+ private readonly typing: TypingIndicator;
48
+ private statusMessageId: number | undefined;
49
+ private editTimer: Timer | undefined;
50
+ private thinking = "";
51
+ private narration = "";
52
+ private currentMessageText = "";
53
+ private streamedFinalText = "";
54
+ private pendingNarrationCount = 0;
55
+ private lastRendered = "";
56
+ private stopped = false;
57
+
58
+ public constructor(session: Session, api: Api) {
59
+ this.api = api;
60
+ this.sessionId = session.id;
61
+ this.logsMode = session.settings.logsMode ?? "text";
62
+ this.typing = new TypingIndicator(api, session.id);
63
+ }
64
+
65
+ public start(): void {
66
+ this.typing.start();
67
+ }
68
+
69
+ public handleEvent(event: AgentSessionEvent): void {
70
+ if (this.stopped) {
71
+ return;
72
+ }
73
+ if (event.type === "message_update") {
74
+ const update = event.assistantMessageEvent as {
75
+ readonly type: string;
76
+ readonly delta?: string;
77
+ readonly content?: string;
78
+ };
79
+ if (update.type === "thinking_delta") {
80
+ this.thinking += update.delta ?? "";
81
+ return;
82
+ }
83
+ if (update.type === "thinking_end") {
84
+ this.thinking = update.content ?? this.thinking;
85
+ this.flushThinking();
86
+ return;
87
+ }
88
+ if (update.type === "text_delta") {
89
+ this.flushThinking();
90
+ this.narration += update.delta ?? "";
91
+ this.currentMessageText += update.delta ?? "";
92
+ return;
93
+ }
94
+ if (update.type === "text_end") {
95
+ this.narration = update.content ?? this.narration;
96
+ this.pushNarration();
97
+ return;
98
+ }
99
+ this.flushThinking();
100
+ return;
101
+ }
102
+ if (event.type === "message_start") {
103
+ this.flushThinking();
104
+ this.narration = "";
105
+ this.currentMessageText = "";
106
+ this.pendingNarrationCount = 0;
107
+ return;
108
+ }
109
+ if (event.type === "tool_execution_start") {
110
+ this.flushThinking();
111
+ if (this.logsMode === "off") {
112
+ return;
113
+ }
114
+ this.addTool(event.toolCallId, event.toolName, event.args);
115
+ return;
116
+ }
117
+ if (event.type === "tool_execution_update") {
118
+ if (this.logsMode === "off") {
119
+ return;
120
+ }
121
+ this.updateSubagentLabel(
122
+ event.toolCallId,
123
+ event.toolName,
124
+ event.partialResult
125
+ );
126
+ return;
127
+ }
128
+ if (event.type === "tool_execution_end") {
129
+ if (this.logsMode === "off") {
130
+ return;
131
+ }
132
+ this.updateSubagentLabel(event.toolCallId, event.toolName, event.result);
133
+ const idx = this.toolIndex.get(event.toolCallId);
134
+ if (idx !== undefined) {
135
+ this.entries[idx]!.state = event.isError ? "error" : "ok";
136
+ this.scheduleEdit();
137
+ }
138
+ return;
139
+ }
140
+ if (event.type === "message_end") {
141
+ this.flushThinking();
142
+ this.settleMessageNarrations(event.message);
143
+ return;
144
+ }
145
+ if (event.type === "agent_end") {
146
+ this.flushThinking();
147
+ this.narration = "";
148
+ }
149
+ }
150
+
151
+ public async finish(finalText: string, state: TurnEndState): Promise<void> {
152
+ this.stopped = true;
153
+ this.clearTimers();
154
+ this.flushThinking();
155
+ this.narration = "";
156
+ await this.flushEdit(state);
157
+ const textToSend = finalText.trim()
158
+ ? finalText
159
+ : this.streamedFinalText.trim();
160
+ if (textToSend) {
161
+ await this.sendFinal(textToSend);
162
+ }
163
+ }
164
+
165
+ private addTool(toolCallId: string, toolName: string, args: unknown): void {
166
+ const name = toolName.toLowerCase();
167
+ if (name === "todo") {
168
+ const content = Renderer.latestInProgressTodoContent(args);
169
+ if (!content) {
170
+ return;
171
+ }
172
+ this.entries.push({
173
+ key: toolCallId,
174
+ kind: "todo",
175
+ emoji: TOOL_EMOJI.todo as string,
176
+ label: content,
177
+ state: "ok",
178
+ });
179
+ this.scheduleEdit();
180
+ return;
181
+ }
182
+ const emoji = TOOL_EMOJI[name] ?? "⚙️";
183
+ const label = Renderer.toolLabel(toolName, args);
184
+ if (name === "subagent") {
185
+ this.subagentBaseById.set(toolCallId, label);
186
+ }
187
+ const last = this.entries.at(-1);
188
+ if (last?.kind === "tool" && last.emoji === emoji && last.label === label) {
189
+ this.toolIndex.set(toolCallId, this.entries.length - 1);
190
+ last.state = "running";
191
+ this.scheduleEdit();
192
+ return;
193
+ }
194
+ this.entries.push({
195
+ key: toolCallId,
196
+ kind: "tool",
197
+ emoji,
198
+ label,
199
+ state: "running",
200
+ });
201
+ this.toolIndex.set(toolCallId, this.entries.length - 1);
202
+ this.scheduleEdit();
203
+ }
204
+
205
+ private updateSubagentLabel(
206
+ toolCallId: string,
207
+ toolName: string,
208
+ payload: unknown
209
+ ): void {
210
+ if (toolName.toLowerCase() !== "subagent") {
211
+ return;
212
+ }
213
+ const idx = this.toolIndex.get(toolCallId);
214
+ if (idx === undefined) {
215
+ return;
216
+ }
217
+ const base = this.subagentBaseById.get(toolCallId);
218
+ if (base === undefined) {
219
+ return;
220
+ }
221
+ const details = (payload as { readonly details?: SubagentDetails } | null)
222
+ ?.details;
223
+ if (!details) {
224
+ return;
225
+ }
226
+ const count = details.toolCalls.length + details.activeToolNames.length;
227
+ const suffix =
228
+ count > 0 ? ` (${count} ${count === 1 ? "tool" : "tools"})` : "";
229
+ const next = `${base}${suffix}`;
230
+ if (this.entries[idx]!.label === next) {
231
+ return;
232
+ }
233
+ this.entries[idx]!.label = next;
234
+ this.scheduleEdit();
235
+ }
236
+
237
+ private flushThinking(): void {
238
+ if (this.logsMode !== "verbose") {
239
+ this.thinking = "";
240
+ return;
241
+ }
242
+ const text = Renderer.cleanProse(this.thinking);
243
+ this.thinking = "";
244
+ if (!text) {
245
+ return;
246
+ }
247
+ const last = this.entries.at(-1);
248
+ if (last?.kind === "thinking" && last.label === text) {
249
+ return;
250
+ }
251
+ this.entries.push({
252
+ key: `thinking-${this.entries.length}`,
253
+ kind: "thinking",
254
+ emoji: "",
255
+ label: text,
256
+ state: "ok",
257
+ });
258
+ this.scheduleEdit();
259
+ }
260
+
261
+ private pushNarration(): void {
262
+ const raw = this.narration.trim();
263
+ this.narration = "";
264
+ if (!raw) {
265
+ return;
266
+ }
267
+ if (this.logsMode !== "text" && this.logsMode !== "verbose") {
268
+ return;
269
+ }
270
+ const text = Renderer.cleanProse(raw);
271
+ const last = this.entries.at(-1);
272
+ if (last?.kind === "narration" && last.label === text) {
273
+ return;
274
+ }
275
+ this.entries.push({
276
+ key: `narration-${this.entries.length}`,
277
+ kind: "narration",
278
+ emoji: "",
279
+ label: text,
280
+ state: "ok",
281
+ });
282
+ this.pendingNarrationCount += 1;
283
+ this.scheduleEdit();
284
+ }
285
+
286
+ private settleMessageNarrations(message: unknown): void {
287
+ const msg = message as {
288
+ readonly role?: string;
289
+ readonly stopReason?: string;
290
+ };
291
+ const isFinal = msg.role === "assistant" && msg.stopReason !== "toolUse";
292
+ if (isFinal) {
293
+ this.streamedFinalText = this.currentMessageText;
294
+ if (this.pendingNarrationCount > 0) {
295
+ let removed = 0;
296
+ for (let i = 0; i < this.pendingNarrationCount; i++) {
297
+ if (this.entries.at(-1)?.kind !== "narration") {
298
+ break;
299
+ }
300
+ this.entries.pop();
301
+ removed += 1;
302
+ }
303
+ if (removed > 0) {
304
+ this.scheduleEdit();
305
+ }
306
+ }
307
+ }
308
+ this.pendingNarrationCount = 0;
309
+ }
310
+
311
+ private scheduleEdit(): void {
312
+ if (this.logsMode === "off") {
313
+ return;
314
+ }
315
+ if (this.editTimer) {
316
+ return;
317
+ }
318
+ this.editTimer = setTimeout(() => {
319
+ this.editTimer = undefined;
320
+ if (this.stopped) {
321
+ return;
322
+ }
323
+ void this.flushEdit("running");
324
+ }, 1_000);
325
+ }
326
+
327
+ private async flushEdit(state: TurnState): Promise<void> {
328
+ if (this.editTimer) {
329
+ clearTimeout(this.editTimer);
330
+ this.editTimer = undefined;
331
+ }
332
+ if (this.logsMode === "off") {
333
+ return;
334
+ }
335
+ const body = this.renderStatus(state);
336
+ if (!body) {
337
+ return;
338
+ }
339
+ if (body === this.lastRendered) {
340
+ return;
341
+ }
342
+ this.lastRendered = body;
343
+ if (this.statusMessageId === undefined) {
344
+ const msg = await this.sendMessage(body, { status: true });
345
+ this.statusMessageId = msg?.message_id;
346
+ return;
347
+ }
348
+ await this.editMessage(body);
349
+ }
350
+
351
+ private renderStatus(state: TurnState): string {
352
+ const visible = this.entries.filter((entry) => this.entryVisible(entry));
353
+ const pieces: string[] = [];
354
+ if (visible.length === 0) {
355
+ return "";
356
+ }
357
+ for (let i = 0; i < visible.length; i++) {
358
+ const entry = visible[i]!;
359
+ if (entry.kind === "todo") {
360
+ pieces.push(`${entry.emoji} <b>${Markdown.escape(entry.label)}</b>`);
361
+ } else if (entry.kind === "thinking") {
362
+ pieces.push(`<i>${Markdown.toHtml(entry.label)}</i>`);
363
+ } else if (entry.kind === "narration") {
364
+ pieces.push(Markdown.toHtml(entry.label));
365
+ } else {
366
+ const isLastEntry = i === visible.length - 1;
367
+ let suffix = "";
368
+ if (entry.state === "error") {
369
+ suffix = " ❌";
370
+ } else if (state === "running" && isLastEntry) {
371
+ suffix = " 🟡";
372
+ }
373
+ pieces.push(`${entry.emoji} ${entry.label}${suffix}`);
374
+ }
375
+ const next = visible[i + 1];
376
+ if (next) {
377
+ pieces.push(
378
+ entry.kind === "tool" && next.kind === "tool" ? "\n" : "\n\n"
379
+ );
380
+ }
381
+ }
382
+
383
+ let body = pieces.join("");
384
+ if (state === "cancelled") {
385
+ body += "\n\n❌ Cancelled";
386
+ } else if (state === "error") {
387
+ body += "\n\n❌ Error";
388
+ }
389
+ return Renderer.capStatus(body);
390
+ }
391
+
392
+ private async sendFinal(markdown: string): Promise<void> {
393
+ const html = Markdown.toHtml(markdown);
394
+ for (const piece of Renderer.chunk(html)) {
395
+ await this.sendMessage(piece, { status: false });
396
+ }
397
+ }
398
+
399
+ private async sendMessage(
400
+ html: string,
401
+ opts: { readonly status: boolean }
402
+ ): Promise<{ readonly message_id: number } | undefined> {
403
+ if (!html) {
404
+ return undefined;
405
+ }
406
+ const other = {
407
+ parse_mode: "HTML" as const,
408
+ message_thread_id: this.sessionId.threadId,
409
+ link_preview_options: { is_disabled: true },
410
+ };
411
+ try {
412
+ const msg = await this.api.sendMessage(
413
+ this.sessionId.chatId,
414
+ Renderer.sanitize(html),
415
+ other
416
+ );
417
+ console.log(
418
+ `[send] chatId=${this.sessionId.chatId} threadId=${this.sessionId.threadId ?? "main"} ${opts.status ? "status" : "answer"} ok (${html.length}b)`
419
+ );
420
+ return msg;
421
+ } catch (err) {
422
+ if (err instanceof GrammyError && err.error_code === 400) {
423
+ console.warn(`[send] HTML 400 (${err.description}) — retry plain`);
424
+ const msg = await this.api.sendMessage(
425
+ this.sessionId.chatId,
426
+ Renderer.stripHtml(Renderer.sanitize(html)),
427
+ {
428
+ message_thread_id: this.sessionId.threadId,
429
+ link_preview_options: { is_disabled: true },
430
+ }
431
+ );
432
+ return msg;
433
+ }
434
+ throw err;
435
+ }
436
+ }
437
+
438
+ private async editMessage(html: string): Promise<void> {
439
+ try {
440
+ await this.api.editMessageText(
441
+ this.sessionId.chatId,
442
+ this.statusMessageId!,
443
+ Renderer.sanitize(html),
444
+ {
445
+ parse_mode: "HTML",
446
+ link_preview_options: { is_disabled: true },
447
+ }
448
+ );
449
+ } catch (err) {
450
+ if (err instanceof GrammyError) {
451
+ if (/message is not modified/i.test(err.description)) {
452
+ return;
453
+ }
454
+ if (err.error_code === 400) {
455
+ await this.api
456
+ .editMessageText(
457
+ this.sessionId.chatId,
458
+ this.statusMessageId!,
459
+ Renderer.stripHtml(Renderer.sanitize(html)),
460
+ {
461
+ link_preview_options: { is_disabled: true },
462
+ }
463
+ )
464
+ .catch(() => {});
465
+ return;
466
+ }
467
+ }
468
+ console.warn(`[send] status edit failed:`, err);
469
+ }
470
+ }
471
+
472
+ private entryVisible(entry: TrackerEntry): boolean {
473
+ if (this.logsMode === "off") {
474
+ return false;
475
+ }
476
+ if (entry.kind === "tool" || entry.kind === "todo") {
477
+ return true;
478
+ }
479
+ if (entry.kind === "narration") {
480
+ return this.logsMode === "text" || this.logsMode === "verbose";
481
+ }
482
+ return this.logsMode === "verbose";
483
+ }
484
+
485
+ private clearTimers(): void {
486
+ this.typing.stop();
487
+ if (this.editTimer) {
488
+ clearTimeout(this.editTimer);
489
+ this.editTimer = undefined;
490
+ }
491
+ }
492
+
493
+ private static toolLabel(toolName: string, args: unknown): string {
494
+ const obj =
495
+ args && typeof args === "object" ? (args as Record<string, unknown>) : {};
496
+ const name = toolName.toLowerCase();
497
+ const code = (s: string): string =>
498
+ `<code>${Markdown.escape(Renderer.truncate(s, 160))}</code>`;
499
+
500
+ if (
501
+ name === "read" ||
502
+ name === "edit" ||
503
+ name === "write" ||
504
+ name === "send_file"
505
+ ) {
506
+ const p = Renderer.stringArg(obj, "path");
507
+ return p ? code(basename(p)) : "";
508
+ }
509
+ if (name === "bash") {
510
+ const cmd = Renderer.stringArg(obj, "command");
511
+ return cmd ? code(Renderer.firstLine(cmd)) : "";
512
+ }
513
+ if (name === "grep" || name === "glob") {
514
+ const pattern =
515
+ Renderer.stringArg(obj, "pattern") ?? Renderer.stringArg(obj, "query");
516
+ const where =
517
+ Renderer.stringArg(obj, "path") ?? Renderer.stringArg(obj, "glob");
518
+ if (pattern && where) {
519
+ return `${code(pattern)} in ${code(where)}`;
520
+ }
521
+ if (pattern) {
522
+ return code(pattern);
523
+ }
524
+ return "";
525
+ }
526
+ if (name === "web_search" || name === "web_fetch") {
527
+ const target =
528
+ Renderer.stringArg(obj, "url") ?? Renderer.stringArg(obj, "query");
529
+ return target ? Markdown.escape(Renderer.truncate(target, 180)) : "";
530
+ }
531
+ if (name === "task") {
532
+ return Renderer.taskLabel(obj, code);
533
+ }
534
+ if (name === "subagent") {
535
+ const prompt = Renderer.stringArg(obj, "prompt");
536
+ return prompt
537
+ ? Markdown.toHtml(Renderer.truncate(Renderer.firstLine(prompt), 180))
538
+ : "";
539
+ }
540
+
541
+ const candidate =
542
+ Renderer.stringArg(obj, "path") ??
543
+ Renderer.stringArg(obj, "command") ??
544
+ Renderer.stringArg(obj, "query") ??
545
+ Renderer.stringArg(obj, "pattern") ??
546
+ Renderer.stringArg(obj, "url");
547
+ return Markdown.escape(
548
+ Renderer.truncate(`${toolName}${candidate ? ` ${candidate}` : ""}`)
549
+ );
550
+ }
551
+
552
+ private static firstLine(text: string): string {
553
+ const idx = text.indexOf("\n");
554
+ if (idx < 0) {
555
+ return text;
556
+ }
557
+ return `${text.slice(0, idx).trimEnd()} …`;
558
+ }
559
+
560
+ private static stringArg(
561
+ obj: Record<string, unknown>,
562
+ key: string
563
+ ): string | undefined {
564
+ const value = obj[key];
565
+ return typeof value === "string" && value ? value : undefined;
566
+ }
567
+
568
+ private static taskScheduleSummary(
569
+ obj: Record<string, unknown>
570
+ ): string | undefined {
571
+ const sched = obj.schedule;
572
+ if (!sched || typeof sched !== "object") {
573
+ return undefined;
574
+ }
575
+ const s = sched as Record<string, unknown>;
576
+ if (s.type === "once" && typeof s.at === "string") {
577
+ return `once @ ${s.at}`;
578
+ }
579
+ if (s.type === "interval" && typeof s.every === "string") {
580
+ return `every ${s.every}`;
581
+ }
582
+ if (s.type === "cron" && typeof s.expr === "string") {
583
+ return `cron ${s.expr}`;
584
+ }
585
+ return undefined;
586
+ }
587
+
588
+ private static taskLabel(
589
+ obj: Record<string, unknown>,
590
+ code: (s: string) => string
591
+ ): string {
592
+ const action = Renderer.stringArg(obj, "action");
593
+ if (!action) {
594
+ return "";
595
+ }
596
+ if (action === "list") {
597
+ return "List tasks";
598
+ }
599
+ if (action === "create") {
600
+ const prompt = Renderer.stringArg(obj, "prompt");
601
+ const sched = Renderer.taskScheduleSummary(obj);
602
+ if (prompt && sched) {
603
+ return `Schedule task: ${code(prompt)} (${Markdown.escape(sched)})`;
604
+ }
605
+ if (prompt) {
606
+ return `Schedule task: ${code(prompt)}`;
607
+ }
608
+ return sched
609
+ ? `Schedule task (${Markdown.escape(sched)})`
610
+ : "Schedule task";
611
+ }
612
+ if (action === "update_prompt") {
613
+ const prompt = Renderer.stringArg(obj, "prompt");
614
+ return prompt ? `Update task: ${code(prompt)}` : "Update task";
615
+ }
616
+ const verb =
617
+ action === "delete"
618
+ ? "Delete"
619
+ : action === "pause"
620
+ ? "Pause"
621
+ : action === "resume"
622
+ ? "Resume"
623
+ : action;
624
+ const id = Renderer.stringArg(obj, "id");
625
+ return id ? `${verb} task: ${code(id)}` : `${verb} task`;
626
+ }
627
+
628
+ private static latestInProgressTodoContent(
629
+ args: unknown
630
+ ): string | undefined {
631
+ const todos =
632
+ args && typeof args === "object" && !Array.isArray(args)
633
+ ? (args as Partial<TodoInput>).todos
634
+ : undefined;
635
+ if (!Array.isArray(todos)) {
636
+ return undefined;
637
+ }
638
+
639
+ for (let i = todos.length - 1; i >= 0; i--) {
640
+ const item = todos[i] as unknown;
641
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
642
+ continue;
643
+ }
644
+ const { content, status } = item as Record<string, unknown>;
645
+ if (status !== "in_progress" || typeof content !== "string") {
646
+ continue;
647
+ }
648
+ const normalized = content.trim().replaceAll(/\s+/g, " ");
649
+ if (normalized) {
650
+ return normalized;
651
+ }
652
+ }
653
+ return undefined;
654
+ }
655
+
656
+ private static cleanProse(text: string): string {
657
+ return Renderer.truncate(text.replace(/\n{3,}/g, "\n\n").trim(), 900);
658
+ }
659
+
660
+ private static truncate(text: string, limit = 180): string {
661
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
662
+ }
663
+
664
+ private static capStatus(text: string): string {
665
+ if (text.length <= MESSAGE_LIMIT) {
666
+ return text;
667
+ }
668
+ const lines = text.split("\n");
669
+ let dropped = 0;
670
+ let total = text.length;
671
+ while (total > MESSAGE_LIMIT && lines.length > 1) {
672
+ const first = lines.shift()!;
673
+ total -= first.length + 1;
674
+ dropped += 1;
675
+ }
676
+ return `… ${dropped} earlier entries\n${lines.join("\n")}`;
677
+ }
678
+
679
+ private static chunk(html: string): readonly string[] {
680
+ if (html.length <= MESSAGE_LIMIT) {
681
+ return [html];
682
+ }
683
+ const chunks: string[] = [];
684
+ let rest = html;
685
+ while (rest.length > MESSAGE_LIMIT) {
686
+ const idx = rest.lastIndexOf("\n", MESSAGE_LIMIT);
687
+ const splitAt = idx > 0 ? idx : MESSAGE_LIMIT;
688
+ chunks.push(rest.slice(0, splitAt).trim());
689
+ rest = rest.slice(splitAt).trim();
690
+ }
691
+ if (rest) {
692
+ chunks.push(rest);
693
+ }
694
+ return chunks;
695
+ }
696
+
697
+ private static sanitize(text: string): string {
698
+ return text.replace(
699
+ /\b(api[_-]?key|token|secret)\b\s*[:=]\s*\S+/gi,
700
+ "$1=[redacted]"
701
+ );
702
+ }
703
+
704
+ private static stripHtml(html: string): string {
705
+ return html
706
+ .replace(/<br\s*\/?>/gi, "\n")
707
+ .replace(/<[^>]+>/g, "")
708
+ .replace(/&lt;/g, "<")
709
+ .replace(/&gt;/g, ">")
710
+ .replace(/&quot;/g, '"')
711
+ .replace(/&amp;/g, "&");
712
+ }
713
+ }