@gajae-code/coding-agent 0.6.5 → 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 (127) hide show
  1. package/CHANGELOG.md +29 -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/notify-cli.d.ts +23 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +38 -0
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  18. package/dist/types/gjc-runtime/state-writer.d.ts +2 -0
  19. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -0
  21. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +1 -1
  24. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  25. package/dist/types/modes/types.d.ts +7 -1
  26. package/dist/types/notifications/config-commands.d.ts +26 -0
  27. package/dist/types/notifications/config.d.ts +61 -0
  28. package/dist/types/notifications/helpers.d.ts +55 -0
  29. package/dist/types/notifications/html-format.d.ts +62 -0
  30. package/dist/types/notifications/index.d.ts +28 -0
  31. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  32. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  33. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  34. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  35. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  36. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  37. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  38. package/dist/types/notifications/threaded-render.d.ts +66 -0
  39. package/dist/types/notifications/topic-registry.d.ts +67 -0
  40. package/dist/types/rlm/index.d.ts +12 -0
  41. package/dist/types/session/agent-session.d.ts +39 -2
  42. package/dist/types/session/auth-storage.d.ts +1 -1
  43. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  44. package/dist/types/setup/credential-import.d.ts +3 -0
  45. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  46. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  47. package/dist/types/tools/index.d.ts +18 -0
  48. package/dist/types/tools/subagent.d.ts +3 -0
  49. package/package.json +7 -7
  50. package/scripts/build-binary.ts +3 -0
  51. package/src/async/job-manager.ts +5 -1
  52. package/src/cli/daemon-cli.ts +122 -0
  53. package/src/cli/notify-cli.ts +274 -0
  54. package/src/cli/setup-cli.ts +173 -84
  55. package/src/cli.ts +2 -0
  56. package/src/commands/daemon.ts +47 -0
  57. package/src/commands/notify.ts +61 -0
  58. package/src/commands/setup.ts +11 -1
  59. package/src/config/model-profile-activation.ts +74 -5
  60. package/src/config/model-profiles.ts +7 -4
  61. package/src/config/model-registry.ts +6 -3
  62. package/src/config/models-config-schema.ts +1 -1
  63. package/src/config/settings-schema.ts +29 -0
  64. package/src/coordinator/contract.ts +3 -0
  65. package/src/coordinator-mcp/server.ts +270 -1
  66. package/src/daemon/builtin.ts +46 -0
  67. package/src/daemon/control-types.ts +65 -0
  68. package/src/daemon/runtime.ts +51 -0
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  70. package/src/extensibility/extensions/runner.ts +4 -0
  71. package/src/extensibility/extensions/types.ts +8 -0
  72. package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
  73. package/src/gjc-runtime/state-runtime.ts +18 -4
  74. package/src/gjc-runtime/state-writer.ts +8 -8
  75. package/src/gjc-runtime/ultragoal-guard.ts +57 -2
  76. package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
  77. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  78. package/src/gjc-runtime/workflow-manifest.ts +11 -1
  79. package/src/goals/tools/goal-tool.ts +11 -2
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/main.ts +30 -0
  82. package/src/modes/acp/acp-event-mapper.ts +1 -0
  83. package/src/modes/components/hook-editor.ts +7 -2
  84. package/src/modes/components/oauth-selector.ts +19 -0
  85. package/src/modes/controllers/event-controller.ts +20 -0
  86. package/src/modes/controllers/selector-controller.ts +80 -17
  87. package/src/modes/interactive-mode.ts +6 -2
  88. package/src/modes/runtime-init.ts +1 -0
  89. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  90. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  91. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  92. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  93. package/src/modes/types.ts +7 -1
  94. package/src/modes/utils/ui-helpers.ts +23 -0
  95. package/src/notifications/config-commands.ts +50 -0
  96. package/src/notifications/config.ts +107 -0
  97. package/src/notifications/helpers.ts +135 -0
  98. package/src/notifications/html-format.ts +389 -0
  99. package/src/notifications/index.ts +663 -0
  100. package/src/notifications/rate-limit-pool.ts +179 -0
  101. package/src/notifications/telegram-cli.ts +194 -0
  102. package/src/notifications/telegram-daemon-cli.ts +74 -0
  103. package/src/notifications/telegram-daemon-control.ts +370 -0
  104. package/src/notifications/telegram-daemon.ts +1370 -0
  105. package/src/notifications/telegram-reference.ts +335 -0
  106. package/src/notifications/threaded-inbound.ts +80 -0
  107. package/src/notifications/threaded-render.ts +155 -0
  108. package/src/notifications/topic-registry.ts +133 -0
  109. package/src/rlm/index.ts +19 -0
  110. package/src/sdk.ts +16 -0
  111. package/src/session/agent-session.ts +113 -3
  112. package/src/session/auth-storage.ts +3 -0
  113. package/src/session/session-dump-format.ts +43 -2
  114. package/src/session/session-manager.ts +39 -5
  115. package/src/setup/credential-auto-import.ts +258 -0
  116. package/src/setup/credential-import.ts +17 -0
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  118. package/src/setup/host-plugin-setup.ts +142 -0
  119. package/src/slash-commands/builtin-registry.ts +4 -1
  120. package/src/task/executor.ts +5 -1
  121. package/src/tools/ask-answer-registry.ts +25 -0
  122. package/src/tools/ask.ts +74 -4
  123. package/src/tools/image-gen.ts +5 -8
  124. package/src/tools/index.ts +19 -0
  125. package/src/tools/inspect-image.ts +16 -11
  126. package/src/tools/subagent-render.ts +7 -0
  127. package/src/tools/subagent.ts +38 -7
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure helpers for the notifications extension.
3
+ *
4
+ * Kept side-effect-free so the mapping logic (ask extraction, idle summary,
5
+ * dedupe keys) is unit-testable without a live session or the native server.
6
+ */
7
+
8
+ import { buildRedactedAction, type RedactableAction } from "./config";
9
+
10
+ /** A pending ask derived from an `ask` tool call. */
11
+ export interface PendingAsk {
12
+ /** Action id: `${toolCallId}:${questionId}`. */
13
+ id: string;
14
+ /** Question text. */
15
+ question: string;
16
+ /** Option labels (may be empty for free-text questions). */
17
+ options: string[];
18
+ }
19
+
20
+ /** Truncate text to `max` chars, appending an ellipsis when cut. */
21
+ export function truncate(text: string, max = 280): string {
22
+ if (max <= 0) return "";
23
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
24
+ }
25
+
26
+ /** Stable per-turn idle dedupe key so exactly one idle action fires per turn. */
27
+ export function idleDedupeKey(sessionId: string, turnIndex: number): string {
28
+ return `${sessionId}#${turnIndex}`;
29
+ }
30
+
31
+ /**
32
+ * Extract pending asks from an `ask` tool call input.
33
+ *
34
+ * Defensive: tolerates partial/unknown shapes and always returns an array.
35
+ */
36
+ export function asksFromAskInput(toolCallId: string, input: unknown): PendingAsk[] {
37
+ const questions = (input as { questions?: unknown } | null | undefined)?.questions;
38
+ if (!Array.isArray(questions)) return [];
39
+ const asks: PendingAsk[] = [];
40
+ for (const raw of questions) {
41
+ if (!raw || typeof raw !== "object") continue;
42
+ const q = raw as { id?: unknown; question?: unknown; options?: unknown };
43
+ const qid = typeof q.id === "string" && q.id.length > 0 ? q.id : String(asks.length);
44
+ const question = typeof q.question === "string" ? q.question : "";
45
+ const options = Array.isArray(q.options)
46
+ ? q.options.map(opt => {
47
+ if (opt && typeof opt === "object" && typeof (opt as { label?: unknown }).label === "string") {
48
+ return (opt as { label: string }).label;
49
+ }
50
+ return String(opt);
51
+ })
52
+ : [];
53
+ asks.push({ id: `${toolCallId}:${qid}`, question, options });
54
+ }
55
+ return asks;
56
+ }
57
+
58
+ /** Prepare an action JSON payload for remote notification delivery. */
59
+ export function notificationActionPayload<T extends RedactableAction>(
60
+ action: T,
61
+ opts: { redact: boolean; sessionTag: string },
62
+ ): RedactableAction {
63
+ return buildRedactedAction(action, opts);
64
+ }
65
+
66
+ /** Extract a plain-text summary from an agent message's content, if any. */
67
+ export function summaryFromMessage(message: unknown, max = 280): string | undefined {
68
+ const content = (message as { content?: unknown } | null | undefined)?.content;
69
+ if (typeof content === "string") {
70
+ const trimmed = content.trim();
71
+ return trimmed ? truncate(trimmed, max) : undefined;
72
+ }
73
+ if (!Array.isArray(content)) return undefined;
74
+ const parts: string[] = [];
75
+ for (const block of content) {
76
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
77
+ const text = (block as { text?: unknown }).text;
78
+ if (typeof text === "string") parts.push(text);
79
+ }
80
+ }
81
+ const joined = parts.join("").trim();
82
+ return joined ? truncate(joined, max) : undefined;
83
+ }
84
+
85
+ /**
86
+ * Extract an idle summary from an `agent_end` event's settled message list: the
87
+ * last message that yields text (i.e. the final assistant message; tool-result
88
+ * messages have no text and are skipped).
89
+ *
90
+ * `agent_end` fires exactly once when the agent loop settles to await the user,
91
+ * so emitting idle from this — instead of per-`turn_end` — produces exactly one
92
+ * idle notification per genuine idle, eliminating the multi-turn flood.
93
+ */
94
+ export function summaryFromMessages(messages: unknown, max = 280): string | undefined {
95
+ if (!Array.isArray(messages)) return undefined;
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const summary = summaryFromMessage(messages[i], max);
98
+ if (summary) return summary;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /** An agent-produced image extracted from a message's content. */
104
+ export interface ExtractedImage {
105
+ source: string;
106
+ mime: string;
107
+ data: string;
108
+ }
109
+
110
+ /**
111
+ * Extract agent-produced images (`{ type: "image", data, mimeType }` blocks)
112
+ * from a message's content — e.g. computer-use/browser screenshots or tool
113
+ * image outputs — for `image_attachment` delivery.
114
+ */
115
+ export function imageAttachmentsFromMessage(message: unknown, source = "agent"): ExtractedImage[] {
116
+ const content = (message as { content?: unknown } | null | undefined)?.content;
117
+ if (!Array.isArray(content)) return [];
118
+ const out: ExtractedImage[] = [];
119
+ for (const block of content) {
120
+ if (
121
+ block &&
122
+ typeof block === "object" &&
123
+ (block as { type?: unknown }).type === "image" &&
124
+ typeof (block as { data?: unknown }).data === "string" &&
125
+ typeof (block as { mimeType?: unknown }).mimeType === "string"
126
+ ) {
127
+ out.push({
128
+ source,
129
+ mime: (block as { mimeType: string }).mimeType,
130
+ data: (block as { data: string }).data,
131
+ });
132
+ }
133
+ }
134
+ return out;
135
+ }
@@ -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
+ }