talon-agent 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/prompts/heartbeat.md +18 -6
  3. package/src/__tests__/compose-tools.test.ts +216 -0
  4. package/src/__tests__/fuzz.test.ts +0 -2
  5. package/src/__tests__/gateway-actions.test.ts +1 -423
  6. package/src/__tests__/heartbeat.test.ts +21 -0
  7. package/src/__tests__/reload-plugins.test.ts +199 -0
  8. package/src/__tests__/sessions.test.ts +155 -121
  9. package/src/backend/claude-sdk/index.ts +230 -109
  10. package/src/backend/opencode/index.ts +5 -20
  11. package/src/bootstrap.ts +8 -44
  12. package/src/core/gateway-actions.ts +42 -88
  13. package/src/core/heartbeat.ts +8 -5
  14. package/src/core/plugin.ts +147 -0
  15. package/src/core/tools/admin.ts +22 -0
  16. package/src/core/tools/bridge.ts +40 -0
  17. package/src/core/tools/chat.ts +52 -0
  18. package/src/core/tools/history.ts +80 -0
  19. package/src/core/tools/index.ts +84 -0
  20. package/src/core/tools/mcp-server.ts +64 -0
  21. package/src/core/tools/media.ts +23 -0
  22. package/src/core/tools/members.ts +46 -0
  23. package/src/core/tools/messaging.ts +300 -0
  24. package/src/core/tools/scheduling.ts +89 -0
  25. package/src/core/tools/stickers.ts +143 -0
  26. package/src/core/tools/types.ts +61 -0
  27. package/src/core/tools/web.ts +26 -0
  28. package/src/frontend/teams/index.ts +9 -10
  29. package/src/frontend/telegram/actions.ts +10 -1
  30. package/src/frontend/telegram/commands.ts +11 -10
  31. package/src/plugins/github/index.ts +106 -0
  32. package/src/plugins/playwright/index.ts +82 -0
  33. package/src/storage/sessions.ts +34 -50
  34. package/src/util/config.ts +20 -1
  35. package/src/util/log.ts +3 -1
  36. package/src/backend/claude-sdk/tools.ts +0 -651
  37. package/src/frontend/teams/tools.ts +0 -175
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared gateway actions — platform-agnostic handlers that work with any frontend.
3
3
  *
4
- * Handles: cron CRUD, fetch_url, in-memory history queries.
4
+ * Handles: cron CRUD, fetch_url, plugin reload, in-memory history queries.
5
5
  * Returns null if the action isn't recognized (so the gateway delegates to the frontend).
6
6
  */
7
7
 
@@ -87,93 +87,6 @@ export async function handleSharedAction(
87
87
  ),
88
88
  };
89
89
 
