talon-agent 1.3.0 → 1.4.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.
package/src/bootstrap.ts CHANGED
@@ -58,13 +58,12 @@ export async function bootstrap(
58
58
  ): Promise<BootstrapResult> {
59
59
  const config = loadConfig();
60
60
 
61
- // Expose search config as env vars for gateway-actions
62
- if (config.braveApiKey) process.env.TALON_BRAVE_API_KEY = config.braveApiKey;
63
- if (config.searxngUrl) process.env.TALON_SEARXNG_URL = config.searxngUrl;
64
-
65
- // Load plugins (external tool packages + built-in mempalace)
61
+ // Load plugins (external tool packages + built-in GitHub, MemPalace, Playwright)
66
62
  const hasPlugins =
67
- config.plugins.length > 0 || config.mempalace?.enabled === true;
63
+ config.plugins.length > 0 ||
64
+ config.github?.enabled === true ||
65
+ config.mempalace?.enabled === true ||
66
+ config.playwright?.enabled === true;
68
67
  if (hasPlugins) {
69
68
  const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
70
69
  await import("./core/plugin.js");
@@ -77,6 +76,35 @@ export async function bootstrap(
77
76
  await loadPlugins(config.plugins, frontends);
78
77
  }
79
78
 
79
+ // Built-in: GitHub
80
+ if (config.github?.enabled) {
81
+ const { createGitHubPlugin } = await import("./plugins/github/index.js");
82
+ const { getPlugin } = await import("./core/plugin.js");
83
+ const githubConfig = config.github as unknown as Record<string, unknown>;
84
+ const gh = createGitHubPlugin({ token: config.github.token });
85
+ registerPlugin(gh, githubConfig);
86
+
87
+ if (getPlugin("github")) {
88
+ try {
89
+ const GITHUB_INIT_TIMEOUT_MS = 15_000;
90
+ await Promise.race([
91
+ gh.init?.(githubConfig),
92
+ new Promise((_, reject) =>
93
+ setTimeout(
94
+ () => reject(new Error("GitHub init timed out after 15s")),
95
+ GITHUB_INIT_TIMEOUT_MS,
96
+ ),
97
+ ),
98
+ ]);
99
+ } catch (err) {
100
+ log(
101
+ "github",
102
+ `Init warning: ${err instanceof Error ? err.message : err}`,
103
+ );
104
+ }
105
+ }
106
+ }
107
+
80
108
  // Built-in: MemPalace
81
109
  if (config.mempalace?.enabled) {
82
110
  const { createMempalacePlugin } =
@@ -115,6 +143,42 @@ export async function bootstrap(
115
143
  }
116
144
  }
117
145
 
146
+ // Built-in: Playwright
147
+ if (config.playwright?.enabled) {
148
+ const { createPlaywrightPlugin } =
149
+ await import("./plugins/playwright/index.js");
150
+ const { getPlugin } = await import("./core/plugin.js");
151
+ const playwrightConfig = config.playwright as unknown as Record<
152
+ string,
153
+ unknown
154
+ >;
155
+ const pw = createPlaywrightPlugin({
156
+ browser: config.playwright.browser,
157
+ headless: config.playwright.headless,
158
+ });
159
+ registerPlugin(pw, playwrightConfig);
160
+
161
+ if (getPlugin("playwright")) {
162
+ try {
163
+ const PW_INIT_TIMEOUT_MS = 15_000;
164
+ await Promise.race([
165
+ pw.init?.(playwrightConfig),
166
+ new Promise((_, reject) =>
167
+ setTimeout(
168
+ () => reject(new Error("Playwright init timed out after 15s")),
169
+ PW_INIT_TIMEOUT_MS,
170
+ ),
171
+ ),
172
+ ]);
173
+ } catch (err) {
174
+ log(
175
+ "playwright",
176
+ `Init warning: ${err instanceof Error ? err.message : err}`,
177
+ );
178
+ }
179
+ }
180
+ }
181
+
118
182
  rebuildSystemPrompt(config, getPluginPromptAdditions());
119
183
  }
120
184
 
@@ -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": {
@@ -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,82 @@
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
+
19
+ /** All built-in tool definitions. */
20
+ export const ALL_TOOLS: readonly ToolDefinition[] = [
21
+ ...messagingTools,
22
+ ...chatTools,
23
+ ...historyTools,
24
+ ...memberTools,
25
+ ...mediaTools,
26
+ ...stickerTools,
27
+ ...schedulingTools,
28
+ ...webTools,
29
+ ];
30
+
31
+ /** Filter options for composing a tool set. */
32
+ export interface ComposeOptions {
33
+ /** Include only tools available on this frontend. */
34
+ frontend?: ToolFrontend;
35
+ /** Include only tools with these tags. */
36
+ tags?: ToolTag[];
37
+ /** Exclude tools with these tags. */
38
+ excludeTags?: ToolTag[];
39
+ /** Exclude specific tools by name. */
40
+ excludeNames?: string[];
41
+ }
42
+
43
+ /**
44
+ * Compose a filtered set of tools at runtime.
45
+ *
46
+ * When no options are provided, returns ALL_TOOLS unchanged.
47
+ * Callers describe what they need and get back matching definitions.
48
+ */
49
+ export function composeTools(options: ComposeOptions = {}): ToolDefinition[] {
50
+ let tools = [...ALL_TOOLS];
51
+
52
+ if (options.frontend) {
53
+ tools = tools.filter(
54
+ (t) =>
55
+ !t.frontends ||
56
+ t.frontends.includes("all") ||
57
+ t.frontends.includes(options.frontend!),
58
+ );
59
+ }
60
+
61
+ if (options.tags?.length) {
62
+ tools = tools.filter((t) => options.tags!.includes(t.tag));
63
+ }
64
+
65
+ if (options.excludeTags?.length) {
66
+ tools = tools.filter((t) => !options.excludeTags!.includes(t.tag));
67
+ }
68
+
69
+ if (options.excludeNames?.length) {
70
+ tools = tools.filter((t) => !options.excludeNames!.includes(t.name));
71
+ }
72
+
73
+ return tools;
74
+ }
75
+
76
+ // Re-export types for convenience
77
+ export type {
78
+ ToolDefinition,
79
+ ToolFrontend,
80
+ ToolTag,
81
+ BridgeFunction,
82
+ } from "./types.js";
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unified MCP server — replaces per-backend tools.ts files.
4
+ *
5
+ * Reads TALON_FRONTEND env var to compose the right tool set,
6
+ * then registers them all on a single MCP server over stdio.
7
+ *
8
+ * Environment:
9
+ * TALON_BRIDGE_URL — HTTP bridge URL (default: http://127.0.0.1:19876)
10
+ * TALON_CHAT_ID — Current chat ID
11
+ * TALON_FRONTEND — Frontend type: "telegram" | "teams" | "terminal" (default: "telegram")
12
+ */
13
+
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { composeTools } from "./index.js";
17
+ import { createBridge, textResult } from "./bridge.js";
18
+ import type { ToolFrontend } from "./types.js";
19
+
20
+ const VALID_FRONTENDS = new Set<ToolFrontend>([
21
+ "telegram",
22
+ "teams",
23
+ "terminal",
24
+ ]);
25
+ const BRIDGE_URL = process.env.TALON_BRIDGE_URL || "http://127.0.0.1:19876";
26
+ const CHAT_ID = process.env.TALON_CHAT_ID || "";
27
+ const rawFrontend = (process.env.TALON_FRONTEND || "telegram") as ToolFrontend;
28
+
29
+ if (!CHAT_ID) {
30
+ console.warn(
31
+ "TALON_CHAT_ID is not set — bridge calls will fail without a valid chat context.",
32
+ );
33
+ }
34
+
35
+ if (!VALID_FRONTENDS.has(rawFrontend)) {
36
+ console.error(
37
+ `Invalid TALON_FRONTEND: "${rawFrontend}". Must be one of: ${[...VALID_FRONTENDS].join(", ")}`,
38
+ );
39
+ process.exit(1);
40
+ }
41
+ const FRONTEND = rawFrontend;
42
+
43
+ const bridge = createBridge(BRIDGE_URL, CHAT_ID);
44
+ const serverName = `${FRONTEND}-tools`;
45
+ const server = new McpServer({ name: serverName, version: "3.0.0" });
46
+
47
+ // Compose and register all tools for the active frontend
48
+ const tools = composeTools({ frontend: FRONTEND });
49
+
50
+ for (const tool of tools) {
51
+ server.tool(tool.name, tool.description, tool.schema, async (params) =>
52
+ textResult(await tool.execute(params, bridge)),
53
+ );
54
+ }
55
+
56
+ async function main() {
57
+ const transport = new StdioServerTransport();
58
+ await server.connect(transport);
59
+ }
60
+
61
+ main().catch((err) => {
62
+ console.error("MCP server error:", err);
63
+ process.exit(1);
64
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Media tools — list recent media in a chat.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ToolDefinition } from "./types.js";
7
+
8
+ export const mediaTools: ToolDefinition[] = [
9
+ {
10
+ name: "list_media",
11
+ description:
12
+ "List recent photos, documents, and other media in the current chat with file paths. Use this to find a previously sent photo or file to re-read or reference.",
13
+ schema: {
14
+ limit: z
15
+ .number()
16
+ .optional()
17
+ .describe("Number of entries (default 10, max 20)"),
18
+ },
19
+ execute: (params, bridge) => bridge("list_media", { limit: params.limit }),
20
+ frontends: ["telegram"],
21
+ tag: "media",
22
+ },
23
+ ];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Member tools — list members, user info, online count, pinned messages.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ToolDefinition } from "./types.js";
7
+
8
+ export const memberTools: ToolDefinition[] = [
9
+ {
10
+ name: "list_chat_members",
11
+ description: "List chat members with names, IDs, online status, badges.",
12
+ schema: { limit: z.number().optional() },
13
+ execute: (params, bridge) =>
14
+ bridge("list_known_users", { limit: params.limit }),
15
+ frontends: ["telegram"],
16
+ tag: "members",
17
+ },
18
+
19
+ {
20
+ name: "get_member_info",
21
+ description: "Get detailed info about a user by ID.",
22
+ schema: { user_id: z.number() },
23
+ execute: (params, bridge) => bridge("get_member_info", params),
24
+ frontends: ["telegram"],
25
+ tag: "members",
26
+ },
27
+
28
+ {
29
+ name: "online_count",
30
+ description:
31
+ "Get how many members are currently online or recently active.",
32
+ schema: {},
33
+ execute: (_params, bridge) => bridge("online_count", {}),
34
+ frontends: ["telegram"],
35
+ tag: "members",
36
+ },
37
+
38
+ {
39
+ name: "get_pinned_messages",
40
+ description: "Get all pinned messages in the current chat.",
41
+ schema: {},
42
+ execute: (_params, bridge) => bridge("get_pinned_messages", {}),
43
+ frontends: ["telegram"],
44
+ tag: "members",
45
+ },
46
+ ];