talon-agent 1.2.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.
Files changed (46) hide show
  1. package/package.json +7 -6
  2. package/prompts/dream.md +6 -2
  3. package/prompts/mempalace.md +57 -0
  4. package/src/__tests__/compose-tools.test.ts +216 -0
  5. package/src/__tests__/cron-store-extended.test.ts +1 -1
  6. package/src/__tests__/dream.test.ts +118 -1
  7. package/src/__tests__/fuzz.test.ts +1 -3
  8. package/src/__tests__/gateway-actions.test.ts +1 -423
  9. package/src/__tests__/gateway-retry.test.ts +0 -4
  10. package/src/__tests__/handlers.test.ts +0 -4
  11. package/src/__tests__/heartbeat.test.ts +3 -0
  12. package/src/__tests__/mempalace-plugin.test.ts +295 -0
  13. package/src/__tests__/plugin.test.ts +169 -0
  14. package/src/__tests__/storage-save-errors.test.ts +1 -1
  15. package/src/__tests__/time.test.ts +1 -1
  16. package/src/__tests__/watchdog.test.ts +1 -3
  17. package/src/__tests__/workspace.test.ts +0 -1
  18. package/src/backend/claude-sdk/index.ts +39 -54
  19. package/src/backend/opencode/index.ts +5 -20
  20. package/src/bootstrap.ts +140 -11
  21. package/src/core/dream.ts +40 -6
  22. package/src/core/gateway-actions.ts +0 -87
  23. package/src/core/plugin.ts +103 -16
  24. package/src/core/tools/bridge.ts +40 -0
  25. package/src/core/tools/chat.ts +52 -0
  26. package/src/core/tools/history.ts +80 -0
  27. package/src/core/tools/index.ts +82 -0
  28. package/src/core/tools/mcp-server.ts +64 -0
  29. package/src/core/tools/media.ts +23 -0
  30. package/src/core/tools/members.ts +46 -0
  31. package/src/core/tools/messaging.ts +300 -0
  32. package/src/core/tools/scheduling.ts +89 -0
  33. package/src/core/tools/stickers.ts +143 -0
  34. package/src/core/tools/types.ts +60 -0
  35. package/src/core/tools/web.ts +26 -0
  36. package/src/frontend/telegram/actions.ts +10 -1
  37. package/src/frontend/telegram/handlers.ts +5 -17
  38. package/src/plugins/github/index.ts +106 -0
  39. package/src/plugins/mempalace/index.ts +147 -0
  40. package/src/plugins/playwright/index.ts +82 -0
  41. package/src/storage/sessions.ts +0 -10
  42. package/src/util/config.ts +31 -1
  43. package/src/util/log.ts +4 -1
  44. package/src/util/paths.ts +9 -0
  45. package/src/backend/claude-sdk/tools.ts +0 -651
  46. package/src/frontend/teams/tools.ts +0 -175
@@ -17,7 +17,6 @@ import {
17
17
  resetSession,
18
18
  } from "../../storage/sessions.js";
19
19
  import { getChatSettings } from "../../storage/chat-settings.js";
20
- import { getRecentHistory } from "../../storage/history.js";
21
20
  import { classify } from "../../core/errors.js";
22
21
  import { log, logError, logWarn } from "../../util/log.js";
23
22
  import { traceMessage } from "../../util/trace.js";
