talon-agent 4.3.1 → 4.3.3

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 (133) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +1 -1
  3. package/src/backend/claude-sdk/doctor.ts +1 -1
  4. package/src/backend/codex/doctor.ts +1 -1
  5. package/src/backend/openai-agents/doctor.ts +1 -1
  6. package/src/bootstrap.ts +3 -3
  7. package/src/cli/doctor.ts +1 -1
  8. package/src/cli/skill.ts +2 -2
  9. package/src/core/agent-runtime/backend-registry.ts +1 -1
  10. package/src/core/auth/expiry-monitor.ts +1 -1
  11. package/src/core/background/cron-spec.ts +1 -1
  12. package/src/core/background/cron.ts +1 -1
  13. package/src/core/background/heartbeat/agent.ts +1 -1
  14. package/src/core/background/triggers/command.ts +1 -1
  15. package/src/core/background/triggers/exit.ts +1 -1
  16. package/src/core/background/triggers/output.ts +1 -1
  17. package/src/core/background/triggers/resume.ts +1 -1
  18. package/src/core/background/triggers/spawn.ts +1 -1
  19. package/src/core/{doctor.ts → doctor/index.ts} +11 -11
  20. package/src/core/engine/gateway-actions/cron.ts +2 -2
  21. package/src/core/engine/gateway-actions/goals.ts +2 -2
  22. package/src/core/engine/gateway-actions/scripts.ts +1 -1
  23. package/src/core/engine/gateway-actions/skills.ts +1 -1
  24. package/src/core/engine/gateway-actions/triggers.ts +2 -2
  25. package/src/core/engine/gateway-actions/{shared.ts → validation.ts} +2 -2
  26. package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
  27. package/src/core/plugin/native-runtimes.ts +1 -1
  28. package/src/core/prompt/assemble.ts +2 -2
  29. package/src/core/scripts/runner.ts +1 -1
  30. package/src/core/soul/forgetting.ts +2 -2
  31. package/src/core/soul/salience.ts +8 -4
  32. package/src/frontend/discord/actions/{shared.ts → buttons.ts} +3 -56
  33. package/src/frontend/discord/actions/channels.ts +34 -0
  34. package/src/frontend/discord/actions/chat-info.ts +1 -1
  35. package/src/frontend/discord/actions/index.ts +1 -1
  36. package/src/frontend/discord/actions/media.ts +1 -1
  37. package/src/frontend/discord/actions/messaging.ts +4 -2
  38. package/src/frontend/discord/actions/try-action.ts +24 -0
  39. package/src/frontend/discord/admin.ts +2 -2
  40. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  41. package/src/frontend/discord/callbacks/modals.ts +1 -1
  42. package/src/frontend/discord/commands/admin.ts +3 -3
  43. package/src/frontend/discord/commands/info.ts +2 -2
  44. package/src/frontend/discord/commands/{shared.ts → interaction.ts} +2 -1
  45. package/src/frontend/discord/commands/router.ts +1 -1
  46. package/src/frontend/discord/commands/session.ts +2 -2
  47. package/src/frontend/discord/commands/settings.ts +2 -2
  48. package/src/frontend/discord/{helpers.ts → render.ts} +3 -2
  49. package/src/frontend/native/extensions.ts +1 -1
  50. package/src/frontend/teams/chat-discovery.ts +69 -0
  51. package/src/frontend/teams/commands.ts +141 -0
  52. package/src/frontend/teams/index.ts +24 -350
  53. package/src/frontend/teams/outbound.ts +44 -0
  54. package/src/frontend/teams/poll.ts +101 -0
  55. package/src/frontend/teams/runtime.ts +51 -0
  56. package/src/frontend/teams/turn.ts +97 -0
  57. package/src/frontend/telegram/actions/index.ts +1 -1
  58. package/src/frontend/telegram/actions/messaging.ts +1 -1
  59. package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
  60. package/src/frontend/telegram/actions/moderation/index.ts +74 -0
  61. package/src/frontend/telegram/actions/moderation/members.ts +77 -0
  62. package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
  63. package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
  64. package/src/frontend/telegram/actions/moderation/types.ts +16 -0
  65. package/src/frontend/telegram/actions/shared.ts +4 -4
  66. package/src/frontend/telegram/admin/background.ts +66 -0
  67. package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
  68. package/src/frontend/telegram/admin/health.ts +81 -0
  69. package/src/frontend/telegram/admin/sessions.ts +105 -0
  70. package/src/frontend/telegram/admin.ts +56 -261
  71. package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
  72. package/src/frontend/telegram/callbacks/model/control.ts +29 -0
  73. package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
  74. package/src/frontend/telegram/callbacks/model/select.ts +100 -0
  75. package/src/frontend/telegram/callbacks/model/types.ts +39 -0
  76. package/src/frontend/telegram/callbacks/model/views.ts +116 -0
  77. package/src/frontend/telegram/callbacks/model.ts +61 -331
  78. package/src/frontend/telegram/commands/admin.ts +108 -77
  79. package/src/frontend/telegram/commands/settings.ts +65 -43
  80. package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
  81. package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
  82. package/src/frontend/telegram/middleware.ts +166 -201
  83. package/src/frontend/telegram/sticker-library.ts +2 -2
  84. package/src/frontend/terminal/builtins/context.ts +184 -0
  85. package/src/frontend/terminal/builtins/help.ts +35 -0
  86. package/src/frontend/terminal/builtins/index.ts +23 -0
  87. package/src/frontend/terminal/builtins/model.ts +198 -0
  88. package/src/frontend/terminal/builtins/session.ts +101 -0
  89. package/src/frontend/terminal/builtins/status.ts +202 -0
  90. package/src/frontend/terminal/command-registry.ts +80 -0
  91. package/src/frontend/terminal/commands.ts +11 -674
  92. package/src/frontend/terminal/input/history.ts +64 -0
  93. package/src/frontend/terminal/input/keys.ts +143 -0
  94. package/src/frontend/terminal/input/parts.ts +33 -0
  95. package/src/frontend/terminal/input/paste.ts +53 -0
  96. package/src/frontend/terminal/input/render.ts +42 -0
  97. package/src/frontend/terminal/input/state.ts +60 -0
  98. package/src/frontend/terminal/input.ts +27 -342
  99. package/src/frontend/whatsapp/actions/account.ts +3 -6
  100. package/src/frontend/whatsapp/actions/chat-info.ts +2 -1
  101. package/src/frontend/whatsapp/actions/coerce.ts +30 -0
  102. package/src/frontend/whatsapp/actions/history.ts +1 -1
  103. package/src/frontend/whatsapp/actions/media-upload.ts +82 -0
  104. package/src/frontend/whatsapp/actions/media.ts +3 -8
  105. package/src/frontend/whatsapp/actions/messaging.ts +2 -7
  106. package/src/frontend/whatsapp/actions/moderation.ts +2 -1
  107. package/src/frontend/whatsapp/actions/{shared.ts → send.ts} +4 -124
  108. package/src/frontend/whatsapp/actions/try-action.ts +24 -0
  109. package/src/frontend/whatsapp/commands.ts +1 -1
  110. package/src/frontend/whatsapp/connection.ts +1 -1
  111. package/src/frontend/whatsapp/inbound.ts +1 -1
  112. package/src/frontend/whatsapp/index.ts +2 -2
  113. package/src/frontend/whatsapp/pairing-service.ts +1 -1
  114. package/src/plugins/github/provision.ts +1 -1
  115. package/src/plugins/mempalace/provision.ts +1 -1
  116. package/src/plugins/playwright/provision.ts +1 -1
  117. package/src/storage/repositories/cron-repo.ts +1 -1
  118. package/src/storage/repositories/goals-repo.ts +1 -1
  119. package/src/storage/repositories/scripts-repo.ts +1 -1
  120. package/src/storage/repositories/triggers-repo.ts +1 -1
  121. package/src/storage/sql/schema.sql +1 -1
  122. package/src/storage/sql/statements.generated.ts +2 -2
  123. package/src/storage/sql/triggers.sql +1 -1
  124. package/src/frontend/telegram/actions/moderation.ts +0 -255
  125. /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
  126. /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
  127. /package/src/storage/{cron-store.ts → cron.ts} +0 -0
  128. /package/src/storage/{goal-store.ts → goals.ts} +0 -0
  129. /package/src/storage/{scheduled-store.ts → scheduled.ts} +0 -0
  130. /package/src/storage/{script-store.ts → scripts.ts} +0 -0
  131. /package/src/storage/{skill-store.ts → skills.ts} +0 -0
  132. /package/src/storage/{sticker-store.ts → stickers.ts} +0 -0
  133. /package/src/storage/{trigger-store.ts → triggers.ts} +0 -0