90
- // ── Web search (SearXNG) ────────────────────────────────────────────
91
-
92
- case "web_search": {
93
- const query = String(body.query ?? "");
94
- if (!query) return { ok: false, error: "Missing query" };
95
- const limit = Math.min(10, Number(body.limit ?? 5));
96
-
97
- // Try Brave first (if API key configured), fall back to SearXNG
98
- const braveKey = process.env.TALON_BRAVE_API_KEY;
99
- const searxUrl = process.env.TALON_SEARXNG_URL || "http://localhost:8080";
100
-
101
- type SearchResult = { title: string; url: string; snippet: string };
102
- let results: SearchResult[] = [];
103
- let provider = "";
104
-
105
- // Brave Search API
106
- if (braveKey) {
107
- try {
108
- const resp = await fetch(
109
- `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
110
- {
111
- signal: AbortSignal.timeout(8_000),
112
- headers: {
113
- "X-Subscription-Token": braveKey,
114
- Accept: "application/json",
115
- },
116
- },
117
- );
118
- if (resp.ok) {
119
- const data = (await resp.json()) as {
120
- web?: {
121
- results?: Array<{
122
- title: string;
123
- url: string;
124
- description: string;
125
- }>;
126
- };
127
- };
128
- results = (data.web?.results ?? []).map((r) => ({
129
- title: r.title,
130
- url: r.url,
131
- snippet: r.description ?? "",
132
- }));
133
- provider = "Brave";
134
- }
135
- } catch {
136
- /* fall through to SearXNG */
137
- }
138
- }
139
-
140
- // SearXNG fallback
141
- if (results.length === 0) {
142
- try {
143
- const resp = await fetch(
144
- `${searxUrl}/search?q=${encodeURIComponent(query)}&format=json`,
145
- { signal: AbortSignal.timeout(10_000) },
146
- );
147
- if (resp.ok) {
148
- const data = (await resp.json()) as {
149
- results?: Array<{ title: string; url: string; content: string }>;
150
- };
151
- results = (data.results ?? []).slice(0, limit).map((r) => ({
152
- title: r.title,
153
- url: r.url,
154
- snippet: r.content ?? "",
155
- }));
156
- provider = "SearXNG";
157
- }
158
- } catch {
159
- /* both failed */
160
- }
161
- }
162
-
163
- if (results.length === 0)
164
- return { ok: true, text: `No results for "${query}".` };
165
- const formatted = results
166
- .map(
167
- (r, i) =>
168
- `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 200)}`,
169
- )
170
- .join("\n\n");
171
- return {
172
- ok: true,
173
- text: `Search results for "${query}" (via ${provider}):\n\n${formatted}`,
174
- };
175
- }
176
-
177
90
  // ── Web fetch ────────────────────────────────────────────────────────
178
91
 
179
92
  case "fetch_url": {
@@ -397,6 +310,47 @@ export async function handleSharedAction(
397
310
  return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
398
311
  }
399
312
 
313
+ // ── Plugin hot-reload ──────────────────────────────────────────────
314
+ case "reload_plugins": {
315
+ try {
316
+ const { reloadPlugins, getPluginPromptAdditions } =
317
+ await import("./plugin.js");
318
+ const { rebuildSystemPrompt } = await import("../util/config.js");
319
+
320
+ // reloadPlugins reads + validates config internally — no double read.
321
+ // Frontends are derived from config if not explicitly provided.
322
+ const { names, config: freshConfig } = await reloadPlugins();
323
+
324
+ // Rebuild system prompt on the freshConfig, then update the backend's
325
+ // live config reference so subsequent messages use the new prompt
326
+ rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
327
+ try {
328
+ const { updateSystemPrompt } =
329
+ await import("../backend/claude-sdk/index.js");
330
+ updateSystemPrompt(freshConfig.systemPrompt);
331
+ } catch (err) {
332
+ // Non-fatal — OpenCode backend doesn't expose updateSystemPrompt
333
+ log(
334
+ "gateway",
335
+ `reload_plugins: could not update backend prompt: ${err instanceof Error ? err.message : err}`,
336
+ );
337
+ }
338
+
339
+ log("gateway", `reload_plugins: ${names.length} plugins loaded`);
340
+ return {
341
+ ok: true,
342
+ text:
343
+ `Plugins reloaded successfully.\n` +
344
+ `Loaded (${names.length}): ${names.length > 0 ? names.join(", ") : "(none)"}`,
345
+ };
346
+ } catch (err) {
347
+ return {
348
+ ok: false,
349
+ error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
350
+ };
351
+ }
352
+ }
353
+
400
354
  default:
401
355
  return null; // not a shared action — delegate to frontend
402
356
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Runs at a configurable interval (default: 60 minutes).
5
5
  * The agent reads instructions from ~/.talon/workspace/heartbeat-instructions.md
6
- * and executes them using filesystem-only tools (no Telegram/MCP access).
6
+ * and executes them using filesystem tools and all loaded MCP plugins.
7
7
  *
8
8
  * Modeled after dream.ts but more general-purpose.
9
9
  */
@@ -17,6 +17,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
17
  import { files as pathFiles, dirs } from "../util/paths.js";
18
18
  import { log, logError, logWarn } from "../util/log.js";
19
19
  import { toYMD } from "../util/time.js";
20
+ import { getPluginMcpServers } from "./plugin.js";
20
21
 
21
22
  // ── Types ────────────────────────────────────────────────────────────────────
22
23
 
@@ -282,15 +283,15 @@ async function runHeartbeatAgent(
282
283
  const options = {
283
284
  model,
284
285
  systemPrompt:
285
- "You are a background heartbeat agent for Talon. Use only filesystem tools. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
286
+ "You are a background heartbeat agent for Talon. You have access to filesystem tools and all registered MCP plugins. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
286
287
  cwd: workspace,
287
288
  permissionMode: "bypassPermissions" as const,
288
289
  allowDangerouslySkipPermissions: true,
289
290
  ...(configRef.claudeBinary
290
291
  ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
291
292
  : {}),
292
- // No MCP servers filesystem tools only
293
- mcpServers: {},
293
+ // Load all registered plugin MCP servers (excludes frontend-specific tools like telegram)
294
+ mcpServers: getPluginMcpServers("", "heartbeat"),
294
295
  disallowedTools: [
295
296
  "EnterPlanMode",
296
297
  "ExitPlanMode",
@@ -315,10 +316,12 @@ async function runHeartbeatAgent(
315
316
  // running lock is not released while the subprocess is still active.
316
317
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
317
318
  const timeoutPromise = new Promise<never>((_, reject) => {
318
- timeoutHandle = setTimeout(
319
+ const t = setTimeout(
319
320
  () => reject(new Error("Heartbeat agent timed out")),
320
321
  HEARTBEAT_TIMEOUT_MS,
321
322
  );
323
+ t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
324
+ timeoutHandle = t;
322
325
  });
323
326
 
324
327
  const agentPromise = (async () => {
@@ -18,6 +18,7 @@ import { resolve } from "node:path";
18
18
  import { existsSync } from "node:fs";
19
19
  import { log, logError, logWarn } from "../util/log.js";
20
20
  import type { ActionResult } from "./types.js";
21
+ import type { TalonConfig } from "../util/config.js";
21
22
 
22
23
  // ── Plugin interfaces ──────────────────────────────────────────────────────
23
24
 
@@ -158,6 +159,18 @@ class PluginRegistry {
158
159
  }
159
160
  }
160
161
  }
162
+
163
+ /** Destroy all plugins, clean up env vars, and clear the registry. Used by hot-reload. */
164
+ async destroyAndClear(): Promise<void> {
165
+ // Clean up env vars set by plugins before destroying
166
+ for (const { envVars } of this.plugins) {
167
+ for (const key of Object.keys(envVars)) {
168
+ delete process.env[key];
169
+ }
170
+ }
171
+ await this.destroyAll();
172
+ this.plugins.length = 0;
173
+ }
161
174
  }
162
175
 
163
176
  // Module-level singleton
@@ -359,6 +372,140 @@ export async function destroyPlugins(): Promise<void> {
359
372
  await registry.destroyAll();
360
373
  }
361
374
 
375
+ /**
376
+ * Load built-in plugins (GitHub, MemPalace, Playwright) based on config flags.
377
+ * Shared by both bootstrap and hot-reload to avoid duplication.
378
+ */
379
+ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
380
+ const github = config.github;
381
+ if (github?.enabled) {
382
+ try {
383
+ const { createGitHubPlugin } = await import("../plugins/github/index.js");
384
+ const gh = createGitHubPlugin({ token: github.token });
385
+ const ghConfig = github as unknown as Record<string, unknown>;
386
+ registerPlugin(gh, ghConfig);
387
+ if (registry.getByName("github")) {
388
+ await Promise.race([
389
+ gh.init?.(ghConfig),
390
+ new Promise((_, reject) =>
391
+ setTimeout(
392
+ () => reject(new Error("GitHub init timed out after 15s")),
393
+ 15_000,
394
+ ),
395
+ ),
396
+ ]);
397
+ }
398
+ } catch (err) {
399
+ logError(
400
+ "plugin",
401
+ `GitHub init: ${err instanceof Error ? err.message : err}`,
402
+ );
403
+ }
404
+ }
405
+
406
+ const mempalace = config.mempalace;
407
+ if (mempalace?.enabled) {
408
+ try {
409
+ const { createMempalacePlugin } =
410
+ await import("../plugins/mempalace/index.js");
411
+ const { dirs, files: pf } = await import("../util/paths.js");
412
+ const pythonPath = mempalace.pythonPath ?? pf.mempalacePython;
413
+ const palacePath = mempalace.palacePath ?? dirs.palace;
414
+ const mp = createMempalacePlugin({ pythonPath, palacePath });
415
+ const mpConfig = mempalace as unknown as Record<string, unknown>;
416
+ registerPlugin(mp, mpConfig);
417
+ if (registry.getByName("mempalace")) {
418
+ await Promise.race([
419
+ mp.init?.(mpConfig),
420
+ new Promise((_, reject) =>
421
+ setTimeout(
422
+ () => reject(new Error("MemPalace init timed out after 30s")),
423
+ 30_000,
424
+ ),
425
+ ),
426
+ ]);
427
+ }
428
+ } catch (err) {
429
+ logError(
430
+ "plugin",
431
+ `MemPalace init: ${err instanceof Error ? err.message : err}`,
432
+ );
433
+ }
434
+ }
435
+
436
+ const playwright = config.playwright;
437
+ if (playwright?.enabled) {
438
+ try {
439
+ const { createPlaywrightPlugin } =
440
+ await import("../plugins/playwright/index.js");
441
+ const pw = createPlaywrightPlugin({
442
+ browser: playwright.browser,
443
+ headless: playwright.headless,
444
+ });
445
+ const pwConfig = playwright as unknown as Record<string, unknown>;
446
+ registerPlugin(pw, pwConfig);
447
+ if (registry.getByName("playwright")) {
448
+ await Promise.race([
449
+ pw.init?.(pwConfig),
450
+ new Promise((_, reject) =>
451
+ setTimeout(
452
+ () => reject(new Error("Playwright init timed out after 15s")),
453
+ 15_000,
454
+ ),
455
+ ),
456
+ ]);
457
+ }
458
+ } catch (err) {
459
+ logError(
460
+ "plugin",
461
+ `Playwright init: ${err instanceof Error ? err.message : err}`,
462
+ );
463
+ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Hot-reload all plugins: destroy current plugins, re-read config via
469
+ * the validated loadConfig() path, re-load everything (external + built-in).
470
+ * Returns the loaded plugin names and the config that was used.
471
+ *
472
+ * Throws on config parse/validation failure so the gateway can report an error.
473
+ *
474
+ * Does NOT restart the main process, Claude session, or bot connection.
475
+ * Active conversations continue uninterrupted — new MCP servers spawn
476
+ * automatically on the next tool call.
477
+ */
478
+ export async function reloadPlugins(
479
+ activeFrontends?: string[],
480
+ ): Promise<{ names: string[]; config: TalonConfig }> {
481
+ // Validate config BEFORE tearing down existing plugins.
482
+ // If the config is malformed the error propagates and current plugins stay intact.
483
+ const { loadConfig, getFrontends } = await import("../util/config.js");
484
+ const config = loadConfig();
485
+
486
+ // Derive frontends from config if not explicitly provided
487
+ const frontends = activeFrontends ?? getFrontends(config);
488
+
489
+ // Config is valid — safe to destroy current plugins now
490
+ log("plugin", "Hot-reload: destroying current plugins...");
491
+ await registry.destroyAndClear();
492
+
493
+ // Re-load external plugins
494
+ if (config.plugins.length > 0) {
495
+ await loadPlugins(config.plugins, frontends);
496
+ }
497
+
498
+ // Re-load built-in plugins using shared helper
499
+ await loadBuiltinPlugins(config);
500
+
501
+ const names = registry.all.map((p) => p.plugin.name);
502
+ log(
503
+ "plugin",
504
+ `Hot-reload complete: ${names.length} plugins loaded [${names.join(", ")}]`,
505
+ );
506
+ return { names, config };
507
+ }
508
+
362
509
  /**
363
510
  * Register a built-in plugin directly (bypasses filesystem loader).
364
511
  * Used for tightly-integrated plugins like mempalace that are configured
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Admin tools — plugin management and system operations.
3
+ *
4
+ * These tools operate on the Talon runtime itself rather than
5
+ * messaging or content. Available on all frontends.
6
+ */
7
+
8
+ import type { ToolDefinition } from "./types.js";
9
+
10
+ export const adminTools: ToolDefinition[] = [
11
+ {
12
+ name: "reload_plugins",
13
+ description: `Hot-reload all MCP plugins without restarting the bot or disrupting active sessions. Re-reads ~/.talon/config.json, tears down current plugin instances, cleans up their env vars, and loads fresh ones.
14
+
15
+ Use this after editing the plugin config (adding, removing, or updating plugin entries) to apply changes without downtime. Active conversations continue uninterrupted — new plugin tools become available on the next tool call.
16
+
17
+ Returns the list of successfully loaded plugins.`,
18
+ schema: {},
19
+ execute: (_params, bridge) => bridge("reload_plugins", {}),
20
+ tag: "admin",
21
+ },
22
+ ];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Bridge utilities — shared by the unified MCP server.
3
+ *
4
+ * Extracted from the old per-backend tools.ts files so there's
5
+ * exactly one copy of callBridge / textResult.
6
+ */
7
+
8
+ import type { BridgeFunction } from "./types.js";
9
+
10
+ /** Create a bridge caller bound to a specific URL and chat. */
11
+ export function createBridge(
12
+ bridgeUrl: string,
13
+ chatId: string,
14
+ ): BridgeFunction {
15
+ return async (action, params) => {
16
+ const resp = await fetch(`${bridgeUrl}/action`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ action, ...params, _chatId: chatId }),
20
+ signal: AbortSignal.timeout(120_000),
21
+ });
22
+ if (!resp.ok) {
23
+ const text = await resp.text();
24
+ throw new Error(`Bridge error (${resp.status}): ${text}`);
25
+ }
26
+ return resp.json();
27
+ };
28
+ }
29
+
30
+ /** Wrap a bridge result into the MCP content format. */
31
+ export function textResult(result: unknown): {
32
+ content: Array<{ type: "text"; text: string }>;
33
+ } {
34
+ const r = result as { text?: string; error?: string };
35
+ return {
36
+ content: [
37
+ { type: "text" as const, text: r.text ?? JSON.stringify(result) },
38
+ ],
39
+ };
40
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Chat info tools — metadata, admins, settings.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ToolDefinition } from "./types.js";
7
+
8
+ export const chatTools: ToolDefinition[] = [
9
+ {
10
+ name: "get_chat_info",
11
+ description: "Get chat title, type, member count.",
12
+ schema: {},
13
+ execute: (_params, bridge) => bridge("get_chat_info", {}),
14
+ tag: "chat",
15
+ },
16
+
17
+ {
18
+ name: "get_chat_admins",
19
+ description: "List chat administrators.",
20
+ schema: {},
21
+ execute: (_params, bridge) => bridge("get_chat_admins", {}),
22
+ frontends: ["telegram"],
23
+ tag: "chat",
24
+ },
25
+
26
+ {
27
+ name: "get_chat_member_count",
28
+ description: "Get total member count.",
29
+ schema: {},
30
+ execute: (_params, bridge) => bridge("get_chat_member_count", {}),
31
+ frontends: ["telegram"],
32
+ tag: "chat",
33
+ },
34
+
35
+ {
36
+ name: "set_chat_title",
37
+ description: "Change chat title (admin).",
38
+ schema: { title: z.string() },
39
+ execute: (params, bridge) => bridge("set_chat_title", params),
40
+ frontends: ["telegram"],
41
+ tag: "chat",
42
+ },
43
+
44
+ {
45
+ name: "set_chat_description",
46
+ description: "Change chat description (admin).",
47
+ schema: { description: z.string() },
48
+ execute: (params, bridge) => bridge("set_chat_description", params),
49
+ frontends: ["telegram"],
50
+ tag: "chat",
51
+ },
52
+ ];
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Chat history tools — read, search, get messages, download media.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ToolDefinition } from "./types.js";
7
+
8
+ export const historyTools: ToolDefinition[] = [
9
+ {
10
+ name: "read_chat_history",
11
+ description:
12
+ "Read messages from the chat. Use 'before' to go back in time (e.g. '2026-03-13').",
13
+ schema: {
14
+ limit: z
15
+ .number()
16
+ .optional()
17
+ .describe("Number of messages (default 30, max 100)"),
18
+ before: z
19
+ .string()
20
+ .optional()
21
+ .describe("Fetch messages before this date (ISO format)"),
22
+ offset_id: z.number().optional().describe("Fetch before this message ID"),
23
+ },
24
+ execute: (params, bridge) =>
25
+ bridge("read_history", {
26
+ limit: params.limit ?? 30,
27
+ before: params.before,
28
+ offset_id: params.offset_id,
29
+ }),
30
+ frontends: ["telegram"],
31
+ tag: "history",
32
+ },
33
+
34
+ {
35
+ name: "search_chat_history",
36
+ description: "Search messages by keyword.",
37
+ schema: {
38
+ query: z.string(),
39
+ limit: z.number().optional(),
40
+ },
41
+ execute: (params, bridge) => bridge("search_history", params),
42
+ frontends: ["telegram"],
43
+ tag: "history",
44
+ },
45
+
46
+ {
47
+ name: "get_user_messages",
48
+ description: "Get messages from a specific user.",
49
+ schema: {
50
+ user_name: z.string(),
51
+ limit: z.number().optional(),
52
+ },
53
+ execute: (params, bridge) => bridge("get_user_messages", params),
54
+ frontends: ["telegram"],
55
+ tag: "history",
56
+ },
57
+
58
+ {
59
+ name: "get_message_by_id",
60
+ description: "Get a specific message by ID.",
61
+ schema: { message_id: z.number() },
62
+ execute: (params, bridge) => bridge("get_message_by_id", params),
63
+ frontends: ["telegram"],
64
+ tag: "history",
65
+ },
66
+
67
+ {
68
+ name: "download_media",
69
+ description:
70
+ "Download a photo, document, or other media from a message by its ID. Saves the file to the workspace and returns the file path so you can read/analyze it. Use this when you see a [photo] or [document] in chat history but don't have the file.",
71
+ schema: {
72
+ message_id: z
73
+ .number()
74
+ .describe("Message ID containing the media to download"),
75
+ },
76
+ execute: (params, bridge) => bridge("download_media", params),
77
+ frontends: ["telegram"],
78
+ tag: "history",
79
+ },
80
+ ];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tool registry — compose filtered tool sets at runtime.
3
+ *
4
+ * Import domain modules, expose a single composeTools() API
5
+ * that backends and the MCP server use to get the right tool set.
6
+ */
7
+
8
+ import type { ToolDefinition, ToolFrontend, ToolTag } from "./types.js";
9
+
10
+ import { messagingTools } from "./messaging.js";
11
+ import { chatTools } from "./chat.js";
12
+ import { historyTools } from "./history.js";
13
+ import { memberTools } from "./members.js";
14
+ import { mediaTools } from "./media.js";
15
+ import { stickerTools } from "./stickers.js";
16
+ import { schedulingTools } from "./scheduling.js";
17
+ import { webTools } from "./web.js";
18
+ import { adminTools } from "./admin.js";
19
+
20
+ /** All built-in tool definitions. */
21
+ export const ALL_TOOLS: readonly ToolDefinition[] = [
22
+ ...messagingTools,
23
+ ...chatTools,
24
+ ...historyTools,
25
+ ...memberTools,
26
+ ...mediaTools,
27
+ ...stickerTools,
28
+ ...schedulingTools,
29
+ ...webTools,
30
+ ...adminTools,
31
+ ];
32
+
33
+ /** Filter options for composing a tool set. */
34
+ export interface ComposeOptions {
35
+ /** Include only tools available on this frontend. */
36
+ frontend?: ToolFrontend;
37
+ /** Include only tools with these tags. */
38
+ tags?: ToolTag[];
39
+ /** Exclude tools with these tags. */
40
+ excludeTags?: ToolTag[];
41
+ /** Exclude specific tools by name. */
42
+ excludeNames?: string[];
43
+ }
44
+
45
+ /**
46
+ * Compose a filtered set of tools at runtime.
47
+ *
48
+ * When no options are provided, returns ALL_TOOLS unchanged.
49
+ * Callers describe what they need and get back matching definitions.
50
+ */
51
+ export function composeTools(options: ComposeOptions = {}): ToolDefinition[] {
52
+ let tools = [...ALL_TOOLS];
53
+
54
+ if (options.frontend) {
55
+ tools = tools.filter(
56
+ (t) =>
57
+ !t.frontends ||
58
+ t.frontends.includes("all") ||
59
+ t.frontends.includes(options.frontend!),
60
+ );
61
+ }
62
+
63
+ if (options.tags?.length) {
64
+ tools = tools.filter((t) => options.tags!.includes(t.tag));
65
+ }
66
+
67
+ if (options.excludeTags?.length) {
68
+ tools = tools.filter((t) => !options.excludeTags!.includes(t.tag));
69
+ }
70
+
71
+ if (options.excludeNames?.length) {
72
+ tools = tools.filter((t) => !options.excludeNames!.includes(t.name));
73
+ }
74
+
75
+ return tools;
76
+ }
77
+
78
+ // Re-export types for convenience
79
+ export type {
80
+ ToolDefinition,
81
+ ToolFrontend,
82
+ ToolTag,
83
+ BridgeFunction,
84
+ } from "./types.js";