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
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../util/log.js", () => ({
4
+ log: vi.fn(),
5
+ logError: vi.fn(),
6
+ logWarn: vi.fn(),
7
+ logDebug: vi.fn(),
8
+ }));
9
+
10
+ const PROMPT_TEMPLATE = `## MemPalace — Long-term Memory
11
+
12
+ mempalace_search mempalace_add_drawer mempalace_kg_query mempalace_kg_invalidate
13
+ mempalace_kg_timeline mempalace_traverse mempalace_find_tunnels
14
+ mempalace_diary_write mempalace_diary_read mempalace_delete_drawer
15
+ Protocol
16
+
17
+ ### Palace location: \`{{palacePath}}\`
18
+ `;
19
+
20
+ describe("mempalace plugin", () => {
21
+ beforeEach(() => {
22
+ vi.resetModules();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ it("creates a plugin with correct name and MCP server config", async () => {
27
+ vi.doMock("node:fs", () => ({
28
+ existsSync: vi.fn(() => true),
29
+ mkdirSync: vi.fn(),
30
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
31
+ }));
32
+ vi.doMock("node:child_process", () => ({
33
+ execFileSync: vi.fn(() => "ok"),
34
+ execFile: vi.fn(
35
+ (
36
+ _cmd: string,
37
+ _args: string[],
38
+ _opts: unknown,
39
+ cb: (
40
+ err: Error | null,
41
+ result: { stdout: string; stderr: string },
42
+ ) => void,
43
+ ) => {
44
+ cb(null, { stdout: "Palace: 42 drawers", stderr: "" });
45
+ },
46
+ ),
47
+ }));
48
+
49
+ const { createMempalacePlugin } =
50
+ await import("../plugins/mempalace/index.js");
51
+ const plugin = createMempalacePlugin({
52
+ pythonPath: "/venv/bin/python",
53
+ palacePath: "/data/palace",
54
+ });
55
+
56
+ expect(plugin.name).toBe("mempalace");
57
+ expect(plugin.version).toBe("1.0.0");
58
+ expect(plugin.mcpServer).toEqual({
59
+ command: "/venv/bin/python",
60
+ args: ["-m", "mempalace.mcp_server", "--palace", "/data/palace"],
61
+ });
62
+ });
63
+
64
+ it("validateConfig returns error when python binary not found", async () => {
65
+ vi.doMock("node:fs", () => ({
66
+ existsSync: vi.fn(() => false),
67
+ mkdirSync: vi.fn(),
68
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
69
+ }));
70
+ vi.doMock("node:child_process", () => ({
71
+ execFileSync: vi.fn(),
72
+ execFile: vi.fn(),
73
+ }));
74
+
75
+ const { createMempalacePlugin } =
76
+ await import("../plugins/mempalace/index.js");
77
+ const plugin = createMempalacePlugin({
78
+ pythonPath: "/nonexistent/python",
79
+ palacePath: "/data/palace",
80
+ });
81
+
82
+ const errors = plugin.validateConfig!({});
83
+ expect(errors).toBeDefined();
84
+ expect(errors!.length).toBeGreaterThan(0);
85
+ expect(errors![0]).toContain("Python binary not found");
86
+ });
87
+
88
+ it("validateConfig passes when python binary exists and mempalace is importable", async () => {
89
+ vi.doMock("node:fs", () => ({
90
+ existsSync: vi.fn(() => true),
91
+ mkdirSync: vi.fn(),
92
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
93
+ }));
94
+ vi.doMock("node:child_process", () => ({
95
+ execFileSync: vi.fn(() => "ok"),
96
+ execFile: vi.fn(),
97
+ }));
98
+
99
+ const { createMempalacePlugin } =
100
+ await import("../plugins/mempalace/index.js");
101
+ const plugin = createMempalacePlugin({
102
+ pythonPath: "/venv/bin/python",
103
+ palacePath: "/data/palace",
104
+ });
105
+
106
+ const errors = plugin.validateConfig!({});
107
+ expect(errors).toBeUndefined();
108
+ });
109
+
110
+ it("validateConfig returns error when mempalace is not importable", async () => {
111
+ vi.doMock("node:fs", () => ({
112
+ existsSync: vi.fn(() => true),
113
+ mkdirSync: vi.fn(),
114
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
115
+ }));
116
+ vi.doMock("node:child_process", () => ({
117
+ execFileSync: vi.fn(() => {
118
+ throw new Error("ModuleNotFoundError");
119
+ }),
120
+ execFile: vi.fn(),
121
+ }));
122
+
123
+ const { createMempalacePlugin } =
124
+ await import("../plugins/mempalace/index.js");
125
+ const plugin = createMempalacePlugin({
126
+ pythonPath: "/venv/bin/python",
127
+ palacePath: "/data/palace",
128
+ });
129
+
130
+ const errors = plugin.validateConfig!({});
131
+ expect(errors).toBeDefined();
132
+ expect(errors!.length).toBeGreaterThan(0);
133
+ expect(errors![0]).toContain("mempalace package not installed");
134
+ });
135
+
136
+ it("init creates palace directory if missing", async () => {
137
+ const mkdirSyncMock = vi.fn();
138
+ vi.doMock("node:fs", () => ({
139
+ existsSync: vi.fn((p: string) =>
140
+ p === "/venv/bin/python" ? true : false,
141
+ ),
142
+ mkdirSync: mkdirSyncMock,
143
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
144
+ }));
145
+ vi.doMock("node:child_process", () => ({
146
+ execFileSync: vi.fn(() => "ok"),
147
+ execFile: vi.fn(
148
+ (
149
+ _cmd: string,
150
+ _args: string[],
151
+ _opts: unknown,
152
+ cb: (
153
+ err: Error | null,
154
+ result: { stdout: string; stderr: string },
155
+ ) => void,
156
+ ) => {
157
+ cb(null, { stdout: "ok", stderr: "" });
158
+ },
159
+ ),
160
+ }));
161
+
162
+ const { createMempalacePlugin } =
163
+ await import("../plugins/mempalace/index.js");
164
+ const plugin = createMempalacePlugin({
165
+ pythonPath: "/venv/bin/python",
166
+ palacePath: "/data/new-palace",
167
+ });
168
+
169
+ await plugin.init!({});
170
+ expect(mkdirSyncMock).toHaveBeenCalledWith("/data/new-palace", {
171
+ recursive: true,
172
+ });
173
+ });
174
+
175
+ it("validateConfig returns error when python binary exists but mempalace import fails with ENOENT", async () => {
176
+ vi.doMock("node:fs", () => ({
177
+ existsSync: vi.fn(() => true),
178
+ mkdirSync: vi.fn(),
179
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
180
+ }));
181
+ vi.doMock("node:child_process", () => ({
182
+ execFileSync: vi.fn(() => {
183
+ const err = new Error("spawn ENOENT") as Error & { code: string };
184
+ err.code = "ENOENT";
185
+ throw err;
186
+ }),
187
+ execFile: vi.fn(),
188
+ }));
189
+
190
+ const { createMempalacePlugin } =
191
+ await import("../plugins/mempalace/index.js");
192
+ const plugin = createMempalacePlugin({
193
+ pythonPath: "/venv/bin/python",
194
+ palacePath: "/data/palace",
195
+ });
196
+
197
+ const errors = plugin.validateConfig!({});
198
+ expect(errors).toBeDefined();
199
+ expect(errors!.length).toBeGreaterThan(0);
200
+ expect(errors![0]).toContain("Cannot execute Python");
201
+ expect(errors![0]).toContain("ENOENT");
202
+ });
203
+
204
+ it("getEnvVars returns MEMPALACE_PALACE_PATH", async () => {
205
+ vi.doMock("node:fs", () => ({
206
+ existsSync: vi.fn(() => true),
207
+ mkdirSync: vi.fn(),
208
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
209
+ }));
210
+ vi.doMock("node:child_process", () => ({
211
+ execFileSync: vi.fn(),
212
+ execFile: vi.fn(),
213
+ }));
214
+
215
+ const { createMempalacePlugin } =
216
+ await import("../plugins/mempalace/index.js");
217
+ const plugin = createMempalacePlugin({
218
+ pythonPath: "/venv/bin/python",
219
+ palacePath: "/data/palace",
220
+ });
221
+
222
+ expect(plugin.getEnvVars!({})).toEqual({
223
+ MEMPALACE_PALACE_PATH: "/data/palace",
224
+ });
225
+ });
226
+
227
+ it("getSystemPromptAddition loads from .md file and interpolates palacePath", async () => {
228
+ vi.doMock("node:fs", () => ({
229
+ existsSync: vi.fn(() => true),
230
+ mkdirSync: vi.fn(),
231
+ readFileSync: vi.fn(() => PROMPT_TEMPLATE),
232
+ }));
233
+ vi.doMock("node:child_process", () => ({
234
+ execFileSync: vi.fn(),
235
+ execFile: vi.fn(),
236
+ }));
237
+
238
+ const { createMempalacePlugin } =
239
+ await import("../plugins/mempalace/index.js");
240
+ const plugin = createMempalacePlugin({
241
+ pythonPath: "/venv/bin/python",
242
+ palacePath: "/custom/palace",
243
+ });
244
+
245
+ const addition = plugin.getSystemPromptAddition!({});
246
+ expect(addition).toContain("MemPalace");
247
+ expect(addition).toContain("mempalace_search");
248
+ expect(addition).toContain("mempalace_add_drawer");
249
+ expect(addition).toContain("mempalace_kg_query");
250
+ expect(addition).toContain("mempalace_kg_invalidate");
251
+ expect(addition).toContain("mempalace_kg_timeline");
252
+ expect(addition).toContain("mempalace_traverse");
253
+ expect(addition).toContain("mempalace_find_tunnels");
254
+ expect(addition).toContain("mempalace_diary_write");
255
+ expect(addition).toContain("mempalace_diary_read");
256
+ expect(addition).toContain("mempalace_delete_drawer");
257
+ expect(addition).toContain("Protocol");
258
+ expect(addition).toContain("/custom/palace");
259
+ // Verify interpolation happened — no raw placeholder
260
+ expect(addition).not.toContain("{{palacePath}}");
261
+ });
262
+
263
+ it("getSystemPromptAddition returns fallback when .md file is missing", async () => {
264
+ vi.doMock("node:fs", () => ({
265
+ existsSync: vi.fn(() => true),
266
+ mkdirSync: vi.fn(),
267
+ readFileSync: vi.fn(() => {
268
+ throw new Error("ENOENT: no such file");
269
+ }),
270
+ }));
271
+ vi.doMock("node:child_process", () => ({
272
+ execFileSync: vi.fn(),
273
+ execFile: vi.fn(),
274
+ }));
275
+
276
+ const { logWarn } = (await import("../util/log.js")) as unknown as {
277
+ logWarn: ReturnType<typeof vi.fn>;
278
+ };
279
+
280
+ const { createMempalacePlugin } =
281
+ await import("../plugins/mempalace/index.js");
282
+ const plugin = createMempalacePlugin({
283
+ pythonPath: "/venv/bin/python",
284
+ palacePath: "/data/palace",
285
+ });
286
+
287
+ const addition = plugin.getSystemPromptAddition!({});
288
+ expect(addition).toContain("MemPalace");
289
+ expect(addition).toContain("/data/palace");
290
+ expect(logWarn).toHaveBeenCalledWith(
291
+ "mempalace",
292
+ expect.stringContaining("Failed to load prompt"),
293
+ );
294
+ });
295
+ });
@@ -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, flushCronJobs } =
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, beforeEach, afterEach, vi } from "vitest";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
2
  import {
3
3
  setTimezone,
4
4
  getTimezone,
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
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)
@@ -5,7 +5,6 @@ import {
5
5
  rmSync,
6
6
  existsSync,
7
7
  readdirSync,
8
- readFileSync,
9
8
  symlinkSync,
10
9
  } from "node:fs";
