ashlrcode 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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Context management — token estimation and automatic compression.
3
+ *
4
+ * Three-tier strategy from Claude Code:
5
+ * 1. autoCompact — summarize older messages when approaching token limit
6
+ * 2. snipCompact — remove stale tool results and zombie messages
7
+ * 3. contextCollapse — restructure for efficiency (future)
8
+ */
9
+
10
+ import type { Message, ContentBlock } from "../providers/types.ts";
11
+ import type { ProviderRouter } from "../providers/router.ts";
12
+
13
+ export interface ContextConfig {
14
+ /** Max tokens before triggering compaction (default: 100000) */
15
+ maxContextTokens: number;
16
+ /** Tokens to reserve for the response (default: 8192) */
17
+ reserveTokens: number;
18
+ /** Number of recent messages to keep at full fidelity (default: 10) */
19
+ recentMessageCount: number;
20
+ }
21
+
22
+ const DEFAULT_CONFIG: ContextConfig = {
23
+ maxContextTokens: 100_000,
24
+ reserveTokens: 8192,
25
+ recentMessageCount: 10,
26
+ };
27
+
28
+ /** Provider-aware context limits (in tokens). */
29
+ const PROVIDER_CONTEXT_LIMITS: Record<string, number> = {
30
+ xai: 2_000_000,
31
+ anthropic: 200_000,
32
+ };
33
+
34
+ /**
35
+ * Get the context token limit for a given provider.
36
+ */
37
+ export function getProviderContextLimit(providerName: string): number {
38
+ const lower = providerName.toLowerCase();
39
+ for (const [key, limit] of Object.entries(PROVIDER_CONTEXT_LIMITS)) {
40
+ if (lower.includes(key)) return limit;
41
+ }
42
+ return DEFAULT_CONFIG.maxContextTokens;
43
+ }
44
+
45
+ /**
46
+ * Estimate token count for messages.
47
+ * Uses ~4 chars per token heuristic (good enough for cost tracking).
48
+ */
49
+ export function estimateTokens(messages: Message[]): number {
50
+ let chars = 0;
51
+ for (const msg of messages) {
52
+ if (typeof msg.content === "string") {
53
+ chars += msg.content.length;
54
+ } else {
55
+ for (const block of msg.content) {
56
+ chars += blockCharCount(block);
57
+ }
58
+ }
59
+ }
60
+ return Math.ceil(chars / 4);
61
+ }
62
+
63
+ function blockCharCount(block: ContentBlock): number {
64
+ switch (block.type) {
65
+ case "text":
66
+ return block.text.length;
67
+ case "tool_use":
68
+ return block.name.length + JSON.stringify(block.input).length;
69
+ case "tool_result":
70
+ return block.content.length;
71
+ case "image_url":
72
+ return 1000; // Estimate ~1000 tokens per image
73
+ default:
74
+ return 0;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Tier 3: contextCollapse — remove redundant messages from older history.
80
+ * - Remove short assistant messages (< 10 chars)
81
+ * - Deduplicate consecutive tool results with similar content
82
+ * - Keep last 5 messages at full fidelity
83
+ */
84
+ export function contextCollapse(messages: Message[]): Message[] {
85
+ if (messages.length <= 5) return messages;
86
+
87
+ const keepRecent = 5;
88
+ const older = messages.slice(0, -keepRecent);
89
+ const recent = messages.slice(-keepRecent);
90
+
91
+ const collapsed: Message[] = [];
92
+ let lastToolResultHash = "";
93
+
94
+ let skipNext = false;
95
+ for (const msg of older) {
96
+ if (skipNext) { skipNext = false; continue; } // Skip tool result after removed assistant
97
+
98
+ // Remove very short assistant messages (and their following tool results)
99
+ if (msg.role === "assistant" && typeof msg.content === "string" && msg.content.trim().length < 10) {
100
+ skipNext = true; // Also skip the next user/tool_result to maintain alternation
101
+ continue;
102
+ }
103
+
104
+ // Deduplicate similar consecutive tool results
105
+ if (msg.role === "user" && Array.isArray(msg.content)) {
106
+ const toolResults = msg.content.filter(b => b.type === "tool_result");
107
+ if (toolResults.length > 0) {
108
+ const hash = toolResults.map(b => b.type === "tool_result" ? b.content.slice(0, 200) : "").join("|");
109
+ if (hash === lastToolResultHash) continue; // skip duplicate
110
+ lastToolResultHash = hash;
111
+ }
112
+ }
113
+
114
+ collapsed.push(msg);
115
+ }
116
+
117
+ return [...collapsed, ...recent];
118
+ }
119
+
120
+ /**
121
+ * Check if context needs compaction.
122
+ *
123
+ * @param actualTokensUsed - If provided, uses the real token count from the
124
+ * last API response instead of the chars/4 estimate.
125
+ */
126
+ export function needsCompaction(
127
+ messages: Message[],
128
+ systemPromptTokens: number,
129
+ config: Partial<ContextConfig> = {},
130
+ actualTokensUsed?: number
131
+ ): boolean {
132
+ const cfg = { ...DEFAULT_CONFIG, ...config };
133
+ const messageTokens = actualTokensUsed ?? estimateTokens(messages);
134
+ return messageTokens + systemPromptTokens > cfg.maxContextTokens - cfg.reserveTokens;
135
+ }
136
+
137
+ /**
138
+ * Tier 1: autoCompact — summarize older messages.
139
+ * Sends the older portion to the model for summarization,
140
+ * then replaces them with a compact summary.
141
+ */
142
+ export async function autoCompact(
143
+ messages: Message[],
144
+ router: ProviderRouter,
145
+ config: Partial<ContextConfig> = {}
146
+ ): Promise<Message[]> {
147
+ const cfg = { ...DEFAULT_CONFIG, ...config };
148
+
149
+ if (messages.length <= cfg.recentMessageCount) {
150
+ return messages; // Nothing to compact
151
+ }
152
+
153
+ // Split: older messages to summarize, recent messages to keep
154
+ const splitIndex = messages.length - cfg.recentMessageCount;
155
+ const olderMessages = messages.slice(0, splitIndex);
156
+ const recentMessages = messages.slice(splitIndex);
157
+
158
+ // Summarize older messages
159
+ const summary = await summarizeMessages(olderMessages, router);
160
+
161
+ // Return: summary + recent messages
162
+ return [
163
+ {
164
+ role: "user",
165
+ content: `[Context Summary — earlier conversation was compacted to save tokens]\n\n${summary}`,
166
+ },
167
+ {
168
+ role: "assistant",
169
+ content: "Understood. I have the context from our earlier conversation. Let me continue from where we left off.",
170
+ },
171
+ ...recentMessages,
172
+ ];
173
+ }
174
+
175
+ /**
176
+ * Tier 2: snipCompact — remove verbose tool results and stale messages.
177
+ */
178
+ export function snipCompact(messages: Message[]): Message[] {
179
+ return messages.map((msg) => {
180
+ if (typeof msg.content === "string") return msg;
181
+
182
+ const trimmedBlocks = msg.content.map((block) => {
183
+ if (block.type === "tool_result" && block.content.length > 2000) {
184
+ // Truncate long tool results, keeping first and last portions
185
+ const truncated =
186
+ block.content.slice(0, 800) +
187
+ "\n\n[... truncated ...]\n\n" +
188
+ block.content.slice(-800);
189
+ return { ...block, content: truncated };
190
+ }
191
+ return block;
192
+ });
193
+
194
+ return { ...msg, content: trimmedBlocks };
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Summarize a set of messages using the model.
200
+ */
201
+ async function summarizeMessages(
202
+ messages: Message[],
203
+ router: ProviderRouter
204
+ ): Promise<string> {
205
+ const conversationText = messages
206
+ .map((msg) => {
207
+ const role = msg.role;
208
+ const content =
209
+ typeof msg.content === "string"
210
+ ? msg.content
211
+ : msg.content
212
+ .map((b) => {
213
+ if (b.type === "text") return b.text;
214
+ if (b.type === "tool_use")
215
+ return `[Tool: ${b.name}(${JSON.stringify(b.input).slice(0, 200)})]`;
216
+ if (b.type === "tool_result")
217
+ return `[Result: ${b.content.slice(0, 300)}]`;
218
+ return "";
219
+ })
220
+ .join("\n");
221
+ return `${role}: ${content}`;
222
+ })
223
+ .join("\n\n");
224
+
225
+ let summary = "";
226
+ const stream = router.stream({
227
+ systemPrompt:
228
+ "Summarize the following conversation concisely. Preserve key decisions, file paths mentioned, code changes made, and important context. Be thorough but compact. Output only the summary, no preamble.",
229
+ messages: [
230
+ {
231
+ role: "user",
232
+ content: `Summarize this conversation:\n\n${conversationText.slice(0, 50000)}`,
233
+ },
234
+ ],
235
+ tools: [],
236
+ });
237
+
238
+ for await (const event of stream) {
239
+ if (event.type === "text_delta" && event.text) {
240
+ summary += event.text;
241
+ }
242
+ }
243
+
244
+ return summary || "[Unable to generate summary]";
245
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Cron Triggers — schedule agent runs on recurring intervals.
3
+ * Stored in ~/.ashlrcode/triggers/ as JSON files.
4
+ */
5
+
6
+ import { existsSync } from "fs";
7
+ import { readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
8
+ import { join } from "path";
9
+ import { getConfigDir } from "../config/settings.ts";
10
+
11
+ export interface CronTrigger {
12
+ id: string;
13
+ name: string;
14
+ schedule: string; // Simplified duration: "5m", "1h", "30s", "2d"
15
+ prompt: string; // Agent prompt to execute
16
+ cwd: string; // Working directory
17
+ enabled: boolean;
18
+ lastRun?: string;
19
+ nextRun?: string;
20
+ runCount: number;
21
+ createdAt: string;
22
+ }
23
+
24
+ function getTriggersDir(): string {
25
+ return join(getConfigDir(), "triggers");
26
+ }
27
+
28
+ function parseDuration(schedule: string): number | null {
29
+ const match = schedule.match(/^(\d+)(s|m|h|d)$/);
30
+ if (!match) return null;
31
+ const value = parseInt(match[1]!, 10);
32
+ const unit = match[2]!;
33
+ const multipliers: Record<string, number> = {
34
+ s: 1000,
35
+ m: 60_000,
36
+ h: 3_600_000,
37
+ d: 86_400_000,
38
+ };
39
+ return value * (multipliers[unit] ?? 60_000);
40
+ }
41
+
42
+ export async function createTrigger(
43
+ name: string,
44
+ schedule: string,
45
+ prompt: string,
46
+ cwd: string,
47
+ ): Promise<CronTrigger> {
48
+ await mkdir(getTriggersDir(), { recursive: true });
49
+
50
+ const intervalMs = parseDuration(schedule);
51
+ if (!intervalMs) {
52
+ throw new Error(
53
+ `Invalid schedule: ${schedule}. Use format like "5m", "1h", "30s", "2d"`,
54
+ );
55
+ }
56
+
57
+ const trigger: CronTrigger = {
58
+ id: `trigger-${Date.now()}`,
59
+ name,
60
+ schedule,
61
+ prompt,
62
+ cwd,
63
+ enabled: true,
64
+ runCount: 0,
65
+ createdAt: new Date().toISOString(),
66
+ nextRun: new Date(Date.now() + intervalMs).toISOString(),
67
+ };
68
+
69
+ await saveTrigger(trigger);
70
+ return trigger;
71
+ }
72
+
73
+ export async function listTriggers(): Promise<CronTrigger[]> {
74
+ const dir = getTriggersDir();
75
+ if (!existsSync(dir)) return [];
76
+
77
+ const files = await readdir(dir);
78
+ const triggers: CronTrigger[] = [];
79
+ for (const file of files.filter((f) => f.endsWith(".json"))) {
80
+ try {
81
+ const raw = await readFile(join(dir, file), "utf-8");
82
+ triggers.push(JSON.parse(raw) as CronTrigger);
83
+ } catch {
84
+ // Skip malformed trigger files
85
+ }
86
+ }
87
+ return triggers.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
88
+ }
89
+
90
+ export async function deleteTrigger(id: string): Promise<boolean> {
91
+ const path = join(getTriggersDir(), `${id}.json`);
92
+ if (!existsSync(path)) return false;
93
+ await unlink(path);
94
+ return true;
95
+ }
96
+
97
+ export async function toggleTrigger(id: string): Promise<CronTrigger | null> {
98
+ const trigger = await loadTrigger(id);
99
+ if (!trigger) return null;
100
+ trigger.enabled = !trigger.enabled;
101
+ await saveTrigger(trigger);
102
+ return trigger;
103
+ }
104
+
105
+ export async function markRun(id: string): Promise<void> {
106
+ const trigger = await loadTrigger(id);
107
+ if (!trigger) return;
108
+ trigger.lastRun = new Date().toISOString();
109
+ trigger.runCount++;
110
+ const intervalMs = parseDuration(trigger.schedule);
111
+ if (intervalMs) {
112
+ trigger.nextRun = new Date(Date.now() + intervalMs).toISOString();
113
+ }
114
+ await saveTrigger(trigger);
115
+ }
116
+
117
+ export function getDueTriggers(triggers: CronTrigger[]): CronTrigger[] {
118
+ const now = Date.now();
119
+ return triggers.filter(
120
+ (t) => t.enabled && t.nextRun && new Date(t.nextRun).getTime() <= now,
121
+ );
122
+ }
123
+
124
+ async function loadTrigger(id: string): Promise<CronTrigger | null> {
125
+ const path = join(getTriggersDir(), `${id}.json`);
126
+ if (!existsSync(path)) return null;
127
+ try {
128
+ return JSON.parse(await readFile(path, "utf-8"));
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ async function saveTrigger(trigger: CronTrigger): Promise<void> {
135
+ await mkdir(getTriggersDir(), { recursive: true });
136
+ await writeFile(
137
+ join(getTriggersDir(), `${trigger.id}.json`),
138
+ JSON.stringify(trigger, null, 2),
139
+ "utf-8",
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Background polling loop that checks for due triggers and executes them.
145
+ */
146
+ export class TriggerRunner {
147
+ private timer: ReturnType<typeof setInterval> | null = null;
148
+ private running = false;
149
+ private onExecute: (trigger: CronTrigger) => Promise<void>;
150
+
151
+ constructor(onExecute: (trigger: CronTrigger) => Promise<void>) {
152
+ this.onExecute = onExecute;
153
+ }
154
+
155
+ start(pollIntervalMs: number = 10_000): void {
156
+ if (this.timer) return;
157
+ this.running = true;
158
+ this.timer = setInterval(async () => {
159
+ if (!this.running) return;
160
+ try {
161
+ const triggers = await listTriggers();
162
+ const due = getDueTriggers(triggers);
163
+ for (const trigger of due) {
164
+ try {
165
+ await this.onExecute(trigger);
166
+ await markRun(trigger.id);
167
+ } catch (err) {
168
+ // Don't mark as run if execution failed
169
+ console.error(`Trigger ${trigger.id} failed:`, err);
170
+ }
171
+ }
172
+ } catch {
173
+ // Silently continue on errors — triggers are best-effort
174
+ }
175
+ }, pollIntervalMs);
176
+ }
177
+
178
+ stop(): void {
179
+ this.running = false;
180
+ if (this.timer) {
181
+ clearInterval(this.timer);
182
+ this.timer = null;
183
+ }
184
+ }
185
+
186
+ isActive(): boolean {
187
+ return this.running && this.timer !== null;
188
+ }
189
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Dream Task — background memory consolidation.
3
+ *
4
+ * When the user goes idle, summarize recent conversation into a
5
+ * persistent "dream" file. On next session, load dreams to restore
6
+ * project context without token bloat.
7
+ */
8
+
9
+ import { existsSync } from "fs";
10
+ import { readFile, writeFile, readdir, mkdir } from "fs/promises";
11
+ import { join } from "path";
12
+ import { getConfigDir } from "../config/settings.ts";
13
+ import type { Message } from "../providers/types.ts";
14
+
15
+ interface Dream {
16
+ id: string;
17
+ timestamp: string;
18
+ summary: string;
19
+ sessionId: string;
20
+ turnCount: number;
21
+ toolsUsed: string[];
22
+ }
23
+
24
+ function getDreamsDir(): string {
25
+ return join(getConfigDir(), "dreams");
26
+ }
27
+
28
+ /**
29
+ * Generate a dream (conversation summary) from recent messages.
30
+ */
31
+ export async function generateDream(
32
+ messages: Message[],
33
+ sessionId: string,
34
+ ): Promise<Dream> {
35
+ await mkdir(getDreamsDir(), { recursive: true });
36
+
37
+ // Extract key info from messages
38
+ const toolsUsed = new Set<string>();
39
+ const summaryParts: string[] = [];
40
+
41
+ for (const msg of messages.slice(-20)) {
42
+ if (typeof msg.content === "string") {
43
+ summaryParts.push(`${msg.role}: ${msg.content.slice(0, 150)}`);
44
+ } else if (Array.isArray(msg.content)) {
45
+ for (const block of msg.content) {
46
+ if (block.type === "tool_use") {
47
+ toolsUsed.add(block.name);
48
+ }
49
+ if (block.type === "text") {
50
+ summaryParts.push(`${msg.role}: ${block.text.slice(0, 150)}`);
51
+ }
52
+ if (block.type === "tool_result") {
53
+ summaryParts.push(`tool_result: ${String(block.content).slice(0, 100)}`);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ const summary = summaryParts.join("\n");
60
+ const userMessages = messages.filter(m => m.role === "user");
61
+
62
+ const dream: Dream = {
63
+ id: `dream-${Date.now()}`,
64
+ timestamp: new Date().toISOString(),
65
+ summary,
66
+ sessionId,
67
+ turnCount: userMessages.length,
68
+ toolsUsed: Array.from(toolsUsed),
69
+ };
70
+
71
+ // Persist
72
+ const dreamPath = join(getDreamsDir(), `${dream.id}.json`);
73
+ await writeFile(dreamPath, JSON.stringify(dream, null, 2), "utf-8");
74
+
75
+ return dream;
76
+ }
77
+
78
+ /**
79
+ * Load recent dreams for context injection.
80
+ */
81
+ export async function loadRecentDreams(limit: number = 3): Promise<Dream[]> {
82
+ const dir = getDreamsDir();
83
+ if (!existsSync(dir)) return [];
84
+
85
+ try {
86
+ const files = await readdir(dir);
87
+ const dreams: Dream[] = [];
88
+
89
+ for (const file of files.filter(f => f.endsWith(".json")).sort().reverse().slice(0, limit * 2)) {
90
+ try {
91
+ const raw = await readFile(join(dir, file), "utf-8");
92
+ dreams.push(JSON.parse(raw) as Dream);
93
+ } catch {
94
+ // Skip malformed dream files
95
+ }
96
+ }
97
+
98
+ return dreams
99
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
100
+ .slice(0, limit);
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Format dreams as context for system prompt injection.
108
+ */
109
+ export function formatDreamsForPrompt(dreams: Dream[]): string {
110
+ if (dreams.length === 0) return "";
111
+
112
+ const lines = dreams.map(d => {
113
+ const date = new Date(d.timestamp).toLocaleDateString();
114
+ const tools = d.toolsUsed.length > 0 ? ` (tools: ${d.toolsUsed.join(", ")})` : "";
115
+ return `### ${date} — ${d.turnCount} turns${tools}\n${d.summary.slice(0, 500)}`;
116
+ });
117
+
118
+ return `## Recent Session Dreams\n\n${lines.join("\n\n---\n\n")}`;
119
+ }
120
+
121
+ /**
122
+ * Idle detector — tracks time since last user input.
123
+ * Calls onIdle when the user has been idle for `thresholdMs`.
124
+ */
125
+ export class IdleDetector {
126
+ private timer: ReturnType<typeof setTimeout> | null = null;
127
+ private callback: () => void;
128
+ private thresholdMs: number;
129
+
130
+ constructor(callback: () => void, thresholdMs: number = 60_000) {
131
+ this.callback = callback;
132
+ this.thresholdMs = thresholdMs;
133
+ }
134
+
135
+ /** Call this on every user action to reset the timer */
136
+ ping(): void {
137
+ if (this.timer) clearTimeout(this.timer);
138
+ this.timer = setTimeout(() => this.callback(), this.thresholdMs);
139
+ }
140
+
141
+ stop(): void {
142
+ if (this.timer) {
143
+ clearTimeout(this.timer);
144
+ this.timer = null;
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Clean up old dreams (keep last N).
151
+ */
152
+ export async function pruneOldDreams(keepCount: number = 10): Promise<number> {
153
+ const dir = getDreamsDir();
154
+ if (!existsSync(dir)) return 0;
155
+
156
+ const files = (await readdir(dir)).filter(f => f.endsWith(".json")).sort();
157
+ const toDelete = files.slice(0, Math.max(0, files.length - keepCount));
158
+
159
+ const { unlink } = await import("fs/promises");
160
+ for (const file of toDelete) {
161
+ await unlink(join(dir, file)).catch(() => {});
162
+ }
163
+
164
+ return toDelete.length;
165
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Error handler — categorized errors with retry logic.
3
+ */
4
+
5
+ export type ErrorCategory = "rate_limit" | "network" | "auth" | "validation" | "tool_failure" | "server" | "unknown";
6
+
7
+ export interface CategorizedError {
8
+ category: ErrorCategory;
9
+ message: string;
10
+ retryable: boolean;
11
+ retryAfterMs?: number;
12
+ }
13
+
14
+ /**
15
+ * Categorize an error for appropriate handling.
16
+ */
17
+ export function categorizeError(error: Error | string): CategorizedError {
18
+ const message = typeof error === "string" ? error : error.message;
19
+ const msg = message.toLowerCase();
20
+
21
+ if (msg.includes("429") || msg.includes("rate_limit") || msg.includes("quota") || msg.includes("too many requests")) {
22
+ return {
23
+ category: "rate_limit",
24
+ message: "Rate limited by provider",
25
+ retryable: true,
26
+ retryAfterMs: extractRetryAfter(message) ?? 5000,
27
+ };
28
+ }
29
+
30
+ if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("forbidden") || msg.includes("invalid api key")) {
31
+ return {
32
+ category: "auth",
33
+ message: "Authentication failed — check your API key",
34
+ retryable: false,
35
+ };
36
+ }
37
+
38
+ if (msg.includes("econnrefused") || msg.includes("enotfound") || msg.includes("timeout") || msg.includes("network") || msg.includes("fetch failed") || msg.includes("socket")) {
39
+ return {
40
+ category: "network",
41
+ message: "Network error — check your connection",
42
+ retryable: true,
43
+ retryAfterMs: 2000,
44
+ };
45
+ }
46
+
47
+ if (msg.includes("500") || msg.includes("502") || msg.includes("503") || msg.includes("504") || msg.includes("529") || msg.includes("internal server error") || msg.includes("bad gateway") || msg.includes("service unavailable") || msg.includes("overloaded")) {
48
+ return {
49
+ category: "server",
50
+ message: "Server error — provider may be experiencing issues",
51
+ retryable: true,
52
+ retryAfterMs: 3000,
53
+ };
54
+ }
55
+
56
+ if (msg.includes("validation") || msg.includes("invalid") || msg.includes("schema")) {
57
+ return {
58
+ category: "validation",
59
+ message,
60
+ retryable: false,
61
+ };
62
+ }
63
+
64
+ return {
65
+ category: "unknown",
66
+ message,
67
+ retryable: false,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Retry with exponential backoff.
73
+ */
74
+ export async function retryWithBackoff<T>(
75
+ fn: () => Promise<T>,
76
+ maxRetries: number = 3,
77
+ baseDelayMs: number = 1000
78
+ ): Promise<T> {
79
+ let lastError: Error | null = null;
80
+
81
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ return await fn();
84
+ } catch (err) {
85
+ lastError = err as Error;
86
+ const categorized = categorizeError(lastError);
87
+
88
+ if (!categorized.retryable || attempt === maxRetries) {
89
+ throw lastError;
90
+ }
91
+
92
+ const delay = categorized.retryAfterMs ?? baseDelayMs * Math.pow(2, attempt);
93
+ await sleep(delay);
94
+ }
95
+ }
96
+
97
+ throw lastError ?? new Error("Retry exhausted");
98
+ }
99
+
100
+ function extractRetryAfter(message: string): number | null {
101
+ const match = message.match(/retry.after.*?(\d+)/i);
102
+ if (match) return parseInt(match[1]!, 10) * 1000;
103
+ return null;
104
+ }
105
+
106
+ function sleep(ms: number): Promise<void> {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }