talon-agent 1.2.0 → 1.3.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 +5 -6
- package/prompts/dream.md +6 -2
- package/prompts/mempalace.md +57 -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 -1
- 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/bootstrap.ts +72 -7
- package/src/core/dream.ts +40 -6
- package/src/core/plugin.ts +103 -16
- package/src/frontend/telegram/handlers.ts +5 -17
- package/src/plugins/mempalace/index.ts +147 -0
- package/src/util/config.ts +11 -0
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +9 -0
|
@@ -218,6 +218,97 @@ describe("plugin system", () => {
|
|
|
218
218
|
});
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it("builds MCP server entries for plugins with custom mcpServer", async () => {
|
|
223
|
+
const plugin = createMockPlugin({
|
|
224
|
+
mcpServer: {
|
|
225
|
+
command: "/usr/bin/python3",
|
|
226
|
+
args: ["-m", "mempalace.mcp_server", "--palace", "/tmp/palace"],
|
|
227
|
+
},
|
|
228
|
+
getEnvVars: () => ({ PALACE_PATH: "/tmp/palace" }),
|
|
229
|
+
});
|
|
230
|
+
// Remove mcpServerPath so mcpServer is used
|
|
231
|
+
delete (plugin as Record<string, unknown>).mcpServerPath;
|
|
232
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
233
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
234
|
+
|
|
235
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
236
|
+
expect(servers["test-plugin-tools"]).toBeDefined();
|
|
237
|
+
expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
|
|
238
|
+
expect(servers["test-plugin-tools"].args).toEqual([
|
|
239
|
+
"-m",
|
|
240
|
+
"mempalace.mcp_server",
|
|
241
|
+
"--palace",
|
|
242
|
+
"/tmp/palace",
|
|
243
|
+
]);
|
|
244
|
+
expect(servers["test-plugin-tools"].env.PALACE_PATH).toBe("/tmp/palace");
|
|
245
|
+
expect(servers["test-plugin-tools"].env.TALON_BRIDGE_URL).toBe(
|
|
246
|
+
"http://localhost:19876",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("mcpServer takes priority over mcpServerPath when both are set", async () => {
|
|
251
|
+
const plugin = createMockPlugin({
|
|
252
|
+
mcpServerPath: "/fake/tools.ts",
|
|
253
|
+
mcpServer: {
|
|
254
|
+
command: "/usr/bin/python3",
|
|
255
|
+
args: ["-m", "my_server"],
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
259
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
260
|
+
|
|
261
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
262
|
+
// mcpServer should win over mcpServerPath
|
|
263
|
+
expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
|
|
264
|
+
expect(servers["test-plugin-tools"].args).toEqual(["-m", "my_server"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("skips plugins without mcpServer or mcpServerPath", async () => {
|
|
268
|
+
const plugin = createMockPlugin();
|
|
269
|
+
delete (plugin as Record<string, unknown>).mcpServerPath;
|
|
270
|
+
delete (plugin as Record<string, unknown>).mcpServer;
|
|
271
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
272
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
273
|
+
|
|
274
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
275
|
+
expect(Object.keys(servers)).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("registerPlugin (built-in)", () => {
|
|
280
|
+
it("registers a built-in plugin directly", async () => {
|
|
281
|
+
const plugin = createMockPlugin({ name: "built-in-test" });
|
|
282
|
+
const { registerPlugin, getPlugin } = await setup(createMockPlugin());
|
|
283
|
+
|
|
284
|
+
registerPlugin(plugin, { key: "val" });
|
|
285
|
+
expect(getPlugin("built-in-test")).toBeDefined();
|
|
286
|
+
expect(getPlugin("built-in-test")!.path).toBe("(built-in)");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("sets env vars from built-in plugin", async () => {
|
|
290
|
+
const plugin = createMockPlugin({
|
|
291
|
+
name: "builtin-env",
|
|
292
|
+
getEnvVars: () => ({
|
|
293
|
+
TEST_PLUGIN_BUILTIN_FOO: "baz",
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
const { registerPlugin } = await setup(createMockPlugin());
|
|
297
|
+
|
|
298
|
+
registerPlugin(plugin);
|
|
299
|
+
expect(process.env.TEST_PLUGIN_BUILTIN_FOO).toBe("baz");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("skips registration when validateConfig returns errors", async () => {
|
|
303
|
+
const plugin = createMockPlugin({
|
|
304
|
+
name: "builtin-invalid",
|
|
305
|
+
validateConfig: () => ["missing required field"],
|
|
306
|
+
});
|
|
307
|
+
const { registerPlugin, getPlugin } = await setup(createMockPlugin());
|
|
308
|
+
|
|
309
|
+
registerPlugin(plugin, {});
|
|
310
|
+
expect(getPlugin("builtin-invalid")).toBeUndefined();
|
|
311
|
+
});
|
|
221
312
|
});
|
|
222
313
|
|
|
223
314
|
describe("system prompt additions", () => {
|
|
@@ -409,6 +500,84 @@ describe("extractPlugin — invalid optional field types", () => {
|
|
|
409
500
|
expect(mod.getPluginCount()).toBe(0);
|
|
410
501
|
});
|
|
411
502
|
|
|
503
|
+
it("rejects plugin when mcpServer is not an object", async () => {
|
|
504
|
+
const plugin = { name: "bad-mcp-server", mcpServer: "not-an-object" };
|
|
505
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
506
|
+
const mod = await import("../core/plugin.js");
|
|
507
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
508
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-server" }]);
|
|
509
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("rejects plugin when mcpServer is null", async () => {
|
|
513
|
+
const plugin = { name: "null-mcp-server", mcpServer: null };
|
|
514
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
515
|
+
const mod = await import("../core/plugin.js");
|
|
516
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
517
|
+
await mod.loadPlugins([{ path: "/fake/null-mcp-server" }]);
|
|
518
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("rejects plugin when mcpServer.command is not a string", async () => {
|
|
522
|
+
const plugin = {
|
|
523
|
+
name: "bad-mcp-cmd",
|
|
524
|
+
mcpServer: { command: 123, args: [] },
|
|
525
|
+
};
|
|
526
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
527
|
+
const mod = await import("../core/plugin.js");
|
|
528
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
529
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-cmd" }]);
|
|
530
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("rejects plugin when mcpServer.args is not an array", async () => {
|
|
534
|
+
const plugin = {
|
|
535
|
+
name: "bad-mcp-args",
|
|
536
|
+
mcpServer: { command: "/usr/bin/python3", args: "not-an-array" },
|
|
537
|
+
};
|
|
538
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
539
|
+
const mod = await import("../core/plugin.js");
|
|
540
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
541
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-args" }]);
|
|
542
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("rejects plugin when mcpServer.command is empty string", async () => {
|
|
546
|
+
const plugin = {
|
|
547
|
+
name: "empty-mcp-cmd",
|
|
548
|
+
mcpServer: { command: "", args: ["-m", "server"] },
|
|
549
|
+
};
|
|
550
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
551
|
+
const mod = await import("../core/plugin.js");
|
|
552
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
553
|
+
await mod.loadPlugins([{ path: "/fake/empty-mcp-cmd" }]);
|
|
554
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("rejects plugin when mcpServer.args contains non-string elements", async () => {
|
|
558
|
+
const plugin = {
|
|
559
|
+
name: "bad-mcp-args-types",
|
|
560
|
+
mcpServer: { command: "/usr/bin/python3", args: ["-m", 42] },
|
|
561
|
+
};
|
|
562
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
563
|
+
const mod = await import("../core/plugin.js");
|
|
564
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
565
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-args-types" }]);
|
|
566
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("accepts plugin with valid mcpServer object", async () => {
|
|
570
|
+
const plugin = {
|
|
571
|
+
name: "good-mcp-server",
|
|
572
|
+
mcpServer: { command: "/usr/bin/python3", args: ["-m", "server"] },
|
|
573
|
+
};
|
|
574
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
575
|
+
const mod = await import("../core/plugin.js");
|
|
576
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
577
|
+
await mod.loadPlugins([{ path: "/fake/good-mcp-server" }]);
|
|
578
|
+
expect(mod.getPluginCount()).toBe(1);
|
|
579
|
+
});
|
|
580
|
+
|
|
412
581
|
it("catches and logs error when importModule throws", async () => {
|
|
413
582
|
vi.resetModules();
|
|
414
583
|
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
@@ -49,7 +49,7 @@ describe("cron-store — save failure logs error", () => {
|
|
|
49
49
|
registerCleanup: vi.fn(),
|
|
50
50
|
}));
|
|
51
51
|
|
|
52
|
-
const { addCronJob, generateCronId
|
|
52
|
+
const { addCronJob, generateCronId } =
|
|
53
53
|
await import("../storage/cron-store.js");
|
|
54
54
|
|
|
55
55
|
const job = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -38,8 +38,6 @@ describe("watchdog", () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("updates lastProcessedAt timestamp", () => {
|
|
41
|
-
const beforeStatus = getHealthStatus();
|
|
42
|
-
const beforeMs = beforeStatus.msSinceLastMessage;
|
|
43
41
|
recordMessageProcessed();
|
|
44
42
|
const afterStatus = getHealthStatus();
|
|
45
43
|
// After recording, msSinceLastMessage should be very small (near 0)
|
package/src/bootstrap.ts
CHANGED
|
@@ -62,14 +62,59 @@ export async function bootstrap(
|
|
|
62
62
|
if (config.braveApiKey) process.env.TALON_BRAVE_API_KEY = config.braveApiKey;
|
|
63
63
|
if (config.searxngUrl) process.env.TALON_SEARXNG_URL = config.searxngUrl;
|
|
64
64
|
|
|
65
|
-
// Load plugins (external tool packages)
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Load plugins (external tool packages + built-in mempalace)
|
|
66
|
+
const hasPlugins =
|
|
67
|
+
config.plugins.length > 0 || config.mempalace?.enabled === true;
|
|
68
|
+
if (hasPlugins) {
|
|
69
|
+
const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
|
|
68
70
|
await import("./core/plugin.js");
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
|
|
72
|
+
// External plugins
|
|
73
|
+
if (config.plugins.length > 0) {
|
|
74
|
+
const frontends =
|
|
75
|
+
options.frontendNames ??
|
|
76
|
+
(Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
|
|
77
|
+
await loadPlugins(config.plugins, frontends);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Built-in: MemPalace
|
|
81
|
+
if (config.mempalace?.enabled) {
|
|
82
|
+
const { createMempalacePlugin } =
|
|
83
|
+
await import("./plugins/mempalace/index.js");
|
|
84
|
+
const { getPlugin } = await import("./core/plugin.js");
|
|
85
|
+
const { dirs, files: pathFiles } = await import("./util/paths.js");
|
|
86
|
+
const pythonPath =
|
|
87
|
+
config.mempalace.pythonPath ?? pathFiles.mempalacePython;
|
|
88
|
+
const palacePath = config.mempalace.palacePath ?? dirs.palace;
|
|
89
|
+
const mempalaceConfig = config.mempalace as unknown as Record<
|
|
90
|
+
string,
|
|
91
|
+
unknown
|
|
92
|
+
>;
|
|
93
|
+
const mp = createMempalacePlugin({ pythonPath, palacePath });
|
|
94
|
+
registerPlugin(mp, mempalaceConfig);
|
|
95
|
+
|
|
96
|
+
// Only call init if registration succeeded (validation passed)
|
|
97
|
+
if (getPlugin("mempalace")) {
|
|
98
|
+
try {
|
|
99
|
+
const MEMPALACE_INIT_TIMEOUT_MS = 30_000;
|
|
100
|
+
await Promise.race([
|
|
101
|
+
mp.init?.(mempalaceConfig),
|
|
102
|
+
new Promise((_, reject) =>
|
|
103
|
+
setTimeout(
|
|
104
|
+
() => reject(new Error("MemPalace init timed out after 30s")),
|
|
105
|
+
MEMPALACE_INIT_TIMEOUT_MS,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
]);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log(
|
|
111
|
+
"mempalace",
|
|
112
|
+
`Init warning: ${err instanceof Error ? err.message : err}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
74
119
|
}
|
|
75
120
|
|
|
@@ -119,11 +164,31 @@ export async function initBackendAndDispatcher(
|
|
|
119
164
|
|
|
120
165
|
initPulse();
|
|
121
166
|
initCron({ sendMessage: frontend.sendMessage });
|
|
167
|
+
|
|
168
|
+
// Only enable mempalace dream integration if the plugin actually registered
|
|
169
|
+
let mempalaceCfg: { pythonPath: string; palacePath: string } | undefined;
|
|
170
|
+
if (config.mempalace?.enabled) {
|
|
171
|
+
const { getPlugin } = await import("./core/plugin.js");
|
|
172
|
+
if (getPlugin("mempalace")) {
|
|
173
|
+
const { dirs, files: pathFiles } = await import("./util/paths.js");
|
|
174
|
+
mempalaceCfg = {
|
|
175
|
+
pythonPath: config.mempalace.pythonPath ?? pathFiles.mempalacePython,
|
|
176
|
+
palacePath: config.mempalace.palacePath ?? dirs.palace,
|
|
177
|
+
};
|
|
178
|
+
} else {
|
|
179
|
+
log(
|
|
180
|
+
"mempalace",
|
|
181
|
+
"Enabled in config but plugin not registered — skipping dream integration",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
initDream({
|
|
123
187
|
model: config.model,
|
|
124
188
|
dreamModel: config.dreamModel,
|
|
125
189
|
claudeBinary: config.claudeBinary,
|
|
126
190
|
workspace: config.workspace,
|
|
191
|
+
mempalace: mempalaceCfg,
|
|
127
192
|
});
|
|
128
193
|
initHeartbeat({
|
|
129
194
|
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",
|
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;
|