@aaroncql/pim-agent 0.4.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/package.json +1 -1
- package/src/telegram/Markdown.ts +62 -17
- package/src/telegram/Renderer.ts +236 -13
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",
|
package/src/telegram/Markdown.ts
CHANGED
|
@@ -10,16 +10,25 @@ type Segment =
|
|
|
10
10
|
readonly aligns: ReadonlyArray<Align | undefined>;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
type RenderOptions = {
|
|
14
|
+
readonly italics?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
13
17
|
const SAFE_LINK = /^(https?:|tg:|mailto:)/i;
|
|
14
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
|
+
|
|
15
24
|
export class Markdown {
|
|
16
|
-
public static toHtml(md: string): string {
|
|
25
|
+
public static toHtml(md: string, options: RenderOptions = {}): string {
|
|
17
26
|
const segments = Markdown.split(md);
|
|
18
27
|
let out = "";
|
|
19
28
|
for (const seg of segments) {
|
|
20
29
|
out +=
|
|
21
30
|
seg.kind === "md"
|
|
22
|
-
? Markdown.renderMd(seg.text)
|
|
31
|
+
? Markdown.renderMd(seg.text, options)
|
|
23
32
|
: Markdown.renderTable(seg.rows, seg.aligns);
|
|
24
33
|
}
|
|
25
34
|
return out.trim();
|
|
@@ -34,7 +43,8 @@ export class Markdown {
|
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
private static readonly RENDERERS = {
|
|
37
|
-
text: (c: string): string =>
|
|
46
|
+
text: (c: string): string =>
|
|
47
|
+
Markdown.escape(c).replace(STRIKETHROUGH, "<s>$1</s>"),
|
|
38
48
|
paragraph: (c: string): string => `<p>${c}</p>`,
|
|
39
49
|
heading: (c: string, meta?: { level?: number }): string => {
|
|
40
50
|
const level = Math.min(6, Math.max(1, meta?.level ?? 1));
|
|
@@ -42,7 +52,6 @@ export class Markdown {
|
|
|
42
52
|
},
|
|
43
53
|
strong: (c: string): string => `<b>${c}</b>`,
|
|
44
54
|
emphasis: (c: string): string => `<i>${c}</i>`,
|
|
45
|
-
strikethrough: (c: string): string => `<s>${c}</s>`,
|
|
46
55
|
codespan: (c: string): string => `<code>${c}</code>`,
|
|
47
56
|
code: (c: string, meta?: { language?: string }): string => {
|
|
48
57
|
const body = c.replace(/\n+$/, "");
|
|
@@ -78,27 +87,63 @@ export class Markdown {
|
|
|
78
87
|
}
|
|
79
88
|
return `<ul>${c}</ul>`;
|
|
80
89
|
},
|
|
81
|
-
listItem: (c: string, meta?: { checked?: boolean }): string =>
|
|
82
|
-
|
|
83
|
-
const checked = meta?.checked;
|
|
84
|
-
if (checked === true) {
|
|
85
|
-
return `<li><input type="checkbox" checked> ${body}</li>`;
|
|
86
|
-
}
|
|
87
|
-
if (checked === false) {
|
|
88
|
-
return `<li><input type="checkbox"> ${body}</li>`;
|
|
89
|
-
}
|
|
90
|
-
return `<li>${body}</li>`;
|
|
91
|
-
},
|
|
90
|
+
listItem: (c: string, meta?: { checked?: boolean }): string =>
|
|
91
|
+
Markdown.listItemHtml(c.replace(/\n+$/, ""), meta?.checked),
|
|
92
92
|
hr: (): string => "<hr/>",
|
|
93
93
|
br: (): string => "<br>",
|
|
94
94
|
table: (c: string): string => c,
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
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 {
|
|
98
122
|
if (!md.trim()) {
|
|
99
123
|
return "";
|
|
100
124
|
}
|
|
101
|
-
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)}`;
|
|
102
147
|
}
|
|
103
148
|
|
|
104
149
|
private static renderInline(md: string): string {
|
package/src/telegram/Renderer.ts
CHANGED
|
@@ -56,6 +56,30 @@ const TOOL_EMOJI: Record<string, string> = {
|
|
|
56
56
|
|
|
57
57
|
const MESSAGE_LIMIT = 32000;
|
|
58
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
|
+
};
|
|
59
83
|
|
|
60
84
|
export class Renderer {
|
|
61
85
|
private readonly api: Api;
|
|
@@ -433,9 +457,9 @@ export class Renderer {
|
|
|
433
457
|
if (entry.kind === "todo") {
|
|
434
458
|
pieces.push(`${entry.emoji} <b>${Markdown.escape(entry.label)}</b>`);
|
|
435
459
|
} else if (entry.kind === "thinking") {
|
|
436
|
-
pieces.push(
|
|
460
|
+
pieces.push(Markdown.toHtml(entry.label, { italics: true }));
|
|
437
461
|
} else if (entry.kind === "narration") {
|
|
438
|
-
pieces.push(
|
|
462
|
+
pieces.push(Markdown.toHtml(entry.label));
|
|
439
463
|
} else {
|
|
440
464
|
const isLastEntry = i === visible.length - 1;
|
|
441
465
|
let suffix = "";
|
|
@@ -448,10 +472,8 @@ export class Renderer {
|
|
|
448
472
|
pieces.push(`${entry.emoji} ${entry.label}${stats}${suffix}`);
|
|
449
473
|
}
|
|
450
474
|
const next = visible[i + 1];
|
|
451
|
-
if (next) {
|
|
452
|
-
pieces.push(
|
|
453
|
-
entry.kind === "tool" && next.kind === "tool" ? "<br>" : "<br><br>"
|
|
454
|
-
);
|
|
475
|
+
if (next && entry.kind === "tool" && next.kind === "tool") {
|
|
476
|
+
pieces.push(BR);
|
|
455
477
|
}
|
|
456
478
|
}
|
|
457
479
|
|
|
@@ -837,7 +859,7 @@ export class Renderer {
|
|
|
837
859
|
}
|
|
838
860
|
|
|
839
861
|
private static cleanProse(text: string): string {
|
|
840
|
-
return
|
|
862
|
+
return text.replace(/\n{3,}/g, "\n\n").trim();
|
|
841
863
|
}
|
|
842
864
|
|
|
843
865
|
private static truncate(text: string, limit = 180): string {
|
|
@@ -848,15 +870,216 @@ export class Renderer {
|
|
|
848
870
|
if (text.length <= MESSAGE_LIMIT) {
|
|
849
871
|
return text;
|
|
850
872
|
}
|
|
851
|
-
const
|
|
873
|
+
const blocks = Renderer.splitStatusBlocks(text);
|
|
852
874
|
let dropped = 0;
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const first = lines.shift()!;
|
|
856
|
-
total -= first.length + BR.length;
|
|
875
|
+
while (blocks.length > 1) {
|
|
876
|
+
blocks.shift();
|
|
857
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
|
+
}
|
|
1012
|
+
}
|
|
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;
|
|
858
1081
|
}
|
|
859
|
-
return
|
|
1082
|
+
return `${escaped.join("").trimEnd()}${marker}`;
|
|
860
1083
|
}
|
|
861
1084
|
|
|
862
1085
|
private static chunk(html: string): readonly string[] {
|