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.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/prompts/heartbeat.md +18 -6
  3. package/src/__tests__/compose-tools.test.ts +216 -0
  4. package/src/__tests__/fuzz.test.ts +0 -2
  5. package/src/__tests__/gateway-actions.test.ts +1 -423
  6. package/src/__tests__/heartbeat.test.ts +21 -0
  7. package/src/__tests__/reload-plugins.test.ts +199 -0
  8. package/src/__tests__/sessions.test.ts +155 -121
  9. package/src/backend/claude-sdk/index.ts +230 -109
  10. package/src/backend/opencode/index.ts +5 -20
  11. package/src/bootstrap.ts +8 -44
  12. package/src/core/gateway-actions.ts +42 -88
  13. package/src/core/heartbeat.ts +8 -5
  14. package/src/core/plugin.ts +147 -0
  15. package/src/core/tools/admin.ts +22 -0
  16. package/src/core/tools/bridge.ts +40 -0
  17. package/src/core/tools/chat.ts +52 -0
  18. package/src/core/tools/history.ts +80 -0
  19. package/src/core/tools/index.ts +84 -0
  20. package/src/core/tools/mcp-server.ts +64 -0
  21. package/src/core/tools/media.ts +23 -0
  22. package/src/core/tools/members.ts +46 -0
  23. package/src/core/tools/messaging.ts +300 -0
  24. package/src/core/tools/scheduling.ts +89 -0
  25. package/src/core/tools/stickers.ts +143 -0
  26. package/src/core/tools/types.ts +61 -0
  27. package/src/core/tools/web.ts +26 -0
  28. package/src/frontend/teams/index.ts +9 -10
  29. package/src/frontend/telegram/actions.ts +10 -1
  30. package/src/frontend/telegram/commands.ts +11 -10
  31. package/src/plugins/github/index.ts +106 -0
  32. package/src/plugins/playwright/index.ts +82 -0
  33. package/src/storage/sessions.ts +34 -50
  34. package/src/util/config.ts +20 -1
  35. package/src/util/log.ts +3 -1
  36. package/src/backend/claude-sdk/tools.ts +0 -651
  37. package/src/frontend/teams/tools.ts +0 -175
@@ -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
 
@@ -42,34 +41,15 @@ export function initAgent(
42
41
  delete process.env.CLAUDECODE;
43
42
  }
44
43
 
45
- // ── Main handler ─────────────────────────────────────────────────────────────
44
+ /** Update the system prompt on the live config. Used by plugin hot-reload
45
+ * so the next message picks up new plugin tool descriptions. */
46
+ export function updateSystemPrompt(prompt: string): void {
47
+ if (config) config.systemPrompt = prompt;
48
+ }
46
49
 
