@aaroncql/pim-agent 0.3.0 โ†’ 0.4.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.4.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,8 +1,14 @@
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
+ };
6
12
 
7
13
  const SAFE_LINK = /^(https?:|tg:|mailto:)/i;
8
14
 
@@ -14,9 +20,9 @@ export class Markdown {
14
20
  out +=
15
21
  seg.kind === "md"
16
22
  ? Markdown.renderMd(seg.text)
17
- : Markdown.renderTable(seg.rows);
23
+ : Markdown.renderTable(seg.rows, seg.aligns);
18
24
  }
19
- return out.replace(/\n{3,}/g, "\n\n").trim();
25
+ return out.trim();
20
26
  }
21
27
 
22
28
  public static escape(s: string): string {
@@ -29,9 +35,11 @@ export class Markdown {
29
35
 
30
36
  private static readonly RENDERERS = {
31
37
  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`,
38
+ paragraph: (c: string): string => `<p>${c}</p>`,
39
+ heading: (c: string, meta?: { level?: number }): string => {
40
+ const level = Math.min(6, Math.max(1, meta?.level ?? 1));
41
+ return `<h${level}>${c}</h${level}>`;
42
+ },
35
43
  strong: (c: string): string => `<b>${c}</b>`,
36
44
  emphasis: (c: string): string => `<i>${c}</i>`,
37
45
  strikethrough: (c: string): string => `<s>${c}</s>`,
@@ -39,11 +47,14 @@ export class Markdown {
39
47
  code: (c: string, meta?: { language?: string }): string => {
40
48
  const body = c.replace(/\n+$/, "");
41
49
  const lang = meta?.language;
50
+ if (lang === "math") {
51
+ return `<tg-math-block>${body}</tg-math-block>`;
52
+ }
42
53
  const open = lang
43
54
  ? `<pre><code class="language-${Markdown.escape(lang)}">`
44
55
  : "<pre>";
45
56
  const close = lang ? "</code></pre>" : "</pre>";
46
- return `${open}${body}${close}\n\n`;
57
+ return `${open}${body}${close}`;
47
58
  },
48
59
  link: (c: string, meta?: { href?: string }): string => {
49
60
  const href = meta?.href ?? "";
@@ -58,38 +69,28 @@ export class Markdown {
58
69
  ? `<a href="${Markdown.escape(src)}">${alt}</a>`
59
70
  : alt;
60
71
  },
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
+ blockquote: (c: string): string => `<blockquote>${c}</blockquote>`,
73
+ list: (c: string, meta?: { ordered?: boolean; start?: number }): string => {
74
+ if (meta?.ordered) {
75
+ const start = meta.start ?? 1;
76
+ const startAttr = start > 1 ? ` start="${start}"` : "";
77
+ return `<ol${startAttr}>${c}</ol>`;
72
78
  }
73
- ): string => {
74
- const depth = meta?.depth ?? 0;
75
- const ordered = meta?.ordered ?? false;
76
- const index = meta?.index ?? 0;
79
+ return `<ul>${c}</ul>`;
80
+ },
81
+ listItem: (c: string, meta?: { checked?: boolean }): string => {
82
+ const body = c.replace(/\n+$/, "");
77
83
  const checked = meta?.checked;
78
- const indent = " ".repeat(depth);
79
- let marker: string;
80
84
  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 ? "โ€ข" : "โ—ฆ";
85
+ return `<li><input type="checkbox" checked> ${body}</li>`;
86
+ }
87
+ if (checked === false) {
88
+ return `<li><input type="checkbox"> ${body}</li>`;
88
89
  }
89
- return `${indent}${marker} ${c.replace(/\n+$/, "")}\n`;
90
+ return `<li>${body}</li>`;
90
91
  },
91
- hr: (): string => "โ”€โ”€โ”€\n\n",
92
- br: (): string => "\n",
92
+ hr: (): string => "<hr/>",
93
+ br: (): string => "<br>",
93
94
  table: (c: string): string => c,
94
95
  };
95
96
 
@@ -100,6 +101,13 @@ export class Markdown {
100
101
  return Bun.markdown.render(md, Markdown.RENDERERS);
101
102
  }
102
103
 
104
+ private static renderInline(md: string): string {
105
+ return Markdown.renderMd(md)
106
+ .replace(/^<p>/, "")
107
+ .replace(/<\/p>$/, "")
108
+ .trim();
109
+ }
110
+
103
111
  private static split(md: string): ReadonlyArray<Segment> {
104
112
  const lines = md.split("\n");
105
113
  const segments: Segment[] = [];
@@ -122,12 +130,13 @@ export class Markdown {
122
130
  ) {
123
131
  flushMd();
124
132
  const rows: string[][] = [Markdown.parseRow(line)];
133
+ const aligns = Markdown.parseAligns(next);
125
134
  i += 1;
126
135
  while (i + 1 < lines.length && Markdown.isPipeLine(lines[i + 1]!)) {
127
136
  i += 1;
128
137
  rows.push(Markdown.parseRow(lines[i]!));
129
138
  }
130
- segments.push({ kind: "table", rows });
139
+ segments.push({ kind: "table", rows, aligns });
131
140
  continue;
132
141
  }
133
142
  buf.push(line);
@@ -149,7 +158,27 @@ export class Markdown {
149
158
  return trimmed.split("|").map((cell) => cell.trim());
150
159
  }
151
160
 
152
- private static renderTable(rows: ReadonlyArray<TableRow>): string {
161
+ private static parseAligns(sep: string): (Align | undefined)[] {
162
+ return Markdown.parseRow(sep).map((cell) => {
163
+ const left = cell.startsWith(":");
164
+ const right = cell.endsWith(":");
165
+ if (left && right) {
166
+ return "center";
167
+ }
168
+ if (right) {
169
+ return "right";
170
+ }
171
+ if (left) {
172
+ return "left";
173
+ }
174
+ return undefined;
175
+ });
176
+ }
177
+
178
+ private static renderTable(
179
+ rows: ReadonlyArray<TableRow>,
180
+ aligns: ReadonlyArray<Align | undefined>
181
+ ): string {
153
182
  if (rows.length < 2) {
154
183
  return "";
155
184
  }
@@ -158,20 +187,24 @@ export class Markdown {
158
187
  if (dataRows.length === 0) {
159
188
  return "";
160
189
  }
161
- const pieces: string[] = [];
190
+ const attr = (col: number): string => {
191
+ const align = aligns[col];
192
+ return align ? ` align="${align}"` : "";
193
+ };
194
+ const cells = (row: TableRow, tag: "th" | "td"): string =>
195
+ header
196
+ .map(
197
+ (_, c) =>
198
+ `<${tag}${attr(c)}>${Markdown.renderInline(row[c] ?? "")}</${tag}>`
199
+ )
200
+ .join("");
201
+
202
+ let out = "<table>";
203
+ out += `<tr>${cells(header, "th")}</tr>`;
162
204
  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
- }
205
+ out += `<tr>${cells(row, "td")}</tr>`;
173
206
  }
174
- pieces.push("โ”€โ”€โ”€");
175
- return `${pieces.join("\n")}\n\n`;
207
+ out += "</table>";
208
+ return out;
176
209
  }
177
210
  }
@@ -54,7 +54,8 @@ 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>";
58
59
 
59
60
  export class Renderer {
60
61
  private readonly api: Api;
@@ -432,9 +433,9 @@ export class Renderer {
432
433
  if (entry.kind === "todo") {
433
434
  pieces.push(`${entry.emoji} <b>${Markdown.escape(entry.label)}</b>`);
434
435
  } else if (entry.kind === "thinking") {
435
- pieces.push(`<i>${Markdown.toHtml(entry.label)}</i>`);
436
+ pieces.push(`<i>${Renderer.escapeInline(entry.label)}</i>`);
436
437
  } else if (entry.kind === "narration") {
437
- pieces.push(Markdown.toHtml(entry.label));
438
+ pieces.push(Renderer.escapeInline(entry.label));
438
439
  } else {
439
440
  const isLastEntry = i === visible.length - 1;
440
441
  let suffix = "";
@@ -449,16 +450,16 @@ export class Renderer {
449
450
  const next = visible[i + 1];
450
451
  if (next) {
451
452
  pieces.push(
452
- entry.kind === "tool" && next.kind === "tool" ? "\n" : "\n\n"
453
+ entry.kind === "tool" && next.kind === "tool" ? "<br>" : "<br><br>"
453
454
  );
454
455
  }
455
456
  }
456
457
 
457
458
  let body = pieces.join("");
458
459
  if (state === "cancelled") {
459
- body += "\n\nโŒ Cancelled";
460
+ body += "<br><br>โŒ Cancelled";
460
461
  } else if (state === "error") {
461
- body += "\n\nโŒ Error";
462
+ body += "<br><br>โŒ Error";
462
463
  }
463
464
  return Renderer.capStatus(body);
464
465
  }
@@ -477,47 +478,41 @@ export class Renderer {
477
478
  if (!html) {
478
479
  return undefined;
479
480
  }
480
- const other = {
481
- parse_mode: "HTML" as const,
482
- message_thread_id: this.sessionId.threadId,
483
- link_preview_options: { is_disabled: true },
484
- };
481
+ const clean = Renderer.sanitize(html);
485
482
  try {
486
- const msg = await this.api.sendMessage(
483
+ const msg = await this.api.sendRichMessage(
487
484
  this.sessionId.chatId,
488
- Renderer.sanitize(html),
489
- other
485
+ { html: clean },
486
+ { message_thread_id: this.sessionId.threadId }
490
487
  );
491
488
  console.log(
492
- `[send] chatId=${this.sessionId.chatId} threadId=${this.sessionId.threadId ?? "main"} ${opts.status ? "status" : "answer"} ok (${html.length}b)`
489
+ `[send] chatId=${this.sessionId.chatId} threadId=${this.sessionId.threadId ?? "main"} ${opts.status ? "status" : "answer"} ok (${clean.length}b)`
493
490
  );
494
491
  return msg;
495
492
  } catch (err) {
496
493
  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(
494
+ console.warn(`[send] rich 400 (${err.description}) โ€” retry plain`);
495
+ return this.api.sendMessage(
499
496
  this.sessionId.chatId,
500
- Renderer.stripHtml(Renderer.sanitize(html)),
497
+ Renderer.stripHtml(clean),
501
498
  {
502
499
  message_thread_id: this.sessionId.threadId,
503
500
  link_preview_options: { is_disabled: true },
504
501
  }
505
502
  );
506
- return msg;
507
503
  }
508
504
  throw err;
509
505
  }
510
506
  }
511
507
 
512
508
  private async editMessage(html: string): Promise<void> {
509
+ const clean = Renderer.sanitize(html);
513
510
  try {
514
511
  await this.api.editMessageText(
515
512
  this.sessionId.chatId,
516
513
  this.statusMessageId!,
517
- Renderer.sanitize(html),
518
514
  {
519
- parse_mode: "HTML",
520
- link_preview_options: { is_disabled: true },
515
+ html: clean,
521
516
  }
522
517
  );
523
518
  } catch (err) {
@@ -530,7 +525,7 @@ export class Renderer {
530
525
  .editMessageText(
531
526
  this.sessionId.chatId,
532
527
  this.statusMessageId!,
533
- Renderer.stripHtml(Renderer.sanitize(html)),
528
+ Renderer.stripHtml(clean),
534
529
  {
535
530
  link_preview_options: { is_disabled: true },
536
531
  }
@@ -575,7 +570,7 @@ export class Renderer {
575
570
  const label = [
576
571
  first.text,
577
572
  ...rest.map((op) => `${op.emoji} ${op.text}`),
578
- ].join("\n");
573
+ ].join("<br>");
579
574
  return { emoji: first.emoji, label };
580
575
  }
581
576
 
@@ -720,7 +715,9 @@ export class Renderer {
720
715
  if (name === "subagent") {
721
716
  const prompt = Renderer.stringArg(obj, "prompt");
722
717
  return prompt
723
- ? Markdown.toHtml(Renderer.truncate(Renderer.firstLine(prompt), 180))
718
+ ? Renderer.escapeInline(
719
+ Renderer.truncate(Renderer.firstLine(prompt), 180)
720
+ )
724
721
  : "";
725
722
  }
726
723
 
@@ -851,15 +848,15 @@ export class Renderer {
851
848
  if (text.length <= MESSAGE_LIMIT) {
852
849
  return text;
853
850
  }
854
- const lines = text.split("\n");
851
+ const lines = text.split(BR);
855
852
  let dropped = 0;
856
853
  let total = text.length;
857
854
  while (total > MESSAGE_LIMIT && lines.length > 1) {
858
855
  const first = lines.shift()!;
859
- total -= first.length + 1;
856
+ total -= first.length + BR.length;
860
857
  dropped += 1;
861
858
  }
862
- return `โ€ฆ ${dropped} earlier entries\n${lines.join("\n")}`;
859
+ return `โ€ฆ ${dropped} earlier entries${BR}${lines.join(BR)}`;
863
860
  }
864
861
 
865
862
  private static chunk(html: string): readonly string[] {
@@ -869,10 +866,14 @@ export class Renderer {
869
866
  const chunks: string[] = [];
870
867
  let rest = html;
871
868
  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();
869
+ const idx = rest.lastIndexOf(BR, MESSAGE_LIMIT);
870
+ if (idx > 0) {
871
+ chunks.push(rest.slice(0, idx).trim());
872
+ rest = rest.slice(idx + BR.length).trim();
873
+ } else {
874
+ chunks.push(rest.slice(0, MESSAGE_LIMIT).trim());
875
+ rest = rest.slice(MESSAGE_LIMIT).trim();
876
+ }
876
877
  }
877
878
  if (rest) {
878
879
  chunks.push(rest);
@@ -880,6 +881,10 @@ export class Renderer {
880
881
  return chunks;
881
882
  }
882
883
 
884
+ private static escapeInline(text: string): string {
885
+ return Markdown.escape(text).replace(/\n/g, BR);
886
+ }
887
+
883
888
  private static sanitize(text: string): string {
884
889
  return text.replace(
885
890
  /\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
  }