@aaroncql/pim-agent 0.3.0 โ†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,7 +232,7 @@ For development, run standalone with `pim --mode telegram` instead.
232
232
 
233
233
  - โฐ **Scheduled tasks** - your bot can create one-time, interval, or cron-based tasks that fire automatically; ask your bot to schedule something.
234
234
  - ๐Ÿ‘€ **Live progress logs** - use `/logs` to choose what you see while the agent works: final replies, tool use, intermediate text, or thinking.
235
- - ๐Ÿ“ **Markdown formatting** - replies render Markdown out of the box, including tables converted to vertical lists for Telegram.
235
+ - ๐Ÿ“ **Rich Markdown** - supports Telegram's [rich text formatting](https://telegram.org/blog/watch-apps-and-more#obscenely-rich-text-formatting-for-bots) with full markdown and LaTeX math support.
236
236
  - ๐Ÿ“Ž **Rich media** - send photos, documents, videos, audio, and voice messages directly in chat; your bot can also send files back to you.
237
237
  - ๐Ÿงต **Thread-specific prompts** - each chat (or thread) gets its own session and optional instructions; ask your bot to modify its instructions.
238
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaroncql/pim-agent",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A Bun-native extension pack for Pi: web access, subagents, revamped core tools, ANSI-compatible themes, fzf-style completions, Telegram mode, and more.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,7 +57,7 @@
57
57
  "dependencies": {
58
58
  "diff": "^9.0.0",
59
59
  "fzf": "^0.5.2",
60
- "grammy": "^1.43.0",
60
+ "grammy": "^1.44.0",
61
61
  "ignore": "^7.0.5",
62
62
  "ky": "^2.0.2"
63
63
  }
@@ -1,9 +1,11 @@
1
1
  import { McpClient, type McpFetch } from "../../shared/McpClient";
2
+ import { RateLimiter } from "../../shared/RateLimiter";
2
3
 
3
4
  type ExaMcpClientOptions = {
4
5
  readonly endpoint?: string;
5
6
  readonly apiKey?: string;
6
7
  readonly fetch?: McpFetch;
8
+ readonly rateLimiter?: RateLimiter;
7
9
  };
8
10
 
