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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * OpenCode backend — uses the OpenCode SDK as an alternative to Claude Agent SDK.
3
+ *
4
+ * Implements the same QueryBackend interface so it's a drop-in replacement.
5
+ * Manages an OpenCode server process and routes queries through it.
6
+ */
7
+
8
+ import { createOpencode, type OpencodeClient } from "@opencode-ai/sdk";
9
+ import type { TalonConfig } from "../../util/config.js";
10
+ import type { QueryParams, QueryResult } from "../../core/types.js";
11
+ import {
12
+ getSession,
13
+ incrementTurns,
14
+ recordUsage,
15
+ setSessionId,
16
+ setSessionName,
17
+ resetSession,
18
+ } from "../../storage/sessions.js";
19
+ import { getChatSettings } from "../../storage/chat-settings.js";
20
+ import { getRecentHistory } from "../../storage/history.js";
21
+ import { classify } from "../../core/errors.js";
22
+ import { log, logError, logWarn } from "../../util/log.js";
23
+ import { traceMessage } from "../../util/trace.js";
24
+
25
+ // ── State ───────────────────────────────────────────────────────────────────
26
+
27
+ let config: TalonConfig;
28
+ let client: OpencodeClient | null = null;
29
+ let serverHandle: { url: string; close(): void } | null = null;
30
+ let gatewayPortFn: () => number = () => 19876;
31
+
32
+ export function initOpenCodeAgent(
33
+ cfg: TalonConfig,
34
+ getGatewayPort?: () => number,
35
+ ): void {
36
+ config = cfg;
37
+ if (getGatewayPort) gatewayPortFn = getGatewayPort;
38
+ }
39
+
40
+ // ── Server lifecycle ────────────────────────────────────────────────────────
41
+
42
+ async function ensureServer(): Promise<OpencodeClient> {
43
+ if (client) return client;
44
+
45
+ log("agent", "Starting OpenCode server...");
46
+ const result = await createOpencode({
47
+ port: 4096,
48
+ timeout: 10_000,
49
+ });
50
+ client = result.client;
51
+ serverHandle = result.server;
52
+ log("agent", `OpenCode server running at ${result.server.url}`);
53
+
54
+ // Register our MCP tools server with OpenCode
55
+ try {
56
+ const toolsPath = new URL("../claude-sdk/tools.ts", import.meta.url)
57
+ .pathname;
58
+ await client.mcp.add({
59
+ body: {
60
+ name: "talon-tools",
61
+ config: {
62
+ type: "local" as const,
63
+ command: ["node", "--import", "tsx", toolsPath],
64
+ environment: {
65
+ TALON_BRIDGE_URL: `http://127.0.0.1:${gatewayPortFn()}`,
66
+ },
67
+ },
68
+ },
69
+ });
70
+ log("agent", "Registered talon-tools MCP server with OpenCode");
71
+ } catch (err) {
72
+ logWarn(
73
+ "agent",
74
+ `MCP registration failed (tools may not be available): ${err instanceof Error ? err.message : err}`,
75
+ );
76
+ }
77
+
78
+ return client;
79
+ }
80
+
81
+ export function stopOpenCodeServer(): void {
82
+ if (serverHandle) {
83
+ serverHandle.close();
84
+ serverHandle = null;
85
+ client = null;
86
+ log("agent", "OpenCode server stopped");
87
+ }
88
+ }
89
+
90
+ // ── Session management ──────────────────────────────────────────────────────
91
+
92
+ async function ensureSession(
93
+ oc: OpencodeClient,
94
+ chatId: string,
95
+ ): Promise<string> {
96
+ const session = getSession(chatId);
97
+
98
+ if (session.sessionId) {
99
+ // Verify session still exists
100
+ try {
101
+ await oc.session.get({ path: { id: session.sessionId } });
102
+ return session.sessionId;
103
+ } catch {
104
+ logWarn(
105
+ "agent",
106
+ `[${chatId}] Session ${session.sessionId} expired, creating new`,
107
+ );
108
+ resetSession(chatId);
109
+ }
110
+ }
111
+
112
+ // Create new session
113
+ const resp = await oc.session.create({
114
+ body: { title: `Chat ${chatId}` },
115
+ });
116
+
117
+ // Extract session ID from response
118
+ const data = resp.data as Record<string, unknown> | undefined;
119
+ const newId = (data?.id as string) ?? String(Date.now());
120
+ setSessionId(chatId, newId);
121
+ log("agent", `[${chatId}] Created OpenCode session: ${newId}`);
122
+ return newId;
123
+ }
124
+
125
+ // ── Main handler ────────────────────────────────────────────────────────────
126
+
127
+ export async function handleMessage(
128
+ params: QueryParams,
129
+ _retried = false,
130
+ ): Promise<QueryResult> {
131
+ if (!config) throw new Error("OpenCode agent not initialized");
132
+
133
+ const { chatId, text, senderName, isGroup, onTextBlock } = params;
134
+ const t0 = Date.now();
135
+
136
+ const chatSettings = getChatSettings(chatId);
137
+ const activeModel = chatSettings.model ?? config.model;
138
+
139
+ // Resolve provider and model ID
140
+ const providerID = activeModel.includes("gpt")
141
+ ? "openai"
142
+ : activeModel.includes("gemini")
143
+ ? "google"
144
+ : "anthropic";
145
+ const modelID = activeModel;
146
+
147
+ const oc = await ensureServer();
148
+ const sessionId = await ensureSession(oc, chatId);
149
+
150
+ // Build prompt with group context
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
+ const prompt = isGroup
168
+ ? `${continuityPrefix}[${senderName}]${msgIdHint}: ${text}`
169
+ : `${continuityPrefix}${text}${msgIdHint}`;
170
+
171
+ log("agent", `[${chatId}] <- (${text.length} chars)`);
172
+ traceMessage(chatId, "in", text, { senderName, isGroup });
173
+
174
+ try {
175
+ const resp = await oc.session.prompt({
176
+ body: {
177
+ parts: [{ type: "text" as const, text: prompt }],
178
+ model: { providerID, modelID },
179
+ system: config.systemPrompt,
180
+ },
181
+ path: { id: sessionId },
182
+ });
183
+
184
+ // Extract text from response parts
185
+ const data = resp.data as Record<string, unknown> | undefined;
186
+ const parts = (data?.parts as Array<Record<string, unknown>>) ?? [];
187
+ let responseText = "";
188
+ let toolCalls = 0;
189
+
190
+ for (const part of parts) {
191
+ if (part.type === "text" && typeof part.text === "string") {
192
+ if (responseText && onTextBlock) {
193
+ await onTextBlock(responseText);
194
+ }
195
+ responseText = part.text;
196
+ } else if (part.type === "tool") {
197
+ toolCalls++;
198
+ }
199
+ }
200
+
201
+ const durationMs = Date.now() - t0;
202
+
203
+ // Persist session state
204
+ incrementTurns(chatId);
205
+ recordUsage(chatId, {
206
+ inputTokens: 0, // OpenCode doesn't expose token counts in the same way
207
+ outputTokens: 0,
208
+ cacheRead: 0,
209
+ cacheWrite: 0,
210
+ durationMs,
211
+ model: activeModel,
212
+ });
213
+
214
+ if (session.turns === 0 && text) {
215
+ const cleanText = text
216
+ .replace(/^\[.*?\]\s*/g, "")
217
+ .replace(/\[msg_id:\d+\]\s*/g, "")
218
+ .trim();
219
+ if (cleanText) {
220
+ setSessionName(
221
+ chatId,
222
+ cleanText.length > 30 ? cleanText.slice(0, 30) + "..." : cleanText,
223
+ );
224
+ }
225
+ }
226
+
227
+ log(
228
+ "agent",
229
+ `[${chatId}] -> (${durationMs}ms${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
230
+ );
231
+ traceMessage(chatId, "out", responseText, { durationMs, toolCalls });
232
+
233
+ return {
234
+ text: responseText.trim(),
235
+ durationMs,
236
+ inputTokens: 0,
237
+ outputTokens: 0,
238
+ cacheRead: 0,
239
+ cacheWrite: 0,
240
+ };
241
+ } catch (err) {
242
+ const classified = classify(err);
243
+ // Session expired — reset and retry once
244
+ if (classified.reason === "session_expired" && !_retried) {
245
+ logWarn("agent", `[${chatId}] OpenCode session expired, retrying`);
246
+ resetSession(chatId);
247
+ return handleMessage(params, true);
248
+ }
249
+ logError("agent", `[${chatId}] OpenCode error: ${classified.message}`);
250
+ throw classified;
251
+ }
252
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared bootstrap logic used by both the main entry point (index.ts)
3
+ * and the CLI chat command (cli.ts).
4
+ *
5
+ * Handles: config loading, env vars, plugin loading, workspace init,
6
+ * storage loading, backend + dispatcher initialization.
7
+ *
8
+ * Frontend creation and lifecycle remain with the callers since they
9
+ * differ (index.ts selects dynamically, cli.ts always uses terminal).
10
+ */
11
+
12
+ import { loadConfig, rebuildSystemPrompt } from "./util/config.js";
13
+ import { initWorkspace } from "./util/workspace.js";
14
+ import { loadSessions } from "./storage/sessions.js";
15
+ import { loadChatSettings } from "./storage/chat-settings.js";
16
+ import { loadCronJobs } from "./storage/cron-store.js";
17
+ import { loadHistory } from "./storage/history.js";
18
+ import { loadMediaIndex } from "./storage/media-index.js";
19
+ import { cleanupOldLogs } from "./storage/daily-log.js";
20
+ import { initDispatcher } from "./core/dispatcher.js";
21
+ import { initPulse, resetPulseTimer } from "./core/pulse.js";
22
+ import { initCron } from "./core/cron.js";
23
+ import { initDream } from "./core/dream.js";
24
+ import { log } from "./util/log.js";
25
+ import type { TalonConfig } from "./util/config.js";
26
+ import type { QueryBackend, ContextManager } from "./core/types.js";
27
+
28
+ // ── Types ────────────────────────────────────────────────────────────────────
29
+
30
+ export type Frontend = {
31
+ context: ContextManager;
32
+ sendTyping: (chatId: number) => Promise<void>;
33
+ sendMessage: (chatId: number, text: string) => Promise<void>;
34
+ getBridgePort: () => number;
35
+ init: () => Promise<void>;
36
+ start: () => Promise<void>;
37
+ stop: () => Promise<void>;
38
+ };
39
+
40
+ export type BootstrapOptions = {
41
+ /** Override frontend names for plugin loading (e.g. ["terminal"]). */
42
+ frontendNames?: string[];
43
+ };
44
+
45
+ export type BootstrapResult = {
46
+ config: TalonConfig;
47
+ };
48
+
49
+ // ── Bootstrap: config, env, plugins, workspace, storage ──────────────────────
50
+
51
+ /**
52
+ * Load config, set env vars, load plugins, init workspace, load all storage.
53
+ * Returns the loaded config for further use by the caller.
54
+ */
55
+ export async function bootstrap(
56
+ options: BootstrapOptions = {},
57
+ ): Promise<BootstrapResult> {
58
+ const config = loadConfig();
59
+
60
+ // Expose search config as env vars for gateway-actions
61
+ if (config.braveApiKey) process.env.TALON_BRAVE_API_KEY = config.braveApiKey;
62
+ if (config.searxngUrl) process.env.TALON_SEARXNG_URL = config.searxngUrl;
63
+
64
+ // Load plugins (external tool packages)
65
+ if (config.plugins.length > 0) {
66
+ const { loadPlugins, getPluginPromptAdditions } = await import(
67
+ "./core/plugin.js"
68
+ );
69
+ const frontends =
70
+ options.frontendNames ??
71
+ (Array.isArray(config.frontend)
72
+ ? config.frontend
73
+ : [config.frontend]);
74
+ await loadPlugins(config.plugins, frontends);
75
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
76
+ }
77
+
78
+ initWorkspace(config.workspace);
79
+ loadSessions();
80
+ loadChatSettings();
81
+ loadCronJobs();
82
+ loadHistory();
83
+ loadMediaIndex();
84
+ cleanupOldLogs();
85
+
86
+ return { config };
87
+ }
88
+
89
+ // ── Backend + dispatcher wiring ──────────────────────────────────────────────
90
+
91
+ /**
92
+ * Create the AI backend and wire the dispatcher.
93
+ * Call this after creating the frontend.
94
+ */
95
+ export async function initBackendAndDispatcher(
96
+ config: TalonConfig,
97
+ frontend: Frontend,
98
+ ): Promise<void> {
99
+ let backend: QueryBackend;
100
+
101
+ if (config.backend === "opencode") {
102
+ const {
103
+ initOpenCodeAgent,
104
+ handleMessage: opencodeHandleMessage,
105
+ } = await import("./backend/opencode/index.js");
106
+ initOpenCodeAgent(config, frontend.getBridgePort);
107
+ backend = { query: (params) => opencodeHandleMessage(params) };
108
+ log("bot", "Backend: OpenCode");
109
+ } else {
110
+ const {
111
+ initAgent: initClaudeAgent,
112
+ handleMessage: claudeHandleMessage,
113
+ } = await import("./backend/claude-sdk/index.js");
114
+ initClaudeAgent(config, frontend.getBridgePort);
115
+ backend = { query: (params) => claudeHandleMessage(params) };
116
+ log("bot", "Backend: Claude SDK");
117
+ }
118
+
119
+ initDispatcher({
120
+ backend,
121
+ context: frontend.context,
122
+ sendTyping: frontend.sendTyping,
123
+ onActivity: () => resetPulseTimer(),
124
+ });
125
+
126
+ initPulse();
127
+ initCron({ sendMessage: frontend.sendMessage });
128
+ initDream({
129
+ model: config.model,
130
+ dreamModel: config.dreamModel,
131
+ claudeBinary: config.claudeBinary,
132
+ workspace: config.workspace,
133
+ });
134
+ }