@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 +1 -1
- package/package.json +2 -2
- package/src/extensions/web-search/ExaMcpClient.ts +20 -3
- package/src/shared/McpClient.ts +90 -28
- package/src/shared/RateLimiter.ts +50 -0
- package/src/telegram/Markdown.ts +136 -58
- package/src/telegram/Renderer.ts +266 -38
- package/src/telegram/SendFileTool.ts +5 -19
- package/src/telegram/Session.ts +1 -1
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
|
|
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
|
+
"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.
|
|
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
|
-
...(
|
|
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
|
}
|
package/src/shared/McpClient.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
59
|
-
|
|
66
|
+
const pending = this.ensureSession();
|
|
67
|
+
const sessionId = await pending;
|
|
60
68
|
|
|
61
69
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
113
|
+
capabilities: {},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
77
116
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
117
|
+
await this.sendNotification({
|
|
118
|
+
sessionId: initialize.sessionId,
|
|
119
|
+
method: "notifications/initialized",
|
|
120
|
+
params: {},
|
|
121
|
+
});
|
|
83
122
|
|
|
84
|
-
|
|
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:
|
|
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,
|
|
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
|
+
}
|
package/src/telegram/Markdown.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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.
|
|
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 =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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}
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
return
|
|
252
|
+
out += "</table>";
|
|
253
|
+
return out;
|
|
176
254
|
}
|
|
177
255
|
}
|
package/src/telegram/Renderer.ts
CHANGED
|
@@ -54,7 +54,32 @@ const TOOL_EMOJI: Record<string, string> = {
|
|
|
54
54
|
subagent: "๐ค",
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
const MESSAGE_LIMIT =
|
|
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(
|
|
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 += "
|
|
482
|
+
body += "<br><br>โ Cancelled";
|
|
460
483
|
} else if (state === "error") {
|
|
461
|
-
body += "
|
|
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
|
|
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.
|
|
505
|
+
const msg = await this.api.sendRichMessage(
|
|
487
506
|
this.sessionId.chatId,
|
|
488
|
-
|
|
489
|
-
|
|
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 (${
|
|
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]
|
|
498
|
-
|
|
516
|
+
console.warn(`[send] rich 400 (${err.description}) โ retry plain`);
|
|
517
|
+
return this.api.sendMessage(
|
|
499
518
|
this.sessionId.chatId,
|
|
500
|
-
Renderer.stripHtml(
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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
|
-
?
|
|
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
|
|
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
|
|
873
|
+
const blocks = Renderer.splitStatusBlocks(text);
|
|
855
874
|
let dropped = 0;
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
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(
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 {
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|
package/src/telegram/Session.ts
CHANGED
|
@@ -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
|
}
|