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,148 @@
1
+ /**
2
+ * Cron scheduler — runs persistent recurring jobs.
3
+ *
4
+ * Every 60 seconds, checks all enabled cron jobs. If one is due, executes it.
5
+ * "message" type sends text via injected sendMessage.
6
+ * "query" type goes through the dispatcher with full tool access.
7
+ *
8
+ * Knows nothing about the backend or frontend — dependencies are injected.
9
+ */
10
+
11
+ import { Cron } from "croner";
12
+ import { execute, getActiveCount } from "./dispatcher.js";
13
+ import {
14
+ getAllCronJobs,
15
+ recordCronRun,
16
+ type CronJob,
17
+ } from "../storage/cron-store.js";
18
+ import { appendDailyLog } from "../storage/daily-log.js";
19
+ import { log, logError } from "../util/log.js";
20
+
21
+ // ── Dependencies (injected at startup) ──────────────────────────────────────
22
+
23
+ type CronDeps = {
24
+ sendMessage: (chatId: number, text: string) => Promise<void>;
25
+ };
26
+
27
+ let deps: CronDeps | null = null;
28
+ let timer: ReturnType<typeof setInterval> | null = null;
29
+
30
+ const TICK_INTERVAL_MS = 60_000;
31
+
32
+ // ── Public API ───────────────────────────────────────────────────────────────
33
+
34
+ export function initCron(d: CronDeps): void {
35
+ deps = d;
36
+ }
37
+
38
+ export function startCronTimer(): void {
39
+ if (timer) return;
40
+ log("cron", "Started: checking every 60s");
41
+ timer = setInterval(() => {
42
+ runCronTick().catch((err) => logError("cron", "Tick failed", err));
43
+ }, TICK_INTERVAL_MS);
44
+ }
45
+
46
+ export function stopCronTimer(): void {
47
+ if (timer) {
48
+ clearInterval(timer);
49
+ timer = null;
50
+ }
51
+ }
52
+
53
+ // ── Core ─────────────────────────────────────────────────────────────────────
54
+
55
+ async function runCronTick(): Promise<void> {
56
+ if (!deps) return;
57
+ if (getActiveCount() > 10) return; // safety valve — don't pile on if heavily loaded
58
+
59
+ const now = new Date();
60
+ const jobs = getAllCronJobs();
61
+
62
+ for (const job of jobs) {
63
+ if (!job.enabled) continue;
64
+ if (!isDue(job, now)) continue;
65
+ if (getActiveCount() > 10) break;
66
+
67
+ try {
68
+ log("cron", `Executing "${job.name}" [${job.id}] (${job.type}) in chat ${job.chatId}`);
69
+ await executeJob(job);
70
+ recordCronRun(job.id);
71
+ appendDailyLog("Cron", `Ran "${job.name}" (${job.type}) in chat ${job.chatId}`);
72
+ log("cron", `Executed "${job.name}" [${job.id}] in chat ${job.chatId}`);
73
+ } catch (err) {
74
+ logError("cron", `Job "${job.name}" [${job.id}] failed`, err);
75
+ }
76
+ }
77
+ }
78
+
79
+ function isDue(job: CronJob, now: Date): boolean {
80
+ try {
81
+ const oneMinuteAgo = new Date(now.getTime() - 60_000);
82
+ const cron = new Cron(job.schedule, { timezone: job.timezone ?? undefined });
83
+ const next = cron.nextRun(oneMinuteAgo);
84
+ if (!next) return false;
85
+
86
+ const nowMinute = Math.floor(now.getTime() / 60_000);
87
+ const nextMinute = Math.floor(next.getTime() / 60_000);
88
+ if (nowMinute !== nextMinute) return false;
89
+
90
+ // Prevent duplicate runs — ensure at least 55 seconds since last execution
91
+ if (job.lastRunAt && now.getTime() - job.lastRunAt < 55_000) return false;
92
+
93
+ // Guard against backward clock jumps (NTP sync, etc.) — if last run is in the
94
+ // future, skip until the clock catches up
95
+ if (job.lastRunAt && job.lastRunAt > now.getTime()) return false;
96
+
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ const CRON_JOB_TIMEOUT_MS = 10 * 60_000; // 10-minute max per job
104
+
105
+ async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
106
+ let timer: ReturnType<typeof setTimeout>;
107
+ const timeout = new Promise<never>((_, reject) => {
108
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
109
+ });
110
+ try {
111
+ return await Promise.race([promise, timeout]);
112
+ } finally {
113
+ clearTimeout(timer!);
114
+ }
115
+ }
116
+
117
+ async function executeJob(job: CronJob): Promise<void> {
118
+ if (!deps) return;
119
+
120
+ const numericChatId = Number(job.chatId);
121
+ if (!Number.isFinite(numericChatId)) {
122
+ logError("cron", `Invalid chatId for job "${job.name}": ${job.chatId}`);
123
+ return;
124
+ }
125
+
126
+ if (job.type === "message") {
127
+ await deps.sendMessage(numericChatId, job.content);
128
+ return;
129
+ }
130
+
131
+ // type === "query" — run through dispatcher with full tool access, timeout-protected
132
+ const prompt =
133
+ `[System: CRON JOB "${job.name}" (schedule: ${job.schedule}). ` +
134
+ `Execute the task. Be concise and action-oriented.]\n\n${job.content}`;
135
+
136
+ await withTimeout(
137
+ execute({
138
+ chatId: job.chatId,
139
+ numericChatId,
140
+ prompt,
141
+ senderName: "Cron",
142
+ isGroup: false,
143
+ source: "cron",
144
+ }),
145
+ CRON_JOB_TIMEOUT_MS,
146
+ `Cron job "${job.name}"`,
147
+ );
148
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Dispatcher — execution path for all AI queries.
3
+ *
4
+ * Manages the lifecycle: acquire context → typing → query → release.
5
+ * True concurrency — every query runs immediately in parallel.
6
+ * No queue, no artificial limits. The Claude API handles its own rate limiting.
7
+ *
8
+ * Dependencies are injected at startup — this module imports nothing from
9
+ * frontend/ or backend/.
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import type {
14
+ QueryBackend,
15
+ ContextManager,
16
+ ExecuteParams,
17
+ ExecuteResult,
18
+ } from "./types.js";
19
+ import { log, logDebug } from "../util/log.js";
20
+ import { maybeStartDream } from "./dream.js";
21
+
22
+ // ── Dependencies (injected at startup) ──────────────────────────────────────
23
+
24
+ type DispatcherDeps = {
25
+ backend: QueryBackend;
26
+ context: ContextManager;
27
+ sendTyping: (chatId: number) => Promise<void>;
28
+ onActivity: () => void;
29
+ };
30
+
31
+ let deps: DispatcherDeps | null = null;
32
+ let activeCount = 0;
33
+
34
+ // Per-chat promise chains — serializes within a chat, parallel across chats.
35
+ // Prevents two queries from resuming the same Claude session simultaneously.
36
+ const chatChains = new Map<string, Promise<unknown>>();
37
+
38
+ export function initDispatcher(d: DispatcherDeps): void {
39
+ deps = d;
40
+ log("dispatcher", "Initialized (per-chat serial, cross-chat parallel)");
41
+ }
42
+
43
+ // ── Public API ──────────────────────────────────────────────────────────────
44
+
45
+ /** Number of queries currently running. */
46
+ export function getActiveCount(): number {
47
+ return activeCount;
48
+ }
49
+
50
+ /**
51
+ * Execute an AI query with full lifecycle management.
52
+ * Same-chat queries are serialized (FIFO) to avoid session conflicts.
53
+ * Different-chat queries run in true parallel.
54
+ */
55
+ export async function execute(params: ExecuteParams): Promise<ExecuteResult> {
56
+ if (!deps) throw new Error("Dispatcher not initialized");
57
+
58
+ const { chatId } = params;
59
+
60
+ // Chain this query behind any pending query for the same chat.
61
+ // Atomic get-or-insert: read and replace in one step to prevent
62
+ // two concurrent calls both seeing the same `prev`.
63
+ const prev = chatChains.get(chatId) ?? Promise.resolve();
64
+ // Use .catch(() => {}) on prev to prevent unhandled rejections —
65
+ // previous query's error is already handled by its own caller.
66
+ const queued = prev.catch(() => {}).then(() => run(params));
67
+ chatChains.set(chatId, queued); // must happen before any await
68
+
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
+ });
73
+
74
+ return queued;
75
+ }
76
+
77
+ async function run(params: ExecuteParams): Promise<ExecuteResult> {
78
+ activeCount++;
79
+ try {
80
+ return await executeInner(params);
81
+ } finally {
82
+ activeCount--;
83
+ }
84
+ }
85
+
86
+ async function executeInner(params: ExecuteParams): Promise<ExecuteResult> {
87
+ const { backend, context, sendTyping, onActivity } = deps!;
88
+ const reqId = randomBytes(4).toString("hex");
89
+
90
+ // Dream check — fire-and-forget background memory consolidation if due
91
+ maybeStartDream();
92
+
93
+ logDebug("dispatcher", `[${reqId}] ${params.source} chat=${params.chatId} started (active=${activeCount})`);
94
+ context.acquire(params.numericChatId, params.chatId);
95
+
96
+ let typingTimer: ReturnType<typeof setInterval> | undefined;
97
+ try {
98
+ await sendTyping(params.numericChatId).catch(() => {});
99
+ typingTimer = setInterval(() => {
100
+ sendTyping(params.numericChatId).catch(() => {});
101
+ }, 4000);
102
+
103
+ const result = await backend.query({
104
+ chatId: params.chatId,
105
+ text: params.prompt,
106
+ senderName: params.senderName,
107
+ isGroup: params.isGroup,
108
+ messageId: params.messageId,
109
+ onStreamDelta: params.onStreamDelta,
110
+ onTextBlock: params.onTextBlock,
111
+ onToolUse: params.onToolUse,
112
+ });
113
+
114
+ onActivity();
115
+
116
+ logDebug("dispatcher", `[${reqId}] completed in ${result.durationMs}ms (in=${result.inputTokens} out=${result.outputTokens})`);
117
+
118
+ return {
119
+ ...result,
120
+ bridgeMessageCount: context.getMessageCount(params.numericChatId),
121
+ };
122
+ } finally {
123
+ if (typingTimer) clearInterval(typingTimer);
124
+ context.release(params.numericChatId);
125
+ }
126
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Dream mode — background memory consolidation.
3
+ *
4
+ * On each invocation, checks whether it's time to consolidate memories.
5
+ * If 12 hours have elapsed since the last dream, it:
6
+ * 1. Immediately writes a "running" lock to dream_state.json
7
+ * 2. Spawns a background Agent that reads recent logs and merges new
8
+ * facts/preferences/events into memory.md
9
+ *
10
+ * The dream agent runs entirely on filesystem tools — no Telegram/MCP access.
11
+ * It does NOT use the main dispatcher (no chat session, no typing indicator).
12
+ */
13
+
14
+ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
15
+ import { resolve, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import writeFileAtomic from "write-file-atomic";
18
+ import { query } from "@anthropic-ai/claude-agent-sdk";
19
+ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
20
+ import { files as pathFiles, dirs } from "../util/paths.js";
21
+ import { log, logError, logWarn } from "../util/log.js";
22
+
23
+ // ── Types ────────────────────────────────────────────────────────────────────
24
+
25
+ export type DreamState = {
26
+ /** Unix millisecond timestamp of the last completed dream run. */
27
+ last_run: number;
28
+ /** "idle" when no dream is running, "running" while one is active. */
29
+ status: "idle" | "running";
30
+ };
31
+
32
+ // ── Constants ────────────────────────────────────────────────────────────────
33
+
34
+ const DREAM_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
35
+ const DREAM_STATE_FILE = pathFiles.dreamState;
36
+ const DREAM_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute max
37
+ const DREAM_LOGS_DIR = resolve(dirs.logs, "dreams");
38
+
39
+ // ── State ────────────────────────────────────────────────────────────────────
40
+
41
+ let dreaming = false; // in-process guard (one dream at a time)
42
+ let configRef: { model?: string; dreamModel?: string; claudeBinary?: string; workspace?: string } | null = null;
43
+
44
+ export function initDream(cfg: {
45
+ model?: string;
46
+ /** Override model used specifically for dream consolidation (e.g. haiku for cost savings). Falls back to main model. */
47
+ dreamModel?: string;
48
+ claudeBinary?: string;
49
+ workspace?: string;
50
+ }): void {
51
+ configRef = cfg;
52
+ }
53
+
54
+ // ── Public API ───────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Called at the start of every invocation.
58
+ * Returns immediately — any dream work is fire-and-forget in the background.
59
+ */
60
+ export function maybeStartDream(): void {
61
+ if (dreaming) return;
62
+
63
+ const state = readDreamState();
64
+ const now = Date.now();
65
+ const elapsed = now - (state?.last_run ?? 0);
66
+ if (elapsed < DREAM_INTERVAL_MS) return;
67
+
68
+ // Fire and forget
69
+ executeDream("auto").catch(() => {});
70
+ }
71
+
72
+ /**
73
+ * Force a dream run immediately, regardless of the 12-hour interval.
74
+ * Returns a promise that resolves when the dream completes (or rejects on failure).
75
+ * Throws if a dream is already running.
76
+ */
77
+ export async function forceDream(): Promise<void> {
78
+ if (dreaming) throw new Error("Dream already running");
79
+ await executeDream("forced");
80
+ }
81
+
82
+ /** Shared dream execution — claims lock, runs agent, releases lock. */
83
+ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
84
+ const state = readDreamState();
85
+ const now = Date.now();
86
+
87
+ dreaming = true;
88
+ 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"})`);
90
+
91
+ try {
92
+ const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
93
+ writeDreamState({ last_run: Date.now(), status: "idle" });
94
+ log("dream", `Memory consolidation complete (${trigger}), log: ${dreamLogPath}`);
95
+ } catch (err) {
96
+ logError("dream", `Memory consolidation failed (${trigger})`, err);
97
+ writeDreamState({ last_run: Date.now(), status: "idle" });
98
+ if (trigger === "forced") throw err;
99
+ } finally {
100
+ dreaming = false;
101
+ }
102
+ }
103
+
104
+ // ── Dream agent ──────────────────────────────────────────────────────────────
105
+
106
+ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
107
+ if (!configRef) {
108
+ logWarn("dream", "Dream agent not initialized — skipping");
109
+ return "";
110
+ }
111
+
112
+ const lastRunIso = lastRunTimestamp > 0
113
+ ? new Date(lastRunTimestamp).toISOString()
114
+ : "the beginning of time";
115
+
116
+ const logsDir = dirs.logs;
117
+ const memoryFile = pathFiles.memory;
118
+ const dreamStateFile = DREAM_STATE_FILE;
119
+
120
+ // Load prompt template from prompts/dream.md and interpolate variables
121
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
122
+ const promptPath = resolve(projectRoot, "prompts/dream.md");
123
+
124
+ let prompt: string;
125
+ try {
126
+ prompt = readFileSync(promptPath, "utf-8")
127
+ .replace(/\{\{dreamStateFile\}\}/g, dreamStateFile)
128
+ .replace(/\{\{logsDir\}\}/g, logsDir)
129
+ .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
130
+ .replace(/\{\{memoryFile\}\}/g, memoryFile);
131
+ } catch {
132
+ throw new Error(`Failed to read dream prompt from ${promptPath}`);
133
+ }
134
+
135
+ const model = configRef.dreamModel ?? configRef.model ?? "claude-sonnet-4-6";
136
+ const workspace = configRef.workspace ?? dirs.workspace;
137
+
138
+ // Set up dream log file
139
+ const dreamLogFile = createDreamLogFile();
140
+ 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`);
143
+
144
+ const options = {
145
+ 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.",
147
+ cwd: workspace,
148
+ permissionMode: "bypassPermissions" as const,
149
+ allowDangerouslySkipPermissions: true,
150
+ ...(configRef.claudeBinary
151
+ ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
152
+ : {}),
153
+ // No MCP servers — filesystem tools only
154
+ mcpServers: {},
155
+ disallowedTools: [
156
+ "EnterPlanMode",
157
+ "ExitPlanMode",
158
+ "EnterWorktree",
159
+ "ExitWorktree",
160
+ "TodoWrite",
161
+ "TodoRead",
162
+ "TaskCreate",
163
+ "TaskUpdate",
164
+ "TaskGet",
165
+ "TaskList",
166
+ "TaskOutput",
167
+ "TaskStop",
168
+ "AskUserQuestion",
169
+ "Agent",
170
+ ],
171
+ };
172
+
173
+ const timeoutPromise = new Promise<never>((_, reject) =>
174
+ setTimeout(() => reject(new Error("Dream agent timed out")), DREAM_TIMEOUT_MS),
175
+ );
176
+
177
+ const agentPromise = (async () => {
178
+ const qi = query({
179
+ prompt,
180
+ options: options as Parameters<typeof query>[0]["options"],
181
+ });
182
+ for await (const msg of qi) {
183
+ logDreamMessage(dreamLogFile, msg);
184
+ }
185
+ appendDreamLog(dreamLogFile, `\n---\n**Dream completed at ${new Date().toISOString()}**\n`);
186
+ })();
187
+
188
+ try {
189
+ await Promise.race([agentPromise, timeoutPromise]);
190
+ } catch (err) {
191
+ appendDreamLog(dreamLogFile, `\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`);
192
+ throw err;
193
+ }
194
+
195
+ return dreamLogFile;
196
+ }
197
+
198
+ // ── Dream logging helpers ─────────────────────────────────────────────────
199
+
200
+ function createDreamLogFile(): string {
201
+ if (!existsSync(DREAM_LOGS_DIR)) {
202
+ mkdirSync(DREAM_LOGS_DIR, { recursive: true });
203
+ }
204
+ const now = new Date();
205
+ const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); // 2026-04-01T21-30-00
206
+ return resolve(DREAM_LOGS_DIR, `dream-${ts}.md`);
207
+ }
208
+
209
+ function appendDreamLog(logFile: string, text: string): void {
210
+ try {
211
+ appendFileSync(logFile, text);
212
+ } catch (err) {
213
+ logError("dream", "Failed to write dream log", err);
214
+ }
215
+ }
216
+
217
+ function logDreamMessage(logFile: string, msg: SDKMessage): void {
218
+ try {
219
+ const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
220
+
221
+ switch (msg.type) {
222
+ case "assistant": {
223
+ // Extract text content from the assistant message
224
+ const textBlocks = msg.message.content
225
+ .filter((b) => b.type === "text")
226
+ .map((b) => "text" in b ? (b as { text: string }).text : "");
227
+ const toolUseBlocks = msg.message.content
228
+ .filter((b) => b.type === "tool_use")
229
+ .map((b) => {
230
+ const tu = b as { name: string; input: unknown };
231
+ return `**Tool call:** \`${tu.name}\`\n\`\`\`json\n${JSON.stringify(tu.input, null, 2)}\n\`\`\``;
232
+ });
233
+
234
+ if (textBlocks.length > 0) {
235
+ appendDreamLog(logFile, `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`);
236
+ }
237
+ if (toolUseBlocks.length > 0) {
238
+ appendDreamLog(logFile, `\n${toolUseBlocks.join("\n\n")}\n`);
239
+ }
240
+ break;
241
+ }
242
+ case "result": {
243
+ // 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`);
247
+ break;
248
+ }
249
+ case "system": {
250
+ appendDreamLog(logFile, `\n### [${ts}] System (${msg.subtype})\n`);
251
+ break;
252
+ }
253
+ case "user": {
254
+ // Tool results come back as user messages
255
+ 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`);
261
+ }
262
+ break;
263
+ }
264
+ default:
265
+ // Skip stream_event and other noisy message types
266
+ break;
267
+ }
268
+ } catch {
269
+ // Don't let logging errors break the dream
270
+ }
271
+ }
272
+
273
+ // ── State helpers ────────────────────────────────────────────────────────────
274
+
275
+ function readDreamState(): DreamState | null {
276
+ try {
277
+ if (!existsSync(DREAM_STATE_FILE)) return null;
278
+ const raw = readFileSync(DREAM_STATE_FILE, "utf-8");
279
+ const parsed = JSON.parse(raw) as DreamState;
280
+ if (typeof parsed.last_run !== "number") return null;
281
+ return parsed;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ function writeDreamState(state: DreamState): void {
288
+ try {
289
+ const dir = resolve(DREAM_STATE_FILE, "..");
290
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
291
+ writeFileAtomic.sync(DREAM_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
292
+ } catch (err) {
293
+ logError("dream", "Failed to write dream state", err);
294
+ }
295
+ }