talon-agent 4.3.1 → 4.3.2

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 (78) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +1 -1
  3. package/src/bootstrap.ts +2 -2
  4. package/src/cli/skill.ts +2 -2
  5. package/src/core/background/cron-spec.ts +1 -1
  6. package/src/core/background/cron.ts +1 -1
  7. package/src/core/background/heartbeat/agent.ts +1 -1
  8. package/src/core/background/triggers/command.ts +1 -1
  9. package/src/core/background/triggers/exit.ts +1 -1
  10. package/src/core/background/triggers/output.ts +1 -1
  11. package/src/core/background/triggers/resume.ts +1 -1
  12. package/src/core/background/triggers/spawn.ts +1 -1
  13. package/src/core/engine/gateway-actions/cron.ts +2 -2
  14. package/src/core/engine/gateway-actions/goals.ts +2 -2
  15. package/src/core/engine/gateway-actions/scripts.ts +1 -1
  16. package/src/core/engine/gateway-actions/skills.ts +1 -1
  17. package/src/core/engine/gateway-actions/triggers.ts +2 -2
  18. package/src/core/engine/gateway-actions/{shared.ts → validation.ts} +2 -2
  19. package/src/core/prompt/assemble.ts +2 -2
  20. package/src/core/scripts/runner.ts +1 -1
  21. package/src/core/soul/forgetting.ts +2 -2
  22. package/src/core/soul/salience.ts +8 -4
  23. package/src/frontend/discord/actions/{shared.ts → buttons.ts} +3 -56
  24. package/src/frontend/discord/actions/channels.ts +34 -0
  25. package/src/frontend/discord/actions/chat-info.ts +1 -1
  26. package/src/frontend/discord/actions/index.ts +1 -1
  27. package/src/frontend/discord/actions/media.ts +1 -1
  28. package/src/frontend/discord/actions/messaging.ts +4 -2
  29. package/src/frontend/discord/actions/try-action.ts +24 -0
  30. package/src/frontend/discord/admin.ts +2 -2
  31. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  32. package/src/frontend/discord/callbacks/modals.ts +1 -1
  33. package/src/frontend/discord/commands/admin.ts +2 -2
  34. package/src/frontend/discord/commands/info.ts +2 -2
  35. package/src/frontend/discord/commands/{shared.ts → interaction.ts} +2 -1
  36. package/src/frontend/discord/commands/router.ts +1 -1
  37. package/src/frontend/discord/commands/session.ts +2 -2
  38. package/src/frontend/discord/commands/settings.ts +2 -2
  39. package/src/frontend/discord/{helpers.ts → render.ts} +2 -1
  40. package/src/frontend/native/extensions.ts +1 -1
  41. package/src/frontend/teams/chat-discovery.ts +69 -0
  42. package/src/frontend/teams/commands.ts +141 -0
  43. package/src/frontend/teams/index.ts +24 -350
  44. package/src/frontend/teams/outbound.ts +44 -0
  45. package/src/frontend/teams/poll.ts +101 -0
  46. package/src/frontend/teams/runtime.ts +51 -0
  47. package/src/frontend/teams/turn.ts +97 -0
  48. package/src/frontend/telegram/actions/messaging.ts +1 -1
  49. package/src/frontend/telegram/actions/shared.ts +4 -4
  50. package/src/frontend/telegram/admin.ts +1 -1
  51. package/src/frontend/telegram/sticker-library.ts +2 -2
  52. package/src/frontend/whatsapp/actions/account.ts +3 -6
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -1
  54. package/src/frontend/whatsapp/actions/coerce.ts +30 -0
  55. package/src/frontend/whatsapp/actions/history.ts +1 -1
  56. package/src/frontend/whatsapp/actions/media-upload.ts +82 -0
  57. package/src/frontend/whatsapp/actions/media.ts +3 -8
  58. package/src/frontend/whatsapp/actions/messaging.ts +2 -7
  59. package/src/frontend/whatsapp/actions/moderation.ts +2 -1
  60. package/src/frontend/whatsapp/actions/{shared.ts → send.ts} +4 -124
  61. package/src/frontend/whatsapp/actions/try-action.ts +24 -0
  62. package/src/frontend/whatsapp/commands.ts +1 -1
  63. package/src/frontend/whatsapp/inbound.ts +1 -1
  64. package/src/frontend/whatsapp/index.ts +1 -1
  65. package/src/storage/repositories/cron-repo.ts +1 -1
  66. package/src/storage/repositories/goals-repo.ts +1 -1
  67. package/src/storage/repositories/scripts-repo.ts +1 -1
  68. package/src/storage/repositories/triggers-repo.ts +1 -1
  69. package/src/storage/sql/schema.sql +1 -1
  70. package/src/storage/sql/statements.generated.ts +2 -2
  71. package/src/storage/sql/triggers.sql +1 -1
  72. /package/src/storage/{cron-store.ts → cron.ts} +0 -0
  73. /package/src/storage/{goal-store.ts → goals.ts} +0 -0
  74. /package/src/storage/{scheduled-store.ts → scheduled.ts} +0 -0
  75. /package/src/storage/{script-store.ts → scripts.ts} +0 -0
  76. /package/src/storage/{skill-store.ts → skills.ts} +0 -0
  77. /package/src/storage/{sticker-store.ts → stickers.ts} +0 -0
  78. /package/src/storage/{trigger-store.ts → triggers.ts} +0 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Teams chat discovery — which group chat Talon listens to, and where in
