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.
- package/package.json +1 -1
- package/src/app.ts +1 -1
- package/src/backend/claude-sdk/doctor.ts +1 -1
- package/src/backend/codex/doctor.ts +1 -1
- package/src/backend/openai-agents/doctor.ts +1 -1
- package/src/bootstrap.ts +3 -3
- package/src/cli/doctor.ts +1 -1
- package/src/cli/skill.ts +2 -2
- package/src/core/agent-runtime/backend-registry.ts +1 -1
- package/src/core/auth/expiry-monitor.ts +1 -1
- package/src/core/background/cron-spec.ts +1 -1
- package/src/core/background/cron.ts +1 -1
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/triggers/command.ts +1 -1
- package/src/core/background/triggers/exit.ts +1 -1
- package/src/core/background/triggers/output.ts +1 -1
- package/src/core/background/triggers/resume.ts +1 -1
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/{doctor.ts → doctor/index.ts} +11 -11
- package/src/core/engine/gateway-actions/cron.ts +2 -2
- package/src/core/engine/gateway-actions/goals.ts +2 -2
- package/src/core/engine/gateway-actions/scripts.ts +1 -1
- package/src/core/engine/gateway-actions/skills.ts +1 -1
- package/src/core/engine/gateway-actions/triggers.ts +2 -2
- package/src/core/engine/gateway-actions/{shared.ts → validation.ts} +2 -2
- package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
- package/src/core/plugin/native-runtimes.ts +1 -1
- package/src/core/prompt/assemble.ts +2 -2
- package/src/core/scripts/runner.ts +1 -1
- package/src/core/soul/forgetting.ts +2 -2
- package/src/core/soul/salience.ts +8 -4
- package/src/frontend/discord/actions/{shared.ts → buttons.ts} +3 -56
- package/src/frontend/discord/actions/channels.ts +34 -0
- package/src/frontend/discord/actions/chat-info.ts +1 -1
- package/src/frontend/discord/actions/index.ts +1 -1
- package/src/frontend/discord/actions/media.ts +1 -1
- package/src/frontend/discord/actions/messaging.ts +4 -2
- package/src/frontend/discord/actions/try-action.ts +24 -0
- package/src/frontend/discord/admin.ts +2 -2
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/callbacks/modals.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +3 -3
- package/src/frontend/discord/commands/info.ts +2 -2
- package/src/frontend/discord/commands/{shared.ts → interaction.ts} +2 -1
- package/src/frontend/discord/commands/router.ts +1 -1
- package/src/frontend/discord/commands/session.ts +2 -2
- package/src/frontend/discord/commands/settings.ts +2 -2
- package/src/frontend/discord/{helpers.ts → render.ts} +3 -2
- package/src/frontend/native/extensions.ts +1 -1
- package/src/frontend/teams/chat-discovery.ts +69 -0
- package/src/frontend/teams/commands.ts +141 -0
- package/src/frontend/teams/index.ts +24 -350
- package/src/frontend/teams/outbound.ts +44 -0
- package/src/frontend/teams/poll.ts +101 -0
- package/src/frontend/teams/runtime.ts +51 -0
- package/src/frontend/teams/turn.ts +97 -0
- package/src/frontend/telegram/actions/index.ts +1 -1
- package/src/frontend/telegram/actions/messaging.ts +1 -1
- package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
- package/src/frontend/telegram/actions/moderation/index.ts +74 -0
- package/src/frontend/telegram/actions/moderation/members.ts +77 -0
- package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
- package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
- package/src/frontend/telegram/actions/moderation/types.ts +16 -0
- package/src/frontend/telegram/actions/shared.ts +4 -4
- package/src/frontend/telegram/admin/background.ts +66 -0
- package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
- package/src/frontend/telegram/admin/health.ts +81 -0
- package/src/frontend/telegram/admin/sessions.ts +105 -0
- package/src/frontend/telegram/admin.ts +56 -261
- package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
- package/src/frontend/telegram/callbacks/model/control.ts +29 -0
- package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
- package/src/frontend/telegram/callbacks/model/select.ts +100 -0
- package/src/frontend/telegram/callbacks/model/types.ts +39 -0
- package/src/frontend/telegram/callbacks/model/views.ts +116 -0
- package/src/frontend/telegram/callbacks/model.ts +61 -331
- package/src/frontend/telegram/commands/admin.ts +108 -77
- package/src/frontend/telegram/commands/settings.ts +65 -43
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
- package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
- package/src/frontend/telegram/middleware.ts +166 -201
- package/src/frontend/telegram/sticker-library.ts +2 -2
- package/src/frontend/terminal/builtins/context.ts +184 -0
- package/src/frontend/terminal/builtins/help.ts +35 -0
- package/src/frontend/terminal/builtins/index.ts +23 -0
- package/src/frontend/terminal/builtins/model.ts +198 -0
- package/src/frontend/terminal/builtins/session.ts +101 -0
- package/src/frontend/terminal/builtins/status.ts +202 -0
- package/src/frontend/terminal/command-registry.ts +80 -0
- package/src/frontend/terminal/commands.ts +11 -674
- package/src/frontend/terminal/input/history.ts +64 -0
- package/src/frontend/terminal/input/keys.ts +143 -0
- package/src/frontend/terminal/input/parts.ts +33 -0
- package/src/frontend/terminal/input/paste.ts +53 -0
- package/src/frontend/terminal/input/render.ts +42 -0
- package/src/frontend/terminal/input/state.ts +60 -0
- package/src/frontend/terminal/input.ts +27 -342
- package/src/frontend/whatsapp/actions/account.ts +3 -6
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -1
- package/src/frontend/whatsapp/actions/coerce.ts +30 -0
- package/src/frontend/whatsapp/actions/history.ts +1 -1
- package/src/frontend/whatsapp/actions/media-upload.ts +82 -0
- package/src/frontend/whatsapp/actions/media.ts +3 -8
- package/src/frontend/whatsapp/actions/messaging.ts +2 -7
- package/src/frontend/whatsapp/actions/moderation.ts +2 -1
- package/src/frontend/whatsapp/actions/{shared.ts → send.ts} +4 -124
- package/src/frontend/whatsapp/actions/try-action.ts +24 -0
- package/src/frontend/whatsapp/commands.ts +1 -1
- package/src/frontend/whatsapp/connection.ts +1 -1
- package/src/frontend/whatsapp/inbound.ts +1 -1
- package/src/frontend/whatsapp/index.ts +2 -2
- package/src/frontend/whatsapp/pairing-service.ts +1 -1
- package/src/plugins/github/provision.ts +1 -1
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/plugins/playwright/provision.ts +1 -1
- package/src/storage/repositories/cron-repo.ts +1 -1
- package/src/storage/repositories/goals-repo.ts +1 -1
- package/src/storage/repositories/scripts-repo.ts +1 -1
- package/src/storage/repositories/triggers-repo.ts +1 -1
- package/src/storage/sql/schema.sql +1 -1
- package/src/storage/sql/statements.generated.ts +2 -2
- package/src/storage/sql/triggers.sql +1 -1
- package/src/frontend/telegram/actions/moderation.ts +0 -255
- /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
- /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
- /package/src/storage/{cron-store.ts → cron.ts} +0 -0
- /package/src/storage/{goal-store.ts → goals.ts} +0 -0
- /package/src/storage/{scheduled-store.ts → scheduled.ts} +0 -0
- /package/src/storage/{script-store.ts → scripts.ts} +0 -0
- /package/src/storage/{skill-store.ts → skills.ts} +0 -0
- /package/src/storage/{sticker-store.ts → stickers.ts} +0 -0
- /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
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
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
|
|
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:
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|