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,328 @@
1
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
2
+ import writeFileAtomic from "write-file-atomic";
3
+ import { log, logError } from "../util/log.js";
4
+ import { recordError } from "../util/watchdog.js";
5
+ import { dirs, files } from "../util/paths.js";
6
+
7
+ /**
8
+ * Session manager — maps Telegram chat IDs to Claude SDK session IDs.
9
+ * The SDK handles actual conversation storage (JSONL); we just track
10
+ * the mapping so conversations persist across messages.
11
+ *
12
+ * Sessions are persisted to disk so they survive restarts.
13
+ */
14
+
15
+ type SessionUsage = {
16
+ totalInputTokens: number;
17
+ totalOutputTokens: number;
18
+ totalCacheRead: number;
19
+ totalCacheWrite: number;
20
+ /** Last turn's prompt tokens (context size snapshot). */
21
+ lastPromptTokens: number;
22
+ /** Estimated cost in USD. */
23
+ estimatedCostUsd: number;
24
+ /** Total response time in ms (for averaging). */
25
+ totalResponseMs: number;
26
+ /** Last response time in ms. */
27
+ lastResponseMs: number;
28
+ /** Fastest response time in ms. */
29
+ fastestResponseMs: number;
30
+ };
31
+
32
+ type SessionState = {
33
+ /** Claude SDK server-side session ID. */
34
+ sessionId: string | undefined;
35
+ /** Turn count. */
36
+ turns: number;
37
+ /** Last activity timestamp. */
38
+ lastActive: number;
39
+ /** Created timestamp. */
40
+ createdAt: number;
41
+ /** Cumulative usage stats. */
42
+ usage: SessionUsage;
43
+ /** ID of the last message sent by the bot in this chat. */
44
+ lastBotMessageId?: number;
45
+ /** Descriptive session name derived from first message. */
46
+ sessionName?: string;
47
+ /** Model used for this session's cost tracking. */
48
+ lastModel?: string;
49
+ };
50
+
51
+ type SessionStore = Record<string, SessionState>;
52
+
53
+ const STORE_FILE = files.sessions;
54
+ let store: SessionStore = {};
55
+ let dirty = false;
56
+
57
+ function ensureDir(): void {
58
+ if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
59
+ }
60
+
61
+ export function loadSessions(): void {
62
+ try {
63
+ if (existsSync(STORE_FILE)) {
64
+ store = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
65
+ }
66
+ } catch {
67
+ // Primary file corrupt — try backup
68
+ const bakFile = STORE_FILE + ".bak";
69
+ try {
70
+ if (existsSync(bakFile)) {
71
+ store = JSON.parse(readFileSync(bakFile, "utf-8"));
72
+ logError("sessions", "Loaded from backup (primary was corrupt)");
73
+ return;
74
+ }
75
+ } catch {
76
+ /* backup also corrupt */
77
+ }
78
+ logError(
79
+ "sessions",
80
+ "Session data corrupt and no valid backup — starting fresh",
81
+ );
82
+ store = {};
83
+ }
84
+ // SDK sessions don't survive process restarts — the embedded Claude Code
85
+ // subprocess is gone. Clear stale session IDs so we don't try to resume
86
+ // a dead session (which causes the SDK to hang silently on Windows).
87
+ // Keep turns/usage intact — they're historical data used by /resume.
88
+ for (const session of Object.values(store)) {
89
+ if (session.sessionId) {
90
+ session.sessionId = undefined;
91
+ dirty = true;
92
+ }
93
+ }
94
+ }
95
+
96
+ function saveSessions(): void {
97
+ if (!dirty) return;
98
+ try {
99
+ ensureDir();
100
+ const data = JSON.stringify(store, null, 2) + "\n";
101
+ // Write backup of current file before overwriting
102
+ if (existsSync(STORE_FILE)) {
103
+ try {
104
+ writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE));
105
+ } catch {
106
+ /* best effort */
107
+ }
108
+ }
109
+ // Atomic write: writes to temp file then renames — prevents corruption on crash
110
+ writeFileAtomic.sync(STORE_FILE, data);
111
+ dirty = false;
112
+ } catch (err) {
113
+ logError("sessions", "Failed to persist sessions", err);
114
+ recordError(
115
+ `Session save failed: ${err instanceof Error ? err.message : err}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ // Auto-save every 10 seconds if dirty
121
+ const autoSaveTimer = setInterval(saveSessions, 10_000);
122
+
123
+ // Periodic stale session pruning (every hour)
124
+
125
+ const emptyUsage = (): SessionUsage => ({
126
+ totalInputTokens: 0,
127
+ totalOutputTokens: 0,
128
+ totalCacheRead: 0,
129
+ totalCacheWrite: 0,
130
+ lastPromptTokens: 0,
131
+ estimatedCostUsd: 0,
132
+ totalResponseMs: 0,
133
+ lastResponseMs: 0,
134
+ fastestResponseMs: Infinity,
135
+ });
136
+
137
+ export function getSession(chatId: string): SessionState {
138
+ let session = store[chatId];
139
+ if (!session) {
140
+ const now = Date.now();
141
+ session = {
142
+ sessionId: undefined,
143
+ turns: 0,
144
+ lastActive: now,
145
+ createdAt: now,
146
+ usage: emptyUsage(),
147
+ };
148
+ store[chatId] = session;
149
+ }
150
+ // Migrate old sessions without usage or missing fields
151
+ if (!session.usage) session.usage = emptyUsage();
152
+ if (!session.createdAt) session.createdAt = session.lastActive;
153
+ if (session.usage.totalResponseMs === undefined)
154
+ session.usage.totalResponseMs = 0;
155
+ if (session.usage.lastResponseMs === undefined)
156
+ session.usage.lastResponseMs = 0;
157
+ if (
158
+ session.usage.fastestResponseMs === undefined ||
159
+ session.usage.fastestResponseMs === 0
160
+ )
161
+ session.usage.fastestResponseMs = Infinity;
162
+ return session;
163
+ }
164
+
165
+ export function setSessionId(chatId: string, sessionId: string): void {
166
+ const session = getSession(chatId);
167
+ session.sessionId = sessionId;
168
+ dirty = true;
169
+ }
170
+
171
+ export function incrementTurns(chatId: string): void {
172
+ const session = getSession(chatId);
173
+ session.turns += 1;
174
+ session.lastActive = Date.now();
175
+ dirty = true;
176
+ }
177
+
178
+ /** Model-specific pricing ($ per million tokens). */
179
+ const MODEL_PRICING: Record<
180
+ string,
181
+ { input: number; output: number; cacheRead: number; cacheWrite: number }
182
+ > = {
183
+ haiku: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
184
+ sonnet: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
185
+ opus: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
186
+ };
187
+
188
+ function getPricing(model?: string): (typeof MODEL_PRICING)["sonnet"] {
189
+ if (!model) return MODEL_PRICING.sonnet;
190
+ const lower = model.toLowerCase();
191
+ if (lower.includes("haiku")) return MODEL_PRICING.haiku;
192
+ if (lower.includes("opus")) return MODEL_PRICING.opus;
193
+ return MODEL_PRICING.sonnet;
194
+ }
195
+
196
+ export function recordUsage(
197
+ chatId: string,
198
+ turn: {
199
+ inputTokens: number;
200
+ outputTokens: number;
201
+ cacheRead: number;
202
+ cacheWrite: number;
203
+ durationMs?: number;
204
+ model?: string;
205
+ },
206
+ ): void {
207
+ const session = getSession(chatId);
208
+ session.usage.totalInputTokens += turn.inputTokens;
209
+ session.usage.totalOutputTokens += turn.outputTokens;
210
+ session.usage.totalCacheRead += turn.cacheRead;
211
+ session.usage.totalCacheWrite += turn.cacheWrite;
212
+ // Snapshot: prompt tokens = input + cache_read + cache_write for this turn
213
+ session.usage.lastPromptTokens =
214
+ turn.inputTokens + turn.cacheRead + turn.cacheWrite;
215
+ // Model-aware cost estimate
216
+ const pricing = getPricing(turn.model);
217
+ session.usage.estimatedCostUsd +=
218
+ (turn.inputTokens * pricing.input +
219
+ turn.cacheWrite * pricing.cacheWrite +
220
+ turn.cacheRead * pricing.cacheRead +
221
+ turn.outputTokens * pricing.output) /
222
+ 1_000_000;
223
+ // Track which model was last used
224
+ if (turn.model) session.lastModel = turn.model;
225
+ // Response time tracking
226
+ if (turn.durationMs && turn.durationMs > 0) {
227
+ session.usage.totalResponseMs =
228
+ (session.usage.totalResponseMs || 0) + turn.durationMs;
229
+ session.usage.lastResponseMs = turn.durationMs;
230
+ const current = session.usage.fastestResponseMs || Infinity;
231
+ if (turn.durationMs < current) {
232
+ session.usage.fastestResponseMs = turn.durationMs;
233
+ }
234
+ }
235
+ dirty = true;
236
+ }
237
+
238
+ export function setSessionName(chatId: string, name: string): void {
239
+ const session = getSession(chatId);
240
+ session.sessionName = name;
241
+ dirty = true;
242
+ }
243
+
244
+ export function setLastBotMessageId(chatId: string, messageId: number): void {
245
+ const session = getSession(chatId);
246
+ session.lastBotMessageId = messageId;
247
+ dirty = true;
248
+ }
249
+
250
+ export function getLastBotMessageId(chatId: string): number | undefined {
251
+ return store[chatId]?.lastBotMessageId;
252
+ }
253
+
254
+ export function resetSession(chatId: string): void {
255
+ const session = store[chatId];
256
+ const turns = session?.turns ?? 0;
257
+ const name = session?.sessionName;
258
+ delete store[chatId];
259
+ dirty = true;
260
+ saveSessions();
261
+ log(
262
+ "sessions",
263
+ `[${chatId}] Reset${name ? ` "${name}"` : ""} (${turns} turns)`,
264
+ );
265
+ }
266
+
267
+ export type SessionInfo = {
268
+ sessionId: string | undefined;
269
+ turns: number;
270
+ lastActive: number;
271
+ createdAt: number;
272
+ usage: SessionUsage;
273
+ sessionName?: string;
274
+ lastModel?: string;
275
+ };
276
+
277
+ export function getSessionInfo(chatId: string): SessionInfo {
278
+ const session = store[chatId];
279
+ return {
280
+ sessionId: session?.sessionId,
281
+ turns: session?.turns ?? 0,
282
+ lastActive: session?.lastActive ?? 0,
283
+ createdAt: session?.createdAt ?? 0,
284
+ usage: session?.usage ?? emptyUsage(),
285
+ sessionName: session?.sessionName,
286
+ lastModel: session?.lastModel,
287
+ };
288
+ }
289
+
290
+ export function getActiveSessionCount(): number {
291
+ return Object.keys(store).length;
292
+ }
293
+
294
+ /** Get all chat IDs with active sessions and their info. */
295
+ export function getAllSessions(): Array<{ chatId: string; info: SessionInfo }> {
296
+ return Object.entries(store).map(([chatId, session]) => ({
297
+ chatId,
298
+ info: {
299
+ sessionId: session.sessionId,
300
+ turns: session.turns,
301
+ lastActive: session.lastActive,
302
+ createdAt: session.createdAt,
303
+ usage: session.usage ?? {
304
+ totalInputTokens: 0,
305
+ totalOutputTokens: 0,
306
+ totalCacheRead: 0,
307
+ totalCacheWrite: 0,
308
+ lastPromptTokens: 0,
309
+ estimatedCostUsd: 0,
310
+ totalResponseMs: 0,
311
+ lastResponseMs: 0,
312
+ fastestResponseMs: Infinity,
313
+ },
314
+ sessionName: session.sessionName,
315
+ lastModel: session.lastModel,
316
+ },
317
+ }));
318
+ }
319
+
320
+ // Flush on exit (signal handlers are in index.ts for graceful shutdown)
321
+ process.on("exit", saveSessions);
322
+
323
+ /** Force-save sessions to disk and stop the auto-save timer. */
324
+ export function flushSessions(): void {
325
+ clearInterval(autoSaveTimer);
326
+ dirty = true;
327
+ saveSessions();
328
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared chat-ID utilities used by terminal and Teams frontends.
3
+ */
4
+
5
+ import { createHash } from "node:crypto";
6
+
7
+ /** Derive a stable 32-bit numeric chat ID from a string chat ID. */
8
+ export function deriveNumericChatId(chatId: string): number {
9
+ const hash = createHash("sha256").update(chatId).digest();
10
+ return hash.readUInt32BE(0);
11
+ }
12
+
13
+ /** Generate a unique terminal chat ID. */
14
+ export function generateTerminalChatId(): string {
15
+ return `t_${Date.now()}`;
16
+ }
17
+
18
+ /** Check if a chat ID belongs to a terminal session. */
19
+ export function isTerminalChatId(chatId: string): boolean {
20
+ return chatId.startsWith("t_") || chatId === "1";
21
+ }
@@ -0,0 +1,244 @@
1
+ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { z } from "zod";
5
+ import { dirs, files as pathFiles } from "./paths.js";
6
+ import { setTimezone, formatFullDatetime } from "./time.js";
7
+ import { log } from "./log.js";
8
+
9
+
10
+ // ── Config schema ───────────────────────────────────────────────────────────
11
+
12
+ const pluginEntrySchema = z.object({
13
+ path: z.string(),
14
+ config: z.record(z.string(), z.unknown()).optional(),
15
+ });
16
+
17
+ const frontendEnum = z.enum(["telegram", "terminal", "teams"]);
18
+
19
+ const configSchema = z.object({
20
+ frontend: z.union([frontendEnum, z.array(frontendEnum)]).default("telegram"),
21
+ botToken: z.string().optional(),
22
+ backend: z.enum(["claude", "opencode"]).default("claude"),
23
+ claudeBinary: z.string().optional(),
24
+ model: z.string().default("claude-sonnet-4-6"),
25
+ dreamModel: z.string().optional(), // Model used for background memory consolidation (defaults to main model)
26
+ maxMessageLength: z.number().int().min(100).default(4000),
27
+ concurrency: z.number().int().min(1).max(20).default(1),
28
+ apiId: z.number().int().optional(),
29
+ apiHash: z.string().optional(),
30
+ adminUserId: z.number().int().optional(),
31
+ pulse: z.boolean().default(true),
32
+ pulseIntervalMs: z.number().int().min(60000).default(300000),
33
+ braveApiKey: z.string().optional(),
34
+ searxngUrl: z.string().default("http://localhost:8080"),
35
+ timezone: z.string().optional(),
36
+ plugins: z.array(pluginEntrySchema).default([]),
37
+
38
+ // Display name shown in terminal UI (defaults to "Talon")
39
+ botDisplayName: z.string().default("Talon"),
40
+
41
+ // Teams frontend (Power Automate webhooks)
42
+ teamsWebhookUrl: z.string().url().optional(),
43
+ teamsWebhookSecret: z.string().optional(),
44
+ teamsWebhookPort: z.number().int().min(1024).max(65535).default(19878),
45
+ teamsBotDisplayName: z.string().optional(),
46
+ teamsTeamName: z.string().optional(),
47
+ teamsChannelName: z.string().optional(),
48
+ teamsChatTopic: z.string().optional(),
49
+ teamsGraphPollMs: z.number().int().min(5000).default(10000),
50
+ });
51
+
52
+ export type TalonConfig = z.infer<typeof configSchema> & {
53
+ systemPrompt: string;
54
+ workspace: string;
55
+ };
56
+
57
+ /** Normalize frontend config to always be an array. */
58
+ export function getFrontends(config: TalonConfig): string[] {
59
+ return Array.isArray(config.frontend) ? config.frontend : [config.frontend];
60
+ }
61
+
62
+ // ── Config file ─────────────────────────────────────────────────────────────
63
+
64
+ const CONFIG_FILE = pathFiles.config;
65
+
66
+ const DEFAULT_CONFIG = {
67
+ botToken: "",
68
+ model: "claude-sonnet-4-6",
69
+ maxMessageLength: 4000,
70
+ concurrency: 1,
71
+ pulse: true,
72
+ pulseIntervalMs: 300000,
73
+ };
74
+
75
+ function loadConfigFile(): Record<string, unknown> {
76
+ try {
77
+ if (existsSync(CONFIG_FILE)) {
78
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
79
+ }
80
+ } catch { /* corrupt — will be recreated */ }
81
+ return {};
82
+ }
83
+
84
+ /**
85
+ * First-run onboarding: creates workspace/talon.json with defaults.
86
+ * Returns true if this is a fresh install.
87
+ */
88
+ function ensureConfigFile(): boolean {
89
+ if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
90
+ if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
91
+ if (!existsSync(CONFIG_FILE)) {
92
+ writeFileAtomic.sync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ // ── System prompt assembly ──────────────────────────────────────────────────
99
+
100
+ function readOptionalFile(path: string): string {
101
+ try {
102
+ if (existsSync(path)) return readFileSync(path, "utf-8").trim();
103
+ } catch { /* ignore */ }
104
+ return "";
105
+ }
106
+
107
+ let lastLoggedPromptKey = "";
108
+
109
+ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]): string {
110
+ const promptDir = dirs.prompts;
111
+ const parts: string[] = [];
112
+
113
+ const loaded: string[] = [];
114
+
115
+ // Identity — static personality from prompts/identity.md + dynamic config from ~/.talon/workspace/identity.md
116
+ const identityPrompt = readOptionalFile(resolve(promptDir, "identity.md"));
117
+ const identityUser = readOptionalFile(pathFiles.identity);
118
+ if (identityPrompt || identityUser) {
119
+ const identityParts = [identityPrompt, identityUser].filter(Boolean);
120
+ parts.push(`## Identity\n\n${identityParts.join("\n\n")}`);
121
+ loaded.push("identity");
122
+ }
123
+
124
+ // Load base prompt (shared across all frontends)
125
+ const custom = readOptionalFile(resolve(promptDir, "custom.md"));
126
+ const basePrompt = readOptionalFile(resolve(promptDir, "base.md"));
127
+ if (custom) { parts.push(custom); loaded.push("custom"); }
128
+ else if (basePrompt) { parts.push(basePrompt); loaded.push("base"); }
129
+ else parts.push("You are a sharp and helpful AI assistant.");
130
+
131
+ // Load frontend-specific prompt
132
+ const frontendFile = `${frontend ?? "telegram"}.md`;
133
+ const frontendPrompt = readOptionalFile(resolve(promptDir, frontendFile));
134
+ if (frontendPrompt) { parts.push(frontendPrompt); loaded.push(frontendFile.replace(".md", "")); }
135
+
136
+ const memory = readOptionalFile(pathFiles.memory);
137
+ if (memory) {
138
+ parts.push(
139
+ `## Persistent Memory\n\nThe following is your memory file. Reference it naturally. Update it via the Write tool when you learn important new information.\nFile: ~/.talon/workspace/memory/memory.md\n\n${memory}`,
140
+ );
141
+ loaded.push("memory");
142
+ }
143
+
144
+ const loadedKey = loaded.join(" + ");
145
+ if (loadedKey && loadedKey !== lastLoggedPromptKey) {
146
+ log("config", `System prompt: ${loadedKey}`);
147
+ lastLoggedPromptKey = loadedKey;
148
+ }
149
+
150
+ // Workspace file listing for context
151
+ const workspaceDir = dirs.workspace;
152
+ let workspaceFiles = "";
153
+ try {
154
+ const listDir = (dir: string, prefix = ""): string[] => {
155
+ const entries: string[] = [];
156
+ try {
157
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
158
+ if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "talon.log") continue;
159
+ const full = resolve(dir, e.name);
160
+ if (e.isDirectory()) {
161
+ const sub = listDir(full, `${prefix}${e.name}/`);
162
+ if (sub.length > 0 && sub.length <= 8) entries.push(...sub);
163
+ else if (sub.length > 8) entries.push(`${prefix}${e.name}/ (${sub.length} files)`);
164
+ } else {
165
+ const sz = statSync(full).size;
166
+ entries.push(`${prefix}${e.name} (${sz < 1024 ? sz + "B" : (sz / 1024).toFixed(0) + "KB"})`);
167
+ }
168
+ }
169
+ } catch { /* skip */ }
170
+ return entries;
171
+ };
172
+ const files = listDir(workspaceDir);
173
+ if (files.length > 0) workspaceFiles = "\n\nCurrent workspace contents:\n" + files.map((f) => ` ${f}`).join("\n");
174
+ } catch { /* no workspace yet */ }
175
+
176
+ parts.push(`## Workspace
177
+
178
+ You have a workspace directory at \`~/.talon/workspace/\`. This is your home — organize it however you want.
179
+ - \`~/.talon/workspace/memory/memory.md\` is your persistent memory file. Update it when you learn important things.
180
+ - Daily interaction logs are saved to \`~/.talon/workspace/logs/\` automatically.
181
+ - Files users send you (photos, docs, voice) are saved to \`~/.talon/workspace/uploads/\`.
182
+ - Persistent cron jobs are managed via the cron tools.
183
+ - Everything else is yours to create and organize as you see fit.${workspaceFiles}
184
+
185
+ ## Cron Jobs
186
+
187
+ You can create persistent recurring scheduled tasks using cron tools. Jobs survive restarts.
188
+ - \`create_cron_job\` — create a new recurring job with a cron schedule
189
+ - \`list_cron_jobs\` — list all jobs in the current chat
190
+ - \`edit_cron_job\` — modify an existing job (schedule, content, enable/disable)
191
+ - \`delete_cron_job\` — remove a job permanently
192
+ Two job types: "message" sends text directly, "query" runs a Claude prompt with full tool access.`);
193
+
194
+ parts.push(`## Current Date & Time\n${formatFullDatetime()}`);
195
+
196
+ // Plugin system prompt contributions (injected by caller)
197
+ if (pluginPromptAdditions) {
198
+ for (const addition of pluginPromptAdditions) {
199
+ parts.push(addition);
200
+ }
201
+ }
202
+
203
+ return parts.join("\n\n---\n\n");
204
+ }
205
+
206
+ // ── Main loader ─────────────────────────────────────────────────────────────
207
+
208
+ export function loadConfig(): TalonConfig {
209
+ ensureConfigFile();
210
+ const fileConfig = loadConfigFile();
211
+
212
+ const parsed = configSchema.parse(fileConfig);
213
+
214
+ // Apply timezone globally before building the system prompt
215
+ setTimezone(parsed.timezone);
216
+
217
+ // Validate per-frontend requirements
218
+ const frontends = Array.isArray(parsed.frontend) ? parsed.frontend : [parsed.frontend];
219
+ for (const fe of frontends) {
220
+ if (fe === "telegram" && !parsed.botToken) {
221
+ throw new Error(`Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`);
222
+ }
223
+ if (fe === "teams" && !parsed.teamsWebhookUrl) {
224
+ throw new Error(`Teams frontend requires "teamsWebhookUrl" in ${CONFIG_FILE}. Run "talon setup" to configure.`);
225
+ }
226
+ }
227
+
228
+ const activeFrontend = frontends[0];
229
+
230
+ return {
231
+ ...parsed,
232
+ workspace: dirs.workspace,
233
+ systemPrompt: loadSystemPrompt(activeFrontend),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Rebuild the system prompt with plugin additions.
239
+ * Called after plugins are loaded to inject their prompt contributions.
240
+ */
241
+ export function rebuildSystemPrompt(config: TalonConfig, pluginAdditions: string[]): void {
242
+ const frontends = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
243
+ config.systemPrompt = loadSystemPrompt(frontends[0], pluginAdditions.length > 0 ? pluginAdditions : undefined);
244
+ }