@@ -53,7 +52,7 @@ async function ensureServer(): Promise<OpencodeClient> {
53
52
 
54
53
  // Register our MCP tools server with OpenCode
55
54
  try {
56
- const toolsPath = new URL("../claude-sdk/tools.ts", import.meta.url)
55
+ const toolsPath = new URL("../../core/tools/mcp-server.ts", import.meta.url)
57
56
  .pathname;
58
57
  await client.mcp.add({
59
58
  body: {
@@ -63,6 +62,7 @@ async function ensureServer(): Promise<OpencodeClient> {
63
62
  command: ["node", "--import", "tsx", toolsPath],
64
63
  environment: {
65
64
  TALON_BRIDGE_URL: `http://127.0.0.1:${gatewayPortFn()}`,
65
+ TALON_FRONTEND: "telegram",
66
66
  },
67
67
  },
68
68
  },
@@ -149,24 +149,9 @@ export async function handleMessage(
149
149
 
150
150
  // Build prompt with group context
151
151
  const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
152
- let continuityPrefix = "";
153
- const session = getSession(chatId);
154
- if (!session.sessionId && session.turns > 0) {
155
- const recent = getRecentHistory(chatId, 3);
156
- if (recent.length > 0) {
157
- const ctx = recent
158
- .map(
159
- (m) =>
160
- `[${new Date(m.timestamp).toISOString().slice(11, 16)}] ${m.senderName}: ${m.text.slice(0, 300)}`,
161
- )
162
- .join("\n");
163
- continuityPrefix = `[Session resumed — recent context:\n${ctx}]\n\n`;
164
- }
165
- }
166
-
167
152
  const prompt = isGroup
168
- ? `${continuityPrefix}[${senderName}]${msgIdHint}: ${text}`
169
- : `${continuityPrefix}${text}${msgIdHint}`;
153
+ ? `[${senderName}]${msgIdHint}: ${text}`
154
+ : `${text}${msgIdHint}`;
170
155
 
171
156
  log("agent", `[${chatId}] <- (${text.length} chars)`);
172
157
  traceMessage(chatId, "in", text, { senderName, isGroup });
@@ -211,7 +196,7 @@ export async function handleMessage(
211
196
  model: activeModel,
212
197
  });
213
198
 
214
- if (session.turns === 0 && text) {
199
+ if (getSession(chatId).turns === 0 && text) {
215
200
  const cleanText = text
216
201
  .replace(/^\[.*?\]\s*/g, "")
217
202
  .replace(/\[msg_id:\d+\]\s*/g, "")
package/src/bootstrap.ts CHANGED
@@ -58,18 +58,127 @@ 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)
66
- if (config.plugins.length > 0) {
67
- const { loadPlugins, getPluginPromptAdditions } =
61
+ // Load plugins (external tool packages + built-in GitHub, MemPalace, Playwright)
62
+ const hasPlugins =
63
+ config.plugins.length > 0 ||
64
+ config.github?.enabled === true ||
65
+ config.mempalace?.enabled === true ||
66
+ config.playwright?.enabled === true;
67
+ if (hasPlugins) {
68
+ const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
68
69
  await import("./core/plugin.js");
69
- const frontends =
70
- options.frontendNames ??
71
- (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
72
- await loadPlugins(config.plugins, frontends);
70
+
71
+ // External plugins
72
+ if (config.plugins.length > 0) {
73
+ const frontends =
74
+ options.frontendNames ??
75
+ (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
76
+ await loadPlugins(config.plugins, frontends);
77
+ }
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
+
108
+ // Built-in: MemPalace
109
+ if (config.mempalace?.enabled) {
110
+ const { createMempalacePlugin } =
111
+ await import("./plugins/mempalace/index.js");
112
+ const { getPlugin } = await import("./core/plugin.js");
113
+ const { dirs, files: pathFiles } = await import("./util/paths.js");
114
+ const pythonPath =
115
+ config.mempalace.pythonPath ?? pathFiles.mempalacePython;
116
+ const palacePath = config.mempalace.palacePath ?? dirs.palace;
117
+ const mempalaceConfig = config.mempalace as unknown as Record<
118
+ string,
119
+ unknown
120
+ >;
121
+ const mp = createMempalacePlugin({ pythonPath, palacePath });
122
+ registerPlugin(mp, mempalaceConfig);
123
+
124
+ // Only call init if registration succeeded (validation passed)
125
+ if (getPlugin("mempalace")) {
126
+ try {
127
+ const MEMPALACE_INIT_TIMEOUT_MS = 30_000;
128
+ await Promise.race([
129
+ mp.init?.(mempalaceConfig),
130
+ new Promise((_, reject) =>
131
+ setTimeout(
132
+ () => reject(new Error("MemPalace init timed out after 30s")),
133
+ MEMPALACE_INIT_TIMEOUT_MS,
134
+ ),
135
+ ),
136
+ ]);
137
+ } catch (err) {
138
+ log(
139
+ "mempalace",
140
+ `Init warning: ${err instanceof Error ? err.message : err}`,
141
+ );
142
+ }
143
+ }
144
+ }
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
+
73
182
  rebuildSystemPrompt(config, getPluginPromptAdditions());
74
183
  }
75
184
 
@@ -119,11 +228,31 @@ export async function initBackendAndDispatcher(
119
228
 
120
229
  initPulse();
121
230
  initCron({ sendMessage: frontend.sendMessage });
231
+
232
+ // Only enable mempalace dream integration if the plugin actually registered
233
+ let mempalaceCfg: { pythonPath: string; palacePath: string } | undefined;
234
+ if (config.mempalace?.enabled) {
235
+ const { getPlugin } = await import("./core/plugin.js");
236
+ if (getPlugin("mempalace")) {
237
+ const { dirs, files: pathFiles } = await import("./util/paths.js");
238
+ mempalaceCfg = {
239
+ pythonPath: config.mempalace.pythonPath ?? pathFiles.mempalacePython,
240
+ palacePath: config.mempalace.palacePath ?? dirs.palace,
241
+ };
242
+ } else {
243
+ log(
244
+ "mempalace",
245
+ "Enabled in config but plugin not registered — skipping dream integration",
246
+ );
247
+ }
248
+ }
249
+
122
250
  initDream({
123
251
  model: config.model,
124
252
  dreamModel: config.dreamModel,
125
253
  claudeBinary: config.claudeBinary,
126
254
  workspace: config.workspace,
255
+ mempalace: mempalaceCfg,
127
256
  });
128
257
  initHeartbeat({
129
258
  model: config.model,
package/src/core/dream.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 2. Spawns a background Agent that reads recent logs and merges new
8
8
  * facts/preferences/events into memory.md
9
9
  *
10
- * The dream agent runs entirely on filesystem tools no Telegram/MCP access.
10
+ * The dream agent runs on filesystem tools, with optional MCP access for MemPalace when configured.
11
11
  * It does NOT use the main dispatcher (no chat session, no typing indicator).
12
12
  */
13
13
 
@@ -19,6 +19,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
19
19
  import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
20
20
  import { files as pathFiles, dirs } from "../util/paths.js";
21
21
  import { log, logError, logWarn } from "../util/log.js";
22
+ import { getPluginMcpServers } from "./plugin.js";
22
23
 
23
24
  // ── Types ────────────────────────────────────────────────────────────────────
24
25
 
@@ -46,6 +47,7 @@ let configRef: {
46
47
  dreamModel?: string;
47
48
  claudeBinary?: string;
48
49
  workspace?: string;
50
+ mempalace?: { pythonPath: string; palacePath: string };
49
51
  } | null = null;
50
52
 
51
53
  export function initDream(cfg: {
@@ -54,6 +56,8 @@ export function initDream(cfg: {
54
56
  dreamModel?: string;
55
57
  claudeBinary?: string;
56
58
  workspace?: string;
59
+ /** MemPalace config for mining logs into the palace during dream runs. */
60
+ mempalace?: { pythonPath: string; palacePath: string };
57
61
  }): void {
58
62
  configRef = cfg;
59
63
  }
@@ -137,12 +141,39 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
137
141
 
138
142
  let prompt: string;
139
143
  try {
144
+ // Build optional mempalace mining section
145
+ const mempalaceSection = configRef.mempalace
146
+ ? `If mempalace is available, mine the daily memory notes (your curated observations, NOT raw logs) into the palace for long-term semantic retrieval.
147
+ Run this command using the Bash tool:
148
+
149
+ \`\`\`bash
150
+ '${configRef.mempalace.pythonPath.replace(/'/g, "'\\''")}' -m mempalace mine '${dirs.dailyMemory.replace(/'/g, "'\\''")}' --palace '${configRef.mempalace.palacePath.replace(/'/g, "'\\''")}' --mode convos --wing daily-notes
151
+ \`\`\`
152
+
153
+ Then write a personal diary entry. This is YOUR journal — not a status report. Reflect on:
154
+ - What happened today. What conversations stood out.
155
+ - How you feel. Your state of mind, emotions, energy.
156
+ - What you learned. What surprised you. What you got wrong.
157
+ - What you're curious about. Loose threads. Things you want to follow up on.
158
+ - Your honest thoughts — about users, projects, yourself.
159
+
160
+ Write the diary using the \`mempalace_diary_write\` MCP tool (available during dream):
161
+ \`\`\`
162
+ mempalace_diary_write(agent_name="talon", entry="YOUR_DIARY_ENTRY_HERE", topic="dream-reflection")
163
+ \`\`\`
164
+
165
+ If the MCP tool is not available, write the diary entry to a file at ${dirs.dailyMemory}/diary-YYYY-MM-DD.md instead.
166
+ Keep the diary authentic. Write in first person. Be honest. This is for you, not for anyone else.
167
+ If commands fail, log the error and continue — this stage is optional.`
168
+ : "MemPalace is not configured. Skip this stage.";
169
+
140
170
  prompt = readFileSync(promptPath, "utf-8")
141
171
  .replace(/\{\{dreamStateFile\}\}/g, dreamStateFile)
142
172
  .replace(/\{\{logsDir\}\}/g, logsDir)
143
173
  .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
144
174
  .replace(/\{\{memoryFile\}\}/g, memoryFile)
145
- .replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory);
175
+ .replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory)
176
+ .replace(/\{\{mempalaceSection\}\}/g, mempalaceSection);
146
177
  } catch {
147
178
  throw new Error(`Failed to read dream prompt from ${promptPath}`);
148
179
  }
@@ -164,16 +195,19 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
164
195
 
165
196
  const options = {
166
197
  model,
167
- systemPrompt:
168
- "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
198
+ systemPrompt: configRef.mempalace
199
+ ? "You are a background memory consolidation agent for Talon. Use filesystem tools and MemPalace MCP tools. Do NOT use Telegram or messaging tools. Be precise and surgical — update memory.md without losing existing accurate information."
200
+ : "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
169
201
  cwd: workspace,
170
202
  permissionMode: "bypassPermissions" as const,
171
203
  allowDangerouslySkipPermissions: true,
172
204
  ...(configRef.claudeBinary
173
205
  ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
174
206
  : {}),
175
- // No MCP serversfilesystem tools only
176
- mcpServers: {},
207
+ // Only load mempalace MCP server for dream no other plugins needed
208
+ mcpServers: configRef.mempalace
209
+ ? getPluginMcpServers("", "dream", ["mempalace"])
210
+ : {},
177
211
  disallowedTools: [
178
212
  "EnterPlanMode",
179
213
  "ExitPlanMode",
@@ -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": {
@@ -61,11 +61,22 @@ export interface TalonPlugin {
61
61
  destroy?(): Promise<void> | void;
62
62
 
63
63
  /**
64
- * Absolute path to the MCP server script (spawned as subprocess).
64
+ * Absolute path to the MCP server script (spawned as subprocess via node/tsx).
65
65
  * Omit if the plugin only provides action handlers without MCP tools.
66
+ * For non-Node MCP servers (Python, Go, etc.), use `mcpServer` instead.
66
67
  */
67
68
  mcpServerPath?: string;
68
69
 
70
+ /**
71
+ * Custom MCP server command and arguments (e.g. Python, Go, Rust servers).
72
+ * Takes priority over `mcpServerPath` when both are set.
73
+ * Example: { command: "/path/to/python", args: ["-m", "mempalace.mcp_server"] }
74
+ */
75
+ mcpServer?: {
76
+ readonly command: string;
77
+ readonly args: readonly string[];
78
+ };
79
+
69
80
  /**
70
81
  * Map plugin config to env vars for the MCP subprocess and action handlers.
71
82
  * Called once at load time. Values are set on process.env for the main
@@ -309,6 +320,18 @@ function extractPlugin(mod: Record<string, unknown>): TalonPlugin | null {
309
320
  typeof plugin.mcpServerPath !== "string"
310
321
  )
311
322
  return null;
323
+ if (plugin.mcpServer !== undefined) {
324
+ if (typeof plugin.mcpServer !== "object" || plugin.mcpServer === null)
325
+ return null;
326
+ const srv = plugin.mcpServer as Record<string, unknown>;
327
+ if (
328
+ typeof srv.command !== "string" ||
329
+ !srv.command ||
330
+ !Array.isArray(srv.args) ||
331
+ !srv.args.every((a) => typeof a === "string")
332
+ )
333
+ return null;
334
+ }
312
335
  if (plugin.frontends !== undefined && !Array.isArray(plugin.frontends))
313
336
  return null;
314
337
  return candidate as TalonPlugin;
@@ -336,6 +359,49 @@ export async function destroyPlugins(): Promise<void> {
336
359
  await registry.destroyAll();
337
360
  }
338
361
 
362
+ /**
363
+ * Register a built-in plugin directly (bypasses filesystem loader).
364
+ * Used for tightly-integrated plugins like mempalace that are configured
365
+ * via dedicated config fields rather than the plugins[] array.
366
+ *
367
+ * NOTE: This only registers the plugin — it does NOT call `init()`.
368
+ * The caller is responsible for calling `plugin.init()` separately
369
+ * after registration if initialization is needed.
370
+ */
371
+ export function registerPlugin(
372
+ plugin: TalonPlugin,
373
+ config: Record<string, unknown> = {},
374
+ ): void {
375
+ // Check for duplicates first — avoids re-running expensive validation
376
+ if (registry.getByName(plugin.name)) {
377
+ logWarn(
378
+ "plugin",
379
+ `Built-in plugin "${plugin.name}" already registered — skipping`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ const errors = plugin.validateConfig?.(config);
385
+ if (errors && errors.length > 0) {
386
+ logError(
387
+ "plugin",
388
+ `Built-in plugin "${plugin.name}" config validation failed:\n ${errors.join("\n ")}`,
389
+ );
390
+ return;
391
+ }
392
+
393
+ const envVars = plugin.getEnvVars?.(config) ?? {};
394
+ for (const [k, v] of Object.entries(envVars)) {
395
+ process.env[k] = v;
396
+ }
397
+ const loaded: LoadedPlugin = { plugin, config, envVars, path: "(built-in)" };
398
+ registry.register(loaded);
399
+
400
+ const version = plugin.version ? ` v${plugin.version}` : "";
401
+ const desc = plugin.description ? ` — ${plugin.description}` : "";
402
+ log("plugin", `Registered built-in: ${plugin.name}${version}${desc}`);
403
+ }
404
+
339
405
  /**
340
406
  * Collect system prompt additions from all plugins.
341
407
  * Called during config/prompt assembly.
@@ -396,13 +462,22 @@ export interface McpServerConfig {
396
462
  }
397
463
 
398
464
  /**
399
- * Build MCP server entries for all plugins that provide an MCP server.
400
- * Plugins without `mcpServerPath` are skipped.
465
+ * Build MCP server entries for plugins that provide an MCP server.
466
+ * Plugins can expose an MCP server in two ways:
467
+ * - `mcpServerPath` — path to a Node/TypeScript MCP server script (run via tsx)
468
+ * - `mcpServer` — custom command/args for non-Node servers (Python, Go, etc.)
469
+ * Plugins with neither are skipped. When both are set, `mcpServer` takes priority.
470
+ *
471
+ * @param only — optional list of plugin names to include. If omitted, all
472
+ * plugins with MCP servers are returned. Pass `[]` to get none.
401
473
  */
402
474
  export function getPluginMcpServers(
403
475
  bridgeUrl: string,
404
476
  chatId: string,
477
+ only?: string[],
405
478
  ): Record<string, McpServerConfig> {
479
+ if (only !== undefined && only.length === 0) return {};
480
+
406
481
  const servers: Record<string, McpServerConfig> = {};
407
482
 
408
483
  // Resolve tsx from Talon's own node_modules (not cwd which may be ~/.talon/workspace/)
@@ -412,20 +487,32 @@ export function getPluginMcpServers(
412
487
  );
413
488
 
414
489
  for (const { plugin, envVars } of registry.all) {
415
- if (!plugin.mcpServerPath) continue;
416
-
417
- servers[`${plugin.name}-tools`] = {
418
- command: process.platform === "win32" ? "npx" : "node",
419
- args:
420
- process.platform === "win32"
421
- ? ["tsx", plugin.mcpServerPath]
422
- : ["--import", tsxPath, plugin.mcpServerPath],
423
- env: {
424
- TALON_BRIDGE_URL: bridgeUrl,
425
- TALON_CHAT_ID: chatId,
426
- ...envVars,
427
- },
490
+ // Skip plugins not in the allow-list when filtering
491
+ if (only !== undefined && !only.includes(plugin.name)) continue;
492
+ const baseEnv = {
493
+ TALON_BRIDGE_URL: bridgeUrl,
494
+ TALON_CHAT_ID: chatId,
495
+ ...envVars,
428
496
  };
497
+
498
+ if (plugin.mcpServer) {
499
+ // Custom command/args (Python, Go, etc.) — no tsx wrapper
500
+ servers[`${plugin.name}-tools`] = {
501
+ command: plugin.mcpServer.command,
502
+ args: [...plugin.mcpServer.args],
503
+ env: baseEnv,
504
+ };
505
+ } else if (plugin.mcpServerPath) {
506
+ // Existing Node/tsx pattern
507
+ servers[`${plugin.name}-tools`] = {
508
+ command: process.platform === "win32" ? "npx" : "node",
509
+ args:
510
+ process.platform === "win32"
511
+ ? ["tsx", plugin.mcpServerPath]
512
+ : ["--import", tsxPath, plugin.mcpServerPath],
513
+ env: baseEnv,
514
+ };
515
+ }
429
516
  }
430
517
 
431
518
  return servers;
@@ -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
+ ];