11
10
  import { join } from "node:path";
@@ -9,7 +9,6 @@ import {
9
9
  setSessionName,
10
10
  } from "../../storage/sessions.js";
11
11
  import { getChatSettings, setChatModel } from "../../storage/chat-settings.js";
12
- import { getRecentHistory } from "../../storage/history.js";
13
12
  import { resolve } from "node:path";
14
13
  import { classify } from "../../core/errors.js";
15
14
  import {
@@ -19,7 +18,7 @@ import {
19
18
  import { rebuildSystemPrompt } from "../../util/config.js";
20
19
  import { log, logError, logWarn } from "../../util/log.js";
21
20
  import { traceMessage } from "../../util/trace.js";
22
- import { formatSmartTimestamp, formatFullDatetime } from "../../util/time.js";
21
+ import { formatFullDatetime } from "../../util/time.js";
23
22
 
24
23
  import type { QueryParams, QueryResult } from "../../core/types.js";
25
24
 
@@ -115,66 +114,68 @@ export async function handleMessage(
115
114
  "TaskOutput",
116
115
  "TaskStop",
117
116
  "AskUserQuestion",
117
+ // Always disable Claude Code built-in web tools — fetch_url is always
118
+ // available, and Brave Search MCP replaces WebSearch when configured.
119
+ "WebSearch",
120
+ "WebFetch",
118
121
  ],
119
122
  ...thinkingConfig,
120
123
  mcpServers: {
121
- // Register frontend-specific MCP tools based on active frontend
124
+ // Register unified MCP tools server one per messaging frontend.
125
+ // Terminal frontend relies on Claude Code built-in tools (Read, Write,
126
+ // Bash, etc.) and doesn't need a custom MCP tools server.
122
127
  ...(() => {
123
- const frontends = Array.isArray(config.frontend)
128
+ const allFrontends = Array.isArray(config.frontend)
124
129
  ? config.frontend
125
130
  : [config.frontend];
131
+ const frontends = allFrontends.filter((f) => f !== "terminal");
126
132
  const bridgeUrl = `http://127.0.0.1:${bridgePortFn()}`;
127
- const mcpEnv = { TALON_BRIDGE_URL: bridgeUrl, TALON_CHAT_ID: chatId };
128
133
  const servers: Record<
129
134
  string,
130
135
  { command: string; args: string[]; env: Record<string, string> }
131
136
  > = {};
132
- // Resolve tsx from Talon's node_modules (cwd may be ~/.talon/workspace/ which has no node_modules)
133
137
  // Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
134
138
  const tsxImport = resolve(
135
139
  import.meta.dirname ?? ".",
136
140
  "../../../node_modules/tsx/dist/esm/index.mjs",
137
141
  );
142
+ // Unified MCP server in core/tools/
143
+ const mcpServerPath = resolve(
144
+ import.meta.dirname ?? ".",
145
+ "../../core/tools/mcp-server.ts",
146
+ );
138
147
 
139
- if (frontends.includes("telegram")) {
140
- servers["telegram-tools"] = {
141
- command: process.platform === "win32" ? "npx" : "node",
142
- args:
143
- process.platform === "win32"
144
- ? ["tsx", resolve(import.meta.dirname ?? ".", "tools.ts")]
145
- : [
146
- "--import",
147
- tsxImport,
148
- resolve(import.meta.dirname ?? ".", "tools.ts"),
149
- ],
150
- env: mcpEnv,
148
+ for (const frontend of frontends) {
149
+ const serverName = `${frontend}-tools`;
150
+ const mcpEnv = {
151
+ TALON_BRIDGE_URL: bridgeUrl,
152
+ TALON_CHAT_ID: chatId,
153
+ TALON_FRONTEND: frontend,
151
154
  };
152
- }
153
- if (frontends.includes("teams")) {
154
- servers["teams-tools"] = {
155
+ servers[serverName] = {
155
156
  command: process.platform === "win32" ? "npx" : "node",
156
157
  args:
157
158
  process.platform === "win32"
158
- ? [
159
- "tsx",
160
- resolve(
161
- import.meta.dirname ?? ".",
162
- "../../frontend/teams/tools.ts",
163
- ),
164
- ]
165
- : [
166
- "--import",
167
- tsxImport,
168
- resolve(
169
- import.meta.dirname ?? ".",
170
- "../../frontend/teams/tools.ts",
171
- ),
172
- ],
159
+ ? ["tsx", mcpServerPath]
160
+ : ["--import", tsxImport, mcpServerPath],
173
161
  env: mcpEnv,
174
162
  };
175
163
  }
176
164
  return servers;
177
165
  })(),
166
+ // Brave Search MCP server — provides brave_web_search and brave_local_search
167
+ ...(config.braveApiKey
168
+ ? {
169
+ "brave-search": {
170
+ command: resolve(
171
+ import.meta.dirname ?? ".",
172
+ "../../../node_modules/.bin/brave-search-mcp-server",
173
+ ),
174
+ args: [],
175
+ env: { BRAVE_API_KEY: config.braveApiKey },
176
+ },
177
+ }
178
+ : {}),
178
179
  ...getPluginMcpServers(`http://127.0.0.1:${bridgePortFn()}`, chatId),
179
180
  },
180
181
  ...(session.sessionId ? { resume: session.sessionId } : {}),
@@ -183,25 +184,9 @@ export async function handleMessage(
183
184
  const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
184
185
  const nowTag = `[${formatFullDatetime()}]`;
185
186
 
186
- // Session continuity: when resuming a session that has history but no active
187
- // SDK session (after restart or /resume), prepend recent messages for context.
188
- let continuityPrefix = "";
189
- if (!session.sessionId && session.turns > 0) {
190
- const recentMsgs = getRecentHistory(chatId, 10);
191
- if (recentMsgs.length > 0) {
192
- const contextLines = recentMsgs
193
- .map((m) => {
194
- const time = formatSmartTimestamp(m.timestamp);
195
- return `[${time}] ${m.senderName}: ${m.text.slice(0, 300)}`;
196
- })
197
- .join("\n");
198
- continuityPrefix = `[Session resumed — recent conversation context:\n${contextLines}]\n\n`;
199
- }
200
- }
201
-
202
187
  const prompt = isGroup
203
- ? `${continuityPrefix}${nowTag} [${senderName}]${msgIdHint}: ${text}`
204
- : `${continuityPrefix}${nowTag}${msgIdHint} ${text}`;
188
+ ? `${nowTag} [${senderName}]${msgIdHint}: ${text}`
189
+ : `${nowTag}${msgIdHint} ${text}`;
205
190
  log("agent", `[${chatId}] <- (${text.length} chars)`);
206
191
  traceMessage(chatId, "in", text, { senderName, isGroup });
207
192