talon-agent 1.0.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/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// Mock log to prevent pino initialization issues
|
|
7
|
+
vi.mock("../util/log.js", () => ({
|
|
8
|
+
log: vi.fn(),
|
|
9
|
+
logError: vi.fn(),
|
|
10
|
+
logWarn: vi.fn(),
|
|
11
|
+
logDebug: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
initWorkspace,
|
|
16
|
+
getWorkspaceDiskUsage,
|
|
17
|
+
cleanupUploads,
|
|
18
|
+
startUploadCleanup,
|
|
19
|
+
stopUploadCleanup,
|
|
20
|
+
} from "../util/workspace.js";
|
|
21
|
+
|
|
22
|
+
const TEST_ROOT = join(tmpdir(), `talon-ws-test-${Date.now()}`);
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
stopUploadCleanup();
|
|
30
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("initWorkspace", () => {
|
|
34
|
+
it("creates root directory if missing", () => {
|
|
35
|
+
initWorkspace(TEST_ROOT);
|
|
36
|
+
expect(existsSync(TEST_ROOT)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is idempotent", () => {
|
|
40
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
41
|
+
expect(() => initWorkspace(TEST_ROOT)).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("getWorkspaceDiskUsage", () => {
|
|
46
|
+
it("returns 0 for empty directory", () => {
|
|
47
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
48
|
+
expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sums file sizes recursively", () => {
|
|
52
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
53
|
+
const sub = join(TEST_ROOT, "sub");
|
|
54
|
+
mkdirSync(sub);
|
|
55
|
+
writeFileSync(join(TEST_ROOT, "a.txt"), "hello");
|
|
56
|
+
writeFileSync(join(sub, "b.txt"), "world!");
|
|
57
|
+
expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(11);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns 0 for non-existent directory", () => {
|
|
61
|
+
expect(getWorkspaceDiskUsage(join(TEST_ROOT, "nope"))).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles deeply nested directories", () => {
|
|
65
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
66
|
+
const deep = join(TEST_ROOT, "a", "b", "c");
|
|
67
|
+
mkdirSync(deep, { recursive: true });
|
|
68
|
+
writeFileSync(join(deep, "deep.txt"), "deep content here");
|
|
69
|
+
expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(17);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores symlink or special entries gracefully", () => {
|
|
73
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
74
|
+
writeFileSync(join(TEST_ROOT, "normal.txt"), "hi");
|
|
75
|
+
// Should not throw
|
|
76
|
+
expect(getWorkspaceDiskUsage(TEST_ROOT)).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("cleanupUploads", () => {
|
|
81
|
+
it("returns 0 if uploads dir doesn't exist", () => {
|
|
82
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
83
|
+
expect(cleanupUploads(TEST_ROOT)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("deletes files older than maxAgeMs", async () => {
|
|
87
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
88
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(uploadsDir, "old.jpg"), "data");
|
|
90
|
+
|
|
91
|
+
// Wait 10ms so the file has age > 0, then cleanup with maxAge=1
|
|
92
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
93
|
+
expect(cleanupUploads(TEST_ROOT, 1)).toBe(1);
|
|
94
|
+
expect(readdirSync(uploadsDir)).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps recent files", () => {
|
|
98
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
99
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
100
|
+
writeFileSync(join(uploadsDir, "new.jpg"), "fresh");
|
|
101
|
+
|
|
102
|
+
// 1 hour window — just-created file survives
|
|
103
|
+
expect(cleanupUploads(TEST_ROOT, 3_600_000)).toBe(0);
|
|
104
|
+
expect(readdirSync(uploadsDir)).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips subdirectories in uploads", () => {
|
|
108
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
109
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
110
|
+
mkdirSync(join(uploadsDir, "subdir"));
|
|
111
|
+
writeFileSync(join(uploadsDir, "file.jpg"), "data");
|
|
112
|
+
|
|
113
|
+
// subdir should be skipped (isFile check)
|
|
114
|
+
expect(cleanupUploads(TEST_ROOT, 3_600_000)).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses default maxAgeMs when not specified", async () => {
|
|
118
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
119
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
120
|
+
writeFileSync(join(uploadsDir, "recent.jpg"), "data");
|
|
121
|
+
|
|
122
|
+
// Default maxAge is 7 days, so a recent file should survive
|
|
123
|
+
expect(cleanupUploads(TEST_ROOT)).toBe(0);
|
|
124
|
+
expect(readdirSync(uploadsDir)).toHaveLength(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("deletes multiple old files", async () => {
|
|
128
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
129
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
130
|
+
writeFileSync(join(uploadsDir, "a.jpg"), "aaa");
|
|
131
|
+
writeFileSync(join(uploadsDir, "b.jpg"), "bbb");
|
|
132
|
+
writeFileSync(join(uploadsDir, "c.jpg"), "ccc");
|
|
133
|
+
|
|
134
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
135
|
+
expect(cleanupUploads(TEST_ROOT, 1)).toBe(3);
|
|
136
|
+
expect(readdirSync(uploadsDir)).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("startUploadCleanup / stopUploadCleanup", () => {
|
|
141
|
+
it("starts periodic cleanup without throwing", () => {
|
|
142
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
143
|
+
expect(() => startUploadCleanup(TEST_ROOT)).not.toThrow();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("is idempotent (calling twice does not create multiple timers)", () => {
|
|
147
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
148
|
+
startUploadCleanup(TEST_ROOT);
|
|
149
|
+
startUploadCleanup(TEST_ROOT); // second call should be no-op
|
|
150
|
+
stopUploadCleanup();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("stopUploadCleanup is safe when not started", () => {
|
|
154
|
+
expect(() => stopUploadCleanup()).not.toThrow();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("can restart after stopping", () => {
|
|
158
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
159
|
+
startUploadCleanup(TEST_ROOT);
|
|
160
|
+
stopUploadCleanup();
|
|
161
|
+
expect(() => startUploadCleanup(TEST_ROOT)).not.toThrow();
|
|
162
|
+
stopUploadCleanup();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("runs immediate cleanup on start", async () => {
|
|
166
|
+
const uploadsDir = join(TEST_ROOT, "uploads");
|
|
167
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
168
|
+
writeFileSync(join(uploadsDir, "old.jpg"), "data");
|
|
169
|
+
|
|
170
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
171
|
+
// startUploadCleanup calls cleanupUploads immediately but with default 7-day maxAge
|
|
172
|
+
// so a fresh file won't be deleted. Just verify it doesn't throw.
|
|
173
|
+
startUploadCleanup(TEST_ROOT);
|
|
174
|
+
expect(existsSync(join(uploadsDir, "old.jpg"))).toBe(true);
|
|
175
|
+
stopUploadCleanup();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("stopUploadCleanup clears the timer (calling twice is safe)", () => {
|
|
179
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
180
|
+
startUploadCleanup(TEST_ROOT);
|
|
181
|
+
stopUploadCleanup();
|
|
182
|
+
expect(() => stopUploadCleanup()).not.toThrow();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
3
|
+
import {
|
|
4
|
+
getSession,
|
|
5
|
+
incrementTurns,
|
|
6
|
+
recordUsage,
|
|
7
|
+
resetSession,
|
|
8
|
+
setSessionId,
|
|
9
|
+
setSessionName,
|
|
10
|
+
} from "../../storage/sessions.js";
|
|
11
|
+
import { getChatSettings, setChatModel } from "../../storage/chat-settings.js";
|
|
12
|
+
import { getRecentHistory } from "../../storage/history.js";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { classify } from "../../core/errors.js";
|
|
15
|
+
import {
|
|
16
|
+
getPluginMcpServers,
|
|
17
|
+
getPluginPromptAdditions,
|
|
18
|
+
} from "../../core/plugin.js";
|
|
19
|
+
import { rebuildSystemPrompt } from "../../util/config.js";
|
|
20
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
21
|
+
import { traceMessage } from "../../util/trace.js";
|
|
22
|
+
import { formatSmartTimestamp, formatFullDatetime } from "../../util/time.js";
|
|
23
|
+
|
|
24
|
+
import type { QueryParams, QueryResult } from "../../core/types.js";
|
|
25
|
+
|
|
26
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
let config: TalonConfig;
|
|
29
|
+
let bridgePortFn: () => number = () => 19876;
|
|
30
|
+
|
|
31
|
+
export function initAgent(
|
|
32
|
+
cfg: TalonConfig,
|
|
33
|
+
getBridgePort?: () => number,
|
|
34
|
+
): void {
|
|
35
|
+
config = cfg;
|
|
36
|
+
if (getBridgePort) bridgePortFn = getBridgePort;
|
|
37
|
+
|
|
38
|
+
// The Agent SDK spawns an embedded Claude Code subprocess.
|
|
39
|
+
// If CLAUDECODE is set (e.g. running from a Claude Code terminal),
|
|
40
|
+
// the subprocess refuses to start with a nested-session error that
|
|
41
|
+
// gets swallowed — causing an infinite hang on Windows.
|
|
42
|
+
delete process.env.CLAUDECODE;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Main handler ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
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.");
|
|
53
|
+
|
|
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
|
|
73
|
+
const chatSettings = getChatSettings(chatId);
|
|
74
|
+
const activeModel = chatSettings.model ?? config.model;
|
|
75
|
+
const activeEffort = chatSettings.effort ?? "adaptive";
|
|
76
|
+
|
|
77
|
+
const EFFORT_MAP: Record<
|
|
78
|
+
string,
|
|
79
|
+
{
|
|
80
|
+
thinking: { type: "adaptive" | "disabled" };
|
|
81
|
+
effort?: "low" | "medium" | "high" | "max";
|
|
82
|
+
}
|
|
83
|
+
> = {
|
|
84
|
+
off: { thinking: { type: "disabled" } },
|
|
85
|
+
low: { thinking: { type: "adaptive" }, effort: "low" },
|
|
86
|
+
medium: { thinking: { type: "adaptive" }, effort: "medium" },
|
|
87
|
+
high: { thinking: { type: "adaptive" }, effort: "high" },
|
|
88
|
+
max: { thinking: { type: "adaptive" }, effort: "max" },
|
|
89
|
+
};
|
|
90
|
+
const thinkingConfig = EFFORT_MAP[activeEffort] ?? {
|
|
91
|
+
thinking: { type: "adaptive" as const },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const options = {
|
|
95
|
+
model: activeModel,
|
|
96
|
+
systemPrompt: config.systemPrompt,
|
|
97
|
+
cwd: config.workspace,
|
|
98
|
+
permissionMode: "bypassPermissions" as const,
|
|
99
|
+
allowDangerouslySkipPermissions: true,
|
|
100
|
+
betas: ["context-1m-2025-08-07"],
|
|
101
|
+
...(config.claudeBinary
|
|
102
|
+
? { pathToClaudeCodeExecutable: config.claudeBinary }
|
|
103
|
+
: {}),
|
|
104
|
+
disallowedTools: [
|
|
105
|
+
"EnterPlanMode",
|
|
106
|
+
"ExitPlanMode",
|
|
107
|
+
"EnterWorktree",
|
|
108
|
+
"ExitWorktree",
|
|
109
|
+
"TodoWrite",
|
|
110
|
+
"TodoRead",
|
|
111
|
+
"TaskCreate",
|
|
112
|
+
"TaskUpdate",
|
|
113
|
+
"TaskGet",
|
|
114
|
+
"TaskList",
|
|
115
|
+
"TaskOutput",
|
|
116
|
+
"TaskStop",
|
|
117
|
+
"AskUserQuestion",
|
|
118
|
+
],
|
|
119
|
+
...thinkingConfig,
|
|
120
|
+
mcpServers: {
|
|
121
|
+
// Register frontend-specific MCP tools based on active frontend
|
|
122
|
+
...(() => {
|
|
123
|
+
const frontends = Array.isArray(config.frontend)
|
|
124
|
+
? config.frontend
|
|
125
|
+
: [config.frontend];
|
|
126
|
+
const bridgeUrl = `http://127.0.0.1:${bridgePortFn()}`;
|
|
127
|
+
const mcpEnv = { TALON_BRIDGE_URL: bridgeUrl, TALON_CHAT_ID: chatId };
|
|
128
|
+
const servers: Record<
|
|
129
|
+
string,
|
|
130
|
+
{ command: string; args: string[]; env: Record<string, string> }
|
|
131
|
+
> = {};
|
|
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
|
+
const tsxImport = resolve(
|
|
135
|
+
import.meta.dirname ?? ".",
|
|
136
|
+
"../../../node_modules/tsx/dist/esm/index.mjs",
|
|
137
|
+
);
|
|
138
|
+
|
|
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,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (frontends.includes("teams")) {
|
|
154
|
+
servers["teams-tools"] = {
|
|
155
|
+
command: process.platform === "win32" ? "npx" : "node",
|
|
156
|
+
args:
|
|
157
|
+
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
|
+
],
|
|
173
|
+
env: mcpEnv,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return servers;
|
|
177
|
+
})(),
|
|
178
|
+
...getPluginMcpServers(`http://127.0.0.1:${bridgePortFn()}`, chatId),
|
|
179
|
+
},
|
|
180
|
+
...(session.sessionId ? { resume: session.sessionId } : {}),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
|
|
184
|
+
const nowTag = `[${formatFullDatetime()}]`;
|
|
185
|
+
|
|
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
|
+
const prompt = isGroup
|
|
203
|
+
? `${continuityPrefix}${nowTag} [${senderName}]${msgIdHint}: ${text}`
|
|
204
|
+
: `${continuityPrefix}${nowTag}${msgIdHint} ${text}`;
|
|
205
|
+
log("agent", `[${chatId}] <- (${text.length} chars)`);
|
|
206
|
+
traceMessage(chatId, "in", text, { senderName, isGroup });
|
|
207
|
+
|
|
208
|
+
// SDK types are not fully exported; cast options at the boundary
|
|
209
|
+
const qi = query({
|
|
210
|
+
prompt,
|
|
211
|
+
options: options as Parameters<typeof query>[0]["options"],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
let currentBlockText = "";
|
|
215
|
+
let allResponseText = "";
|
|
216
|
+
let newSessionId: string | undefined;
|
|
217
|
+
let inputTokens = 0;
|
|
218
|
+
let outputTokens = 0;
|
|
219
|
+
let cacheRead = 0;
|
|
220
|
+
let cacheWrite = 0;
|
|
221
|
+
let toolCalls = 0;
|
|
222
|
+
|
|
223
|
+
// Streaming throttle
|
|
224
|
+
let lastStreamUpdate = 0;
|
|
225
|
+
const STREAM_INTERVAL = 1000;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
for await (const message of qi) {
|
|
229
|
+
const msg = message as Record<string, unknown>;
|
|
230
|
+
const type = msg.type as string;
|
|
231
|
+
|
|
232
|
+
// Session ID capture
|
|
233
|
+
if (
|
|
234
|
+
type === "system" &&
|
|
235
|
+
msg.subtype === "init" &&
|
|
236
|
+
typeof msg.session_id === "string"
|
|
237
|
+
) {
|
|
238
|
+
newSessionId = msg.session_id;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Stream text deltas and thinking deltas
|
|
242
|
+
if (type === "stream_event" && onStreamDelta) {
|
|
243
|
+
const event = msg.event as Record<string, unknown> | undefined;
|
|
244
|
+
if (event?.type === "content_block_delta") {
|
|
245
|
+
const delta = event.delta as Record<string, unknown> | undefined;
|
|
246
|
+
if (
|
|
247
|
+
delta?.type === "thinking_delta" &&
|
|
248
|
+
typeof delta.thinking === "string"
|
|
249
|
+
) {
|
|
250
|
+
// Thinking phase: notify but don't accumulate text
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
if (now - lastStreamUpdate >= STREAM_INTERVAL) {
|
|
253
|
+
lastStreamUpdate = now;
|
|
254
|
+
onStreamDelta(currentBlockText, "thinking");
|
|
255
|
+
}
|
|
256
|
+
} else if (
|
|
257
|
+
delta?.type === "text_delta" &&
|
|
258
|
+
typeof delta.text === "string"
|
|
259
|
+
) {
|
|
260
|
+
currentBlockText += delta.text;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
if (now - lastStreamUpdate >= STREAM_INTERVAL) {
|
|
263
|
+
lastStreamUpdate = now;
|
|
264
|
+
onStreamDelta(currentBlockText, "text");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Complete assistant message — may contain multiple text blocks
|
|
271
|
+
// and tool_use blocks. Each text block before a tool_use is a
|
|
272
|
+
// "progress message" that should be sent immediately.
|
|
273
|
+
if (type === "assistant") {
|
|
274
|
+
const content = (msg.message as { content?: unknown[] })?.content;
|
|
275
|
+
if (Array.isArray(content)) {
|
|
276
|
+
let blockText = "";
|
|
277
|
+
for (const block of content) {
|
|
278
|
+
const b = block as { type: string; text?: string; name?: string };
|
|
279
|
+
if (b.type === "text" && b.text) {
|
|
280
|
+
blockText += b.text;
|
|
281
|
+
}
|
|
282
|
+
if (b.type === "tool_use") {
|
|
283
|
+
toolCalls++;
|
|
284
|
+
const tb = block as {
|
|
285
|
+
type: string;
|
|
286
|
+
name?: string;
|
|
287
|
+
input?: Record<string, unknown>;
|
|
288
|
+
};
|
|
289
|
+
if (onToolUse && tb.name) {
|
|
290
|
+
try {
|
|
291
|
+
onToolUse(tb.name, tb.input ?? {});
|
|
292
|
+
} catch {
|
|
293
|
+
/* non-fatal */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// If there's text before this tool call, send it as a progress message
|
|
297
|
+
if (blockText.trim() && onTextBlock) {
|
|
298
|
+
try {
|
|
299
|
+
await onTextBlock(blockText.trim());
|
|
300
|
+
} catch {
|
|
301
|
+
/* non-fatal — don't abort the stream loop */
|
|
302
|
+
}
|
|
303
|
+
allResponseText += blockText;
|
|
304
|
+
blockText = "";
|
|
305
|
+
currentBlockText = "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Remaining text after all tool calls (or if no tool calls)
|
|
310
|
+
if (blockText.trim()) {
|
|
311
|
+
currentBlockText = blockText;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Final result
|
|
317
|
+
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;
|
|
324
|
+
}
|
|
325
|
+
// If we still have unsent text and no streaming captured it
|
|
326
|
+
if (
|
|
327
|
+
!allResponseText &&
|
|
328
|
+
!currentBlockText &&
|
|
329
|
+
typeof msg.result === "string"
|
|
330
|
+
) {
|
|
331
|
+
currentBlockText = msg.result;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const classified = classify(err);
|
|
337
|
+
if (classified.reason === "session_expired" && !_retried) {
|
|
338
|
+
logWarn(
|
|
339
|
+
"agent",
|
|
340
|
+
`[${chatId}] Stale session, retrying with fresh session`,
|
|
341
|
+
);
|
|
342
|
+
resetSession(chatId);
|
|
343
|
+
return handleMessage(params, true);
|
|
344
|
+
}
|
|
345
|
+
// Context length exceeded — reset session and retry (SDK auto-compaction should prevent
|
|
346
|
+
// this, but handle it as a safety net for edge cases)
|
|
347
|
+
if (classified.reason === "context_length" && !_retried) {
|
|
348
|
+
logWarn(
|
|
349
|
+
"agent",
|
|
350
|
+
`[${chatId}] Context length exceeded, resetting session and retrying`,
|
|
351
|
+
);
|
|
352
|
+
resetSession(chatId);
|
|
353
|
+
return handleMessage(params, true);
|
|
354
|
+
}
|
|
355
|
+
// Model fallback: if overloaded/timeout, retry with a faster model
|
|
356
|
+
if (!_retried && classified.retryable) {
|
|
357
|
+
const fallbackModel = activeModel.includes("opus")
|
|
358
|
+
? "claude-sonnet-4-6"
|
|
359
|
+
: activeModel.includes("sonnet")
|
|
360
|
+
? "claude-haiku-4-5"
|
|
361
|
+
: null;
|
|
362
|
+
if (fallbackModel) {
|
|
363
|
+
logWarn(
|
|
364
|
+
"agent",
|
|
365
|
+
`[${chatId}] ${classified.reason}, falling back to ${fallbackModel.replace("claude-", "")}`,
|
|
366
|
+
);
|
|
367
|
+
resetSession(chatId);
|
|
368
|
+
const originalModel = getChatSettings(chatId).model;
|
|
369
|
+
setChatModel(chatId, fallbackModel);
|
|
370
|
+
try {
|
|
371
|
+
return await handleMessage(params, true);
|
|
372
|
+
} finally {
|
|
373
|
+
setChatModel(chatId, originalModel);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
logError("agent", `[${chatId}] SDK error: ${classified.message}`);
|
|
378
|
+
throw classified;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Persist session and usage
|
|
382
|
+
const durationMs = Date.now() - t0;
|
|
383
|
+
if (newSessionId) setSessionId(chatId, newSessionId);
|
|
384
|
+
incrementTurns(chatId);
|
|
385
|
+
recordUsage(chatId, {
|
|
386
|
+
inputTokens,
|
|
387
|
+
outputTokens,
|
|
388
|
+
cacheRead,
|
|
389
|
+
cacheWrite,
|
|
390
|
+
durationMs,
|
|
391
|
+
model: activeModel,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Set a descriptive session name from the first message
|
|
395
|
+
if (session.turns === 0 && text) {
|
|
396
|
+
// Strip metadata prefixes like [DM from ...] or [Name]:
|
|
397
|
+
const cleanText = text
|
|
398
|
+
.replace(/^\[.*?\]\s*/g, "")
|
|
399
|
+
.replace(/\[msg_id:\d+\]\s*/g, "")
|
|
400
|
+
.trim();
|
|
401
|
+
if (cleanText) {
|
|
402
|
+
const name =
|
|
403
|
+
cleanText.length > 30 ? cleanText.slice(0, 30) + "..." : cleanText;
|
|
404
|
+
setSessionName(chatId, name);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The remaining currentBlockText is the final response text
|
|
409
|
+
allResponseText += currentBlockText;
|
|
410
|
+
|
|
411
|
+
const totalPrompt = inputTokens + cacheRead + cacheWrite;
|
|
412
|
+
const cacheHitPct =
|
|
413
|
+
totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0;
|
|
414
|
+
|
|
415
|
+
log(
|
|
416
|
+
"agent",
|
|
417
|
+
`[${chatId}] -> (${durationMs}ms, in=${inputTokens} out=${outputTokens} cache=${cacheHitPct}%` +
|
|
418
|
+
`${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
|
|
419
|
+
);
|
|
420
|
+
traceMessage(chatId, "out", allResponseText, {
|
|
421
|
+
durationMs,
|
|
422
|
+
inputTokens,
|
|
423
|
+
outputTokens,
|
|
424
|
+
cacheRead,
|
|
425
|
+
cacheWrite,
|
|
426
|
+
toolCalls,
|
|
427
|
+
model: activeModel,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
text: allResponseText.trim(),
|
|
432
|
+
durationMs,
|
|
433
|
+
inputTokens,
|
|
434
|
+
outputTokens,
|
|
435
|
+
cacheRead,
|
|
436
|
+
cacheWrite,
|
|
437
|
+
};
|
|
438
|
+
}
|