talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Prompt enrichment — adds context to raw user messages before sending to the AI.
3
+ * Platform-agnostic; works with any messaging frontend.
4
+ */
5
+
6
+ import { getRecentBySenderId } from "../storage/history.js";
7
+ import { formatSmartTimestamp } from "../util/time.js";
8
+
9
+ /**
10
+ * Enrich a DM prompt with sender metadata.
11
+ */
12
+ export function enrichDMPrompt(
13
+ prompt: string,
14
+ senderName: string,
15
+ senderUsername?: string,
16
+ ): string {
17
+ const userTag = senderUsername ? ` (@${senderUsername})` : "";
18
+ return `[DM from ${senderName}${userTag}]\n${prompt}`;
19
+ }
20
+
21
+ /**
22
+ * Enrich a group prompt with the sender's recent messages for threading context.
23
+ */
24
+ export function enrichGroupPrompt(
25
+ prompt: string,
26
+ chatId: string,
27
+ senderId: number,
28
+ ): string {
29
+ const recentMsgs = getRecentBySenderId(chatId, senderId, 5);
30
+ if (recentMsgs.length <= 1) return prompt;
31
+
32
+ const priorMsgs = recentMsgs.slice(0, -1);
33
+ if (priorMsgs.length === 0) return prompt;
34
+
35
+ const senderName = priorMsgs[0].senderName;
36
+ const contextLines = priorMsgs
37
+ .map(
38
+ (m) =>
39
+ ` [${formatSmartTimestamp(m.timestamp)}] ${m.text.slice(0, 200)}`,
40
+ )
41
+ .join("\n");
42
+ return `[${senderName}'s recent messages in this group:\n${contextLines}]\n\n${prompt}`;
43
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Pulse — conversation-aware engagement.
3
+ *
4
+ * Every N minutes, checks for new messages in registered chats.
5
+ * If there are new messages, feeds them to the dispatcher so Claude
6
+ * can decide whether to respond. Same session, same cache.
7
+ *
8
+ * Knows nothing about the backend or frontend — uses the dispatcher.
9
+ */
10
+
11
+ import { execute, getActiveCount } from "./dispatcher.js";
12
+ import {
13
+ setChatPulse,
14
+ getRegisteredPulseChats,
15
+ getChatSettings,
16
+ setPulseLastCheckMsgId,
17
+ } from "../storage/chat-settings.js";
18
+ import { getRecentHistory, getLatestMessageId } from "../storage/history.js";
19
+ import { log, logError } from "../util/log.js";
20
+ import { formatSmartTimestamp } from "../util/time.js";
21
+
22
+ // ── State ────────────────────────────────────────────────────────────────────
23
+
24
+ let timer: ReturnType<typeof setInterval> | null = null;
25
+ const registeredChats = new Set<string>();
26
+ const lastCheckMessageId = new Map<string, number>();
27
+
28
+ const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
29
+ let activeIntervalMs = DEFAULT_INTERVAL_MS;
30
+
31
+ // ── Public API ───────────────────────────────────────────────────────────────
32
+
33
+ export function initPulse(): void {
34
+ for (const chatId of getRegisteredPulseChats()) {
35
+ registeredChats.add(chatId);
36
+ // Restore persisted last-check message ID to avoid reprocessing on restart
37
+ const settings = getChatSettings(chatId);
38
+ if (settings.pulseLastCheckMsgId !== undefined) {
39
+ lastCheckMessageId.set(chatId, settings.pulseLastCheckMsgId);
40
+ }
41
+ }
42
+ if (registeredChats.size > 0) {
43
+ log("pulse", `Loaded ${registeredChats.size} registered chat(s)`);
44
+ }
45
+ }
46
+
47
+ export function registerChat(chatId: string): void {
48
+ registeredChats.add(chatId);
49
+ }
50
+
51
+ export function enablePulse(chatId: string): void {
52
+ setChatPulse(chatId, true);
53
+ registeredChats.add(chatId);
54
+ }
55
+
56
+ export function disablePulse(chatId: string): void {
57
+ setChatPulse(chatId, false);
58
+ }
59
+
60
+ /** Clear pulse checkpoint for a chat (call on /reset to avoid stale state). */
61
+ export function resetPulseCheckpoint(chatId: string): void {
62
+ lastCheckMessageId.delete(chatId);
63
+ setPulseLastCheckMsgId(chatId, undefined);
64
+ }
65
+
66
+ export function isPulseEnabled(chatId: string): boolean {
67
+ return getChatSettings(chatId).pulse === true;
68
+ }
69
+
70
+ export function startPulseTimer(intervalMs?: number): void {
71
+ if (timer) return;
72
+ const envMs = parseInt(process.env.TALON_PULSE_INTERVAL_MS ?? "", 10);
73
+ const ms = intervalMs ?? (envMs > 0 ? envMs : DEFAULT_INTERVAL_MS);
74
+ if (ms <= 0) return;
75
+
76
+ activeIntervalMs = ms;
77
+ log("pulse", `Started: checking every ${Math.round(ms / 60000)}m`);
78
+ timer = setInterval(() => {
79
+ runPulse().catch((err) => logError("pulse", "Check failed", err));
80
+ }, ms);
81
+ }
82
+
83
+ /** Reset the pulse timer — call when bot sends a message to avoid
84
+ * redundant check-ins during active conversation. */
85
+ export function resetPulseTimer(): void {
86
+ if (!timer) return;
87
+ clearInterval(timer);
88
+ timer = setInterval(() => {
89
+ runPulse().catch((err) => logError("pulse", "Check failed", err));
90
+ }, activeIntervalMs);
91
+ }
92
+
93
+ export function stopPulseTimer(): void {
94
+ if (timer) {
95
+ clearInterval(timer);
96
+ timer = null;
97
+ }
98
+ }
99
+
100
+ /** Get pulse status for admin visibility. */
101
+ export function getPulseStatus(): Array<{
102
+ chatId: string;
103
+ enabled: boolean;
104
+ lastChecked: number | undefined;
105
+ }> {
106
+ return [...registeredChats].map((chatId) => ({
107
+ chatId,
108
+ enabled: isPulseEnabled(chatId),
109
+ lastChecked: lastCheckMessageId.get(chatId),
110
+ }));
111
+ }
112
+
113
+ // ── Core ─────────────────────────────────────────────────────────────────────
114
+
115
+ async function runPulse(): Promise<void> {
116
+ if (getActiveCount() > 10) return; // safety valve — don't pile on if heavily loaded
117
+
118
+ for (const chatId of registeredChats) {
119
+ if (!isPulseEnabled(chatId)) continue;
120
+ await pulseChat(chatId);
121
+ }
122
+ }
123
+
124
+ async function pulseChat(chatId: string): Promise<void> {
125
+ const numericChatId = parseInt(chatId, 10);
126
+ if (isNaN(numericChatId)) {
127
+ logError("pulse", `Invalid chatId: ${chatId}`);
128
+ return;
129
+ }
130
+
131
+ // Any new messages since last check?
132
+ const latestMsgId = getLatestMessageId(chatId);
133
+ const lastChecked = lastCheckMessageId.get(chatId);
134
+ if (latestMsgId === undefined) return;
135
+ if (lastChecked !== undefined && latestMsgId <= lastChecked) return;
136
+
137
+ // Get unread messages
138
+ const recent = getRecentHistory(chatId, 15);
139
+ const unread = lastChecked !== undefined
140
+ ? recent.filter((m) => m.msgId > lastChecked)
141
+ : recent;
142
+ if (unread.length === 0) return;
143
+
144
+ const summary = unread
145
+ .map((m) => {
146
+ const time = formatSmartTimestamp(m.timestamp);
147
+ const media = m.mediaType ? ` [${m.mediaType}]` : "";
148
+ return `[msg:${m.msgId} ${time}] ${m.senderName}${media}: ${m.text.slice(0, 200)}`;
149
+ })
150
+ .join("\n");
151
+
152
+ try {
153
+ const prompt =
154
+ `[System: Pulse check — ${unread.length} new message(s) since last check. ` +
155
+ `Read them and decide: respond, react with an emoji, or stay silent. ` +
156
+ `Don't announce yourself. Reactions are great for simple acknowledgements.]\n\n${summary}`;
157
+
158
+ await execute({
159
+ chatId,
160
+ numericChatId,
161
+ prompt,
162
+ senderName: "System",
163
+ isGroup: true,
164
+ source: "pulse",
165
+ });
166
+
167
+ // Mark as checked AFTER successful execution — if it failed, retry next tick
168
+ lastCheckMessageId.set(chatId, latestMsgId);
169
+ setPulseLastCheckMsgId(chatId, latestMsgId);
170
+ log("pulse", `Checked ${chatId} (${unread.length} new msgs)`);
171
+ } catch (err) {
172
+ logError("pulse", `Chat ${chatId} failed`, err);
173
+ // Don't update lastCheckMessageId — messages will be retried next pulse
174
+ }
175
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Core interfaces — the contract between modules.
3
+ * Every module depends on these abstractions, never on concrete implementations.
4
+ *
5
+ * Dependency rule: core/ imports nothing from frontend/ or backend/.
6
+ * frontend/ and backend/ depend on core/types, never on each other.
7
+ */
8
+
9
+ // ── Query lifecycle ─────────────────────────────────────────────────────────
10
+
11
+ /** Parameters for a backend AI query. */
12
+ export type QueryParams = {
13
+ chatId: string;
14
+ text: string;
15
+ senderName: string;
16
+ isGroup?: boolean;
17
+ messageId?: number;
18
+ onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
19
+ onTextBlock?: (text: string) => Promise<void>;
20
+ onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
21
+ };
22
+
23
+ /** Result of a backend AI query. */
24
+ export type QueryResult = {
25
+ text: string;
26
+ durationMs: number;
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ cacheRead: number;
30
+ cacheWrite: number;
31
+ };
32
+
33
+ /** Backend interface — any AI provider implements this. */
34
+ export interface QueryBackend {
35
+ query(params: QueryParams): Promise<QueryResult>;
36
+ }
37
+
38
+ // ── Execution context ───────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Manages the tool-execution context for the active chat.
42
+ * The frontend provides an implementation so the AI's tool calls
43
+ * can reach the messaging platform.
44
+ */
45
+ export interface ContextManager {
46
+ acquire(chatId: number, stringId?: string): void;
47
+ release(chatId: number): void;
48
+ getMessageCount(chatId: number): number;
49
+ }
50
+
51
+ /** Parameters for the dispatcher. */
52
+ export type ExecuteParams = {
53
+ chatId: string;
54
+ numericChatId: number;
55
+ prompt: string;
56
+ senderName: string;
57
+ isGroup: boolean;
58
+ messageId?: number;
59
+ source: "message" | "pulse" | "cron";
60
+ onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
61
+ onTextBlock?: (text: string) => Promise<void>;
62
+ onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
63
+ };
64
+
65
+ /** What the dispatcher returns after execution. */
66
+ export type ExecuteResult = QueryResult & {
67
+ bridgeMessageCount: number;
68
+ };
69
+
70
+ // ── Gateway types ───────────────────────────────────────────────────────────
71
+
72
+ /** Result from an action handler. */
73
+ export type ActionResult = {
74
+ ok: boolean;
75
+ text?: string;
76
+ error?: string;
77
+ message_id?: number;
78
+ [key: string]: unknown;
79
+ };
80
+
81
+ /** Frontend-specific action handler. Return null if action not recognized. */
82
+ export type FrontendActionHandler = (
83
+ body: Record<string, unknown>,
84
+ chatId: number,
85
+ ) => Promise<ActionResult | null>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Teams action handler — routes MCP tool calls to Teams via Power Automate webhook.
3
+ */
4
+
5
+ import type { ActionResult, FrontendActionHandler } from "../../core/types.js";
6
+ import type { Gateway } from "../../core/gateway.js";
7
+ import { buildAdaptiveCard, splitTeamsMessage } from "./formatting.js";
8
+ import { log, logError } from "../../util/log.js";
9
+ import { proxyFetch } from "./proxy-fetch.js";
10
+
11
+ /**
12
+ * POST an Adaptive Card to the Power Automate workflow webhook URL.
13
+ */
14
+ async function postToTeams(webhookUrl: string, text: string): Promise<void> {
15
+ const chunks = splitTeamsMessage(text);
16
+ for (const chunk of chunks) {
17
+ const card = buildAdaptiveCard(chunk);
18
+ const resp = await proxyFetch(webhookUrl, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify(card),
22
+ signal: AbortSignal.timeout(15_000),
23
+ });
24
+ if (!resp.ok) {
25
+ const body = await resp.text().catch(() => "");
26
+ throw new Error(`Teams webhook POST failed: ${resp.status} ${body}`);
27
+ }
28
+ }
29
+ }
30
+
31
+ export function createTeamsActionHandler(
32
+ webhookUrl: string,
33
+ gateway: Gateway,
34
+ ): FrontendActionHandler {
35
+ return async (body, chatId): Promise<ActionResult | null> => {
36
+ const action = body.action as string;
37
+
38
+ switch (action) {
39
+ case "send_message": {
40
+ const text = String(body.text ?? "");
41
+ if (!text) return { ok: true, message_id: Date.now() };
42
+ try {
43
+ await postToTeams(webhookUrl, text);
44
+ gateway.incrementMessages(chatId);
45
+ log("teams", `Sent message to chat ${chatId} (${text.length} chars)`);
46
+ return { ok: true, message_id: Date.now() };
47
+ } catch (err) {
48
+ const msg = err instanceof Error ? err.message : String(err);
49
+ logError("teams", `send_message failed: ${msg}`);
50
+ return { ok: false, error: msg };
51
+ }
52
+ }
53
+
54
+ case "send_message_with_buttons": {
55
+ const text = String(body.text ?? "");
56
+ const rows = body.rows as Array<Array<{ text: string; url?: string }>> | undefined;
57
+ const buttons = rows?.flat().map((b) => ({ text: b.text, url: b.url }));
58
+ try {
59
+ const card = buildAdaptiveCard(text, buttons);
60
+ const resp = await proxyFetch(webhookUrl, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(card),
64
+ signal: AbortSignal.timeout(15_000),
65
+ });
66
+ if (!resp.ok) throw new Error(`${resp.status}`);
67
+ gateway.incrementMessages(chatId);
68
+ return { ok: true, message_id: Date.now() };
69
+ } catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ logError("teams", `send_message_with_buttons failed: ${msg}`);
72
+ return { ok: false, error: msg };
73
+ }
74
+ }
75
+
76
+ // Graceful no-ops for unsupported actions
77
+ case "react":
78
+ case "edit_message":
79
+ case "delete_message":
80
+ case "pin_message":
81
+ case "unpin_message":
82
+ case "forward_message":
83
+ case "copy_message":
84
+ case "send_chat_action":
85
+ return { ok: true };
86
+
87
+ case "get_chat_info":
88
+ return {
89
+ ok: true,
90
+ id: chatId,
91
+ type: "channel",
92
+ title: "Teams Channel",
93
+ };
94
+
95
+ default:
96
+ return null;
97
+ }
98
+ };
99
+ }
100
+
101
+ export { postToTeams };
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Teams message formatting — markdown to Adaptive Cards + HTML stripping.
3
+ *
4
+ * Uses `marked` lexer to parse markdown into tokens, then converts each
5
+ * token to the appropriate Adaptive Card element:
6
+ * - Paragraphs/text → TextBlock (with bold/italic markdown, no backticks)
7
+ * - Fenced code blocks → monospace TextBlock in emphasis Container
8
+ * - Tables → native Table element (Adaptive Cards v1.5)
9
+ * - Lists → TextBlock with bullet/number prefixes
10
+ * - Headings → bold TextBlock
11
+ */
12
+
13
+ import * as cheerio from "cheerio";
14
+ import { marked } from "marked";
15
+
16
+ /** Max safe message length for a single Adaptive Card. */
17
+ const MAX_CHUNK = 10_000;
18
+
19
+ // ── Markdown → Adaptive Card ──────────────────────────────────────────────
20
+
21
+ type CardElement = Record<string, unknown>;
22
+
23
+ /**
24
+ * Convert markdown text to Adaptive Card body elements.
25
+ */
26
+ function markdownToCardBody(text: string): CardElement[] {
27
+ const body: CardElement[] = [];
28
+ const tokens = marked.lexer(text);
29
+
30
+ for (const token of tokens) {
31
+ switch (token.type) {
32
+ case "heading":
33
+ body.push({ type: "TextBlock", text: `**${cleanInline(token.text)}**`, wrap: true, size: "Medium", weight: "Bolder" });
34
+ break;
35
+
36
+ case "paragraph":
37
+ body.push({ type: "TextBlock", text: cleanInline(token.text), wrap: true });
38
+ break;
39
+
40
+ case "code": {
41
+ // Each line as a separate TextBlock to preserve newlines.
42
+ // Replace spaces with non-breaking spaces (\u00a0) to preserve alignment —
43
+ // Teams TextBlock collapses consecutive regular spaces.
44
+ const lines = (token.text as string).split("\n");
45
+ body.push({
46
+ type: "Container",
47
+ style: "emphasis",
48
+ items: lines.map((line) => ({
49
+ type: "TextBlock",
50
+ text: (line || " ").replace(/ /g, "\u00a0"),
51
+ wrap: false,
52
+ fontType: "Monospace",
53
+ size: "Small",
54
+ spacing: "None",
55
+ })),
56
+ });
57
+ break;
58
+ }
59
+
60
+ case "list": {
61
+ const listToken = token as Record<string, unknown>;
62
+ const items = listToken.items as Array<{ text: string }>;
63
+ const ordered = listToken.ordered as boolean;
64
+ const lines = items.map((item, i) => {
65
+ const prefix = ordered ? `${i + 1}. ` : "- ";
66
+ return prefix + cleanInline(item.text);
67
+ });
68
+ body.push({ type: "TextBlock", text: lines.join("\n"), wrap: true });
69
+ break;
70
+ }
71
+
72
+ case "table": {
73
+ const tableToken = token as Record<string, unknown>;
74
+ const header = tableToken.header as Array<{ text: string }>;
75
+ const rows = tableToken.rows as Array<Array<{ text: string }>>;
76
+
77
+ const headerRow = {
78
+ type: "TableRow",
79
+ style: "accent",
80
+ cells: header.map((cell) => ({
81
+ type: "TableCell",
82
+ items: [{ type: "TextBlock", text: cleanInline(cell.text), weight: "Bolder", wrap: true }],
83
+ })),
84
+ };
85
+
86
+ const dataRows = rows.map((row) => ({
87
+ type: "TableRow",
88
+ cells: row.map((cell) => ({
89
+ type: "TableCell",
90
+ items: [{ type: "TextBlock", text: cleanInline(cell.text), wrap: true }],
91
+ })),
92
+ }));
93
+
94
+ body.push({
95
+ type: "Table",
96
+ gridStyle: "accent",
97
+ firstRowAsHeader: true,
98
+ columns: header.map(() => ({ width: 1 })),
99
+ rows: [headerRow, ...dataRows],
100
+ });
101
+ break;
102
+ }
103
+
104
+ case "blockquote": {
105
+ const bqToken = token as Record<string, unknown>;
106
+ body.push({
107
+ type: "Container",
108
+ style: "emphasis",
109
+ items: [{ type: "TextBlock", text: cleanInline(String(bqToken.text ?? "")), wrap: true, isSubtle: true }],
110
+ });
111
+ break;
112
+ }
113
+
114
+ case "hr":
115
+ body.push({ type: "TextBlock", text: "───────────────────────────────", wrap: false, isSubtle: true });
116
+ break;
117
+
118
+ case "space":
119
+ break; // skip whitespace tokens
120
+
121
+ default:
122
+ // Fallback: render as plain text
123
+ if ("text" in token && typeof token.text === "string" && token.text.trim()) {
124
+ body.push({ type: "TextBlock", text: cleanInline(token.text), wrap: true });
125
+ }
126
+ break;
127
+ }
128
+ }
129
+
130
+ if (body.length === 0) {
131
+ body.push({ type: "TextBlock", text: text || " ", wrap: true });
132
+ }
133
+
134
+ return body;
135
+ }
136
+
137
+ /**
138
+ * Clean inline markdown for Teams TextBlock compatibility.
139
+ * Teams supports **bold** and _italic_ but NOT `inline code`.
140
+ */
141
+ function cleanInline(text: string): string {
142
+ // Remove inline backticks — Teams doesn't render them
143
+ return text.replace(/`([^`]+)`/g, "$1");
144
+ }
145
+
146
+ // ── Public API ────────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Build an Adaptive Card payload for the Power Automate webhook.
150
+ */
151
+ export function buildAdaptiveCard(
152
+ text: string,
153
+ buttons?: Array<{ text: string; url?: string }>,
154
+ ): Record<string, unknown> {
155
+ const body = markdownToCardBody(text);
156
+
157
+ const card: Record<string, unknown> = {
158
+ type: "AdaptiveCard",
159
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
160
+ version: "1.5",
161
+ body,
162
+ };
163
+
164
+ if (buttons && buttons.length > 0) {
165
+ card.actions = buttons.map((b) =>
166
+ b.url
167
+ ? { type: "Action.OpenUrl", title: b.text, url: b.url }
168
+ : { type: "Action.Submit", title: b.text, data: { choice: b.text } },
169
+ );
170
+ }
171
+
172
+ return {
173
+ type: "message",
174
+ attachments: [
175
+ {
176
+ contentType: "application/vnd.microsoft.card.adaptive",
177
+ contentUrl: null,
178
+ content: card,
179
+ },
180
+ ],
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Split a long message into chunks that each fit within an Adaptive Card.
186
+ * Splits on paragraph boundaries when possible.
187
+ */
188
+ export function splitTeamsMessage(text: string, maxLen = MAX_CHUNK): string[] {
189
+ if (text.length <= maxLen) return [text];
190
+
191
+ const chunks: string[] = [];
192
+ let remaining = text;
193
+
194
+ while (remaining.length > maxLen) {
195
+ let splitIdx = remaining.lastIndexOf("\n\n", maxLen);
196
+ if (splitIdx < maxLen * 0.3) {
197
+ splitIdx = remaining.lastIndexOf("\n", maxLen);
198
+ }
199
+ if (splitIdx < maxLen * 0.3) {
200
+ splitIdx = maxLen;
201
+ }
202
+ chunks.push(remaining.slice(0, splitIdx).trimEnd());
203
+ remaining = remaining.slice(splitIdx).trimStart();
204
+ }
205
+ if (remaining) chunks.push(remaining);
206
+ return chunks;
207
+ }
208
+
209
+ /**
210
+ * Strip HTML tags and decode entities from inbound Teams message HTML.
211
+ */
212
+ export function stripHtml(html: string): string {
213
+ if (!html || !html.includes("<")) return html;
214
+ try {
215
+ const $ = cheerio.load(html, { xml: false });
216
+ return $.text().trim();
217
+ } catch {
218
+ return html.replace(/<[^>]*>/g, "").trim();
219
+ }
220
+ }