@@ -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
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Teams poll loop — Graph API chat polling is the whole receive side (no
3
+ * Bot Framework, no inbound endpoint). Each tick fetches the newest
4
+ * messages, cuts at the last one seen, and hands the rest — oldest first —
5
+ * to the slash-command router or a model turn.
6
+ */
7
+
8
+ import { log, logError } from "../../util/log.js";
9
+ import { handleSlashCommand } from "./commands.js";
10
+ import type { ChatMessage } from "./graph.js";
11
+ import type { TeamsRuntime } from "./runtime.js";
12
+ import { runTurn } from "./turn.js";
13
+
14
+ /**
15
+ * Messages arrive newest first; everything before `lastSeenId` is new. IDs
16
+ * are opaque strings, so this is a cut at the seen id, not a comparison.
17
+ */
18
+ export function selectNewMessages(
19
+ messages: ChatMessage[],
20
+ lastSeenId: string | null,
21
+ ): ChatMessage[] {
22
+ const fresh: ChatMessage[] = [];
23
+ for (const msg of messages) {
24
+ if (lastSeenId && msg.id === lastSeenId) break;
25
+ fresh.push(msg);
26
+ }
27
+ return fresh;
28
+ }
29
+
30
+ // Skip bot/workflow messages by display name (echo loop prevention).
31
+ // We do NOT filter by user ID — the authenticated user also sends
32
+ // real messages that Talon should respond to.
33
+ function isBotEcho(runtime: TeamsRuntime, msg: ChatMessage): boolean {
34
+ return (
35
+ !!runtime.botDisplayName &&
36
+ msg.senderName.toLowerCase() === runtime.botDisplayName.toLowerCase()
37
+ );
38
+ }
39
+
40
+ async function handleMessage(
41
+ runtime: TeamsRuntime,
42
+ msg: ChatMessage,
43
+ ): Promise<void> {
44
+ const talonChatId = `teams_chat_${msg.chatId}`;
45
+ if (await handleSlashCommand(runtime, msg, talonChatId)) return;
46
+
47
+ log(
48
+ "teams",
49
+ `[${msg.senderName}]: ${msg.text.slice(0, 80)}${msg.text.length > 80 ? "..." : ""}`,
50
+ );
51
+ runTurn(runtime, msg, talonChatId);
52
+ }
53
+
54
+ async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
55
+ if (runtime.polling) return;
56
+ runtime.polling = true;
57
+
58
+ try {
59
+ if (!runtime.graphClient) return;
60
+ const messages = await runtime.graphClient.getChatMessages(chatId, 20);
61
+ const newMessages = selectNewMessages(messages, runtime.lastSeenMessageId);
62
+
63
+ if (newMessages.length > 0) {
64
+ runtime.lastSeenMessageId = newMessages[0].id;
65
+ }
66
+
67
+ // Process in chronological order (oldest first)
68
+ for (const msg of newMessages.reverse()) {
69
+ if (!msg.text.trim()) continue;
70
+ if (msg.edited) continue;
71
+ if (isBotEcho(runtime, msg)) continue;
72
+ await handleMessage(runtime, msg);
73
+ }
74
+ } catch (err) {
75
+ logError(
76
+ "teams",
77
+ `Poll error: ${err instanceof Error ? err.message : err}`,
78
+ );
79
+ } finally {
80
+ runtime.polling = false;
81
+ }
82
+ }
83
+
84
+ /** Initial poll, then the interval; the timer lives on the runtime so stop can clear it. */
85
+ export async function startPolling(
86
+ runtime: TeamsRuntime,
87
+ chatId: string,
88
+ ): Promise<void> {
89
+ await poll(runtime, chatId);
90
+ runtime.pollTimer = setInterval(
91
+ () => void poll(runtime, chatId),
92
+ runtime.pollIntervalMs,
93
+ );
94
+ }
95
+
96
+ export function stopPolling(runtime: TeamsRuntime): void {
97
+ if (runtime.pollTimer) {
98
+ clearInterval(runtime.pollTimer);
99
+ runtime.pollTimer = null;
100
+ }
101
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Teams frontend runtime — the state every module shares.
3
+ *
4
+ * `createTeamsFrontend` used to hold all of this as closure variables, with
5
+ * the poll loop and the slash commands nested inside `.start`. It is now one
6
+ * explicit object, constructed once, that each module (chat-discovery,
7
+ * poll, commands, turn, outbound) takes as its first parameter.
8
+ */
9
+
10
+ import type { TalonConfig } from "../../util/config.js";
11
+ import type { Gateway } from "../../core/engine/gateway.js";
12
+ import type { GraphClient } from "./graph.js";
13
+
14
+ export type TeamsRuntime = {
15
+ readonly config: TalonConfig;
16
+ readonly gateway: Gateway;
17
+ /** Power Automate workflow webhook URL every outbound card is POSTed to. */
18
+ readonly webhookUrl: string;
19
+ /** Display name the workflow posts under — its messages are skipped. */
20
+ readonly botDisplayName: string;
21
+ readonly pollIntervalMs: number;
22
+ /** Optional topic substring used to pick the chat on first run. */
23
+ readonly configChatTopic: string;
24
+ graphClient: GraphClient | null;
25
+ pollTimer: ReturnType<typeof setInterval> | null;
26
+ /** Newest message id already handled — the poll loop cuts at it. */
27
+ lastSeenMessageId: string | null;
28
+ myUserId: string | null;
29
+ /** Re-entrancy guard: a slow poll never overlaps the next tick. */
30
+ polling: boolean;
31
+ };
32
+
33
+ export function createTeamsRuntime(
34
+ config: TalonConfig,
35
+ gateway: Gateway,
36
+ ): TeamsRuntime {
37
+ const raw = config as Record<string, unknown>;
38
+ return {
39
+ config,
40
+ gateway,
41
+ webhookUrl: raw.teamsWebhookUrl as string,
42
+ botDisplayName: (raw.teamsBotDisplayName as string) || "",
43
+ pollIntervalMs: (raw.teamsGraphPollMs as number) || 10_000,
44
+ configChatTopic: (raw.teamsChatTopic as string) || "",
45
+ graphClient: null,
46
+ pollTimer: null,
47
+ lastSeenMessageId: null,
48
+ myUserId: null,
49
+ polling: false,
50
+ };
51
+ }