3
+ * its history polling starts.
4
+ */
5
+
6
+ import { log, logError } from "../../util/log.js";
7
+ import type { GraphClient } from "./graph.js";
8
+ import type { TeamsRuntime } from "./runtime.js";
9
+
10
+ /**
11
+ * Load the stored chat, or pick one — by topic when configured, else the
12
+ * most recent — and persist the choice for next boot.
13
+ */
14
+ export async function resolveChatId(
15
+ runtime: TeamsRuntime,
16
+ graphClient: GraphClient,
17
+ userId: string,
18
+ ): Promise<string> {
19
+ const stored = graphClient.getStoredChatId();
20
+ if (stored) {
21
+ log("teams", `Using chat: ${graphClient.getStoredChatTopic() || stored}`);
22
+ return stored;
23
+ }
24
+
25
+ log("teams", "No chat configured, discovering...");
26
+ const chats = await graphClient.listChats();
27
+
28
+ if (chats.length === 0) throw new Error("No chats found");
29
+
30
+ // Try to match by topic name if configured
31
+ let selectedChat = chats[0];
32
+ const { configChatTopic } = runtime;
33
+ if (configChatTopic) {
34
+ const match = chats.find((c) =>
35
+ c.topic?.toLowerCase().includes(configChatTopic.toLowerCase()),
36
+ );
37
+ if (match) selectedChat = match;
38
+ else
39
+ log(
40
+ "teams",
41
+ `No chat matching topic "${configChatTopic}", using most recent`,
42
+ );
43
+ }
44
+
45
+ const topic = selectedChat.topic || "(unnamed chat)";
46
+ graphClient.saveChatConfig(selectedChat.id, topic, userId);
47
+ log("teams", `Configured chat: ${topic} [${selectedChat.chatType}]`);
48
+ return selectedChat.id;
49
+ }
50
+
51
+ /** Start after the newest existing message so old history is never replayed. */
52
+ export async function seedLastSeen(
53
+ runtime: TeamsRuntime,
54
+ graphClient: GraphClient,
55
+ chatId: string,
56
+ ): Promise<void> {
57
+ try {
58
+ const existing = await graphClient.getChatMessages(chatId, 5);
59
+ if (existing.length > 0) {
60
+ runtime.lastSeenMessageId = existing[0].id;
61
+ log("teams", `Seeded last message ID: ${runtime.lastSeenMessageId}`);
62
+ }
63
+ } catch (err) {
64
+ logError(
65
+ "teams",
66
+ `Failed to seed messages: ${err instanceof Error ? err.message : err}`,
67
+ );
68
+ }
69
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Teams slash commands — /reset, /status, /help. Each answers with a card
3
+ * through the webhook and ignores delivery failures; the poll loop moves on
4
+ * either way.
5
+ */
6
+
7
+ import { log } from "../../util/log.js";
8
+ import { resolveModel } from "../../core/models/catalog.js";
9
+ import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
10
+ import { getSessionInfo } from "../../storage/sessions.js";
11
+ import { getChatSettings } from "../../storage/chat-settings.js";
12
+ import { performSessionReset } from "../shared/session-status.js";
13
+ import { buildCacheDisplay } from "../shared/status-context.js";
14
+ import { buildAdaptiveCard } from "./formatting.js";
15
+ import type { ChatMessage } from "./graph.js";
16
+ import { postCard } from "./outbound.js";
17
+ import type { TeamsRuntime } from "./runtime.js";
18
+
19
+ const HELP_TEXT =
20
+ "**Commands:**\n- `/reset` — clear session & history\n- `/status` — session stats\n- `/help` — this message";
21
+
22
+ type Fact = { title: string; value: string };
23
+
24
+ function reply(
25
+ runtime: TeamsRuntime,
26
+ card: Record<string, unknown>,
27
+ ): Promise<unknown> {
28
+ return postCard(runtime.webhookUrl, card).catch(() => {});
29
+ }
30
+
31
+ async function handleReset(
32
+ runtime: TeamsRuntime,
33
+ msg: ChatMessage,
34
+ talonChatId: string,
35
+ ): Promise<void> {
36
+ await performSessionReset(
37
+ talonChatId,
38
+ resolveChatBackend(talonChatId, runtime.gateway.backend),
39
+ );
40
+ log("teams", `Session reset by ${msg.senderName}`);
41
+ await reply(runtime, buildAdaptiveCard("Session cleared."));
42
+ }
43
+
44
+ function statusFacts(runtime: TeamsRuntime, talonChatId: string): Fact[] {
45
+ const info = getSessionInfo(talonChatId);
46
+ const u = info.usage;
47
+ const cache = buildCacheDisplay({
48
+ cacheMetrics: runtime.gateway.backend?.cacheMetrics,
49
+ inputTokens: u.totalInputTokens,
50
+ cacheRead: u.totalCacheRead,
51
+ cacheWrite: u.totalCacheWrite,
52
+ });
53
+ const rawModel =
54
+ getChatSettings(talonChatId).model ?? (runtime.config.model as string);
55
+ const model = resolveModel(rawModel)?.displayName ?? rawModel;
56
+ const avgMs = info.turns > 0 ? Math.round(u.totalResponseMs / info.turns) : 0;
57
+ const ctxUsed = u.contextTokens || u.lastPromptTokens;
58
+ const ctxMax = u.contextWindow;
59
+ const ctxPct =
60
+ ctxMax > 0 ? Math.min(100, Math.round((ctxUsed / ctxMax) * 100)) : 0;
61
+ return [
62
+ { title: "Model", value: model },
63
+ { title: "Turns", value: String(info.turns) },
64
+ {
65
+ title: "Context",
66
+ value: `${(ctxUsed / 1000).toFixed(0)}K / ${(ctxMax / 1000).toFixed(0)}K (${ctxPct}%)`,
67
+ },
68
+ ...(cache ? [{ title: "Cache", value: `${cache.hitPct}% hit` }] : []),
69
+ { title: "Input", value: `${u.totalInputTokens.toLocaleString()} tokens` },
70
+ {
71
+ title: "Output",
72
+ value: `${u.totalOutputTokens.toLocaleString()} tokens`,
73
+ },
74
+ {
75
+ title: "Avg response",
76
+ value: avgMs > 0 ? `${(avgMs / 1000).toFixed(1)}s` : "—",
77
+ },
78
+ ];
79
+ }
80
+
81
+ function buildStatusCard(
82
+ runtime: TeamsRuntime,
83
+ talonChatId: string,
84
+ ): Record<string, unknown> {
85
+ return {
86
+ type: "message",
87
+ attachments: [
88
+ {
89
+ contentType: "application/vnd.microsoft.card.adaptive",
90
+ contentUrl: null,
91
+ content: {
92
+ type: "AdaptiveCard",
93
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
94
+ version: "1.4",
95
+ body: [
96
+ {
97
+ type: "TextBlock",
98
+ text: "**Session**",
99
+ wrap: true,
100
+ size: "Medium",
101
+ weight: "Bolder",
102
+ },
103
+ { type: "FactSet", facts: statusFacts(runtime, talonChatId) },
104
+ ],
105
+ },
106
+ },
107
+ ],
108
+ };
109
+ }
110
+
111
+ type CommandHandler = (
112
+ runtime: TeamsRuntime,
113
+ msg: ChatMessage,
114
+ talonChatId: string,
115
+ ) => Promise<void>;
116
+
117
+ /** Exact-match table: the whole message, trimmed and lowercased, is the key. */
118
+ export const TEAMS_COMMANDS: Record<string, CommandHandler> = Object.assign(
119
+ Object.create(null) as Record<string, CommandHandler>,
120
+ {
121
+ "/reset": handleReset,
122
+ "/status": async (runtime, _msg, talonChatId) => {
123
+ await reply(runtime, buildStatusCard(runtime, talonChatId));
124
+ },
125
+ "/help": async (runtime) => {
126
+ await reply(runtime, buildAdaptiveCard(HELP_TEXT));
127
+ },
128
+ } satisfies Record<string, CommandHandler>,
129
+ );
130
+
131
+ /** Returns true when `msg` was a slash command and has been answered. */
132
+ export async function handleSlashCommand(
133
+ runtime: TeamsRuntime,
134
+ msg: ChatMessage,
135
+ talonChatId: string,
136
+ ): Promise<boolean> {
137
+ const handler = TEAMS_COMMANDS[msg.text.trim().toLowerCase()];
138
+ if (!handler) return false;
139
+ await handler(runtime, msg, talonChatId);
140
+ return true;
141
+ }
@@ -6,29 +6,22 @@
6
6
  * using Chat.Read scope (no admin consent needed).
7
7
  *
8
8
  * No Azure AD app registration, no Bot Framework, no admin consent.
9
+ *
10
+ * This file is wiring only: it constructs the shared runtime (runtime.ts)
11
+ * and owns the frontend lifecycle. The behaviour lives in the modules —
12
+ * chat-discovery, poll, commands, turn, outbound.
9
13
  */
10
14
 
11
15
  import type { TalonConfig } from "../../util/config.js";
12
16
  import type { ContextManager } from "../../core/types.js";
13
17
  import type { Gateway } from "../../core/engine/gateway.js";
14
- import { log, logError } from "../../util/log.js";
15
- import { deriveNumericChatId } from "../../util/chat-id.js";
16
- import { resolveModel } from "../../core/models/catalog.js";
17
- import { toolInputToRecord } from "../../core/agent-runtime/events.js";
18
- import { execute } from "../../core/engine/dispatcher.js";
19
- import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
20
- import { getSessionInfo } from "../../storage/sessions.js";
21
- import { getChatSettings } from "../../storage/chat-settings.js";
22
- import { performSessionReset } from "../shared/session-status.js";
23
- import { createTeamsActionHandler, postToTeams } from "./actions.js";
24
- import { splitTeamsMessage, buildAdaptiveCard } from "./formatting.js";
25
- import { buildCacheDisplay } from "../shared/status-context.js";
26
- import {
27
- initGraphClient,
28
- type GraphClient,
29
- type ChatMessage,
30
- } from "./graph.js";
31
- import { proxyFetch } from "./proxy-fetch.js";
18
+ import { log } from "../../util/log.js";
19
+ import { createTeamsActionHandler } from "./actions.js";
20
+ import { resolveChatId, seedLastSeen } from "./chat-discovery.js";
21
+ import { initGraphClient } from "./graph.js";
22
+ import { sendText } from "./outbound.js";
23
+ import { startPolling, stopPolling } from "./poll.js";
24
+ import { createTeamsRuntime } from "./runtime.js";
32
25
 
33
26
  // ── Types ────────────────────────────────────────────────────────────────────
34
27
 
@@ -49,20 +42,7 @@ export function createTeamsFrontend(
49
42
  config: TalonConfig,
50
43
  gateway: Gateway,
51
44
  ): TeamsFrontend {
52
- const webhookUrl = (config as Record<string, unknown>)
53
- .teamsWebhookUrl as string;
54
- const botDisplayName =
55
- ((config as Record<string, unknown>).teamsBotDisplayName as string) || "";
56
- const pollIntervalMs =
57
- ((config as Record<string, unknown>).teamsGraphPollMs as number) || 10_000;
58
- const configChatTopic =
59
- ((config as Record<string, unknown>).teamsChatTopic as string) || "";
60
-
61
- let graphClient: GraphClient | null = null;
62
- let pollTimer: ReturnType<typeof setInterval> | null = null;
63
- let lastSeenMessageId: string | null = null;
64
- let myUserId: string | null = null;
65
- let polling = false;
45
+ const runtime = createTeamsRuntime(config, gateway);
66
46
 
67
47
  const context: ContextManager = {
68
48
  acquire: (chatId: number, stringId?: string) =>
@@ -78,26 +58,7 @@ export function createTeamsFrontend(
78
58
  // Teams has no typing indicator via webhooks
79
59
  sendTyping: async () => {},
80
60
 
81
- sendMessage: async (_chatId: number, text: string) => {
82
- if (!text.trim()) return;
83
- try {
84
- const chunks = splitTeamsMessage(text);
85
- for (const chunk of chunks) {
86
- const card = buildAdaptiveCard(chunk);
87
- await proxyFetch(webhookUrl, {
88
- method: "POST",
89
- headers: { "Content-Type": "application/json" },
90
- body: JSON.stringify(card),
91
- signal: AbortSignal.timeout(15_000),
92
- });
93
- }
94
- } catch (err) {
95
- logError(
96
- "teams",
97
- `sendMessage failed: ${err instanceof Error ? err.message : err}`,
98
- );
99
- }
100
- },
61
+ sendMessage: (_chatId: number, text: string) => sendText(runtime, text),
101
62
 
102
63
  getBridgePort: () => gateway.getPort(),
103
64
 
@@ -105,332 +66,45 @@ export function createTeamsFrontend(
105
66
  // Register action handler with the gateway
106
67
  gateway.registerFrontendHandler(
107
68
  "teams",
108
- createTeamsActionHandler(webhookUrl, gateway),
69
+ createTeamsActionHandler(runtime.webhookUrl, gateway),
109
70
  );
110
71
  const port = await gateway.start(19876);
111
72
  log("teams", `Gateway on port ${port}`);
112
73
 
113
74
  // Authenticate with Microsoft Graph
114
75
  log("teams", "Initializing Microsoft Graph client...");
115
- graphClient = await initGraphClient();
76
+ const graphClient = await initGraphClient();
77
+ runtime.graphClient = graphClient;
116
78
 
117
79
  // Get our own user ID (to filter out our own messages)
118
80
  const me = await graphClient.getMe();
119
- myUserId = me.id;
81
+ runtime.myUserId = me.id;
120
82
  log("teams", `Authenticated as: ${me.displayName} (${me.id})`);
121
83
 
122
- // Discover or load chat
123
- let chatId = graphClient.getStoredChatId();
124
-
125
- if (!chatId) {
126
- log("teams", "No chat configured, discovering...");
127
- const chats = await graphClient.listChats();
128
-
129
- if (chats.length === 0) throw new Error("No chats found");
130
-
131
- // Try to match by topic name if configured
132
- let selectedChat = chats[0];
133
- if (configChatTopic) {
134
- const match = chats.find((c) =>
135
- c.topic?.toLowerCase().includes(configChatTopic.toLowerCase()),
136
- );
137
- if (match) selectedChat = match;
138
- else
139
- log(
140
- "teams",
141
- `No chat matching topic "${configChatTopic}", using most recent`,
142
- );
143
- }
144
-
145
- chatId = selectedChat.id;
146
- const topic = selectedChat.topic || "(unnamed chat)";
147
-
148
- graphClient.saveChatConfig(chatId, topic, myUserId);
149
- log("teams", `Configured chat: ${topic} [${selectedChat.chatType}]`);
150
- } else {
151
- log(
152
- "teams",
153
- `Using chat: ${graphClient.getStoredChatTopic() || chatId}`,
154
- );
155
- }
156
-
157
- // Seed lastSeenMessageId from current messages (don't process old messages)
158
- try {
159
- const existing = await graphClient.getChatMessages(chatId, 5);
160
- if (existing.length > 0) {
161
- lastSeenMessageId = existing[0].id;
162
- log("teams", `Seeded last message ID: ${lastSeenMessageId}`);
163
- }
164
- } catch (err) {
165
- logError(
166
- "teams",
167
- `Failed to seed messages: ${err instanceof Error ? err.message : err}`,
168
- );
169
- }
84
+ const chatId = await resolveChatId(runtime, graphClient, me.id);
85
+ await seedLastSeen(runtime, graphClient, chatId);
170
86
  },
171
87
 
172
88
  async start() {
173
- if (!graphClient) throw new Error("Graph client not initialized");
89
+ if (!runtime.graphClient) throw new Error("Graph client not initialized");
174
90
 
175
- const chatId = graphClient.getStoredChatId()!;
91
+ const chatId = runtime.graphClient.getStoredChatId()!;
176
92
 
177
93
  log("teams", "Teams frontend running");
178
94
  log("teams", `Send: Power Automate webhook`);
179
95
  log(
180
96
  "teams",
181
- `Receive: Graph API chat polling every ${pollIntervalMs / 1000}s`,
97
+ `Receive: Graph API chat polling every ${runtime.pollIntervalMs / 1000}s`,
182
98
  );
183
99
 
184
- // ── Poll loop ──────────────────────────────────────────────────────
185
- async function poll(): Promise<void> {
186
- if (polling) return;
187
- polling = true;
188
-
189
- try {
190
- if (!graphClient) return;
191
- const messages = await graphClient.getChatMessages(chatId, 20);
192
-
193
- // Find new messages — messages are returned newest first
194
- // IDs are opaque strings, so compare by createdDateTime
195
- const newMessages: ChatMessage[] = [];
196
- for (const msg of messages) {
197
- if (lastSeenMessageId && msg.id === lastSeenMessageId) break;
198
- newMessages.push(msg);
199
- }
200
-
201
- if (newMessages.length > 0) {
202
- lastSeenMessageId = newMessages[0].id;
203
- }
204
-
205
- // Process in chronological order (oldest first)
206
- for (const msg of newMessages.reverse()) {
207
- if (!msg.text.trim()) continue;
208
- if (msg.edited) continue;
209
-
210
- // Skip bot/workflow messages by display name (echo loop prevention).
211
- // We do NOT filter by user ID — the authenticated user also sends
212
- // real messages that Talon should respond to.
213
- if (
214
- botDisplayName &&
215
- msg.senderName.toLowerCase() === botDisplayName.toLowerCase()
216
- ) {
217
- continue;
218
- }
219
-
220
- const numericChatId = deriveNumericChatId(msg.chatId);
221
- const talonChatId = `teams_chat_${msg.chatId}`;
222
-
223
- // ── Slash commands ──
224
- const trimmed = msg.text.trim().toLowerCase();
225
- if (trimmed === "/reset") {
226
- await performSessionReset(
227
- talonChatId,
228
- resolveChatBackend(talonChatId, gateway.backend),
229
- );
230
- log("teams", `Session reset by ${msg.senderName}`);
231
- const card = buildAdaptiveCard("Session cleared.");
232
- await proxyFetch(webhookUrl, {
233
- method: "POST",
234
- headers: { "Content-Type": "application/json" },
235
- body: JSON.stringify(card),
236
- signal: AbortSignal.timeout(15_000),
237
- }).catch(() => {});
238
- continue;
239
- }
240
- if (trimmed === "/status") {
241
- const info = getSessionInfo(talonChatId);
242
- const u = info.usage;
243
- const cache = buildCacheDisplay({
244
- cacheMetrics: gateway.backend?.cacheMetrics,
245
- inputTokens: u.totalInputTokens,
246
- cacheRead: u.totalCacheRead,
247
- cacheWrite: u.totalCacheWrite,
248
- });
249
- const rawModel =
250
- getChatSettings(talonChatId).model ?? (config.model as string);
251
- const model = resolveModel(rawModel)?.displayName ?? rawModel;
252
- const avgMs =
253
- info.turns > 0 ? Math.round(u.totalResponseMs / info.turns) : 0;
254
- const ctxUsed = u.contextTokens || u.lastPromptTokens;
255
- const ctxMax = u.contextWindow;
256
- const ctxPct =
257
- ctxMax > 0
258
- ? Math.min(100, Math.round((ctxUsed / ctxMax) * 100))
259
- : 0;
260
- const card = {
261
- type: "message",
262
- attachments: [
263
- {
264
- contentType: "application/vnd.microsoft.card.adaptive",
265
- contentUrl: null,
266
- content: {
267
- type: "AdaptiveCard",
268
- $schema:
269
- "http://adaptivecards.io/schemas/adaptive-card.json",
270
- version: "1.4",
271
- body: [
272
- {
273
- type: "TextBlock",
274
- text: "**Session**",
275
- wrap: true,
276
- size: "Medium",
277
- weight: "Bolder",
278
- },
279
- {
280
- type: "FactSet",
281
- facts: [
282
- { title: "Model", value: model },
283
- { title: "Turns", value: String(info.turns) },
284
- {
285
- title: "Context",
286
- value: `${(ctxUsed / 1000).toFixed(0)}K / ${(ctxMax / 1000).toFixed(0)}K (${ctxPct}%)`,
287
- },
288
- ...(cache
289
- ? [
290
- {
291
- title: "Cache",
292
- value: `${cache.hitPct}% hit`,
293
- },
294
- ]
295
- : []),
296
- {
297
- title: "Input",
298
- value: `${u.totalInputTokens.toLocaleString()} tokens`,
299
- },
300
- {
301
- title: "Output",
302
- value: `${u.totalOutputTokens.toLocaleString()} tokens`,
303
- },
304
- {
305
- title: "Avg response",
306
- value:
307
- avgMs > 0
308
- ? `${(avgMs / 1000).toFixed(1)}s`
309
- : "—",
310
- },
311
- ],
312
- },
313
- ],
314
- },
315
- },
316
- ],
317
- };
318
- await proxyFetch(webhookUrl, {
319
- method: "POST",
320
- headers: { "Content-Type": "application/json" },
321
- body: JSON.stringify(card),
322
- signal: AbortSignal.timeout(15_000),
323
- }).catch(() => {});
324
- continue;
325
- }
326
- if (trimmed === "/help") {
327
- const helpText =
328
- "**Commands:**\n- `/reset` — clear session & history\n- `/status` — session stats\n- `/help` — this message";
329
- const card = buildAdaptiveCard(helpText);
330
- await proxyFetch(webhookUrl, {
331
- method: "POST",
332
- headers: { "Content-Type": "application/json" },
333
- body: JSON.stringify(card),
334
- signal: AbortSignal.timeout(15_000),
335
- }).catch(() => {});
336
- continue;
337
- }
338
-
339
- log(
340
- "teams",
341
- `[${msg.senderName}]: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? "..." : ""}`,
342
- );
343
-
344
- execute({
345
- chatId: talonChatId,
346
- numericChatId,
347
- prompt: `[${msg.senderName}]: ${msg.text}`,
348
- senderName: msg.senderName,
349
- isGroup: true,
350
- source: "message",
351
- onEvent: async (event) => {
352
- switch (event.type) {
353
- case "text_delta":
354
- log("teams", ` phase: text`);
355
- break;
356
- case "reasoning":
357
- log("teams", ` phase: thinking`);
358
- break;
359
- case "tool_call": {
360
- const input = toolInputToRecord(event.name, event.input);
361
- const detail = (input.description ??
362
- input.command ??
363
- input.action ??
364
- input.query ??
365
- input.url ??
366
- input.name ??
367
- "") as string;
368
- log(
369
- "teams",
370
- ` tool: ${event.name}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
371
- );
372
- break;
373
- }
374
- // Deliver assistant text (progress text before tool calls AND
375
- // the end-of-turn trailing-text fallback) to the Teams chat.
376
- // Without this, prose-only assistant turns would be silently
377
- // dropped — same scratchpad bug Telegram hit.
378
- case "assistant_message": {
379
- if (!event.text.trim()) break;
380
- try {
381
- await postToTeams(webhookUrl, event.text);
382
- gateway.incrementMessages(numericChatId);
383
- } catch (err) {
384
- // Post failures are swallowed (logged, not rethrown) —
385
- // preserves the old `onTextBlock` semantics where Teams
386
- // never propagated delivery errors back to the backend.
387
- // Returning normally lets the dispatcher resolve the ack.
388
- logError(
389
- "teams",
390
- `onEvent postToTeams failed: ${err instanceof Error ? err.message : err}`,
391
- );
392
- }
393
- break;
394
- }
395
- }
396
- },
397
- })
398
- .then(async (_result) => {
399
- // No fallback delivery — turns without end_turn / send_message
400
- // are intentional silent ends. Trailing prose without a tool
401
- // call is scratchpad and dropped; the SDK handler emits a
402
- // `scratchpad.trailing_text_dropped` metric on those.
403
- })
404
- .catch((err) => {
405
- logError(
406
- "teams",
407
- `execute failed: ${err instanceof Error ? err.message : err}`,
408
- );
409
- });
410
- }
411
- } catch (err) {
412
- logError(
413
- "teams",
414
- `Poll error: ${err instanceof Error ? err.message : err}`,
415
- );
416
- } finally {
417
- polling = false;
418
- }
419
- }
420
-
421
- // Initial poll, then interval
422
- await poll();
423
- pollTimer = setInterval(poll, pollIntervalMs);
100
+ await startPolling(runtime, chatId);
424
101
 
425
102
  // Hold process open
426
103
  await new Promise(() => {});
427
104
  },
428
105
 
429
106
  async stop() {
430
- if (pollTimer) {
431
- clearInterval(pollTimer);
432
- pollTimer = null;
433
- }
107
+ stopPolling(runtime);
434
108
  await gateway.stop();
435
109
  log("teams", "Teams frontend stopped");
436
110
  },
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Teams outbound — every card Talon sends is an Adaptive Card POSTed to the
3
+ * Power Automate workflow webhook. There is no other send path.
4
+ */
5
+
6
+ import { logError } from "../../util/log.js";
7
+ import { buildAdaptiveCard, splitTeamsMessage } from "./formatting.js";
8
+ import { proxyFetch } from "./proxy-fetch.js";
9
+ import type { TeamsRuntime } from "./runtime.js";
10
+
11
+ /** POST one card to the webhook; the caller decides what a non-2xx means. */
12
+ export function postCard(
13
+ webhookUrl: string,
14
+ card: Record<string, unknown>,
15
+ ): Promise<Response> {
16
+ return proxyFetch(webhookUrl, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(card),
20
+ signal: AbortSignal.timeout(15_000),
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Out-of-turn delivery (cron / pulse / heartbeat). Failures are logged and
26
+ * swallowed — the callers have nowhere to surface them.
27
+ */
28
+ export async function sendText(
29
+ runtime: TeamsRuntime,
30
+ text: string,
31
+ ): Promise<void> {
32
+ if (!text.trim()) return;
33
+ try {
34
+ const chunks = splitTeamsMessage(text);
35
+ for (const chunk of chunks) {
36
+ await postCard(runtime.webhookUrl, buildAdaptiveCard(chunk));
37
+ }
38
+ } catch (err) {
39
+ logError(
40
+ "teams",
41
+ `sendMessage failed: ${err instanceof Error ? err.message : err}`,
42
+ );
43
+ }
44
+ }