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.
- package/package.json +4 -2
- package/prompts/heartbeat.md +18 -6
- package/src/__tests__/compose-tools.test.ts +216 -0
- package/src/__tests__/fuzz.test.ts +0 -2
- package/src/__tests__/gateway-actions.test.ts +1 -423
- package/src/__tests__/heartbeat.test.ts +21 -0
- package/src/__tests__/reload-plugins.test.ts +199 -0
- package/src/__tests__/sessions.test.ts +155 -121
- package/src/backend/claude-sdk/index.ts +230 -109
- package/src/backend/opencode/index.ts +5 -20
- package/src/bootstrap.ts +8 -44
- package/src/core/gateway-actions.ts +42 -88
- package/src/core/heartbeat.ts +8 -5
- package/src/core/plugin.ts +147 -0
- package/src/core/tools/admin.ts +22 -0
- package/src/core/tools/bridge.ts +40 -0
- package/src/core/tools/chat.ts +52 -0
- package/src/core/tools/history.ts +80 -0
- package/src/core/tools/index.ts +84 -0
- package/src/core/tools/mcp-server.ts +64 -0
- package/src/core/tools/media.ts +23 -0
- package/src/core/tools/members.ts +46 -0
- package/src/core/tools/messaging.ts +300 -0
- package/src/core/tools/scheduling.ts +89 -0
- package/src/core/tools/stickers.ts +143 -0
- package/src/core/tools/types.ts +61 -0
- package/src/core/tools/web.ts +26 -0
- package/src/frontend/teams/index.ts +9 -10
- package/src/frontend/telegram/actions.ts +10 -1
- package/src/frontend/telegram/commands.ts +11 -10
- package/src/plugins/github/index.ts +106 -0
- package/src/plugins/playwright/index.ts +82 -0
- package/src/storage/sessions.ts +34 -50
- package/src/util/config.ts +20 -1
- package/src/util/log.ts +3 -1
- package/src/backend/claude-sdk/tools.ts +0 -651
- 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
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
?
|
|
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: `${(
|
|
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
|
|
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
|
-
"
|
|
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
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
|
|
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((
|
|
435
|
+
const filled = Math.round((ctxPct / 100) * barLen);
|
|
435
436
|
const contextBar =
|
|
436
437
|
"\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
437
|
-
const contextWarn =
|
|
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(
|
|
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
|
+
}
|
package/src/storage/sessions.ts
CHANGED
|
@@ -18,8 +18,14 @@ type SessionUsage = {
|
|
|
18
18
|
totalOutputTokens: number;
|
|
19
19
|
totalCacheRead: number;
|
|
20
20
|
totalCacheWrite: number;
|
|
21
|
-
/** Last turn's prompt tokens (
|
|
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
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
},
|
package/src/util/config.ts
CHANGED
|
@@ -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
|
|