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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- 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
|
+
}
|