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
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared types for the modular tool system.
3
+ *
4
+ * Tool definitions are pure data + execute logic — no MCP imports,
5
+ * no bridge coupling. The MCP server consumes these via composeTools().
6
+ */
7
+
8
+ import type { ZodRawShape } from "zod";
9
+
10
+ /** Which frontends a tool is available on. "all" = every frontend. */
11
+ export type ToolFrontend = "telegram" | "teams" | "terminal" | "all";
12
+
13
+ /** Domain tags for runtime filtering and grouping. */
14
+ export type ToolTag =
15
+ | "messaging"
16
+ | "chat"
17
+ | "history"
18
+ | "members"
19
+ | "media"
20
+ | "stickers"
21
+ | "scheduling"
22
+ | "web"
23
+ | "admin";
24
+
25
+ /** The bridge caller signature — injected into execute(). */
26
+ export type BridgeFunction = (
27
+ action: string,
28
+ params: Record<string, unknown>,
29
+ ) => Promise<unknown>;
30
+
31
+ /**
32
+ * A self-contained tool definition.
33
+ *
34
+ * Contains everything needed to register it with an MCP server
35
+ * AND to know which bridge action it maps to.
36
+ */
37
+ export interface ToolDefinition {
38
+ /** MCP tool name (e.g. "send", "react", "fetch_url"). */
39
+ readonly name: string;
40
+
41
+ /** Human-readable description shown to the model. */
42
+ readonly description: string;
43
+
44
+ /** Zod schema shape for the tool's input parameters. */
45
+ readonly schema: ZodRawShape;
46
+
47
+ /**
48
+ * Execute the tool. Receives validated params and a bridge caller.
49
+ * Returns the raw bridge result (wrapped by the MCP layer).
50
+ */
51
+ readonly execute: (
52
+ params: Record<string, unknown>,
53
+ bridge: BridgeFunction,
54
+ ) => Promise<unknown>;
55
+
56
+ /** Which frontends this tool appears on. Omit for all frontends. */
57
+ readonly frontends?: readonly ToolFrontend[];
58
+
59
+ /** Grouping tag. */
60
+ readonly tag: ToolTag;
61
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Web tools — URL fetching.
3
+ *
4
+ * Platform-agnostic, available on all frontends.
5
+ * Web search is handled by the Brave Search MCP server (registered in
6
+ * src/backend/claude-sdk/index.ts) when configured. URL fetching is provided here via
7
+ * the `fetch_url` tool, so Claude Code's built-in WebSearch / WebFetch are
8
+ * disabled in favor of these project-specific replacements.
9
+ * These can be excluded via composeTools({ excludeTags: ["web"] }).
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import type { ToolDefinition } from "./types.js";
14
+
15
+ export const webTools: ToolDefinition[] = [
16
+ {
17
+ name: "fetch_url",
18
+ description:
19
+ "Fetch a URL — web pages return text content, image URLs are downloaded to workspace. Use to read articles, download images, or fetch any URL.",
20
+ schema: {
21
+ url: z.string().describe("The URL to fetch"),
22
+ },
23
+ execute: (params, bridge) => bridge("fetch_url", params),
24
+ tag: "web",
25
+ },
26
+ ];
@@ -217,6 +217,9 @@ export function createTeamsFrontend(
217
217
  resetSession(talonChatId);
218
218
  clearHistory(talonChatId);
219
219
  log("teams", `Session reset by ${msg.senderName}`);
220
+ const { warmSession } =
221
+ await import("../../backend/claude-sdk/index.js");
222
+ await warmSession(talonChatId);
220
223
  const card = buildAdaptiveCard("Session cleared.");
221
224
  await proxyFetch(webhookUrl, {
222
225
  method: "POST",
@@ -246,15 +249,11 @@ export function createTeamsFrontend(
246
249
  ).replace("claude-", "");
247
250
  const avgMs =
248
251
  info.turns > 0 ? Math.round(u.totalResponseMs / info.turns) : 0;
249
- const contextUsed = u.lastPromptTokens;
250
- const contextMax = model.includes("opus")
251
- ? 1_000_000
252
- : model.includes("sonnet")
253
- ? 1_000_000
254
- : 200_000;
255
- const contextPct =
256
- contextMax > 0
257
- ? Math.round((contextUsed / contextMax) * 100)
252
+ const ctxUsed = u.contextTokens || u.lastPromptTokens;
253
+ const ctxMax = u.contextWindow;
254
+ const ctxPct =
255
+ ctxMax > 0
256
+ ? Math.min(100, Math.round((ctxUsed / ctxMax) * 100))
258
257
  : 0;
259
258
  const card = {
260
259
  type: "message",
@@ -282,7 +281,7 @@ export function createTeamsFrontend(
282
281
  { title: "Turns", value: String(info.turns) },
283
282
  {
284
283
  title: "Context",
285
- value: `${(contextUsed / 1000).toFixed(0)}K / ${(contextMax / 1000).toFixed(0)}K (${contextPct}%)`,
284
+ value: `${(ctxUsed / 1000).toFixed(0)}K / ${(ctxMax / 1000).toFixed(0)}K (${ctxPct}%)`,
286
285
  },
287
286
  { title: "Cache", value: `${cacheHit}% hit` },
288
287
  {
@@ -278,7 +278,10 @@ export function createTelegramActionHandler(
278
278
  case "send_voice":
279
279
  case "send_audio": {
280
280
  const filePath = String(body.file_path ?? "");
281
- const caption = body.caption ? String(body.caption) : undefined;
281
+ const caption = body.caption
282
+ ? markdownToTelegramHtml(String(body.caption))
283
+ : undefined;
284
+ const captionParseMode = caption ? ("HTML" as const) : undefined;
282
285
  gateway.incrementMessages(chatId);
283
286
  if (action === "send_file") {
284
287
  const stat = statSync(filePath);
@@ -294,6 +297,7 @@ export function createTelegramActionHandler(
294
297
  sent = await withRetry(() =>
295
298
  bot.api.sendDocument(chatId, file, {
296
299
  caption,
300
+ parse_mode: captionParseMode,
297
301
  reply_parameters: rp,
298
302
  }),
299
303
  );
@@ -302,6 +306,7 @@ export function createTelegramActionHandler(
302
306
  sent = await withRetry(() =>
303
307
  bot.api.sendPhoto(chatId, file, {
304
308
  caption,
309
+ parse_mode: captionParseMode,
305
310
  reply_parameters: rp,
306
311
  }),
307
312
  );
@@ -310,6 +315,7 @@ export function createTelegramActionHandler(
310
315
  sent = await withRetry(() =>
311
316
  bot.api.sendVideo(chatId, file, {
312
317
  caption,
318
+ parse_mode: captionParseMode,
313
319
  reply_parameters: rp,
314
320
  }),
315
321
  );
@@ -318,6 +324,7 @@ export function createTelegramActionHandler(
318
324
  sent = await withRetry(() =>
319
325
  bot.api.sendAnimation(chatId, file, {
320
326
  caption,
327
+ parse_mode: captionParseMode,
321
328
  reply_parameters: rp,
322
329
  }),
323
330
  );
@@ -326,6 +333,7 @@ export function createTelegramActionHandler(
326
333
  sent = await withRetry(() =>
327
334
  bot.api.sendAudio(chatId, file, {
328
335
  caption,
336
+ parse_mode: captionParseMode,
329
337
  reply_parameters: rp,
330
338
  title: body.title as string | undefined,
331
339
  performer: body.performer as string | undefined,
@@ -336,6 +344,7 @@ export function createTelegramActionHandler(
336
344
  sent = await withRetry(() =>
337
345
  bot.api.sendVoice(chatId, file, {
338
346
  caption,
347
+ parse_mode: captionParseMode,
339
348
  reply_parameters: rp,
340
349
  }),
341
350
  );
@@ -38,6 +38,7 @@ import { appendDailyLog } from "../../storage/daily-log.js";
38
38
  import { escapeHtml } from "./formatting.js";
39
39
  import { handleAdminCommand } from "./admin.js";
40
40
  import { getLoadedPlugins } from "../../core/plugin.js";
41
+ import { warmSession } from "../../backend/claude-sdk/index.js";
41
42
  import {
42
43
  formatDuration,
43
44
  formatTokenCount,
@@ -61,7 +62,7 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
61
62
  [
62
63
  "<b>\uD83E\uDD85 Talon</b>",
63
64
  "",
64
- "Claude-powered Telegram assistant with 31 tools.",
65
+ "Agentic AI harness for Telegram.",
65
66
  "",
66
67
  "Send a message, photo, doc, or voice note.",
67
68
  "In groups, @mention or reply to activate.",
@@ -140,6 +141,8 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
140
141
  resetSession(cid);
141
142
  clearHistory(cid);
142
143
  resetPulseCheckpoint(cid);
144
+ // Warm up the new session so /status has context data immediately
145
+ await warmSession(cid);
143
146
  await ctx.reply("Session cleared.");
144
147
  });
145
148
 
@@ -424,17 +427,15 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
424
427
  const effortName = chatSets.effort ?? "adaptive";
425
428
  const pulseOn = isPulseEnabled(cid);
426
429
 
427
- const contextMax = activeModel.includes("haiku") ? 200_000 : 1_000_000;
428
- const contextUsed = u.lastPromptTokens;
429
- const contextPct =
430
- contextMax > 0
431
- ? Math.min(100, Math.round((contextUsed / contextMax) * 100))
432
- : 0;
430
+ const ctxUsed = u.contextTokens || u.lastPromptTokens;
431
+ const ctxMax = u.contextWindow; // from SDK modelUsage, preserved across turns
432
+ const ctxPct =
433
+ ctxMax > 0 ? Math.min(100, Math.round((ctxUsed / ctxMax) * 100)) : 0;
433
434
  const barLen = 20;
434
- const filled = Math.round((contextPct / 100) * barLen);
435
+ const filled = Math.round((ctxPct / 100) * barLen);
435
436
  const contextBar =
436
437
  "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
437
- const contextWarn = contextPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
438
+ const contextWarn = ctxPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
438
439
 
439
440
  const totalPrompt =
440
441
  u.totalInputTokens + u.totalCacheRead + u.totalCacheWrite;
@@ -455,7 +456,7 @@ export function registerCommands(bot: Bot, config: TalonConfig): void {
455
456
  const lines = [
456
457
  `<b>\uD83E\uDD85 Talon</b> \u00B7 <code>${escapeHtml(activeModel)}</code> \u00B7 effort: ${effortName}`,
457
458
  "",
458
- `<b>Context</b> ${formatTokenCount(contextUsed)} / ${formatTokenCount(contextMax)} (${contextPct}%)${contextWarn}`,
459
+ `<b>Context</b> ${formatTokenCount(ctxUsed)} / ${formatTokenCount(ctxMax)} (${ctxPct}%)${contextWarn}`,
459
460
  `<code>${contextBar}</code>`,
460
461
  "",
461
462
  `<b>Session Stats</b>`,
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GitHub plugin — GitHub API access via the official GitHub MCP server.
3
+ *
4
+ * Registers the GitHub MCP server (Docker image: ghcr.io/github/github-mcp-server),
5
+ * giving the agent access to repository management, issues, PRs, code search, etc.
6
+ *
7
+ * Configuration in ~/.talon/config.json:
8
+ * "github": {
9
+ * "enabled": true,
10
+ * "token": "ghp_..." // optional, defaults to `gh auth token` output
11
+ * }
12
+ */
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import type { TalonPlugin } from "../../core/plugin.js";
16
+ import { log, logWarn } from "../../util/log.js";
17
+
18
+ /**
19
+ * Resolve a GitHub personal access token.
20
+ * Priority: explicit config > `gh auth token` CLI.
21
+ */
22
+ function resolveToken(configToken?: string): string | undefined {
23
+ const trimmed = configToken?.trim();
24
+ if (trimmed) return trimmed;
25
+ try {
26
+ return execFileSync("gh", ["auth", "token"], {
27
+ timeout: 5_000,
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ })
30
+ .toString("utf-8")
31
+ .trim();
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export function createGitHubPlugin(config: { token?: string }): TalonPlugin {
38
+ const token = resolveToken(config.token);
39
+
40
+ return {
41
+ name: "github",
42
+ description: "GitHub API access via the official GitHub MCP server",
43
+ version: "1.0.0",
44
+
45
+ mcpServer: {
46
+ command: "docker",
47
+ args: [
48
+ "run",
49
+ "--rm",
50
+ "-i",
51
+ "-e",
52
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
53
+ "ghcr.io/github/github-mcp-server",
54
+ ],
55
+ },
56
+
57
+ validateConfig() {
58
+ const errors: string[] = [];
59
+
60
+ if (!token) {
61
+ errors.push(
62
+ 'No GitHub token found. Set "token" in github config or run `gh auth login`.',
63
+ );
64
+ }
65
+
66
+ // Check Docker is available
67
+ try {
68
+ execFileSync("docker", ["info"], {
69
+ timeout: 10_000,
70
+ stdio: "pipe",
71
+ });
72
+ } catch {
73
+ errors.push(
74
+ "Docker is not available or not running. The GitHub MCP server requires Docker.",
75
+ );
76
+ }
77
+
78
+ return errors.length > 0 ? errors : undefined;
79
+ },
80
+
81
+ async init() {
82
+ // Verify the Docker image exists locally
83
+ try {
84
+ execFileSync(
85
+ "docker",
86
+ ["image", "inspect", "ghcr.io/github/github-mcp-server"],
87
+ { timeout: 10_000, stdio: "pipe" },
88
+ );
89
+ log("github", "Docker image verified");
90
+ } catch {
91
+ logWarn(
92
+ "github",
93
+ "Docker image not found locally — will pull on first use (may be slow)",
94
+ );
95
+ }
96
+
97
+ log("github", "Ready");
98
+ },
99
+
100
+ getEnvVars() {
101
+ return {
102
+ ...(token ? { GITHUB_PERSONAL_ACCESS_TOKEN: token } : {}),
103
+ };
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Playwright plugin — browser automation via the official Playwright MCP server.
3
+ *
4
+ * Gives the agent headless Chromium for web scraping, screenshots, PDF generation,
5
+ * and general browser automation.
6
+ *
7
+ * Configuration in ~/.talon/config.json:
8
+ * "playwright": {
9
+ * "enabled": true,
10
+ * "browser": "chromium", // optional, default "chromium"
11
+ * "headless": true // optional, default true
12
+ * }
13
+ */
14
+
15
+ import { existsSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import type { TalonPlugin } from "../../core/plugin.js";
18
+ import { log } from "../../util/log.js";
19
+
20
+ export function createPlaywrightPlugin(config: {
21
+ browser?: string;
22
+ headless?: boolean;
23
+ }): TalonPlugin {
24
+ const browser = config.browser ?? "chromium";
25
+ const headless = config.headless !== false; // default true
26
+
27
+ // Resolve path from Talon's node_modules
28
+ const mcpBin = resolve(
29
+ import.meta.dirname ?? ".",
30
+ "../../../node_modules/@playwright/mcp/cli.js",
31
+ );
32
+
33
+ const args = ["--no-sandbox"];
34
+
35
+ if (headless) {
36
+ args.push("--headless");
37
+ }
38
+
39
+ if (browser !== "chromium") {
40
+ args.push("--browser", browser);
41
+ }
42
+
43
+ return {
44
+ name: "playwright",
45
+ description: "Browser automation via Playwright MCP (headless Chromium)",
46
+ version: "1.0.0",
47
+
48
+ mcpServer: {
49
+ command: "node",
50
+ args: [mcpBin, ...args],
51
+ },
52
+
53
+ validateConfig() {
54
+ const errors: string[] = [];
55
+
56
+ const validBrowsers = [
57
+ "chromium",
58
+ "chrome",
59
+ "firefox",
60
+ "webkit",
61
+ "msedge",
62
+ ];
63
+ if (!validBrowsers.includes(browser)) {
64
+ errors.push(
65
+ `Invalid browser "${browser}". Valid options: ${validBrowsers.join(", ")}`,
66
+ );
67
+ }
68
+
69
+ if (!existsSync(mcpBin)) {
70
+ errors.push(
71
+ `@playwright/mcp not found at ${mcpBin} — run "npm install @playwright/mcp"`,
72
+ );
73
+ }
74
+
75
+ return errors.length > 0 ? errors : undefined;
76
+ },
77
+
78
+ async init() {
79
+ log("playwright", `Ready (${browser}, headless=${headless})`);
80
+ },
81
+ };
82
+ }
@@ -18,8 +18,14 @@ type SessionUsage = {
18
18
  totalOutputTokens: number;
19
19
  totalCacheRead: number;
20
20
  totalCacheWrite: number;
21
- /** Last turn's prompt tokens (context size snapshot). */
21
+ /** Last turn's total prompt tokens (cumulative across all API calls in the turn, including tool-use loops). */
22
22
  lastPromptTokens: number;
23
+ /** Actual context window fill from the last API call (last iteration's prompt tokens). */
24
+ contextTokens: number;
25
+ /** Model's context window size in tokens (from SDK modelUsage). */
26
+ contextWindow: number;
27
+ /** Number of API round-trips in the last turn (tool-use steps). */
28
+ numApiCalls: number;
23
29
  /** Estimated cost in USD. */
24
30
  estimatedCostUsd: number;
25
31
  /** Total response time in ms (for averaging). */
@@ -82,16 +88,6 @@ export function loadSessions(): void {
82
88
  );
83
89
  store = {};
84
90
  }
85
- // SDK sessions don't survive process restarts — the embedded Claude Code
86
- // subprocess is gone. Clear stale session IDs so we don't try to resume
87
- // a dead session (which causes the SDK to hang silently on Windows).
88
- // Keep turns/usage intact — they're historical data used by /resume.
89
- for (const session of Object.values(store)) {
90
- if (session.sessionId) {
91
- session.sessionId = undefined;
92
- dirty = true;
93
- }
94
- }
95
91
  }
96
92
 
97
93
  function saveSessions(): void {
@@ -129,6 +125,9 @@ const emptyUsage = (): SessionUsage => ({
129
125
  totalCacheRead: 0,
130
126
  totalCacheWrite: 0,
131
127
  lastPromptTokens: 0,
128
+ contextTokens: 0,
129
+ contextWindow: 0,
130
+ numApiCalls: 0,
132
131
  estimatedCostUsd: 0,
133
132
  totalResponseMs: 0,
134
133
  lastResponseMs: 0,
@@ -160,6 +159,12 @@ export function getSession(chatId: string): SessionState {
160
159
  session.usage.fastestResponseMs === 0
161
160
  )
162
161
  session.usage.fastestResponseMs = Infinity;
162
+ // Migrate sessions from before context tracking was added
163
+ if (session.usage.contextTokens === undefined)
164
+ session.usage.contextTokens = 0;
165
+ if (session.usage.contextWindow === undefined)
166
+ session.usage.contextWindow = 0;
167
+ if (session.usage.numApiCalls === undefined) session.usage.numApiCalls = 0;
163
168
  return session;
164
169
  }
165
170
 
@@ -176,24 +181,6 @@ export function incrementTurns(chatId: string): void {
176
181
  dirty = true;
177
182
  }
178
183
 
179
- /** Model-specific pricing ($ per million tokens). */
180
- const MODEL_PRICING: Record<
181
- string,
182
- { input: number; output: number; cacheRead: number; cacheWrite: number }
183
- > = {
184
- haiku: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
185
- sonnet: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
186
- opus: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
187
- };
188
-
189
- function getPricing(model?: string): (typeof MODEL_PRICING)["sonnet"] {
190
- if (!model) return MODEL_PRICING.sonnet;
191
- const lower = model.toLowerCase();
192
- if (lower.includes("haiku")) return MODEL_PRICING.haiku;
193
- if (lower.includes("opus")) return MODEL_PRICING.opus;
194
- return MODEL_PRICING.sonnet;
195
- }
196
-
197
184
  export function recordUsage(
198
185
  chatId: string,
199
186
  turn: {
@@ -203,25 +190,32 @@ export function recordUsage(
203
190
  cacheWrite: number;
204
191
  durationMs?: number;
205
192
  model?: string;
193
+ /** Actual context fill from the last API call (last iteration's prompt tokens). */
194
+ contextTokens?: number;
195
+ /** Model context window size from SDK modelUsage. */
196
+ contextWindow?: number;
197
+ /** Number of agentic turns / API round-trips in this turn. */
198
+ numApiCalls?: number;
206
199
  },
207
200
  ): void {
208
201
  const session = getSession(chatId);
202
+ // Token counts from SDK modelUsage (accumulated per-turn)
209
203
  session.usage.totalInputTokens += turn.inputTokens;
210
204
  session.usage.totalOutputTokens += turn.outputTokens;
211
205
  session.usage.totalCacheRead += turn.cacheRead;
212
206
  session.usage.totalCacheWrite += turn.cacheWrite;
213
- // Snapshot: prompt tokens = input + cache_read + cache_write for this turn
214
207
  session.usage.lastPromptTokens =
215
208
  turn.inputTokens + turn.cacheRead + turn.cacheWrite;
216
- // Model-aware cost estimate
217
- const pricing = getPricing(turn.model);
218
- session.usage.estimatedCostUsd +=
219
- (turn.inputTokens * pricing.input +
220
- turn.cacheWrite * pricing.cacheWrite +
221
- turn.cacheRead * pricing.cacheRead +
222
- turn.outputTokens * pricing.output) /
223
- 1_000_000;
224
- // Track which model was last used
209
+ // Context info from SDK
210
+ session.usage.contextTokens = turn.contextTokens ?? 0;
211
+ if (
212
+ turn.contextWindow !== undefined &&
213
+ Number.isFinite(turn.contextWindow) &&
214
+ turn.contextWindow > 0
215
+ ) {
216
+ session.usage.contextWindow = turn.contextWindow;
217
+ }
218
+ session.usage.numApiCalls = turn.numApiCalls ?? 0;
225
219
  if (turn.model) session.lastModel = turn.model;
226
220
  // Response time tracking
227
221
  if (turn.durationMs && turn.durationMs > 0) {
@@ -301,17 +295,7 @@ export function getAllSessions(): Array<{ chatId: string; info: SessionInfo }> {
301
295
  turns: session.turns,
302
296
  lastActive: session.lastActive,
303
297
  createdAt: session.createdAt,
304
- usage: session.usage ?? {
305
- totalInputTokens: 0,
306
- totalOutputTokens: 0,
307
- totalCacheRead: 0,
308
- totalCacheWrite: 0,
309
- lastPromptTokens: 0,
310
- estimatedCostUsd: 0,
311
- totalResponseMs: 0,
312
- lastResponseMs: 0,
313
- fastestResponseMs: Infinity,
314
- },
298
+ usage: session.usage ?? emptyUsage(),
315
299
  sessionName: session.sessionName,
316
300
  lastModel: session.lastModel,
317
301
  },
@@ -40,10 +40,18 @@ const configSchema = z.object({
40
40
  heartbeatIntervalMinutes: z.number().int().min(5).default(60),
41
41
  heartbeatModel: z.string().optional(), // Model for heartbeat agent (defaults to main model)
42
42
  braveApiKey: z.string().optional(),
43
- searxngUrl: z.string().default("http://localhost:8080"),
44
43
  timezone: z.string().optional(),
45
44
  plugins: z.array(pluginEntrySchema).default([]),
46
45
 
46
+ // GitHub — GitHub API access via official MCP server
47
+ github: z
48
+ .object({
49
+ enabled: z.boolean().default(false),
50
+ /** GitHub personal access token (default: from `gh auth token`) */
51
+ token: z.string().min(1).optional(),
52
+ })
53
+ .optional(),
54
+
47
55
  // MemPalace — structured long-term memory with vector search
48
56
  mempalace: z
49
57
  .object({
@@ -55,6 +63,17 @@ const configSchema = z.object({
55
63
  })
56
64
  .optional(),
57
65
 
66
+ // Playwright — headless browser automation via MCP
67
+ playwright: z
68
+ .object({
69
+ enabled: z.boolean().default(false),
70
+ /** Browser engine: chromium (default), chrome, firefox, webkit, msedge */
71
+ browser: z.string().optional(),
72
+ /** Run headless (default: true) */
73
+ headless: z.boolean().default(true),
74
+ })
75
+ .optional(),
76
+
58
77
  // Display name shown in terminal UI (defaults to "Talon")
59
78
  botDisplayName: z.string().default("Talon"),
60
79
 
package/src/util/log.ts CHANGED
@@ -42,7 +42,9 @@ export type LogComponent =
42
42
  | "teams"
43
43
  | "config"
44
44
  | "access"
45
- | "mempalace";
45
+ | "github"
46
+ | "mempalace"
47
+ | "playwright";
46
48
 
47
49
  const LOG_FILE = files.log;
48
50