47
- export async function handleMessage(
48
- params: QueryParams,
49
- _retried = false,
50
- ): Promise<QueryResult> {
51
- if (!config)
52
- throw new Error("Agent not initialized. Call initAgent() first.");
50
+ // ── Shared options builder ───────────────────────────────────────────────────
53
51
 
54
- const {
55
- chatId,
56
- text,
57
- senderName,
58
- isGroup,
59
- onTextBlock,
60
- onStreamDelta,
61
- onToolUse,
62
- } = params;
63
- const session = getSession(chatId);
64
- const t0 = Date.now();
65
-
66
- // Rebuild system prompt on first turn of a new/reset session so identity,
67
- // memory, and workspace listing are fresh
68
- if (session.turns === 0) {
69
- rebuildSystemPrompt(config, getPluginPromptAdditions());
70
- }
71
-
72
- // Per-chat settings override global config
52
+ function buildSdkOptions(chatId: string) {
73
53
  const chatSettings = getChatSettings(chatId);
74
54
  const activeModel = chatSettings.model ?? config.model;
75
55
  const activeEffort = chatSettings.effort ?? "adaptive";
@@ -91,13 +71,18 @@ export async function handleMessage(
91
71
  thinking: { type: "adaptive" as const },
92
72
  };
93
73
 
74
+ const supports1m =
75
+ !activeModel.includes("haiku") && !activeModel.includes("[1m]");
76
+ const sdkModel = supports1m ? `${activeModel}[1m]` : activeModel;
77
+
78
+ const session = getSession(chatId);
79
+
94
80
  const options = {
95
- model: activeModel,
81
+ model: sdkModel,
96
82
  systemPrompt: config.systemPrompt,
97
83
  cwd: config.workspace,
98
84
  permissionMode: "bypassPermissions" as const,
99
85
  allowDangerouslySkipPermissions: true,
100
- betas: ["context-1m-2025-08-07"],
101
86
  ...(config.claudeBinary
102
87
  ? { pathToClaudeCodeExecutable: config.claudeBinary }
103
88
  : {}),
@@ -115,93 +100,172 @@ export async function handleMessage(
115
100
  "TaskOutput",
116
101
  "TaskStop",
117
102
  "AskUserQuestion",
103
+ "WebSearch",
104
+ "WebFetch",
118
105
  ],
119
106
  ...thinkingConfig,
120
107
  mcpServers: {
121
- // Register frontend-specific MCP tools based on active frontend
122
108
  ...(() => {
123
- const frontends = Array.isArray(config.frontend)
109
+ const allFrontends = Array.isArray(config.frontend)
124
110
  ? config.frontend
125
111
  : [config.frontend];
112
+ const frontends = allFrontends.filter((f) => f !== "terminal");
126
113
  const bridgeUrl = `http://127.0.0.1:${bridgePortFn()}`;
127
- const mcpEnv = { TALON_BRIDGE_URL: bridgeUrl, TALON_CHAT_ID: chatId };
128
114
  const servers: Record<
129
115
  string,
130
116
  { command: string; args: string[]; env: Record<string, string> }
131
117
  > = {};
132
- // Resolve tsx from Talon's node_modules (cwd may be ~/.talon/workspace/ which has no node_modules)
133
- // Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
134
118
  const tsxImport = resolve(
135
119
  import.meta.dirname ?? ".",
136
120
  "../../../node_modules/tsx/dist/esm/index.mjs",
137
121
  );
122
+ const mcpServerPath = resolve(
123
+ import.meta.dirname ?? ".",
124
+ "../../core/tools/mcp-server.ts",
125
+ );
138
126
 
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,
127
+ for (const frontend of frontends) {
128
+ const serverName = `${frontend}-tools`;
129
+ const mcpEnv = {
130
+ TALON_BRIDGE_URL: bridgeUrl,
131
+ TALON_CHAT_ID: chatId,
132
+ TALON_FRONTEND: frontend,
151
133
  };
152
- }
153
- if (frontends.includes("teams")) {
154
- servers["teams-tools"] = {
134
+ servers[serverName] = {
155
135
  command: process.platform === "win32" ? "npx" : "node",
156
136
  args:
157
137
  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
- ],
138
+ ? ["tsx", mcpServerPath]
139
+ : ["--import", tsxImport, mcpServerPath],
173
140
  env: mcpEnv,
174
141
  };
175
142
  }
176
143
  return servers;
177
144
  })(),
145
+ ...(config.braveApiKey
146
+ ? {
147
+ "brave-search": {
148
+ command: resolve(
149
+ import.meta.dirname ?? ".",
150
+ "../../../node_modules/.bin/brave-search-mcp-server",
151
+ ),
152
+ args: [],
153
+ env: { BRAVE_API_KEY: config.braveApiKey },
154
+ },
155
+ }
156
+ : {}),
178
157
  ...getPluginMcpServers(`http://127.0.0.1:${bridgePortFn()}`, chatId),
179
158
  },
180
159
  ...(session.sessionId ? { resume: session.sessionId } : {}),
181
160
  };
182
161
 
183
- const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
184
- const nowTag = `[${formatFullDatetime()}]`;
162
+ return { options, activeModel, session };
163
+ }
185
164
 
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
- }
165
+ // ── Session warm-up ─────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Cold-start a session by spawning an SDK subprocess in streaming input mode,
169
+ * calling getContextUsage() to populate contextWindow and baseline contextTokens,
170
+ * then tearing it down. Fire-and-forget does not block the caller.
171
+ */
172
+ export async function warmSession(chatId: string): Promise<void> {
173
+ if (!config) return;
174
+ const abort = new AbortController();
175
+ try {
176
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
177
+ const { options } = buildSdkOptions(chatId);
178
+
179
+ // Streaming input mode: pass an async iterable that never yields a user message
180
+ const neverYield = async function* (): AsyncGenerator<never> {
181
+ await new Promise<never>((_, reject) => {
182
+ abort.signal.addEventListener("abort", () =>
183
+ reject(new Error("aborted")),
184
+ );
185
+ });
186
+ };
187
+
188
+ const q = query({
189
+ prompt: neverYield(),
190
+ options: {
191
+ ...options,
192
+ abortController: abort,
193
+ } as Parameters<typeof query>[0]["options"],
194
+ });
195
+
196
+ // Drain the stream in the background so the SDK's internal message loop
197
+ // doesn't stall — control responses are processed in readMessages() which
198
+ // needs the inputStream consumer to not back-pressure.
199
+ const drainPromise = (async () => {
200
+ try {
201
+ for await (const _ of q) {
202
+ // discard SDK messages; we only care about the control response
203
+ }
204
+ } catch {
205
+ // expected: abort causes the stream to end with an error
206
+ }
207
+ })();
208
+
209
+ // Race getContextUsage against a timeout so /reset doesn't hang
210
+ const timeout = new Promise<never>((_, reject) =>
211
+ setTimeout(() => reject(new Error("warm-up timed out")), 15_000),
212
+ );
213
+ const ctx = await Promise.race([q.getContextUsage(), timeout]);
214
+ const session = getSession(chatId);
215
+ if (ctx.maxTokens > 0) session.usage.contextWindow = ctx.maxTokens;
216
+ if (ctx.totalTokens > 0) session.usage.contextTokens = ctx.totalTokens;
217
+ log(
218
+ "agent",
219
+ `[${chatId}] warm-up: context ${ctx.totalTokens}/${ctx.maxTokens} (${ctx.percentage.toFixed(1)}%) model=${ctx.model}`,
220
+ );
221
+
222
+ abort.abort();
223
+ await drainPromise;
224
+ } catch (err) {
225
+ abort.abort();
226
+ // Non-fatal — /status will just show 0 until first real message
227
+ logWarn(
228
+ "agent",
229
+ `[${chatId}] warm-up failed: ${err instanceof Error ? err.message : err}`,
230
+ );
231
+ }
232
+ }
233
+
234
+ // ── Main handler ─────────────────────────────────────────────────────────────
235
+
236
+ export async function handleMessage(
237
+ params: QueryParams,
238
+ _retried = false,
239
+ ): Promise<QueryResult> {
240
+ if (!config)
241
+ throw new Error("Agent not initialized. Call initAgent() first.");
242
+
243
+ const {
244
+ chatId,
245
+ text,
246
+ senderName,
247
+ isGroup,
248
+ onTextBlock,
249
+ onStreamDelta,
250
+ onToolUse,
251
+ } = params;
252
+ const session = getSession(chatId);
253
+ const t0 = Date.now();
254
+
255
+ // Rebuild system prompt on first turn of a new/reset session so identity,
256
+ // memory, and workspace listing are fresh
257
+ if (session.turns === 0) {
258
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
200
259
  }
201
260
 
261
+ const { options, activeModel } = buildSdkOptions(chatId);
262
+
263
+ const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
264
+ const nowTag = `[${formatFullDatetime()}]`;
265
+
202
266
  const prompt = isGroup
203
- ? `${continuityPrefix}${nowTag} [${senderName}]${msgIdHint}: ${text}`
204
- : `${continuityPrefix}${nowTag}${msgIdHint} ${text}`;
267
+ ? `${nowTag} [${senderName}]${msgIdHint}: ${text}`
268
+ : `${nowTag}${msgIdHint} ${text}`;
205
269
  log("agent", `[${chatId}] <- (${text.length} chars)`);
206
270
  traceMessage(chatId, "in", text, { senderName, isGroup });
207
271
 
@@ -214,11 +278,16 @@ export async function handleMessage(
214
278
  let currentBlockText = "";
215
279
  let allResponseText = "";
216
280
  let newSessionId: string | undefined;
217
- let inputTokens = 0;
218
- let outputTokens = 0;
219
- let cacheRead = 0;
220
- let cacheWrite = 0;
221
281
  let toolCalls = 0;
282
+ // Populated from SDK result message
283
+ let contextTokens = 0; // actual context fill from last iteration
284
+ let contextWindow: number | undefined;
285
+ let numApiCalls = 0;
286
+ // Cumulative token counts from SDK modelUsage (aggregated across models)
287
+ let sdkInputTokens = 0;
288
+ let sdkOutputTokens = 0;
289
+ let sdkCacheRead = 0;
290
+ let sdkCacheWrite = 0;
222
291
 
223
292
  // Streaming throttle
224
293
  let lastStreamUpdate = 0;
@@ -313,15 +382,64 @@ export async function handleMessage(
313
382
  }
314
383
  }
315
384
 
316
- // Final result
385
+ // Final result — read all data from SDK result fields
317
386
  if (type === "result") {
318
- const usage = msg.usage as Record<string, number> | undefined;
319
- if (usage) {
320
- inputTokens = usage.input_tokens ?? 0;
321
- outputTokens = usage.output_tokens ?? 0;
322
- cacheRead = usage.cache_read_input_tokens ?? 0;
323
- cacheWrite = usage.cache_creation_input_tokens ?? 0;
387
+ numApiCalls =
388
+ ((msg as Record<string, unknown>).num_turns as number) ?? 0;
389
+
390
+ // Context fill from last API iteration (only available in raw usage)
391
+ const usage = msg.usage as
392
+ | {
393
+ iterations?: Array<{
394
+ input_tokens: number;
395
+ cache_read_input_tokens: number;
396
+ cache_creation_input_tokens: number;
397
+ }>;
398
+ }
399
+ | undefined;
400
+ if (
401
+ usage &&
402
+ Array.isArray(usage.iterations) &&
403
+ usage.iterations.length > 0
404
+ ) {
405
+ const last = usage.iterations[usage.iterations.length - 1];
406
+ contextTokens =
407
+ (last.input_tokens ?? 0) +
408
+ (last.cache_read_input_tokens ?? 0) +
409
+ (last.cache_creation_input_tokens ?? 0);
410
+ }
411
+
412
+ // Token counts, context window from SDK modelUsage (aggregated per model)
413
+ type MU = {
414
+ inputTokens?: number;
415
+ outputTokens?: number;
416
+ cacheReadInputTokens?: number;
417
+ cacheCreationInputTokens?: number;
418
+ contextWindow?: number;
419
+ };
420
+ const modelUsage = (msg as Record<string, unknown>).modelUsage as
421
+ | Record<string, MU>
422
+ | undefined;
423
+ if (modelUsage) {
424
+ for (const mu of Object.values(modelUsage)) {
425
+ sdkInputTokens += mu.inputTokens ?? 0;
426
+ sdkOutputTokens += mu.outputTokens ?? 0;
427
+ sdkCacheRead += mu.cacheReadInputTokens ?? 0;
428
+ sdkCacheWrite += mu.cacheCreationInputTokens ?? 0;
429
+ if (
430
+ mu.contextWindow &&
431
+ mu.contextWindow > 0 &&
432
+ contextWindow === undefined
433
+ ) {
434
+ contextWindow = mu.contextWindow;
435
+ }
436
+ }
324
437
  }
438
+ log(
439
+ "agent",
440
+ `SDK result: modelUsage=${JSON.stringify(modelUsage)}, contextWindow=${contextWindow}, contextTokens=${contextTokens}, numApiCalls=${numApiCalls}`,
441
+ );
442
+
325
443
  // If we still have unsent text and no streaming captured it
326
444
  if (
327
445
  !allResponseText &&
@@ -383,12 +501,15 @@ export async function handleMessage(
383
501
  if (newSessionId) setSessionId(chatId, newSessionId);
384
502
  incrementTurns(chatId);
385
503
  recordUsage(chatId, {
386
- inputTokens,
387
- outputTokens,
388
- cacheRead,
389
- cacheWrite,
504
+ inputTokens: sdkInputTokens,
505
+ outputTokens: sdkOutputTokens,
506
+ cacheRead: sdkCacheRead,
507
+ cacheWrite: sdkCacheWrite,
390
508
  durationMs,
391
509
  model: activeModel,
510
+ contextTokens,
511
+ contextWindow,
512
+ numApiCalls,
392
513
  });
393
514
 
394
515
  // Set a descriptive session name from the first message
@@ -408,21 +529,21 @@ export async function handleMessage(
408
529
  // The remaining currentBlockText is the final response text
409
530
  allResponseText += currentBlockText;
410
531
 
411
- const totalPrompt = inputTokens + cacheRead + cacheWrite;
532
+ const totalPrompt = sdkInputTokens + sdkCacheRead + sdkCacheWrite;
412
533
  const cacheHitPct =
413
- totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0;
534
+ totalPrompt > 0 ? Math.round((sdkCacheRead / totalPrompt) * 100) : 0;
414
535
 
415
536
  log(
416
537
  "agent",
417
- `[${chatId}] -> (${durationMs}ms, in=${inputTokens} out=${outputTokens} cache=${cacheHitPct}%` +
538
+ `[${chatId}] -> (${durationMs}ms, in=${sdkInputTokens} out=${sdkOutputTokens} cache=${cacheHitPct}%` +
418
539
  `${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
419
540
  );
420
541
  traceMessage(chatId, "out", allResponseText, {
421
542
  durationMs,
422
- inputTokens,
423
- outputTokens,
424
- cacheRead,
425
- cacheWrite,
543
+ inputTokens: sdkInputTokens,
544
+ outputTokens: sdkOutputTokens,
545
+ cacheRead: sdkCacheRead,
546
+ cacheWrite: sdkCacheWrite,
426
547
  toolCalls,
427
548
  model: activeModel,
428
549
  });
@@ -430,9 +551,9 @@ export async function handleMessage(
430
551
  return {
431
552
  text: allResponseText.trim(),
432
553
  durationMs,
433
- inputTokens,
434
- outputTokens,
435
- cacheRead,
436
- cacheWrite,
554
+ inputTokens: sdkInputTokens,
555
+ outputTokens: sdkOutputTokens,
556
+ cacheRead: sdkCacheRead,
557
+ cacheWrite: sdkCacheWrite,
437
558
  };
438
559
  }
@@ -17,7 +17,6 @@ import {
17
17
  resetSession,
18
18
  } from "../../storage/sessions.js";
19
19
  import { getChatSettings } from "../../storage/chat-settings.js";
20
- import { getRecentHistory } from "../../storage/history.js";
21
20
  import { classify } from "../../core/errors.js";
22
21
  import { log, logError, logWarn } from "../../util/log.js";
23
22
  import { traceMessage } from "../../util/trace.js";
@@ -53,7 +52,7 @@ async function ensureServer(): Promise<OpencodeClient> {
53
52
 
54
53
  // Register our MCP tools server with OpenCode
55
54
  try {
56
- const toolsPath = new URL("../claude-sdk/tools.ts", import.meta.url)
55
+ const toolsPath = new URL("../../core/tools/mcp-server.ts", import.meta.url)
57
56
  .pathname;
58
57
  await client.mcp.add({
59
58
  body: {
@@ -63,6 +62,7 @@ async function ensureServer(): Promise<OpencodeClient> {
63
62
  command: ["node", "--import", "tsx", toolsPath],
64
63
  environment: {
65
64
  TALON_BRIDGE_URL: `http://127.0.0.1:${gatewayPortFn()}`,
65
+ TALON_FRONTEND: "telegram",
66
66
  },
67
67
  },
68
68
  },
@@ -149,24 +149,9 @@ export async function handleMessage(
149
149
 
150
150
  // Build prompt with group context
151
151
  const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
152
- let continuityPrefix = "";
153
- const session = getSession(chatId);
154
- if (!session.sessionId && session.turns > 0) {
155
- const recent = getRecentHistory(chatId, 3);
156
- if (recent.length > 0) {
157
- const ctx = recent
158
- .map(
159
- (m) =>
160
- `[${new Date(m.timestamp).toISOString().slice(11, 16)}] ${m.senderName}: ${m.text.slice(0, 300)}`,
161
- )
162
- .join("\n");
163
- continuityPrefix = `[Session resumed — recent context:\n${ctx}]\n\n`;
164
- }
165
- }
166
-
167
152
  const prompt = isGroup
168
- ? `${continuityPrefix}[${senderName}]${msgIdHint}: ${text}`
169
- : `${continuityPrefix}${text}${msgIdHint}`;
153
+ ? `[${senderName}]${msgIdHint}: ${text}`
154
+ : `${text}${msgIdHint}`;
170
155
 
171
156
  log("agent", `[${chatId}] <- (${text.length} chars)`);
172
157
  traceMessage(chatId, "in", text, { senderName, isGroup });
@@ -211,7 +196,7 @@ export async function handleMessage(
211
196
  model: activeModel,
212
197
  });
213
198
 
214
- if (session.turns === 0 && text) {
199
+ if (getSession(chatId).turns === 0 && text) {
215
200
  const cleanText = text
216
201
  .replace(/^\[.*?\]\s*/g, "")
217
202
  .replace(/\[msg_id:\d+\]\s*/g, "")
package/src/bootstrap.ts CHANGED
@@ -58,15 +58,14 @@ export async function bootstrap(
58
58
  ): Promise<BootstrapResult> {
59
59
  const config = loadConfig();
60
60
 
61
- // Expose search config as env vars for gateway-actions
62
- if (config.braveApiKey) process.env.TALON_BRAVE_API_KEY = config.braveApiKey;
63
- if (config.searxngUrl) process.env.TALON_SEARXNG_URL = config.searxngUrl;
64
-
65
- // Load plugins (external tool packages + built-in mempalace)
61
+ // Load plugins (external tool packages + built-in GitHub, MemPalace, Playwright)
66
62
  const hasPlugins =
67
- config.plugins.length > 0 || config.mempalace?.enabled === true;
63
+ config.plugins.length > 0 ||
64
+ config.github?.enabled === true ||
65
+ config.mempalace?.enabled === true ||
66
+ config.playwright?.enabled === true;
68
67
  if (hasPlugins) {
69
- const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
68
+ const { loadPlugins, loadBuiltinPlugins, getPluginPromptAdditions } =
70
69
  await import("./core/plugin.js");
71
70
 
72
71
  // External plugins
@@ -77,43 +76,8 @@ export async function bootstrap(
77
76
  await loadPlugins(config.plugins, frontends);
78
77
  }
79
78
 
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
- }
79
+ // Built-in plugins (GitHub, MemPalace, Playwright) — shared with hot-reload
80
+ await loadBuiltinPlugins(config);
117
81
 
118
82
  rebuildSystemPrompt(config, getPluginPromptAdditions());
119
83
  }