@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 +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 +82 -49
- package/src/telegram/Renderer.ts +37 -32
- 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.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.
|
|
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,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
|
-
| {
|
|
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.
|
|
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 =>
|
|
33
|
-
heading: (c: string, meta?: { level?: number }): string =>
|
|
34
|
-
|
|
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}
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
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
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
90
|
+
return `<li>${body}</li>`;
|
|
90
91
|
},
|
|
91
|
-
hr: (): string => "
|
|
92
|
-
br: (): string => "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
return
|
|
207
|
+
out += "</table>";
|
|
208
|
+
return out;
|
|
176
209
|
}
|
|
177
210
|
}
|
package/src/telegram/Renderer.ts
CHANGED
|
@@ -54,7 +54,8 @@ 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>";
|
|
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>${
|
|
436
|
+
pieces.push(`<i>${Renderer.escapeInline(entry.label)}</i>`);
|
|
436
437
|
} else if (entry.kind === "narration") {
|
|
437
|
-
pieces.push(
|
|
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" ? "
|
|
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 += "
|
|
460
|
+
body += "<br><br>โ Cancelled";
|
|
460
461
|
} else if (state === "error") {
|
|
461
|
-
body += "
|
|
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
|
|
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.
|
|
483
|
+
const msg = await this.api.sendRichMessage(
|
|
487
484
|
this.sessionId.chatId,
|
|
488
|
-
|
|
489
|
-
|
|
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 (${
|
|
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]
|
|
498
|
-
|
|
494
|
+
console.warn(`[send] rich 400 (${err.description}) โ retry plain`);
|
|
495
|
+
return this.api.sendMessage(
|
|
499
496
|
this.sessionId.chatId,
|
|
500
|
-
Renderer.stripHtml(
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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
|
-
?
|
|
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(
|
|
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 +
|
|
856
|
+
total -= first.length + BR.length;
|
|
860
857
|
dropped += 1;
|
|
861
858
|
}
|
|
862
|
-
return `โฆ ${dropped} earlier entries
|
|
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(
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 {
|
|
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
|
}
|