@gajae-code/coding-agent 0.6.4 → 0.7.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/migrate-cli.d.ts +20 -0
  5. package/dist/types/cli/notify-cli.d.ts +23 -0
  6. package/dist/types/cli/setup-cli.d.ts +20 -1
  7. package/dist/types/commands/daemon.d.ts +41 -0
  8. package/dist/types/commands/migrate.d.ts +33 -0
  9. package/dist/types/commands/notify.d.ts +41 -0
  10. package/dist/types/config/keybindings.d.ts +4 -0
  11. package/dist/types/config/model-profile-activation.d.ts +12 -0
  12. package/dist/types/config/model-profiles.d.ts +2 -1
  13. package/dist/types/config/model-registry.d.ts +3 -3
  14. package/dist/types/config/models-config-schema.d.ts +5 -0
  15. package/dist/types/config/settings-schema.d.ts +38 -0
  16. package/dist/types/coordinator/contract.d.ts +1 -1
  17. package/dist/types/daemon/builtin.d.ts +20 -0
  18. package/dist/types/daemon/control-types.d.ts +57 -0
  19. package/dist/types/daemon/runtime.d.ts +25 -0
  20. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  21. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +2 -0
  22. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -2
  23. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  24. package/dist/types/gjc-runtime/session-layout.d.ts +59 -0
  25. package/dist/types/gjc-runtime/session-resolution.d.ts +47 -0
  26. package/dist/types/gjc-runtime/state-graph.d.ts +1 -1
  27. package/dist/types/gjc-runtime/state-runtime.d.ts +5 -4
  28. package/dist/types/gjc-runtime/state-schema.d.ts +2 -0
  29. package/dist/types/gjc-runtime/state-writer.d.ts +38 -7
  30. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  31. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  32. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +1 -1
  33. package/dist/types/gjc-runtime/workflow-manifest.d.ts +1 -1
  34. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  35. package/dist/types/hooks/skill-state.d.ts +12 -4
  36. package/dist/types/migrate/action-planner.d.ts +11 -0
  37. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  38. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  39. package/dist/types/migrate/adapters/index.d.ts +45 -0
  40. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  41. package/dist/types/migrate/executor.d.ts +2 -0
  42. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  43. package/dist/types/migrate/report.d.ts +18 -0
  44. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  45. package/dist/types/migrate/types.d.ts +126 -0
  46. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  47. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  48. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  49. package/dist/types/modes/interactive-mode.d.ts +1 -1
  50. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  51. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  52. package/dist/types/modes/types.d.ts +7 -1
  53. package/dist/types/notifications/config-commands.d.ts +26 -0
  54. package/dist/types/notifications/config.d.ts +61 -0
  55. package/dist/types/notifications/helpers.d.ts +55 -0
  56. package/dist/types/notifications/html-format.d.ts +62 -0
  57. package/dist/types/notifications/index.d.ts +28 -0
  58. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  59. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  60. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  61. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  62. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  63. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  64. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  65. package/dist/types/notifications/threaded-render.d.ts +66 -0
  66. package/dist/types/notifications/topic-registry.d.ts +67 -0
  67. package/dist/types/research-plan/index.d.ts +1 -0
  68. package/dist/types/research-plan/ledger.d.ts +33 -0
  69. package/dist/types/rlm/artifacts.d.ts +1 -1
  70. package/dist/types/rlm/index.d.ts +12 -0
  71. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  72. package/dist/types/session/agent-session.d.ts +39 -2
  73. package/dist/types/session/auth-storage.d.ts +1 -1
  74. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  75. package/dist/types/setup/credential-import.d.ts +3 -0
  76. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  77. package/dist/types/skill-state/active-state.d.ts +6 -11
  78. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  79. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  80. package/dist/types/task/spawn-gate.d.ts +1 -10
  81. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  82. package/dist/types/tools/index.d.ts +18 -0
  83. package/dist/types/tools/subagent.d.ts +3 -0
  84. package/package.json +7 -7
  85. package/scripts/build-binary.ts +3 -0
  86. package/src/async/job-manager.ts +5 -1
  87. package/src/cli/daemon-cli.ts +122 -0
  88. package/src/cli/migrate-cli.ts +106 -0
  89. package/src/cli/notify-cli.ts +274 -0
  90. package/src/cli/setup-cli.ts +173 -84
  91. package/src/cli.ts +3 -0
  92. package/src/commands/daemon.ts +47 -0
  93. package/src/commands/deep-interview.ts +2 -2
  94. package/src/commands/migrate.ts +46 -0
  95. package/src/commands/notify.ts +61 -0
  96. package/src/commands/setup.ts +11 -1
  97. package/src/commands/state.ts +2 -1
  98. package/src/commands/team.ts +7 -3
  99. package/src/config/model-profile-activation.ts +74 -5
  100. package/src/config/model-profiles.ts +7 -4
  101. package/src/config/model-registry.ts +6 -3
  102. package/src/config/models-config-schema.ts +1 -1
  103. package/src/config/settings-schema.ts +29 -0
  104. package/src/coordinator/contract.ts +3 -0
  105. package/src/coordinator-mcp/policy.ts +10 -2
  106. package/src/coordinator-mcp/server.ts +270 -1
  107. package/src/daemon/builtin.ts +46 -0
  108. package/src/daemon/control-types.ts +65 -0
  109. package/src/daemon/runtime.ts +51 -0
  110. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +0 -1
  111. package/src/defaults/gjc/skills/deep-interview/SKILL.md +28 -24
  112. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  113. package/src/defaults/gjc/skills/team/SKILL.md +51 -47
  114. package/src/defaults/gjc/skills/ultragoal/SKILL.md +33 -13
  115. package/src/extensibility/custom-commands/loader.ts +0 -7
  116. package/src/extensibility/extensions/runner.ts +4 -0
  117. package/src/extensibility/extensions/types.ts +8 -0
  118. package/src/extensibility/gjc-plugins/injection.ts +23 -4
  119. package/src/extensibility/gjc-plugins/state.ts +16 -1
  120. package/src/gjc-runtime/deep-interview-recorder.ts +51 -18
  121. package/src/gjc-runtime/deep-interview-runtime.ts +49 -23
  122. package/src/gjc-runtime/goal-mode-request.ts +26 -11
  123. package/src/gjc-runtime/launch-tmux.ts +6 -1
  124. package/src/gjc-runtime/ralplan-runtime.ts +79 -50
  125. package/src/gjc-runtime/session-layout.ts +180 -0
  126. package/src/gjc-runtime/session-resolution.ts +217 -0
  127. package/src/gjc-runtime/state-graph.ts +1 -2
  128. package/src/gjc-runtime/state-migrations.ts +1 -0
  129. package/src/gjc-runtime/state-runtime.ts +247 -124
  130. package/src/gjc-runtime/state-schema.ts +2 -0
  131. package/src/gjc-runtime/state-writer.ts +289 -41
  132. package/src/gjc-runtime/team-runtime.ts +43 -19
  133. package/src/gjc-runtime/tmux-sessions.ts +7 -1
  134. package/src/gjc-runtime/ultragoal-guard.ts +102 -4
  135. package/src/gjc-runtime/ultragoal-runtime.ts +226 -60
  136. package/src/gjc-runtime/workflow-command-ref.ts +1 -2
  137. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  138. package/src/gjc-runtime/workflow-manifest.ts +12 -3
  139. package/src/goals/tools/goal-tool.ts +11 -2
  140. package/src/harness-control-plane/storage.ts +14 -4
  141. package/src/hooks/native-skill-hook.ts +38 -12
  142. package/src/hooks/skill-state.ts +178 -83
  143. package/src/internal-urls/docs-index.generated.ts +9 -6
  144. package/src/main.ts +30 -0
  145. package/src/migrate/action-planner.ts +318 -0
  146. package/src/migrate/adapters/claude-code.ts +39 -0
  147. package/src/migrate/adapters/codex.ts +70 -0
  148. package/src/migrate/adapters/index.ts +277 -0
  149. package/src/migrate/adapters/opencode.ts +52 -0
  150. package/src/migrate/executor.ts +81 -0
  151. package/src/migrate/mcp-mapper.ts +152 -0
  152. package/src/migrate/report.ts +104 -0
  153. package/src/migrate/skill-normalizer.ts +80 -0
  154. package/src/migrate/types.ts +163 -0
  155. package/src/modes/acp/acp-event-mapper.ts +1 -0
  156. package/src/modes/bridge/bridge-mode.ts +2 -2
  157. package/src/modes/components/custom-editor.ts +30 -20
  158. package/src/modes/components/hook-editor.ts +7 -2
  159. package/src/modes/components/oauth-selector.ts +19 -0
  160. package/src/modes/controllers/event-controller.ts +20 -0
  161. package/src/modes/controllers/selector-controller.ts +80 -17
  162. package/src/modes/interactive-mode.ts +6 -2
  163. package/src/modes/rpc/rpc-mode.ts +2 -2
  164. package/src/modes/runtime-init.ts +1 -0
  165. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  166. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  167. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  168. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  169. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  170. package/src/modes/types.ts +7 -1
  171. package/src/modes/utils/ui-helpers.ts +23 -0
  172. package/src/notifications/config-commands.ts +50 -0
  173. package/src/notifications/config.ts +107 -0
  174. package/src/notifications/helpers.ts +135 -0
  175. package/src/notifications/html-format.ts +389 -0
  176. package/src/notifications/index.ts +663 -0
  177. package/src/notifications/rate-limit-pool.ts +179 -0
  178. package/src/notifications/telegram-cli.ts +194 -0
  179. package/src/notifications/telegram-daemon-cli.ts +74 -0
  180. package/src/notifications/telegram-daemon-control.ts +370 -0
  181. package/src/notifications/telegram-daemon.ts +1370 -0
  182. package/src/notifications/telegram-reference.ts +335 -0
  183. package/src/notifications/threaded-inbound.ts +80 -0
  184. package/src/notifications/threaded-render.ts +155 -0
  185. package/src/notifications/topic-registry.ts +133 -0
  186. package/src/prompts/agents/init.md +1 -1
  187. package/src/prompts/system/plan-mode-active.md +1 -1
  188. package/src/prompts/tools/ast-grep.md +1 -1
  189. package/src/prompts/tools/search.md +1 -1
  190. package/src/prompts/tools/task.md +1 -2
  191. package/src/research-plan/index.ts +1 -0
  192. package/src/research-plan/ledger.ts +177 -0
  193. package/src/rlm/artifacts.ts +12 -3
  194. package/src/rlm/index.ts +26 -0
  195. package/src/runtime-mcp/config-writer.ts +46 -0
  196. package/src/sdk.ts +16 -0
  197. package/src/session/agent-session.ts +128 -24
  198. package/src/session/auth-storage.ts +3 -0
  199. package/src/session/session-dump-format.ts +43 -2
  200. package/src/session/session-manager.ts +39 -5
  201. package/src/setup/credential-auto-import.ts +258 -0
  202. package/src/setup/credential-import.ts +17 -0
  203. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  204. package/src/setup/hermes-setup.ts +1 -1
  205. package/src/setup/host-plugin-setup.ts +142 -0
  206. package/src/skill-state/active-state.ts +72 -108
  207. package/src/skill-state/canonical-skills.ts +4 -0
  208. package/src/skill-state/deep-interview-mutation-guard.ts +28 -109
  209. package/src/skill-state/workflow-hud.ts +4 -2
  210. package/src/skill-state/workflow-state-contract.ts +3 -3
  211. package/src/slash-commands/builtin-registry.ts +4 -1
  212. package/src/task/agents.ts +1 -22
  213. package/src/task/executor.ts +5 -1
  214. package/src/task/index.ts +1 -41
  215. package/src/task/spawn-gate.ts +1 -38
  216. package/src/task/types.ts +1 -1
  217. package/src/tools/ask-answer-registry.ts +25 -0
  218. package/src/tools/ask.ts +108 -16
  219. package/src/tools/computer.ts +58 -4
  220. package/src/tools/image-gen.ts +5 -8
  221. package/src/tools/index.ts +19 -0
  222. package/src/tools/inspect-image.ts +16 -11
  223. package/src/tools/subagent-render.ts +7 -0
  224. package/src/tools/subagent.ts +38 -7
  225. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  226. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  227. package/src/prompts/agents/explore.md +0 -58
  228. package/src/prompts/agents/plan.md +0 -49
  229. package/src/prompts/agents/reviewer.md +0 -141
  230. package/src/prompts/agents/task.md +0 -16
  231. package/src/prompts/review-request.md +0 -70
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Telegram HTML formatting helpers for the notifications SDK.
3
+ *
4
+ * All notifications-SDK Telegram output is sent with `parse_mode: "HTML"`. This
5
+ * module is the single source of truth for: escaping dynamic text, converting a
6
+ * bounded markdown subset into Telegram HTML, safely truncating a finished
7
+ * message to Telegram's 4096-char limit without breaking tags/entities, and
8
+ * laying out inline-keyboard buttons as a numbered grid.
9
+ *
10
+ * Discipline: escape first, tag second. Telegram only parses a small tag set
11
+ * (b, i, u, s, code, pre, a, blockquote, tg-spoiler); a stray `<` or unbalanced
12
+ * tag can make Telegram reject the whole message, so dynamic text is always
13
+ * escaped before any tag is emitted.
14
+ */
15
+
16
+ export const TELEGRAM_PARSE_MODE = "HTML" as const;
17
+ export const TELEGRAM_MESSAGE_LIMIT = 4096;
18
+
19
+ /** Tags Telegram parses in HTML mode (used by the truncation guard). */
20
+ const ALLOWED_TAGS = new Set(["b", "i", "u", "s", "code", "pre", "a", "blockquote", "tg-spoiler"]);
21
+
22
+ /** Escape text for Telegram HTML body content (`& < >`). */
23
+ export function escapeHtml(value: string): string {
24
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
25
+ }
26
+
27
+ /** Escape a value for use inside a double-quoted HTML attribute. */
28
+ function escapeAttr(value: string): string {
29
+ return escapeHtml(value).replace(/"/g, "&quot;");
30
+ }
31
+
32
+ /** Wrap already-escaped text in a Telegram tag. */
33
+ function tag(name: string, escaped: string): string {
34
+ return `<${name}>${escaped}</${name}>`;
35
+ }
36
+
37
+ /** Bold the given raw text (escaped internally). */
38
+ export function bold(raw: string): string {
39
+ return tag("b", escapeHtml(raw));
40
+ }
41
+
42
+ /** Italicize the given raw text (escaped internally). */
43
+ export function italic(raw: string): string {
44
+ return tag("i", escapeHtml(raw));
45
+ }
46
+
47
+ /** Render the given raw text as inline code (escaped internally). */
48
+ export function code(raw: string): string {
49
+ return tag("code", escapeHtml(raw));
50
+ }
51
+
52
+ /** Render the given raw text as a preformatted block (escaped internally). */
53
+ export function pre(raw: string): string {
54
+ return tag("pre", escapeHtml(raw));
55
+ }
56
+
57
+ const PLACEHOLDER_PREFIX = "\u0000ph";
58
+ const PLACEHOLDER_SUFFIX = "\u0000";
59
+
60
+ /** Only http(s) and mailto links are emitted; anything else stays literal. */
61
+ function isSafeUrl(url: string): boolean {
62
+ return /^(https?:\/\/|mailto:)/i.test(url);
63
+ }
64
+
65
+ /** Column alignment parsed from a GFM table separator cell. */
66
+ type ColumnAlign = "left" | "right" | "center";
67
+
68
+ /** Split a markdown table row into trimmed cells, honoring escaped `\|`. */
69
+ function splitTableRow(line: string): string[] {
70
+ let s = line.trim();
71
+ if (s.startsWith("|")) s = s.slice(1);
72
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
73
+ const cells: string[] = [];
74
+ let cur = "";
75
+ for (let i = 0; i < s.length; i++) {
76
+ const ch = s[i]!;
77
+ if (ch === "\\" && s[i + 1] === "|") {
78
+ cur += "|";
79
+ i++;
80
+ continue;
81
+ }
82
+ if (ch === "|") {
83
+ cells.push(cur);
84
+ cur = "";
85
+ continue;
86
+ }
87
+ cur += ch;
88
+ }
89
+ cells.push(cur);
90
+ return cells.map(c => c.trim());
91
+ }
92
+
93
+ /** A line is a candidate table row when it contains an unescaped `|`. */
94
+ function looksLikeTableRow(line: string): boolean {
95
+ return /(?:^|[^\\])\|/.test(line);
96
+ }
97
+
98
+ /** A separator row has only dashes/colons/spaces per cell, with at least one dash. */
99
+ function isTableSeparator(line: string): boolean {
100
+ if (!looksLikeTableRow(line)) return false;
101
+ const cells = splitTableRow(line);
102
+ return cells.length > 0 && cells.every(c => /^:?-+:?$/.test(c));
103
+ }
104
+
105
+ /** Derive a column alignment from a separator cell (`:---`, `---:`, `:---:`). */
106
+ function parseAlign(cell: string): ColumnAlign {
107
+ const c = cell.trim();
108
+ const left = c.startsWith(":");
109
+ const right = c.endsWith(":");
110
+ if (left && right) return "center";
111
+ if (right) return "right";
112
+ return "left";
113
+ }
114
+
115
+ /** Render parsed table parts as an aligned, monospace-friendly plain-text grid. */
116
+ function renderTableText(header: string[], aligns: ColumnAlign[], body: string[][]): string {
117
+ const rows = [header, ...body];
118
+ const cols = Math.max(header.length, ...body.map(r => r.length));
119
+ const widths: number[] = [];
120
+ for (let c = 0; c < cols; c++) {
121
+ let w = 1;
122
+ for (const row of rows) w = Math.max(w, (row[c] ?? "").length);
123
+ widths[c] = w;
124
+ }
125
+ const padCell = (value: string, c: number): string => {
126
+ const width = widths[c]!;
127
+ const pad = width - value.length;
128
+ if (pad <= 0) return value;
129
+ const align = aligns[c] ?? "left";
130
+ if (align === "right") return " ".repeat(pad) + value;
131
+ if (align === "center") {
132
+ const leftPad = Math.floor(pad / 2);
133
+ return " ".repeat(leftPad) + value + " ".repeat(pad - leftPad);
134
+ }
135
+ return value + " ".repeat(pad);
136
+ };
137
+ const renderRow = (row: string[]): string =>
138
+ Array.from({ length: cols }, (_v, c) => padCell(row[c] ?? "", c)).join(" | ");
139
+ const divider = widths.map(w => "-".repeat(w)).join("-|-");
140
+ return [renderRow(header), divider, ...body.map(renderRow)].join("\n");
141
+ }
142
+
143
+ /**
144
+ * Replace GFM tables with stashed monospace `<pre>` blocks. Telegram HTML has no
145
+ * table primitive, so a header row followed by a `|---|` separator is rendered as
146
+ * an aligned plain-text grid (cell content escaped by `pre`).
147
+ */
148
+ function convertMarkdownTables(text: string, stash: (html: string) => string): string {
149
+ const lines = text.split("\n");
150
+ const out: string[] = [];
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const headerLine = lines[i]!;
153
+ const separatorLine = lines[i + 1];
154
+ if (looksLikeTableRow(headerLine) && separatorLine !== undefined && isTableSeparator(separatorLine)) {
155
+ const header = splitTableRow(headerLine);
156
+ const aligns = splitTableRow(separatorLine).map(parseAlign);
157
+ const body: string[][] = [];
158
+ let j = i + 2;
159
+ for (; j < lines.length; j++) {
160
+ const row = lines[j]!;
161
+ if (!looksLikeTableRow(row) || isTableSeparator(row)) break;
162
+ body.push(splitTableRow(row));
163
+ }
164
+ out.push(stash(pre(renderTableText(header, aligns, body))));
165
+ i = j - 1;
166
+ continue;
167
+ }
168
+ out.push(headerLine);
169
+ }
170
+ return out.join("\n");
171
+ }
172
+
173
+ /**
174
+ * Convert a bounded markdown subset into Telegram HTML. Supported: fenced code,
175
+ * inline code, `**bold**`, `*italic*`, `[text](url)` (safe schemes only),
176
+ * `#` headers, `>` blockquotes, and GFM tables (rendered as a monospace block).
177
+ * Unsupported or malformed markdown is left as escaped literal text — never
178
+ * emitted as unbalanced tags.
179
+ */
180
+ export function markdownToTelegramHtml(markdown: string): string {
181
+ const placeholders: string[] = [];
182
+ const stash = (html: string): string => {
183
+ const token = `${PLACEHOLDER_PREFIX}${placeholders.length}${PLACEHOLDER_SUFFIX}`;
184
+ placeholders.push(html);
185
+ return token;
186
+ };
187
+
188
+ let text = markdown;
189
+
190
+ // 1. Fenced code blocks (protect literal content before any other transform).
191
+ text = text.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body: string) => stash(pre(body)));
192
+
193
+ // 1b. GFM tables -> aligned monospace <pre> block (no native table primitive).
194
+ text = convertMarkdownTables(text, stash);
195
+
196
+ // 2. Inline code.
197
+ text = text.replace(/`([^`\n]+)`/g, (_m, body: string) => stash(code(body)));
198
+
199
+ // 3. Links (capture raw URL before escaping). Unsafe/malformed links stay literal.
200
+ text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label: string, url: string) => {
201
+ if (!isSafeUrl(url)) return whole;
202
+ return stash(`<a href="${escapeAttr(url)}">${escapeHtml(label)}</a>`);
203
+ });
204
+
205
+ // 4. Escape everything that remains (placeholders contain no escapable chars).
206
+ text = escapeHtml(text);
207
+
208
+ // 5. Line-level transforms on escaped text: headers and merged blockquotes.
209
+ const lines = text.split("\n");
210
+ const out: string[] = [];
211
+ let quoteBuffer: string[] | null = null;
212
+ const flushQuote = () => {
213
+ if (quoteBuffer) {
214
+ out.push(tag("blockquote", quoteBuffer.join("\n")));
215
+ quoteBuffer = null;
216
+ }
217
+ };
218
+ for (const line of lines) {
219
+ const quote = /^&gt;\s?(.*)$/.exec(line);
220
+ if (quote) {
221
+ if (!quoteBuffer) {
222
+ quoteBuffer = [];
223
+ }
224
+ quoteBuffer.push(quote[1] ?? "");
225
+ continue;
226
+ }
227
+ flushQuote();
228
+ const header = /^(#{1,6})\s+(.*)$/.exec(line);
229
+ out.push(header ? tag("b", header[2] ?? "") : line);
230
+ }
231
+ flushQuote();
232
+ text = out.join("\n");
233
+
234
+ // 6. Inline emphasis (bold before italic; unbalanced markers stay literal).
235
+ text = text.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => tag("b", body));
236
+ text = text.replace(/\*([^*\n]+)\*/g, (_m, body: string) => tag("i", body));
237
+
238
+ // 7. Restore protected placeholders.
239
+ text = text.replace(
240
+ new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"),
241
+ (_m, i: string) => placeholders[Number(i)] ?? "",
242
+ );
243
+
244
+ return text;
245
+ }
246
+
247
+ interface Token {
248
+ value: string;
249
+ /** Tag name if this token opens a tag, else undefined. */
250
+ open?: string;
251
+ /** Tag name if this token closes a tag, else undefined. */
252
+ close?: string;
253
+ }
254
+
255
+ /** Tokenize HTML into tags, entities, and single characters (never splits them). */
256
+ function tokenize(html: string): Token[] {
257
+ const tokens: Token[] = [];
258
+ let i = 0;
259
+ while (i < html.length) {
260
+ const ch = html[i]!;
261
+ if (ch === "<") {
262
+ const end = html.indexOf(">", i);
263
+ if (end !== -1) {
264
+ const raw = html.slice(i, end + 1);
265
+ const close = /^<\/([a-z-]+)>$/i.exec(raw);
266
+ const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
267
+ const token: Token = { value: raw };
268
+ if (close && ALLOWED_TAGS.has(close[1]!.toLowerCase())) token.close = close[1]!.toLowerCase();
269
+ else if (openMatch && ALLOWED_TAGS.has(openMatch[1]!.toLowerCase()))
270
+ token.open = openMatch[1]!.toLowerCase();
271
+ tokens.push(token);
272
+ i = end + 1;
273
+ continue;
274
+ }
275
+ }
276
+ if (ch === "&") {
277
+ const end = html.indexOf(";", i);
278
+ if (end !== -1 && end - i <= 10) {
279
+ tokens.push({ value: html.slice(i, end + 1) });
280
+ i = end + 1;
281
+ continue;
282
+ }
283
+ }
284
+ tokens.push({ value: ch });
285
+ i++;
286
+ }
287
+ return tokens;
288
+ }
289
+
290
+ /**
291
+ * Truncate a finished Telegram HTML message to at most `max` chars without
292
+ * splitting a tag or entity, closing any still-open allowed tags and appending
293
+ * `marker`. The final string is guaranteed to be <= `max`.
294
+ */
295
+ export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT, marker = "… [truncated]"): string {
296
+ if (message.length <= max) return message;
297
+
298
+ // When `max` is too small to even hold the marker, drop it so the hard
299
+ // length guarantee (output.length <= max) still holds.
300
+ const effectiveMarker = marker.length <= max ? marker : "";
301
+
302
+ const tokens = tokenize(message);
303
+ const stack: string[] = [];
304
+ let out = "";
305
+
306
+ const closersFor = (s: string[]): string =>
307
+ s
308
+ .map(t => `</${t}>`)
309
+ .reverse()
310
+ .join("");
311
+
312
+ for (const token of tokens) {
313
+ // Simulate accepting this token, then ensure we can still close + mark.
314
+ const nextStack = [...stack];
315
+ if (token.open) nextStack.push(token.open);
316
+ else if (token.close) {
317
+ const idx = nextStack.lastIndexOf(token.close);
318
+ if (idx !== -1) nextStack.splice(idx, 1);
319
+ }
320
+ const projected = out.length + token.value.length + closersFor(nextStack).length + effectiveMarker.length;
321
+ if (projected > max) break;
322
+ out += token.value;
323
+ if (token.open) stack.push(token.open);
324
+ else if (token.close) {
325
+ const idx = stack.lastIndexOf(token.close);
326
+ if (idx !== -1) stack.splice(idx, 1);
327
+ }
328
+ }
329
+
330
+ return out + closersFor(stack) + effectiveMarker;
331
+ }
332
+
333
+ /** Finalize an optional message: undefined passthrough, else safe-truncate. */
334
+ export function finalizeTelegramHtml(message?: string): string | undefined {
335
+ if (message === undefined) return undefined;
336
+ return truncateTelegramHtml(message);
337
+ }
338
+
339
+ /**
340
+ * One-based, plain-text button label (Telegram does not parse HTML in labels).
341
+ *
342
+ * Strips any leading `N.`/`N)` index already embedded in the label (e.g.
343
+ * deep-interview options pre-numbered by the ask tool) and applies the canonical
344
+ * one-based button index instead. This avoids duplicated numbering like
345
+ * `1. 1. …` and keeps the displayed number aligned with the button's real index.
346
+ */
347
+ export function buttonLabel(label: string, index: number): string {
348
+ const stripped = label.replace(/^\s*\d+[.)]\s+/, "");
349
+ return `${index + 1}. ${stripped}`;
350
+ }
351
+
352
+ export interface InlineButton {
353
+ text: string;
354
+ callback_data: string;
355
+ }
356
+
357
+ /** A prefixed button label is "long" when it is wide or contains a newline. */
358
+ function isLongLabel(label: string): boolean {
359
+ return label.length > 18 || /[\r\n]/.test(label);
360
+ }
361
+
362
+ /**
363
+ * Lay out option labels as a numbered button grid. Long buttons take a
364
+ * full-width row; runs of short buttons are packed into rows of up to 3. The
365
+ * callback value comes from `callbackForIndex(i)` using the original zero-based
366
+ * option index — layout never changes callback semantics.
367
+ */
368
+ export function buildButtonGrid(labels: string[], callbackForIndex: (index: number) => string): InlineButton[][] {
369
+ const rows: InlineButton[][] = [];
370
+ let run: InlineButton[] = [];
371
+ const flush = () => {
372
+ if (run.length) {
373
+ rows.push(run);
374
+ run = [];
375
+ }
376
+ };
377
+ labels.forEach((label, i) => {
378
+ const button: InlineButton = { text: buttonLabel(label, i), callback_data: callbackForIndex(i) };
379
+ if (isLongLabel(button.text)) {
380
+ flush();
381
+ rows.push([button]);
382
+ return;
383
+ }
384
+ run.push(button);
385
+ if (run.length === 3) flush();
386
+ });
387
+ flush();
388
+ return rows;
389
+ }