@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaroncql/pim-agent",
3
- "version": "0.4.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",
@@ -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 => Markdown.escape(c),
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
- const body = c.replace(/\n+$/, "");
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 renderMd(md: string): string {
97
+ private static readonly ITALIC_RENDERERS = {
98
+ ...Markdown.RENDERERS,
99
+ paragraph: (c: string): string => `<p>${Markdown.italic(c)}</p>`,
100
+ heading: (c: string, meta?: { level?: number }): string => {
101
+ const level = Math.min(6, Math.max(1, meta?.level ?? 1));
102
+ return `<h${level}>${Markdown.italic(c)}</h${level}>`;
103
+ },
104
+ listItem: (c: string, meta?: { checked?: boolean }): string =>
105
+ Markdown.listItemHtml(
106
+ Markdown.italicListItemBody(c.replace(/\n+$/, "")),
107
+ meta?.checked
108
+ ),
109
+ };
110
+
111
+ private static listItemHtml(body: string, checked?: boolean): string {
112
+ if (checked === true) {
113
+ return `<li><input type="checkbox" checked> ${body}</li>`;
114
+ }
115
+ if (checked === false) {
116
+ return `<li><input type="checkbox"> ${body}</li>`;
117
+ }
118
+ return `<li>${body}</li>`;
119
+ }
120
+
121
+ private static renderMd(md: string, options: RenderOptions = {}): string {
98
122
  if (!md.trim()) {
99
123
  return "";
100
124
  }
101
- return Bun.markdown.render(md, Markdown.RENDERERS);
125
+ return Bun.markdown.render(md, Markdown.renderers(options), {
126
+ strikethrough: false,
127
+ });
128
+ }
129
+
130
+ private static renderers(options: RenderOptions): typeof Markdown.RENDERERS {
131
+ return options.italics ? Markdown.ITALIC_RENDERERS : Markdown.RENDERERS;
132
+ }
133
+
134
+ private static italic(text: string): string {
135
+ return text ? `<i>${text}</i>` : "";
136
+ }
137
+
138
+ // A nested list is appended to its parent item's content, so italicize only
139
+ // the leading text; wrapping a child <ul>/<ol> in <i> would be invalid.
140
+ private static italicListItemBody(body: string): string {
141
+ const nestedListIndex = body.search(/<(?:ul|ol)\b/);
142
+ if (nestedListIndex < 0) {
143
+ return Markdown.italic(body);
144
+ }
145
+ const before = body.slice(0, nestedListIndex);
146
+ return `${Markdown.italic(before)}${body.slice(nestedListIndex)}`;
102
147
  }
103
148
 
104
149
  private static renderInline(md: string): string {
@@ -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(`<i>${Renderer.escapeInline(entry.label)}</i>`);
460
+ pieces.push(Markdown.toHtml(entry.label, { italics: true }));
437
461
  } else if (entry.kind === "narration") {
438
- pieces.push(Renderer.escapeInline(entry.label));
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 Renderer.truncate(text.replace(/\n{3,}/g, "\n\n").trim(), 900);
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 lines = text.split(BR);
873
+ const blocks = Renderer.splitStatusBlocks(text);
852
874
  let dropped = 0;
853
- let total = text.length;
854
- while (total > MESSAGE_LIMIT && lines.length > 1) {
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 `… ${dropped} earlier entries${BR}${lines.join(BR)}`;
1082
+ return `${escaped.join("").trimEnd()}${marker}`;
860
1083
  }
861
1084
 
862
1085
  private static chunk(html: string): readonly string[] {