talon-agent 1.0.0 → 1.2.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
package/src/core/cron.ts CHANGED
@@ -65,10 +65,16 @@ async function runCronTick(): Promise<void> {
65
65
  if (getActiveCount() > 10) break;
66
66
 
67
67
  try {
68
- log("cron", `Executing "${job.name}" [${job.id}] (${job.type}) in chat ${job.chatId}`);
68
+ log(
69
+ "cron",
70
+ `Executing "${job.name}" [${job.id}] (${job.type}) in chat ${job.chatId}`,
71
+ );
69
72
  await executeJob(job);
70
73
  recordCronRun(job.id);
71
- appendDailyLog("Cron", `Ran "${job.name}" (${job.type}) in chat ${job.chatId}`);
74
+ appendDailyLog(
75
+ "Cron",
76
+ `Ran "${job.name}" (${job.type}) in chat ${job.chatId}`,
77
+ );
72
78
  log("cron", `Executed "${job.name}" [${job.id}] in chat ${job.chatId}`);
73
79
  } catch (err) {
74
80
  logError("cron", `Job "${job.name}" [${job.id}] failed`, err);
@@ -79,7 +85,9 @@ async function runCronTick(): Promise<void> {
79
85
  function isDue(job: CronJob, now: Date): boolean {
80
86
  try {
81
87
  const oneMinuteAgo = new Date(now.getTime() - 60_000);
82
- const cron = new Cron(job.schedule, { timezone: job.timezone ?? undefined });
88
+ const cron = new Cron(job.schedule, {
89
+ timezone: job.timezone ?? undefined,
90
+ });
83
91
  const next = cron.nextRun(oneMinuteAgo);
84
92
  if (!next) return false;
85
93
 
@@ -102,10 +110,17 @@ function isDue(job: CronJob, now: Date): boolean {
102
110
 
103
111
  const CRON_JOB_TIMEOUT_MS = 10 * 60_000; // 10-minute max per job
104
112
 
105
- async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
113
+ async function withTimeout<T>(
114
+ promise: Promise<T>,
115
+ ms: number,
116
+ label: string,
117
+ ): Promise<T> {
106
118
  let timer: ReturnType<typeof setTimeout>;
107
119
  const timeout = new Promise<never>((_, reject) => {
108
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
120
+ timer = setTimeout(
121
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
122
+ ms,
123
+ );
109
124
  });
110
125
  try {
111
126
  return await Promise.race([promise, timeout]);
@@ -16,7 +16,7 @@ import type {
16
16
  ExecuteParams,
17
17
  ExecuteResult,
18
18
  } from "./types.js";
19
- import { log, logDebug } from "../util/log.js";
19
+ import { log, logDebug, logWarn } from "../util/log.js";
20
20
  import { maybeStartDream } from "./dream.js";
21
21
 
22
22
  // ── Dependencies (injected at startup) ──────────────────────────────────────
@@ -67,9 +67,11 @@ export async function execute(params: ExecuteParams): Promise<ExecuteResult> {
67
67
  chatChains.set(chatId, queued); // must happen before any await
68
68
 
69
69
  // Clean up chain entry when this is the last in the chain
70
- queued.catch(() => {}).finally(() => {
71
- if (chatChains.get(chatId) === queued) chatChains.delete(chatId);
72
- });
70
+ queued
71
+ .catch(() => {})
72
+ .finally(() => {
73
+ if (chatChains.get(chatId) === queued) chatChains.delete(chatId);
74
+ });
73
75
 
74
76
  return queued;
75
77
  }
@@ -90,14 +92,27 @@ async function executeInner(params: ExecuteParams): Promise<ExecuteResult> {
90
92
  // Dream check — fire-and-forget background memory consolidation if due
91
93
  maybeStartDream();
92
94
 
93
- logDebug("dispatcher", `[${reqId}] ${params.source} chat=${params.chatId} started (active=${activeCount})`);
95
+ logDebug(
96
+ "dispatcher",
97
+ `[${reqId}] ${params.source} chat=${params.chatId} started (active=${activeCount})`,
98
+ );
94
99
  context.acquire(params.numericChatId, params.chatId);
95
100
 
96
101
  let typingTimer: ReturnType<typeof setInterval> | undefined;
97
102
  try {
98
- await sendTyping(params.numericChatId).catch(() => {});
103
+ await sendTyping(params.numericChatId).catch((err: unknown) => {
104
+ logWarn(
105
+ "dispatcher",
106
+ `sendTyping failed: ${err instanceof Error ? err.message : String(err)}`,
107
+ );
108
+ });
99
109
  typingTimer = setInterval(() => {
100
- sendTyping(params.numericChatId).catch(() => {});
110
+ sendTyping(params.numericChatId).catch((err: unknown) => {
111
+ logWarn(
112
+ "dispatcher",
113
+ `sendTyping interval failed: ${err instanceof Error ? err.message : String(err)}`,
114
+ );
115
+ });
101
116
  }, 4000);
102
117
 
103
118
  const result = await backend.query({
@@ -113,14 +128,17 @@ async function executeInner(params: ExecuteParams): Promise<ExecuteResult> {
113
128
 
114
129
  onActivity();
115
130
 
116
- logDebug("dispatcher", `[${reqId}] completed in ${result.durationMs}ms (in=${result.inputTokens} out=${result.outputTokens})`);
131
+ logDebug(
132
+ "dispatcher",
133
+ `[${reqId}] completed in ${result.durationMs}ms (in=${result.inputTokens} out=${result.outputTokens})`,
134
+ );
117
135
 
118
136
  return {
119
137
  ...result,
120
138
  bridgeMessageCount: context.getMessageCount(params.numericChatId),
121
139
  };
122
140
  } finally {
123
- if (typingTimer) clearInterval(typingTimer);
141
+ clearInterval(typingTimer);
124
142
  context.release(params.numericChatId);
125
143
  }
126
144
  }
package/src/core/dream.ts CHANGED
@@ -25,6 +25,8 @@ import { log, logError, logWarn } from "../util/log.js";
25
25
  export type DreamState = {
26
26
  /** Unix millisecond timestamp of the last completed dream run. */
27
27
  last_run: number;
28
+ /** Human-readable ISO timestamp of the last completed dream run. */
29
+ last_run_at?: string;
28
30
  /** "idle" when no dream is running, "running" while one is active. */
29
31
  status: "idle" | "running";
30
32
  };
@@ -39,7 +41,12 @@ const DREAM_LOGS_DIR = resolve(dirs.logs, "dreams");
39
41
  // ── State ────────────────────────────────────────────────────────────────────
40
42
 
41
43
  let dreaming = false; // in-process guard (one dream at a time)
42
- let configRef: { model?: string; dreamModel?: string; claudeBinary?: string; workspace?: string } | null = null;
44
+ let configRef: {
45
+ model?: string;
46
+ dreamModel?: string;
47
+ claudeBinary?: string;
48
+ workspace?: string;
49
+ } | null = null;
43
50
 
44
51
  export function initDream(cfg: {
45
52
  model?: string;
@@ -86,12 +93,18 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
86
93
 
87
94
  dreaming = true;
88
95
  writeDreamState({ last_run: now, status: "running" });
89
- log("dream", `${trigger === "forced" ? "Force-triggering" : "Triggering"} memory consolidation (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`);
96
+ log(
97
+ "dream",
98
+ `${trigger === "forced" ? "Force-triggering" : "Triggering"} memory consolidation (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`,
99
+ );
90
100
 
91
101
  try {
92
102
  const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
93
103
  writeDreamState({ last_run: Date.now(), status: "idle" });
94
- log("dream", `Memory consolidation complete (${trigger}), log: ${dreamLogPath}`);
104
+ log(
105
+ "dream",
106
+ `Memory consolidation complete (${trigger}), log: ${dreamLogPath}`,
107
+ );
95
108
  } catch (err) {
96
109
  logError("dream", `Memory consolidation failed (${trigger})`, err);
97
110
  writeDreamState({ last_run: Date.now(), status: "idle" });
@@ -109,9 +122,10 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
109
122
  return "";
110
123
  }
111
124
 
112
- const lastRunIso = lastRunTimestamp > 0
113
- ? new Date(lastRunTimestamp).toISOString()
114
- : "the beginning of time";
125
+ const lastRunIso =
126
+ lastRunTimestamp > 0
127
+ ? new Date(lastRunTimestamp).toISOString()
128
+ : "the beginning of time";
115
129
 
116
130
  const logsDir = dirs.logs;
117
131
  const memoryFile = pathFiles.memory;
@@ -127,7 +141,8 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
127
141
  .replace(/\{\{dreamStateFile\}\}/g, dreamStateFile)
128
142
  .replace(/\{\{logsDir\}\}/g, logsDir)
129
143
  .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
130
- .replace(/\{\{memoryFile\}\}/g, memoryFile);
144
+ .replace(/\{\{memoryFile\}\}/g, memoryFile)
145
+ .replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory);
131
146
  } catch {
132
147
  throw new Error(`Failed to read dream prompt from ${promptPath}`);
133
148
  }
@@ -138,12 +153,19 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
138
153
  // Set up dream log file
139
154
  const dreamLogFile = createDreamLogFile();
140
155
  appendDreamLog(dreamLogFile, `# Dream Run — ${new Date().toISOString()}\n`);
141
- appendDreamLog(dreamLogFile, `**Trigger:** last_run=${lastRunIso}, model=${model}\n`);
142
- appendDreamLog(dreamLogFile, `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`);
156
+ appendDreamLog(
157
+ dreamLogFile,
158
+ `**Trigger:** last_run=${lastRunIso}, model=${model}\n`,
159
+ );
160
+ appendDreamLog(
161
+ dreamLogFile,
162
+ `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
163
+ );
143
164
 
144
165
  const options = {
145
166
  model,
146
- systemPrompt: "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
167
+ systemPrompt:
168
+ "You are a background memory consolidation agent for Talon. Use only filesystem tools. Be precise and surgical — update memory.md without losing existing accurate information.",
147
169
  cwd: workspace,
148
170
  permissionMode: "bypassPermissions" as const,
149
171
  allowDangerouslySkipPermissions: true,
@@ -171,7 +193,10 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
171
193
  };
172
194
 
173
195
  const timeoutPromise = new Promise<never>((_, reject) =>
174
- setTimeout(() => reject(new Error("Dream agent timed out")), DREAM_TIMEOUT_MS),
196
+ setTimeout(
197
+ () => reject(new Error("Dream agent timed out")),
198
+ DREAM_TIMEOUT_MS,
199
+ ),
175
200
  );
176
201
 
177
202
  const agentPromise = (async () => {
@@ -182,13 +207,19 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
182
207
  for await (const msg of qi) {
183
208
  logDreamMessage(dreamLogFile, msg);
184
209
  }
185
- appendDreamLog(dreamLogFile, `\n---\n**Dream completed at ${new Date().toISOString()}**\n`);
210
+ appendDreamLog(
211
+ dreamLogFile,
212
+ `\n---\n**Dream completed at ${new Date().toISOString()}**\n`,
213
+ );
186
214
  })();
187
215
 
188
216
  try {
189
217
  await Promise.race([agentPromise, timeoutPromise]);
190
218
  } catch (err) {
191
- appendDreamLog(dreamLogFile, `\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`);
219
+ appendDreamLog(
220
+ dreamLogFile,
221
+ `\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`,
222
+ );
192
223
  throw err;
193
224
  }
194
225
 
@@ -223,7 +254,7 @@ function logDreamMessage(logFile: string, msg: SDKMessage): void {
223
254
  // Extract text content from the assistant message
224
255
  const textBlocks = msg.message.content
225
256
  .filter((b) => b.type === "text")
226
- .map((b) => "text" in b ? (b as { text: string }).text : "");
257
+ .map((b) => ("text" in b ? (b as { text: string }).text : ""));
227
258
  const toolUseBlocks = msg.message.content
228
259
  .filter((b) => b.type === "tool_use")
229
260
  .map((b) => {
@@ -232,7 +263,10 @@ function logDreamMessage(logFile: string, msg: SDKMessage): void {
232
263
  });
233
264
 
234
265
  if (textBlocks.length > 0) {
235
- appendDreamLog(logFile, `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`);
266
+ appendDreamLog(
267
+ logFile,
268
+ `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`,
269
+ );
236
270
  }
237
271
  if (toolUseBlocks.length > 0) {
238
272
  appendDreamLog(logFile, `\n${toolUseBlocks.join("\n\n")}\n`);
@@ -241,9 +275,18 @@ function logDreamMessage(logFile: string, msg: SDKMessage): void {
241
275
  }
242
276
  case "result": {
243
277
  // Final result of the dream agent run
244
- const result = "result" in msg ? (msg as { result: string }).result : JSON.stringify(msg);
245
- const truncated = result.length > 2000 ? result.slice(0, 2000) + "\n... (truncated)" : result;
246
- appendDreamLog(logFile, `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`);
278
+ const result =
279
+ "result" in msg
280
+ ? (msg as { result: string }).result
281
+ : JSON.stringify(msg);
282
+ const truncated =
283
+ result.length > 2000
284
+ ? result.slice(0, 2000) + "\n... (truncated)"
285
+ : result;
286
+ appendDreamLog(
287
+ logFile,
288
+ `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`,
289
+ );
247
290
  break;
248
291
  }
249
292
  case "system": {
@@ -253,11 +296,16 @@ function logDreamMessage(logFile: string, msg: SDKMessage): void {
253
296
  case "user": {
254
297
  // Tool results come back as user messages
255
298
  if (msg.tool_use_result != null) {
256
- const raw = typeof msg.tool_use_result === "string"
257
- ? msg.tool_use_result
258
- : JSON.stringify(msg.tool_use_result, null, 2);
259
- const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw;
260
- appendDreamLog(logFile, `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`);
299
+ const raw =
300
+ typeof msg.tool_use_result === "string"
301
+ ? msg.tool_use_result
302
+ : JSON.stringify(msg.tool_use_result, null, 2);
303
+ const truncated =
304
+ raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw;
305
+ appendDreamLog(
306
+ logFile,
307
+ `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`,
308
+ );
261
309
  }
262
310
  break;
263
311
  }
@@ -288,7 +336,14 @@ function writeDreamState(state: DreamState): void {
288
336
  try {
289
337
  const dir = resolve(DREAM_STATE_FILE, "..");
290
338
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
291
- writeFileAtomic.sync(DREAM_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
339
+ const enriched: DreamState = {
340
+ ...state,
341
+ last_run_at: new Date(state.last_run).toISOString(),
342
+ };
343
+ writeFileAtomic.sync(
344
+ DREAM_STATE_FILE,
345
+ JSON.stringify(enriched, null, 2) + "\n",
346
+ );
292
347
  } catch (err) {
293
348
  logError("dream", "Failed to write dream state", err);
294
349
  }
@@ -59,7 +59,13 @@ export function classify(err: unknown): TalonError {
59
59
  let msg: string;
60
60
  if (err instanceof Error) msg = err.message;
61
61
  else if (typeof err === "string") msg = err;
62
- else { try { msg = String(err); } catch { msg = "[non-stringifiable error]"; } }
62
+ else {
63
+ try {
64
+ msg = String(err);
65
+ } catch {
66
+ msg = "[non-stringifiable error]";
67
+ }
68
+ }
63
69
  const cause = err instanceof Error ? err : undefined;
64
70
 
65
71
  // Extract HTTP status if present
@@ -93,7 +99,11 @@ export function classify(err: unknown): TalonError {
93
99
  }
94
100
 
95
101
  // Network errors
96
- if (/network|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed/i.test(msg)) {
102
+ if (
103
+ /network|ECONNREFUSED|ECONNRESET|ECONNABORTED|ETIMEDOUT|ENOTFOUND|fetch failed|connection reset/i.test(
104
+ msg,
105
+ )
106
+ ) {
97
107
  return new TalonError(msg, {
98
108
  reason: "network",
99
109
  retryable: true,