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.
- package/package.json +4 -2
- package/prompts/heartbeat.md +18 -6
- package/src/__tests__/compose-tools.test.ts +216 -0
- package/src/__tests__/fuzz.test.ts +0 -2
- package/src/__tests__/gateway-actions.test.ts +1 -423
- package/src/__tests__/heartbeat.test.ts +21 -0
- package/src/__tests__/reload-plugins.test.ts +199 -0
- package/src/__tests__/sessions.test.ts +155 -121
- package/src/backend/claude-sdk/index.ts +230 -109
- package/src/backend/opencode/index.ts +5 -20
- package/src/bootstrap.ts +8 -44
- package/src/core/gateway-actions.ts +42 -88
- package/src/core/heartbeat.ts +8 -5
- package/src/core/plugin.ts +147 -0
- package/src/core/tools/admin.ts +22 -0
- package/src/core/tools/bridge.ts +40 -0
- package/src/core/tools/chat.ts +52 -0
- package/src/core/tools/history.ts +80 -0
- package/src/core/tools/index.ts +84 -0
- package/src/core/tools/mcp-server.ts +64 -0
- package/src/core/tools/media.ts +23 -0
- package/src/core/tools/members.ts +46 -0
- package/src/core/tools/messaging.ts +300 -0
- package/src/core/tools/scheduling.ts +89 -0
- package/src/core/tools/stickers.ts +143 -0
- package/src/core/tools/types.ts +61 -0
- package/src/core/tools/web.ts +26 -0
- package/src/frontend/teams/index.ts +9 -10
- package/src/frontend/telegram/actions.ts +10 -1
- package/src/frontend/telegram/commands.ts +11 -10
- package/src/plugins/github/index.ts +106 -0
- package/src/plugins/playwright/index.ts +82 -0
- package/src/storage/sessions.ts +34 -50
- package/src/util/config.ts +20 -1
- package/src/util/log.ts +3 -1
- package/src/backend/claude-sdk/tools.ts +0 -651
- 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
|
}
|
package/src/core/heartbeat.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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 () => {
|
package/src/core/plugin.ts
CHANGED
|
@@ -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";
|