9
11
  type ExaSearchInput = {
@@ -28,15 +30,30 @@ class ExaSearchError extends Error {
28
30
  export class ExaMcpClient {
29
31
  private static readonly defaultEndpoint = "https://mcp.exa.ai/mcp";
30
32
  private static readonly toolName = "web_search_exa";
33
+ private static readonly maxRequestsPerWindow = 3;
34
+ private static readonly windowMs = 1000;
31
35
 
32
36
  private readonly client: McpClient;
33
37
 
34
38
  public constructor(options: ExaMcpClientOptions = {}) {
39
+ const apiKey =
40
+ options.apiKey === undefined || options.apiKey.length === 0
41
+ ? undefined
42
+ : options.apiKey;
43
+ // Throttle only on the free tier; an API key lifts the request rate limit.
44
+ const rateLimiter =
45
+ apiKey !== undefined
46
+ ? undefined
47
+ : (options.rateLimiter ??
48
+ new RateLimiter({
49
+ maxRequests: ExaMcpClient.maxRequestsPerWindow,
50
+ windowMs: ExaMcpClient.windowMs,
51
+ }));
52
+
35
53
  this.client = new McpClient({
36
54
  endpoint: options.endpoint ?? ExaMcpClient.defaultEndpoint,
37
- ...(options.apiKey === undefined || options.apiKey.length === 0
38
- ? {}
39
- : { headers: { "x-api-key": options.apiKey } }),
55
+ ...(apiKey === undefined ? {} : { headers: { "x-api-key": apiKey } }),
56
+ ...(rateLimiter === undefined ? {} : { rateLimiter }),
40
57
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
41
58
  });
42
59
  }
@@ -1,4 +1,5 @@
1
1
  import ky, { HTTPError, type KyInstance } from "ky";
2
+ import type { RateLimiter } from "./RateLimiter";
2
3
 
3
4
  export type McpFetch = (
4
5
  input: Parameters<typeof fetch>[0],
@@ -11,6 +12,7 @@ export type McpClientOptions = {
11
12
  readonly fetch?: McpFetch;
12
13
  readonly clientName?: string;
13
14
  readonly clientVersion?: string;
15
+ readonly rateLimiter?: RateLimiter;
14
16
  };
15
17
 
16
18
  export type CallToolInput = {
@@ -26,9 +28,12 @@ type JsonRpcResponse = {
26
28
  };
27
29
 
28
30
  export class McpClientError extends Error {
29
- public constructor(message: string) {
31
+ public readonly status: number | undefined;
32
+
33
+ public constructor(message: string, status?: number) {
30
34
  super(message);
31
35
  this.name = "McpClientError";
36
+ this.status = status;
32
37
  }
33
38
  }
34
39
 
@@ -40,13 +45,16 @@ export class McpClient {
40
45
  private readonly clientName: string;
41
46
  private readonly clientVersion: string;
42
47
  private readonly ky: KyInstance;
48
+ private readonly rateLimiter: RateLimiter | undefined;
43
49
  private nextRequestId = 1;
50
+ private sessionPromise: Promise<string | undefined> | undefined;
44
51
 
45
52
  public constructor(options: McpClientOptions) {
46
53
  this.endpoint = options.endpoint;
47
54
  this.extraHeaders = options.headers ?? {};
48
55
  this.clientName = options.clientName ?? "pim-agent";
49
56
  this.clientVersion = options.clientVersion ?? "0.0.0";
57
+ this.rateLimiter = options.rateLimiter;
50
58
  this.ky = ky.create(
51
59
  options.fetch === undefined
52
60
  ? {}
@@ -55,35 +63,75 @@ export class McpClient {
55
63
  }
56
64
 
57
65
  public async callTool(input: CallToolInput): Promise<unknown> {
58
- let sessionId: string | undefined;
59
- let activeRequestId: number | undefined;
66
+ const pending = this.ensureSession();
67
+ const sessionId = await pending;
60
68
 
61
69
  try {
62
- activeRequestId = this.nextRequestId++;
63
- const initialize = await this.sendRpcRequest({
64
- id: activeRequestId,
65
- method: "initialize",
66
- params: {
67
- protocolVersion: McpClient.protocolVersion,
68
- clientInfo: {
69
- name: this.clientName,
70
- version: this.clientVersion,
71
- },
72
- capabilities: {},
70
+ return await this.invokeTool(input, sessionId);
71
+ } catch (error) {
72
+ if (
73
+ input.signal?.aborted ||
74
+ sessionId === undefined ||
75
+ !isStaleSessionError(error)
76
+ ) {
77
+ throw error;
78
+ }
79
+
80
+ this.invalidateSession(pending);
81
+
82
+ return this.invokeTool(input, await this.ensureSession());
83
+ }
84
+ }
85
+
86
+ private ensureSession(): Promise<string | undefined> {
87
+ const pending = (this.sessionPromise ??= this.handshake().catch(
88
+ (error: unknown) => {
89
+ this.invalidateSession(pending);
90
+ throw error;
91
+ }
92
+ ));
93
+
94
+ return pending;
95
+ }
96
+
97
+ private invalidateSession(pending: Promise<string | undefined>): void {
98
+ if (this.sessionPromise === pending) {
99
+ this.sessionPromise = undefined;
100
+ }
101
+ }
102
+
103
+ private async handshake(): Promise<string | undefined> {
104
+ const initialize = await this.sendRpcRequest({
105
+ id: this.nextRequestId++,
106
+ method: "initialize",
107
+ params: {
108
+ protocolVersion: McpClient.protocolVersion,
109
+ clientInfo: {
110
+ name: this.clientName,
111
+ version: this.clientVersion,
73
112
  },
74
- ...(input.signal === undefined ? {} : { signal: input.signal }),
75
- });
76
- sessionId = initialize.sessionId;
113
+ capabilities: {},
114
+ },
115
+ });
77
116
 
78
- await this.sendNotification({
79
- sessionId,
80
- method: "notifications/initialized",
81
- params: {},
82
- });
117
+ await this.sendNotification({
118
+ sessionId: initialize.sessionId,
119
+ method: "notifications/initialized",
120
+ params: {},
121
+ });
83
122
 
84
- activeRequestId = this.nextRequestId++;
123
+ return initialize.sessionId;
124
+ }
125
+
126
+ private async invokeTool(
127
+ input: CallToolInput,
128
+ sessionId: string | undefined
129
+ ): Promise<unknown> {
130
+ const requestId = this.nextRequestId++;
131
+
132
+ try {
85
133
  const toolCall = await this.sendRpcRequest({
86
- id: activeRequestId,
134
+ id: requestId,
87
135
  ...(sessionId === undefined ? {} : { sessionId }),
88
136
  method: "tools/call",
89
137
  params: {
@@ -96,7 +144,7 @@ export class McpClient {
96
144
  return toolCall.envelope.result;
97
145
  } catch (error) {
98
146
  if (input.signal?.aborted) {
99
- await this.sendCancellation(sessionId, activeRequestId);
147
+ await this.sendCancellation(sessionId, requestId);
100
148
  throw new McpClientError("MCP request aborted.");
101
149
  }
102
150
 
@@ -141,6 +189,7 @@ export class McpClient {
141
189
  readonly sessionId: string | undefined;
142
190
  readonly method: string;
143
191
  readonly params: Readonly<Record<string, unknown>>;
192
+ readonly rateLimited?: boolean;
144
193
  }): Promise<void> {
145
194
  await this.postJson(
146
195
  {
@@ -148,7 +197,9 @@ export class McpClient {
148
197
  method: input.method,
149
198
  params: input.params,
150
199
  },
151
- input.sessionId
200
+ input.sessionId,
201
+ undefined,
202
+ input.rateLimited ?? true
152
203
  );
153
204
  }
154
205
 
@@ -168,6 +219,7 @@ export class McpClient {
168
219
  requestId,
169
220
  reason: "Tool call aborted.",
170
221
  },
222
+ rateLimited: false,
171
223
  });
172
224
  } catch {
173
225
  // abort already surfaces; cancel is best-effort
@@ -177,8 +229,13 @@ export class McpClient {
177
229
  private async postJson(
178
230
  body: Readonly<Record<string, unknown>>,
179
231
  sessionId?: string,
180
- signal?: AbortSignal
232
+ signal?: AbortSignal,
233
+ rateLimited = true
181
234
  ): Promise<Response> {
235
+ if (rateLimited) {
236
+ await this.rateLimiter?.acquire();
237
+ }
238
+
182
239
  try {
183
240
  return await this.ky(this.endpoint, {
184
241
  method: "POST",
@@ -193,7 +250,8 @@ export class McpClient {
193
250
 
194
251
  if (error instanceof HTTPError) {
195
252
  throw new McpClientError(
196
- `MCP request failed with HTTP ${error.response.status}: ${excerpt(stringifyErrorData(error.data))}`
253
+ `MCP request failed with HTTP ${error.response.status}: ${excerpt(stringifyErrorData(error.data))}`,
254
+ error.response.status
197
255
  );
198
256
  }
199
257
 
@@ -330,6 +388,10 @@ export class McpClient {
330
388
  }
331
389
  }
332
390
 
391
+ function isStaleSessionError(error: unknown): boolean {
392
+ return error instanceof McpClientError && error.status === 404;
393
+ }
394
+
333
395
  function asRecord(
334
396
  value: unknown
335
397
  ): Readonly<Record<string, unknown>> | undefined {
@@ -0,0 +1,50 @@
1
+ export type RateLimiterOptions = {
2
+ readonly maxRequests: number;
3
+ readonly windowMs: number;
4
+ readonly now?: () => number;
5
+ readonly sleep?: (ms: number) => Promise<void>;
6
+ };
7
+
8
+ export class RateLimiter {
9
+ private readonly maxRequests: number;
10
+ private readonly windowMs: number;
11
+ private readonly now: () => number;
12
+ private readonly sleep: (ms: number) => Promise<void>;
13
+ private readonly timestamps: number[] = [];
14
+ private tail: Promise<void> = Promise.resolve();
15
+
16
+ public constructor(options: RateLimiterOptions) {
17
+ this.maxRequests = Math.max(1, options.maxRequests);
18
+ this.windowMs = options.windowMs;
19
+ this.now = options.now ?? Date.now;
20
+ this.sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
21
+ }
22
+
23
+ public acquire(): Promise<void> {
24
+ const reserved = this.tail.then(() => this.reserve());
25
+ this.tail = reserved.catch(() => {});
26
+
27
+ return reserved;
28
+ }
29
+
30
+ private async reserve(): Promise<void> {
31
+ while (true) {
32
+ const now = this.now();
33
+ const threshold = now - this.windowMs;
34
+
35
+ while (this.timestamps.length > 0 && this.timestamps[0]! <= threshold) {
36
+ this.timestamps.shift();
37
+ }
38
+
39
+ if (this.timestamps.length < this.maxRequests) {
40
+ this.timestamps.push(now);
41
+
42
+ return;
43
+ }
44
+
45
+ const waitMs = this.timestamps[0]! + this.windowMs - now;
46
+
47
+ await this.sleep(Math.max(0, waitMs));
48
+ }
49
+ }
50
+ }
@@ -1,22 +1,37 @@
1
+ type Align = "left" | "center" | "right";
2
+
1
3
  type TableRow = ReadonlyArray<string>;
2
4
 
3
5
  type Segment =
4
6
  | { readonly kind: "md"; readonly text: string }
5
- | { readonly kind: "table"; readonly rows: ReadonlyArray<TableRow> };
7
+ | {
8
+ readonly kind: "table";
9
+ readonly rows: ReadonlyArray<TableRow>;
10
+ readonly aligns: ReadonlyArray<Align | undefined>;
11
+ };
12
+
13
+ type RenderOptions = {
14
+ readonly italics?: boolean;
15
+ };
6
16
 
7
17
  const SAFE_LINK = /^(https?:|tg:|mailto:)/i;
8
18
 
19
+ // Bun's GFM strikethrough strikes on a lone `~`, but Telegram (and CommonMark)
20
+ // only strike on `~~`. We disable the parser's strikethrough and re-apply
21
+ // double-tilde runs in the text callback, where code spans/blocks never reach.
22
+ const STRIKETHROUGH = /(?<!~)~~(?!~)((?:[^~]|~(?!~))+?)~~(?!~)/g;
23
+
9
24
  export class Markdown {
10
- public static toHtml(md: string): string {
25
+ public static toHtml(md: string, options: RenderOptions = {}): string {
11
26
  const segments = Markdown.split(md);
12
27
  let out = "";
13
28
  for (const seg of segments) {
14
29
  out +=
15
30
  seg.kind === "md"
16
- ? Markdown.renderMd(seg.text)
17
- : Markdown.renderTable(seg.rows);
31
+ ? Markdown.renderMd(seg.text, options)
32
+ : Markdown.renderTable(seg.rows, seg.aligns);
18
33
  }
19
- return out.replace(/\n{3,}/g, "\n\n").trim();
34
+ return out.trim();
20
35
  }
21
36
 
22
37
  public static escape(s: string): string {
@@ -28,22 +43,27 @@ export class Markdown {
28
43
  }
29
44
 
30
45
  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`,
46
+ text: (c: string): string =>
47
+ Markdown.escape(c).replace(STRIKETHROUGH, "<s>$1</s>"),
48
+ paragraph: (c: string): string => `<p>${c}</p>`,
49
+ heading: (c: string, meta?: { level?: number }): string => {
50
+ const level = Math.min(6, Math.max(1, meta?.level ?? 1));
51
+ return `<h${level}>${c}</h${level}>`;
52
+ },
35
53
  strong: (c: string): string => `<b>${c}</b>`,
36
54
  emphasis: (c: string): string => `<i>${c}</i>`,
37
- strikethrough: (c: string): string => `<s>${c}</s>`,
38
55
  codespan: (c: string): string => `<code>${c}</code>`,
39
56
  code: (c: string, meta?: { language?: string }): string => {
40
57
  const body = c.replace(/\n+$/, "");
41
58
  const lang = meta?.language;
59
+ if (lang === "math") {
60
+ return `<tg-math-block>${body}</tg-math-block>`;
61
+ }
42
62
  const open = lang
43
63
  ? `<pre><code class="language-${Markdown.escape(lang)}">`
44
64
  : "<pre>";
45
65
  const close = lang ? "</code></pre>" : "</pre>";
46
- return `${open}${body}${close}\n\n`;
66
+ return `${open}${body}${close}`;
47
67
  },
48
68
  link: (c: string, meta?: { href?: string }): string => {
49
69
  const href = meta?.href ?? "";
@@ -58,46 +78,79 @@ export class Markdown {
58
78
  ? `<a href="${Markdown.escape(src)}">${alt}</a>`
59
79
  : alt;
60
80
  },
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;
81
+ blockquote: (c: string): string => `<blockquote>${c}</blockquote>`,
82
+ list: (c: string, meta?: { ordered?: boolean; start?: number }): string => {
83
+ if (meta?.ordered) {
84
+ const start = meta.start ?? 1;
85
+ const startAttr = start > 1 ? ` start="${start}"` : "";
86
+ return `<ol${startAttr}>${c}</ol>`;
72
87
  }
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`;
88
+ return `<ul>${c}</ul>`;
90
89
  },
91
- hr: (): string => "โ”€โ”€โ”€\n\n",
92
- br: (): string => "\n",
90
+ listItem: (c: string, meta?: { checked?: boolean }): string =>
91
+ Markdown.listItemHtml(c.replace(/\n+$/, ""), meta?.checked),
92
+ hr: (): string => "<hr/>",
93
+ br: (): string => "<br>",
93
94
  table: (c: string): string => c,
94
95
  };
95
96
 
96
- private static renderMd(md: string): string {
97
+ private static readonly ITALIC_RENDERERS = {
98
+ ...Markdown.RENDERERS,
99
+ paragraph: (c: string): string => `<p>${Markdown.italic(c)}</p>`,
100
+ heading: (c: string, meta?: { level?: number }): string => {
101
+ const level = Math.min(6, Math.max(1, meta?.level ?? 1));
102
+ return `<h${level}>${Markdown.italic(c)}</h${level}>`;
103
+ },
104
+ listItem: (c: string, meta?: { checked?: boolean }): string =>
105
+ Markdown.listItemHtml(
106
+ Markdown.italicListItemBody(c.replace(/\n+$/, "")),
107
+ meta?.checked
108
+ ),
109
+ };
110
+
111
+ private static listItemHtml(body: string, checked?: boolean): string {
112
+ if (checked === true) {
113
+ return `<li><input type="checkbox" checked> ${body}</li>`;
114
+ }
115
+ if (checked === false) {
116
+ return `<li><input type="checkbox"> ${body}</li>`;
117
+ }
118
+ return `<li>${body}</li>`;
119
+ }
120
+
121
+ private static renderMd(md: string, options: RenderOptions = {}): string {
97
122
  if (!md.trim()) {
98
123
  return "";
99
124
  }
100
- return Bun.markdown.render(md, Markdown.RENDERERS);
125
+ return Bun.markdown.render(md, Markdown.renderers(options), {
126
+ strikethrough: false,
127
+ });
128
+ }
129
+
130
+ private static renderers(options: RenderOptions): typeof Markdown.RENDERERS {
131
+ return options.italics ? Markdown.ITALIC_RENDERERS : Markdown.RENDERERS;
132
+ }
133
+
134
+ private static italic(text: string): string {
135
+ return text ? `<i>${text}</i>` : "";
136
+ }
137
+
138
+ // A nested list is appended to its parent item's content, so italicize only
139
+ // the leading text; wrapping a child <ul>/<ol> in <i> would be invalid.
140
+ private static italicListItemBody(body: string): string {
141
+ const nestedListIndex = body.search(/<(?:ul|ol)\b/);
142
+ if (nestedListIndex < 0) {
143
+ return Markdown.italic(body);
144
+ }
145
+ const before = body.slice(0, nestedListIndex);
146
+ return `${Markdown.italic(before)}${body.slice(nestedListIndex)}`;
147
+ }
148
+
149
+ private static renderInline(md: string): string {
150
+ return Markdown.renderMd(md)
151
+ .replace(/^<p>/, "")
152
+ .replace(/<\/p>$/, "")
153
+ .trim();
101
154
  }
102
155
 
103
156
  private static split(md: string): ReadonlyArray<Segment> {
@@ -122,12 +175,13 @@ export class Markdown {
122
175
  ) {
123
176
  flushMd();
124
177
  const rows: string[][] = [Markdown.parseRow(line)];
178
+ const aligns = Markdown.parseAligns(next);
125
179
  i += 1;
126
180
  while (i + 1 < lines.length && Markdown.isPipeLine(lines[i + 1]!)) {
127
181
  i += 1;
128
182
  rows.push(Markdown.parseRow(lines[i]!));
129
183
  }
130
- segments.push({ kind: "table", rows });
184
+ segments.push({ kind: "table", rows, aligns });
131
185
  continue;
132
186
  }
133
187
  buf.push(line);
@@ -149,7 +203,27 @@ export class Markdown {
149
203
  return trimmed.split("|").map((cell) => cell.trim());
150
204
  }
151
205
 
152
- private static renderTable(rows: ReadonlyArray<TableRow>): string {
206
+ private static parseAligns(sep: string): (Align | undefined)[] {
207
+ return Markdown.parseRow(sep).map((cell) => {
208
+ const left = cell.startsWith(":");
209
+ const right = cell.endsWith(":");
210
+ if (left && right) {
211
+ return "center";
212
+ }
213
+ if (right) {
214
+ return "right";
215
+ }
216
+ if (left) {
217
+ return "left";
218
+ }
219
+ return undefined;
220
+ });
221
+ }
222
+
223
+ private static renderTable(
224
+ rows: ReadonlyArray<TableRow>,
225
+ aligns: ReadonlyArray<Align | undefined>
226
+ ): string {
153
227
  if (rows.length < 2) {
154
228
  return "";
155
229
  }
@@ -158,20 +232,24 @@ export class Markdown {
158
232
  if (dataRows.length === 0) {
159
233
  return "";
160
234
  }
161
- const pieces: string[] = [];
235
+ const attr = (col: number): string => {
236
+ const align = aligns[col];
237
+ return align ? ` align="${align}"` : "";
238
+ };
239
+ const cells = (row: TableRow, tag: "th" | "td"): string =>
240
+ header
241
+ .map(
242
+ (_, c) =>
243
+ `<${tag}${attr(c)}>${Markdown.renderInline(row[c] ?? "")}</${tag}>`
244
+ )
245
+ .join("");
246
+
247
+ let out = "<table>";
248
+ out += `<tr>${cells(header, "th")}</tr>`;
162
249
  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
- }
250
+ out += `<tr>${cells(row, "td")}</tr>`;
173
251
  }
174
- pieces.push("โ”€โ”€โ”€");
175
- return `${pieces.join("\n")}\n\n`;
252
+ out += "</table>";
253
+ return out;
176
254
  }
177
255
  }
@@ -54,7 +54,32 @@ const TOOL_EMOJI: Record<string, string> = {
54
54
  subagent: "๐Ÿค–",
55
55
  };
56
56
 
57
- const MESSAGE_LIMIT = 4000;
57
+ const MESSAGE_LIMIT = 32000;
58
+ const BR = "<br>";
59
+ const BLOCK_TAGS = new Set([
60
+ "blockquote",
61
+ "h1",
62
+ "h2",
63
+ "h3",
64
+ "h4",
65
+ "h5",
66
+ "h6",
67
+ "ol",
68
+ "p",
69
+ "pre",
70
+ "table",
71
+ "tg-math-block",
72
+ "ul",
73
+ ]);
74
+ const VOID_BLOCK_TAGS = new Set(["br", "hr"]);
75
+
76
+ type HtmlTag = {
77
+ readonly start: number;
78
+ readonly end: number;
79
+ readonly name: string;
80
+ readonly closing: boolean;
81
+ readonly selfClosing: boolean;
82
+ };
58
83
 
59
84
  export class Renderer {
60
85
  private readonly api: Api;
@@ -432,7 +457,7 @@ export class Renderer {
432
457
  if (entry.kind === "todo") {
433
458
  pieces.push(`${entry.emoji} <b>${Markdown.escape(entry.label)}</b>`);
434
459
  } else if (entry.kind === "thinking") {
435
- pieces.push(`<i>${Markdown.toHtml(entry.label)}</i>`);
460
+ pieces.push(Markdown.toHtml(entry.label, { italics: true }));
436
461
  } else if (entry.kind === "narration") {
437
462
  pieces.push(Markdown.toHtml(entry.label));
438
463
  } else {
@@ -447,18 +472,16 @@ export class Renderer {
447
472
  pieces.push(`${entry.emoji} ${entry.label}${stats}${suffix}`);
448
473
  }
449
474
  const next = visible[i + 1];
450
- if (next) {
451
- pieces.push(
452
- entry.kind === "tool" && next.kind === "tool" ? "\n" : "\n\n"
453
- );
475
+ if (next && entry.kind === "tool" && next.kind === "tool") {
476
+ pieces.push(BR);
454
477
  }
455
478
  }
456
479
 
457
480
  let body = pieces.join("");
458
481
  if (state === "cancelled") {
459
- body += "\n\nโŒ Cancelled";
482
+ body += "<br><br>โŒ Cancelled";
460
483
  } else if (state === "error") {
461
- body += "\n\nโŒ Error";
484
+ body += "<br><br>โŒ Error";
462
485
  }
463
486
  return Renderer.capStatus(body);
464
487
  }
@@ -477,47 +500,41 @@ export class Renderer {
477
500
  if (!html) {
478
501
  return undefined;
479
502
  }
480
- const other = {
481
- parse_mode: "HTML" as const,
482
- message_thread_id: this.sessionId.threadId,
483
- link_preview_options: { is_disabled: true },
484
- };
503
+ const clean = Renderer.sanitize(html);
485
504
  try {
486
- const msg = await this.api.sendMessage(
505
+ const msg = await this.api.sendRichMessage(
487
506
  this.sessionId.chatId,
488
- Renderer.sanitize(html),
489
- other
507
+ { html: clean },
508
+ { message_thread_id: this.sessionId.threadId }
490
509
  );
491
510
  console.log(
492
- `[send] chatId=${this.sessionId.chatId} threadId=${this.sessionId.threadId ?? "main"} ${opts.status ? "status" : "answer"} ok (${html.length}b)`
511
+ `[send] chatId=${this.sessionId.chatId} threadId=${this.sessionId.threadId ?? "main"} ${opts.status ? "status" : "answer"} ok (${clean.length}b)`
493
512
  );
494
513
  return msg;
495
514
  } catch (err) {
496
515
  if (err instanceof GrammyError && err.error_code === 400) {
497
- console.warn(`[send] HTML 400 (${err.description}) โ€” retry plain`);
498
- const msg = await this.api.sendMessage(
516
+ console.warn(`[send] rich 400 (${err.description}) โ€” retry plain`);
517
+ return this.api.sendMessage(
499
518
  this.sessionId.chatId,
500
- Renderer.stripHtml(Renderer.sanitize(html)),
519
+ Renderer.stripHtml(clean),
501
520
  {
502
521
  message_thread_id: this.sessionId.threadId,
503
522
  link_preview_options: { is_disabled: true },
504
523
  }
505
524
  );
506
- return msg;
507
525
  }
508
526
  throw err;
509
527
  }
510
528
  }
511
529
 
512
530
  private async editMessage(html: string): Promise<void> {
531
+ const clean = Renderer.sanitize(html);
513
532
  try {
514
533
  await this.api.editMessageText(
515
534
  this.sessionId.chatId,
516
535
  this.statusMessageId!,
517
- Renderer.sanitize(html),
518
536
  {
519
- parse_mode: "HTML",
520
- link_preview_options: { is_disabled: true },
537
+ html: clean,
521
538
  }
522
539
  );
523
540
  } catch (err) {
@@ -530,7 +547,7 @@ export class Renderer {
530
547
  .editMessageText(
531
548
  this.sessionId.chatId,
532
549
  this.statusMessageId!,
533
- Renderer.stripHtml(Renderer.sanitize(html)),
550
+ Renderer.stripHtml(clean),
534
551
  {
535
552
  link_preview_options: { is_disabled: true },
536
553
  }
@@ -575,7 +592,7 @@ export class Renderer {
575
592
  const label = [
576
593
  first.text,
577
594
  ...rest.map((op) => `${op.emoji} ${op.text}`),
578
- ].join("\n");
595
+ ].join("<br>");
579
596
  return { emoji: first.emoji, label };
580
597
  }
581
598
 
@@ -720,7 +737,9 @@ export class Renderer {
720
737
  if (name === "subagent") {
721
738
  const prompt = Renderer.stringArg(obj, "prompt");
722
739
  return prompt
723
- ? Markdown.toHtml(Renderer.truncate(Renderer.firstLine(prompt), 180))
740
+ ? Renderer.escapeInline(
741
+ Renderer.truncate(Renderer.firstLine(prompt), 180)
742
+ )
724
743
  : "";
725
744
  }
726
745
 
@@ -840,7 +859,7 @@ export class Renderer {
840
859
  }
841
860
 
842
861
  private static cleanProse(text: string): string {
843
- return Renderer.truncate(text.replace(/\n{3,}/g, "\n\n").trim(), 900);
862
+ return text.replace(/\n{3,}/g, "\n\n").trim();
844
863
  }
845
864
 
846
865
  private static truncate(text: string, limit = 180): string {
@@ -851,15 +870,216 @@ export class Renderer {
851
870
  if (text.length <= MESSAGE_LIMIT) {
852
871
  return text;
853
872
  }
854
- const lines = text.split("\n");
873
+ const blocks = Renderer.splitStatusBlocks(text);
855
874
  let dropped = 0;
856
- let total = text.length;
857
- while (total > MESSAGE_LIMIT && lines.length > 1) {
858
- const first = lines.shift()!;
859
- total -= first.length + 1;
875
+ while (blocks.length > 1) {
876
+ blocks.shift();
860
877
  dropped += 1;
878
+ const rest = Renderer.trimLeadingBreaks(blocks.join("").trimStart());
879
+ const candidate = `<p>โ€ฆ ${dropped} earlier entries</p>${rest}`;
880
+ if (candidate.length <= MESSAGE_LIMIT) {
881
+ return candidate;
882
+ }
883
+ }
884
+ return Renderer.capPlainStatus(blocks);
885
+ }
886
+
887
+ private static splitStatusBlocks(html: string): string[] {
888
+ const blocks: string[] = [];
889
+ let cursor = 0;
890
+ while (cursor < html.length) {
891
+ const tag = Renderer.nextStatusBlockTag(html, cursor);
892
+ if (!tag) {
893
+ Renderer.pushStatusBlock(blocks, html.slice(cursor));
894
+ break;
895
+ }
896
+ if (VOID_BLOCK_TAGS.has(tag.name)) {
897
+ Renderer.pushStatusBlock(blocks, html.slice(cursor, tag.end));
898
+ cursor = tag.end;
899
+ continue;
900
+ }
901
+ Renderer.pushStatusBlock(blocks, html.slice(cursor, tag.start));
902
+ const end = Renderer.statusBlockEnd(html, tag);
903
+ Renderer.pushStatusBlock(blocks, html.slice(tag.start, end));
904
+ cursor = end;
905
+ }
906
+ return blocks;
907
+ }
908
+
909
+ private static nextStatusBlockTag(
910
+ html: string,
911
+ start: number
912
+ ): HtmlTag | undefined {
913
+ const tags = Renderer.htmlTags(html, start);
914
+ for (const tag of tags) {
915
+ if (tag.closing) {
916
+ continue;
917
+ }
918
+ if (BLOCK_TAGS.has(tag.name) || VOID_BLOCK_TAGS.has(tag.name)) {
919
+ return tag;
920
+ }
921
+ }
922
+ return undefined;
923
+ }
924
+
925
+ private static statusBlockEnd(html: string, opener: HtmlTag): number {
926
+ if (opener.selfClosing) {
927
+ return opener.end;
928
+ }
929
+ const stack = [opener.name];
930
+ const tags = Renderer.htmlTags(html, opener.end);
931
+ for (const tag of tags) {
932
+ if (VOID_BLOCK_TAGS.has(tag.name)) {
933
+ continue;
934
+ }
935
+ if (!BLOCK_TAGS.has(tag.name)) {
936
+ continue;
937
+ }
938
+ if (tag.closing) {
939
+ if (stack.at(-1) === tag.name) {
940
+ stack.pop();
941
+ }
942
+ } else if (!tag.selfClosing) {
943
+ stack.push(tag.name);
944
+ }
945
+ if (stack.length === 0) {
946
+ return tag.end;
947
+ }
948
+ }
949
+ return html.length;
950
+ }
951
+
952
+ private static *htmlTags(html: string, start: number): Generator<HtmlTag> {
953
+ const re = /<\s*(\/)?\s*([a-z][\w:-]*)(?:\s[^>]*)?\/?\s*>/gi;
954
+ re.lastIndex = start;
955
+ for (let match = re.exec(html); match; match = re.exec(html)) {
956
+ const raw = match[0]!;
957
+ yield {
958
+ start: match.index,
959
+ end: re.lastIndex,
960
+ name: match[2]!.toLowerCase(),
961
+ closing: match[1] !== undefined,
962
+ selfClosing: /\/\s*>$/.test(raw),
963
+ };
964
+ }
965
+ }
966
+
967
+ private static pushStatusBlock(blocks: string[], block: string): void {
968
+ if (block) {
969
+ blocks.push(block);
970
+ }
971
+ }
972
+
973
+ private static trimLeadingBreaks(text: string): string {
974
+ return text.replace(/^(?:<br\s*\/?>)+/i, "").trimStart();
975
+ }
976
+
977
+ private static capPlainStatus(blocks: readonly string[]): string {
978
+ let head = "";
979
+ for (let i = 0; i < blocks.length; i++) {
980
+ const block = blocks[i]!;
981
+ const candidate = `${head}${block}`;
982
+ if (candidate.length <= MESSAGE_LIMIT) {
983
+ head = candidate;
984
+ continue;
985
+ }
986
+ const remaining = MESSAGE_LIMIT - head.length;
987
+ const truncated = Renderer.truncateHtmlHead(block, remaining);
988
+ if (truncated) {
989
+ head = `${head}${truncated}`;
990
+ }
991
+ break;
992
+ }
993
+ return head.trimEnd();
994
+ }
995
+
996
+ private static truncateHtmlHead(html: string, limit: number): string {
997
+ if (html.length <= limit) {
998
+ return html;
999
+ }
1000
+ if (limit <= 0) {
1001
+ return "";
1002
+ }
1003
+ const wrapper = Renderer.outerHtmlWrapper(html);
1004
+ if (wrapper) {
1005
+ const innerLimit = limit - wrapper.open.length - wrapper.close.length;
1006
+ if (innerLimit > 0) {
1007
+ const inner = Renderer.truncateHtmlHead(wrapper.inner, innerLimit);
1008
+ if (inner) {
1009
+ return `${wrapper.open}${inner}${wrapper.close}`;
1010
+ }
1011
+ }
861
1012
  }
862
- return `โ€ฆ ${dropped} earlier entries\n${lines.join("\n")}`;
1013
+ return Renderer.escapePlainHead(Renderer.stripHtml(html), limit);
1014
+ }
1015
+
1016
+ private static outerHtmlWrapper(html: string):
1017
+ | {
1018
+ readonly open: string;
1019
+ readonly inner: string;
1020
+ readonly close: string;
1021
+ }
1022
+ | undefined {
1023
+ const opener = /^<\s*([a-z][\w:-]*)(?:\s[^>]*)?\/?\s*>/i.exec(html);
1024
+ if (!opener) {
1025
+ return undefined;
1026
+ }
1027
+ const open = opener[0]!;
1028
+ if (/\/\s*>$/.test(open)) {
1029
+ return undefined;
1030
+ }
1031
+ const name = opener[1]!.toLowerCase();
1032
+ const close = Renderer.matchingHtmlCloseTag(html, name, open.length);
1033
+ if (!close || close.end !== html.length) {
1034
+ return undefined;
1035
+ }
1036
+ return {
1037
+ open,
1038
+ inner: html.slice(open.length, close.start),
1039
+ close: html.slice(close.start, close.end),
1040
+ };
1041
+ }
1042
+
1043
+ private static matchingHtmlCloseTag(
1044
+ html: string,
1045
+ name: string,
1046
+ start: number
1047
+ ): HtmlTag | undefined {
1048
+ let depth = 1;
1049
+ const tags = Renderer.htmlTags(html, start);
1050
+ for (const tag of tags) {
1051
+ if (tag.name !== name) {
1052
+ continue;
1053
+ }
1054
+ if (tag.closing) {
1055
+ depth -= 1;
1056
+ if (depth === 0) {
1057
+ return tag;
1058
+ }
1059
+ } else if (!tag.selfClosing && !VOID_BLOCK_TAGS.has(tag.name)) {
1060
+ depth += 1;
1061
+ }
1062
+ }
1063
+ return undefined;
1064
+ }
1065
+
1066
+ private static escapePlainHead(text: string, limit: number): string {
1067
+ const marker = "โ€ฆ";
1068
+ if (limit < marker.length) {
1069
+ return "";
1070
+ }
1071
+ const budget = limit - marker.length;
1072
+ const escaped: string[] = [];
1073
+ let length = 0;
1074
+ for (const char of text) {
1075
+ const next = char === "\n" ? BR : Markdown.escape(char);
1076
+ if (length + next.length > budget) {
1077
+ break;
1078
+ }
1079
+ escaped.push(next);
1080
+ length += next.length;
1081
+ }
1082
+ return `${escaped.join("").trimEnd()}${marker}`;
863
1083
  }
864
1084
 
865
1085
  private static chunk(html: string): readonly string[] {
@@ -869,10 +1089,14 @@ export class Renderer {
869
1089
  const chunks: string[] = [];
870
1090
  let rest = html;
871
1091
  while (rest.length > MESSAGE_LIMIT) {
872
- const idx = rest.lastIndexOf("\n", MESSAGE_LIMIT);
873
- const splitAt = idx > 0 ? idx : MESSAGE_LIMIT;
874
- chunks.push(rest.slice(0, splitAt).trim());
875
- rest = rest.slice(splitAt).trim();
1092
+ const idx = rest.lastIndexOf(BR, MESSAGE_LIMIT);
1093
+ if (idx > 0) {
1094
+ chunks.push(rest.slice(0, idx).trim());
1095
+ rest = rest.slice(idx + BR.length).trim();
1096
+ } else {
1097
+ chunks.push(rest.slice(0, MESSAGE_LIMIT).trim());
1098
+ rest = rest.slice(MESSAGE_LIMIT).trim();
1099
+ }
876
1100
  }
877
1101
  if (rest) {
878
1102
  chunks.push(rest);
@@ -880,6 +1104,10 @@ export class Renderer {
880
1104
  return chunks;
881
1105
  }
882
1106
 
1107
+ private static escapeInline(text: string): string {
1108
+ return Markdown.escape(text).replace(/\n/g, BR);
1109
+ }
1110
+
883
1111
  private static sanitize(text: string): string {
884
1112
  return text.replace(
885
1113
  /\b(api[_-]?key|token|secret)\b\s*[:=]\s*\S+/gi,
@@ -2,12 +2,11 @@ import {
2
2
  defineTool,
3
3
  type ToolDefinition,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { GrammyError, InputFile, type Api } from "grammy";
5
+ import { InputFile, type Api } from "grammy";
6
6
  import { basename } from "node:path";
7
7
 
8
8
  import { FsErrors } from "../shared/FsErrors";
9
9
  import { Paths } from "../shared/Paths";
10
- import { Markdown } from "./Markdown";
11
10
  import {
12
11
  MAX_CAPTION_CHARS,
13
12
  MAX_DOCUMENT_BYTES,
@@ -73,22 +72,9 @@ export class SendFileTool {
73
72
  path: string,
74
73
  caption: string | undefined
75
74
  ): Promise<void> {
76
- const html = caption ? Markdown.toHtml(caption) : undefined;
77
- try {
78
- await api.sendDocument(sessionId.chatId, new InputFile(path), {
79
- message_thread_id: sessionId.threadId,
80
- caption: html,
81
- parse_mode: html ? "HTML" : undefined,
82
- });
83
- } catch (err) {
84
- if (err instanceof GrammyError && err.error_code === 400 && html) {
85
- await api.sendDocument(sessionId.chatId, new InputFile(path), {
86
- message_thread_id: sessionId.threadId,
87
- caption,
88
- });
89
- return;
90
- }
91
- throw err;
92
- }
75
+ await api.sendDocument(sessionId.chatId, new InputFile(path), {
76
+ message_thread_id: sessionId.threadId,
77
+ caption,
78
+ });
93
79
  }
94
80
  }
@@ -498,7 +498,7 @@ export class Session {
498
498
  }
499
499
  const username = this.deps.getBotUsername();
500
500
  const handle = username ? ` (@${username})` : "";
501
- const systemIx = `You are running as a Telegram bot${handle} powered by Pim Agent. The telegram_user_instructions below are your editable instructions - edit the file at its \`path\` attribute to update your instructions.`;
501
+ const systemIx = `You are running as a Telegram bot${handle} powered by Pim Agent. The telegram_user_instructions below are your editable instructions - edit the file at its \`path\` attribute to update your instructions. Wrap LaTeX math in a \`\`\`math fenced block without the $$ delimitters; inline math is not supported.`;
502
502
  const userIx = `<telegram_user_instructions path="${path}">${userContent ? `\n${userContent}\n` : ""}</telegram_user_instructions>`;
503
503
  return `<telegram_system_instructions>\n${systemIx}\n${userIx}\n</telegram_system_instructions>`;
504
504
  }