@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,177 @@
1
+ type TableRow = ReadonlyArray<string>;
2
+
3
+ type Segment =
4
+ | { readonly kind: "md"; readonly text: string }
5
+ | { readonly kind: "table"; readonly rows: ReadonlyArray<TableRow> };
6
+
7
+ const SAFE_LINK = /^(https?:|tg:|mailto:)/i;
8
+
9
+ export class Markdown {
10
+ public static toHtml(md: string): string {
11
+ const segments = Markdown.split(md);
12
+ let out = "";
13
+ for (const seg of segments) {
14
+ out +=
15
+ seg.kind === "md"
16
+ ? Markdown.renderMd(seg.text)
17
+ : Markdown.renderTable(seg.rows);
18
+ }
19
+ return out.replace(/\n{3,}/g, "\n\n").trim();
20
+ }
21
+
22
+ public static escape(s: string): string {
23
+ return s
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;");
28
+ }
29
+
30
+ private static readonly RENDERERS = {
31
+ text: (c: string): string => Markdown.escape(c),
32
+ paragraph: (c: string): string => `${c}\n\n`,
33
+ heading: (c: string, meta?: { level?: number }): string =>
34
+ meta?.level === 1 ? `<u><b>${c}</b></u>\n\n` : `<b>${c}</b>\n\n`,
35
+ strong: (c: string): string => `<b>${c}</b>`,
36
+ emphasis: (c: string): string => `<i>${c}</i>`,
37
+ strikethrough: (c: string): string => `<s>${c}</s>`,
38
+ codespan: (c: string): string => `<code>${c}</code>`,
39
+ code: (c: string, meta?: { language?: string }): string => {
40
+ const body = c.replace(/\n+$/, "");
41
+ const lang = meta?.language;
42
+ const open = lang
43
+ ? `<pre><code class="language-${Markdown.escape(lang)}">`
44
+ : "<pre>";
45
+ const close = lang ? "</code></pre>" : "</pre>";
46
+ return `${open}${body}${close}\n\n`;
47
+ },
48
+ link: (c: string, meta?: { href?: string }): string => {
49
+ const href = meta?.href ?? "";
50
+ return SAFE_LINK.test(href)
51
+ ? `<a href="${Markdown.escape(href)}">${c}</a>`
52
+ : c;
53
+ },
54
+ image: (c: string, meta?: { src?: string }): string => {
55
+ const src = meta?.src ?? "";
56
+ const alt = c || src;
57
+ return SAFE_LINK.test(src)
58
+ ? `<a href="${Markdown.escape(src)}">${alt}</a>`
59
+ : alt;
60
+ },
61
+ blockquote: (c: string): string =>
62
+ `<blockquote>${c.replace(/\n+$/, "")}</blockquote>\n\n`,
63
+ list: (c: string, meta?: { depth?: number }): string =>
64
+ (meta?.depth ?? 0) > 0 ? `\n${c}` : `${c}\n`,
65
+ listItem: (
66
+ c: string,
67
+ meta?: {
68
+ depth?: number;
69
+ ordered?: boolean;
70
+ index?: number;
71
+ checked?: boolean;
72
+ }
73
+ ): string => {
74
+ const depth = meta?.depth ?? 0;
75
+ const ordered = meta?.ordered ?? false;
76
+ const index = meta?.index ?? 0;
77
+ const checked = meta?.checked;
78
+ const indent = " ".repeat(depth);
79
+ let marker: string;
80
+ if (checked === true) {
81
+ marker = "✅";
82
+ } else if (checked === false) {
83
+ marker = "⬜";
84
+ } else if (ordered) {
85
+ marker = `${index + 1}.`;
86
+ } else {
87
+ marker = depth === 0 ? "•" : "◦";
88
+ }
89
+ return `${indent}${marker} ${c.replace(/\n+$/, "")}\n`;
90
+ },
91
+ hr: (): string => "───\n\n",
92
+ br: (): string => "\n",
93
+ table: (c: string): string => c,
94
+ };
95
+
96
+ private static renderMd(md: string): string {
97
+ if (!md.trim()) {
98
+ return "";
99
+ }
100
+ return Bun.markdown.render(md, Markdown.RENDERERS);
101
+ }
102
+
103
+ private static split(md: string): ReadonlyArray<Segment> {
104
+ const lines = md.split("\n");
105
+ const segments: Segment[] = [];
106
+ let buf: string[] = [];
107
+
108
+ const flushMd = (): void => {
109
+ if (buf.length > 0) {
110
+ segments.push({ kind: "md", text: buf.join("\n") });
111
+ buf = [];
112
+ }
113
+ };
114
+
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const line = lines[i]!;
117
+ const next = lines[i + 1];
118
+ if (
119
+ Markdown.isPipeLine(line) &&
120
+ next !== undefined &&
121
+ Markdown.isTableSeparator(next)
122
+ ) {
123
+ flushMd();
124
+ const rows: string[][] = [Markdown.parseRow(line)];
125
+ i += 1;
126
+ while (i + 1 < lines.length && Markdown.isPipeLine(lines[i + 1]!)) {
127
+ i += 1;
128
+ rows.push(Markdown.parseRow(lines[i]!));
129
+ }
130
+ segments.push({ kind: "table", rows });
131
+ continue;
132
+ }
133
+ buf.push(line);
134
+ }
135
+ flushMd();
136
+ return segments;
137
+ }
138
+
139
+ private static isPipeLine(line: string): boolean {
140
+ return /^\s*\|.*\|\s*$/.test(line);
141
+ }
142
+
143
+ private static isTableSeparator(line: string): boolean {
144
+ return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(line);
145
+ }
146
+
147
+ private static parseRow(line: string): string[] {
148
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
149
+ return trimmed.split("|").map((cell) => cell.trim());
150
+ }
151
+
152
+ private static renderTable(rows: ReadonlyArray<TableRow>): string {
153
+ if (rows.length < 2) {
154
+ return "";
155
+ }
156
+ const header = rows[0]!;
157
+ const dataRows = rows.slice(1);
158
+ if (dataRows.length === 0) {
159
+ return "";
160
+ }
161
+ const pieces: string[] = [];
162
+ for (const row of dataRows) {
163
+ pieces.push("───");
164
+ for (let c = 0; c < header.length; c++) {
165
+ const label = Markdown.renderMd(header[c] ?? "").trim();
166
+ const value = Markdown.renderMd(row[c] ?? "").trim();
167
+ if (label) {
168
+ pieces.push(`<b>${label}</b>: ${value}`);
169
+ } else if (value) {
170
+ pieces.push(value);
171
+ }
172
+ }
173
+ }
174
+ pieces.push("───");
175
+ return `${pieces.join("\n")}\n\n`;
176
+ }
177
+ }
@@ -0,0 +1,211 @@
1
+ import type { PromptOptions } from "@earendil-works/pi-coding-agent";
2
+ import type { Context, Filter } from "grammy";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { basename, extname, join } from "node:path";
5
+
6
+ import type { SessionId } from "./Session";
7
+
8
+ type DownloadedFile = {
9
+ readonly path: string;
10
+ readonly mimeType: string;
11
+ readonly imageBase64: string | undefined;
12
+ };
13
+
14
+ type FileRef = {
15
+ readonly fileId: string;
16
+ readonly uniqueId?: string;
17
+ readonly name?: string;
18
+ readonly mimeType: string;
19
+ readonly ext: string;
20
+ };
21
+
22
+ export type Prompt = {
23
+ readonly text: string;
24
+ readonly options: Pick<PromptOptions, "images">;
25
+ };
26
+
27
+ const IMAGE_BYTES_LIMIT = 4 * 1024 * 1024;
28
+ const REPLY_QUOTE_HEAD = 128;
29
+ const REPLY_QUOTE_TAIL = 128;
30
+
31
+ export class Message {
32
+ public static async toPrompt(
33
+ ctx: Filter<Context, "message">,
34
+ token: string,
35
+ configDir: string,
36
+ sessionId: SessionId
37
+ ): Promise<Prompt | undefined> {
38
+ const message = ctx.message;
39
+ const text = ("text" in message ? message.text : undefined) ?? "";
40
+ const caption = ("caption" in message ? message.caption : undefined) ?? "";
41
+ const files = await Message.download(ctx, token, configDir, sessionId);
42
+
43
+ const attachments: string[] = [];
44
+ const images: NonNullable<PromptOptions["images"]> = [];
45
+ for (const file of files) {
46
+ if (file.imageBase64) {
47
+ images.push({
48
+ type: "image",
49
+ data: file.imageBase64,
50
+ mimeType: file.mimeType,
51
+ });
52
+ attachments.push(`[Image attachment: ${file.path}]`);
53
+ continue;
54
+ }
55
+ attachments.push(`[Attachment: ${file.path}]`);
56
+ }
57
+
58
+ const body = (text || caption || "").trim();
59
+ if (!body && images.length === 0 && attachments.length === 0) {
60
+ return undefined;
61
+ }
62
+ const replyContext = Message.buildReplyContext(ctx);
63
+ const promptText = [replyContext, body, ...attachments]
64
+ .filter(Boolean)
65
+ .join("\n\n")
66
+ .trim();
67
+ return {
68
+ text: promptText,
69
+ options: images.length > 0 ? { images } : {},
70
+ };
71
+ }
72
+
73
+ private static buildReplyContext(
74
+ ctx: Filter<Context, "message">
75
+ ): string | undefined {
76
+ const reply = ctx.message.reply_to_message;
77
+ if (!reply) {
78
+ return undefined;
79
+ }
80
+ const raw = ctx.message.quote?.text ?? reply.text ?? reply.caption ?? "";
81
+ const trimmed = raw.trim();
82
+ if (!trimmed) {
83
+ return undefined;
84
+ }
85
+ const truncated =
86
+ trimmed.length > REPLY_QUOTE_HEAD + REPLY_QUOTE_TAIL + 1
87
+ ? `${trimmed.slice(0, REPLY_QUOTE_HEAD)}…${trimmed.slice(-REPLY_QUOTE_TAIL)}`
88
+ : trimmed;
89
+ const quoted = truncated
90
+ .split("\n")
91
+ .map((line) => `> ${line}`)
92
+ .join("\n");
93
+ const isFromBot = reply.from?.id === ctx.me.id;
94
+ const label = isFromBot
95
+ ? "Replying to your earlier message:"
96
+ : "Replying to my earlier message:";
97
+ return `${label}\n${quoted}`;
98
+ }
99
+
100
+ private static async download(
101
+ ctx: Filter<Context, "message">,
102
+ token: string,
103
+ configDir: string,
104
+ sessionId: SessionId
105
+ ): Promise<ReadonlyArray<DownloadedFile>> {
106
+ const refs = Message.refs(ctx);
107
+ if (refs.length === 0) {
108
+ return [];
109
+ }
110
+
111
+ const dir = join(configDir, "attachments", String(sessionId.chatId));
112
+ await mkdir(dir, { recursive: true });
113
+ const out: DownloadedFile[] = [];
114
+ for (const ref of refs) {
115
+ const telegramFile = await ctx.api.getFile(ref.fileId);
116
+ if (!telegramFile.file_path) {
117
+ continue;
118
+ }
119
+ const url = `https://api.telegram.org/file/bot${token}/${telegramFile.file_path}`;
120
+ const response = await fetch(url);
121
+ if (!response.ok) {
122
+ throw new Error(`Telegram file download failed: ${response.status}`);
123
+ }
124
+ const ext =
125
+ extname(telegramFile.file_path) || extname(ref.name ?? "") || ref.ext;
126
+ const filename = Message.safeName(
127
+ `${ref.uniqueId ?? ref.fileId}-${Date.now()}${ext}`
128
+ );
129
+ const path = join(dir, filename);
130
+ const bytes = await response.arrayBuffer();
131
+ await Bun.write(path, bytes);
132
+ const isImage = ref.mimeType.startsWith("image/");
133
+ out.push({
134
+ path,
135
+ mimeType: ref.mimeType,
136
+ imageBase64:
137
+ isImage && bytes.byteLength <= IMAGE_BYTES_LIMIT
138
+ ? Buffer.from(bytes).toString("base64")
139
+ : undefined,
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ private static refs(ctx: Filter<Context, "message">): ReadonlyArray<FileRef> {
146
+ const message = ctx.message;
147
+ if ("photo" in message && message.photo) {
148
+ const photo = message.photo.at(-1)!;
149
+ return [
150
+ {
151
+ fileId: photo.file_id,
152
+ uniqueId: photo.file_unique_id,
153
+ mimeType: "image/jpeg",
154
+ ext: ".jpg",
155
+ },
156
+ ];
157
+ }
158
+ if ("document" in message && message.document) {
159
+ const doc = message.document;
160
+ return [
161
+ {
162
+ fileId: doc.file_id,
163
+ uniqueId: doc.file_unique_id,
164
+ name: doc.file_name,
165
+ mimeType: doc.mime_type ?? "application/octet-stream",
166
+ ext: extname(doc.file_name ?? "") || ".bin",
167
+ },
168
+ ];
169
+ }
170
+ if ("video" in message && message.video) {
171
+ const video = message.video;
172
+ return [
173
+ {
174
+ fileId: video.file_id,
175
+ uniqueId: video.file_unique_id,
176
+ name: video.file_name,
177
+ mimeType: video.mime_type ?? "video/mp4",
178
+ ext: extname(video.file_name ?? "") || ".mp4",
179
+ },
180
+ ];
181
+ }
182
+ if ("audio" in message && message.audio) {
183
+ const audio = message.audio;
184
+ return [
185
+ {
186
+ fileId: audio.file_id,
187
+ uniqueId: audio.file_unique_id,
188
+ name: audio.file_name,
189
+ mimeType: audio.mime_type ?? "audio/mpeg",
190
+ ext: extname(audio.file_name ?? "") || ".mp3",
191
+ },
192
+ ];
193
+ }
194
+ if ("voice" in message && message.voice) {
195
+ const voice = message.voice;
196
+ return [
197
+ {
198
+ fileId: voice.file_id,
199
+ uniqueId: voice.file_unique_id,
200
+ mimeType: voice.mime_type ?? "audio/ogg",
201
+ ext: ".ogg",
202
+ },
203
+ ];
204
+ }
205
+ return [];
206
+ }
207
+
208
+ private static safeName(name: string): string {
209
+ return basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
210
+ }
211
+ }
@@ -0,0 +1,216 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
+ import type { Api } from "grammy";
4
+
5
+ import { Renderer } from "./Renderer";
6
+ import type { Session } from "./Session";
7
+
8
+ type SentMessage = {
9
+ readonly chatId: number;
10
+ readonly text: string;
11
+ readonly options: unknown;
12
+ };
13
+
14
+ type EditedMessage = {
15
+ readonly chatId: number;
16
+ readonly messageId: number;
17
+ readonly text: string;
18
+ readonly options: unknown;
19
+ };
20
+
21
+ class FakeApi {
22
+ public readonly sent: SentMessage[] = [];
23
+ public readonly edited: EditedMessage[] = [];
24
+
25
+ public async sendMessage(
26
+ chatId: number,
27
+ text: string,
28
+ options: unknown
29
+ ): Promise<{ readonly message_id: number }> {
30
+ this.sent.push({ chatId, text, options });
31
+ return { message_id: this.sent.length };
32
+ }
33
+
34
+ public async editMessageText(
35
+ chatId: number,
36
+ messageId: number,
37
+ text: string,
38
+ options: unknown
39
+ ): Promise<void> {
40
+ this.edited.push({ chatId, messageId, text, options });
41
+ }
42
+
43
+ public async sendChatAction(): Promise<void> {}
44
+ }
45
+
46
+ const session = {
47
+ id: { chatId: 123, threadId: undefined },
48
+ settings: { logsMode: "text" },
49
+ } as unknown as Session;
50
+
51
+ function makeRenderer(): {
52
+ readonly api: FakeApi;
53
+ readonly renderer: Renderer;
54
+ } {
55
+ const api = new FakeApi();
56
+ return { api, renderer: new Renderer(session, api as unknown as Api) };
57
+ }
58
+
59
+ function todoStart(
60
+ todos: readonly unknown[],
61
+ toolCallId = "todo-1"
62
+ ): AgentSessionEvent {
63
+ return todoStartWithArgs({ todos }, toolCallId);
64
+ }
65
+
66
+ function todoStartWithArgs(
67
+ args: unknown,
68
+ toolCallId = "todo-1"
69
+ ): AgentSessionEvent {
70
+ return {
71
+ type: "tool_execution_start",
72
+ toolCallId,
73
+ toolName: "todo",
74
+ args,
75
+ } as AgentSessionEvent;
76
+ }
77
+
78
+ function todoEnd(
79
+ todos: readonly unknown[],
80
+ toolCallId = "todo-1"
81
+ ): AgentSessionEvent {
82
+ return {
83
+ type: "tool_execution_end",
84
+ toolCallId,
85
+ toolName: "todo",
86
+ result: { content: [], details: { todos } },
87
+ isError: false,
88
+ } as AgentSessionEvent;
89
+ }
90
+
91
+ function assistantText(text: string): readonly AgentSessionEvent[] {
92
+ return [
93
+ { type: "message_start" } as AgentSessionEvent,
94
+ {
95
+ type: "message_update",
96
+ assistantMessageEvent: { type: "text_delta", delta: text },
97
+ } as AgentSessionEvent,
98
+ {
99
+ type: "message_update",
100
+ assistantMessageEvent: { type: "text_end", content: text },
101
+ } as AgentSessionEvent,
102
+ {
103
+ type: "message_end",
104
+ message: { role: "assistant", stopReason: "toolUse" },
105
+ } as AgentSessionEvent,
106
+ ];
107
+ }
108
+
109
+ async function flush(renderer: Renderer): Promise<void> {
110
+ await (
111
+ renderer as unknown as {
112
+ readonly flushEdit: (state: "running") => Promise<void>;
113
+ }
114
+ ).flushEdit("running");
115
+ }
116
+
117
+ describe("Telegram Renderer todo status", () => {
118
+ test("renders the latest in-progress todo in bold", async () => {
119
+ const { api, renderer } = makeRenderer();
120
+
121
+ renderer.handleEvent(
122
+ todoStart([
123
+ { content: "First task", status: "in_progress" },
124
+ { content: "Second <task> & verify", status: "in_progress" },
125
+ ])
126
+ );
127
+ await renderer.finish("", "ok");
128
+
129
+ expect(api.sent.map((msg) => msg.text)).toEqual([
130
+ "📋 <b>Second &lt;task&gt; &amp; verify</b>",
131
+ ]);
132
+ });
133
+
134
+ test("keeps todo entries in event order instead of replacing the prior one", async () => {
135
+ const { api, renderer } = makeRenderer();
136
+
137
+ renderer.handleEvent(
138
+ todoStart([{ content: "Remember to buy milk", status: "in_progress" }])
139
+ );
140
+ await flush(renderer);
141
+
142
+ for (const event of assistantText(
143
+ "First item is in progress. Now let me finish it and start the next one:"
144
+ )) {
145
+ renderer.handleEvent(event);
146
+ }
147
+ renderer.handleEvent(
148
+ todoStart(
149
+ [
150
+ { content: "Remember to buy milk", status: "completed" },
151
+ { content: "Remember to get water", status: "in_progress" },
152
+ ],
153
+ "todo-2"
154
+ )
155
+ );
156
+ await renderer.finish("", "ok");
157
+
158
+ expect(api.sent.map((msg) => msg.text)).toEqual([
159
+ "📋 <b>Remember to buy milk</b>",
160
+ ]);
161
+ expect(api.edited.map((msg) => msg.text)).toEqual([
162
+ [
163
+ "📋 <b>Remember to buy milk</b>",
164
+ "",
165
+ "First item is in progress. Now let me finish it and start the next one:",
166
+ "",
167
+ "📋 <b>Remember to get water</b>",
168
+ ].join("\n"),
169
+ ]);
170
+ });
171
+
172
+ test("does not render todo calls with no in-progress item", async () => {
173
+ const { api, renderer } = makeRenderer();
174
+
175
+ renderer.handleEvent(
176
+ todoStart([
177
+ { content: "Plan", status: "pending" },
178
+ { content: "Done", status: "completed" },
179
+ ])
180
+ );
181
+ await renderer.finish("", "ok");
182
+
183
+ expect(api.sent).toEqual([]);
184
+ expect(api.edited).toEqual([]);
185
+ });
186
+
187
+ test("ignores malformed todo args", async () => {
188
+ const { api, renderer } = makeRenderer();
189
+
190
+ expect(() =>
191
+ renderer.handleEvent(todoStartWithArgs({ text: "done" }))
192
+ ).not.toThrow();
193
+ await renderer.finish("", "ok");
194
+
195
+ expect(api.sent).toEqual([]);
196
+ expect(api.edited).toEqual([]);
197
+ });
198
+
199
+ test("does not emit a new todo entry when no item remains in progress", async () => {
200
+ const { api, renderer } = makeRenderer();
201
+
202
+ renderer.handleEvent(
203
+ todoStart([{ content: "Build feature", status: "in_progress" }])
204
+ );
205
+ await flush(renderer);
206
+ renderer.handleEvent(
207
+ todoEnd([{ content: "Build feature", status: "completed" }])
208
+ );
209
+ await flush(renderer);
210
+
211
+ expect(api.sent.map((msg) => msg.text)).toEqual([
212
+ "📋 <b>Build feature</b>",
213
+ ]);
214
+ expect(api.edited).toEqual([]);
215
+ });
216
+ });