talon-agent 4.3.0 → 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.
- package/package.json +1 -1
- package/src/app.ts +1 -1
- package/src/bootstrap.ts +5 -2
- package/src/cli/skill.ts +2 -2
- package/src/core/background/cron-spec.ts +1 -1
- package/src/core/background/cron.ts +3 -5
- 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/engine/gateway-actions/cron.ts +18 -18
- package/src/core/engine/gateway-actions/goals.ts +10 -10
- package/src/core/engine/gateway-actions/history.ts +10 -17
- package/src/core/engine/gateway-actions/index.ts +9 -2
- package/src/core/engine/gateway-actions/models.ts +6 -6
- package/src/core/engine/gateway-actions/plugins.ts +2 -8
- 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 +18 -27
- package/src/core/engine/gateway-actions/types.ts +11 -1
- package/src/core/engine/gateway-actions/{shared.ts → validation.ts} +5 -6
- package/src/core/engine/gateway.ts +12 -2
- 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/core/weaver/loom.ts +11 -0
- 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 +2 -2
- 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} +2 -1
- 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/messaging.ts +1 -1
- package/src/frontend/telegram/actions/shared.ts +4 -4
- package/src/frontend/telegram/admin.ts +1 -1
- package/src/frontend/telegram/sticker-library.ts +2 -2
- 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/inbound.ts +1 -1
- package/src/frontend/whatsapp/index.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/util/chat-id.ts +13 -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
|
@@ -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
|
|
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
|
+
}
|