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.
- package/package.json +7 -6
- package/prompts/dream.md +6 -2
- package/prompts/mempalace.md +57 -0
- package/src/__tests__/compose-tools.test.ts +216 -0
- package/src/__tests__/cron-store-extended.test.ts +1 -1
- package/src/__tests__/dream.test.ts +118 -1
- package/src/__tests__/fuzz.test.ts +1 -3
- package/src/__tests__/gateway-actions.test.ts +1 -423
- package/src/__tests__/gateway-retry.test.ts +0 -4
- package/src/__tests__/handlers.test.ts +0 -4
- package/src/__tests__/heartbeat.test.ts +3 -0
- package/src/__tests__/mempalace-plugin.test.ts +295 -0
- package/src/__tests__/plugin.test.ts +169 -0
- package/src/__tests__/storage-save-errors.test.ts +1 -1
- package/src/__tests__/time.test.ts +1 -1
- package/src/__tests__/watchdog.test.ts +1 -3
- package/src/__tests__/workspace.test.ts +0 -1
- package/src/backend/claude-sdk/index.ts +39 -54
- package/src/backend/opencode/index.ts +5 -20
- package/src/bootstrap.ts +140 -11
- package/src/core/dream.ts +40 -6
- package/src/core/gateway-actions.ts +0 -87
- package/src/core/plugin.ts +103 -16
- 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 +82 -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 +60 -0
- package/src/core/tools/web.ts +26 -0
- package/src/frontend/telegram/actions.ts +10 -1
- package/src/frontend/telegram/handlers.ts +5 -17
- package/src/plugins/github/index.ts +106 -0
- package/src/plugins/mempalace/index.ts +147 -0
- package/src/plugins/playwright/index.ts +82 -0
- package/src/storage/sessions.ts +0 -10
- package/src/util/config.ts +31 -1
- package/src/util/log.ts +4 -1
- package/src/util/paths.ts +9 -0
- package/src/backend/claude-sdk/tools.ts +0 -651
- 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("
|
|
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
|
-
?
|
|
169
|
-
: `${
|
|
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 (
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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": {
|
package/src/core/plugin.ts
CHANGED
|
@@ -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
|
|
400
|
-
* Plugins
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
];
|