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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core agent loop — the heart of AshlrCode.
|
|
3
|
+
*
|
|
4
|
+
* Pattern from Claude Code's query.ts:
|
|
5
|
+
* User Input → messages[] → Provider API (streaming) → stop_reason check
|
|
6
|
+
* → "tool_use"? → Execute tool → Append result → Loop
|
|
7
|
+
* → No tool_use? → Return text to user
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ProviderRouter } from "../providers/router.ts";
|
|
11
|
+
import type { ToolRegistry } from "../tools/registry.ts";
|
|
12
|
+
import type { ToolContext } from "../tools/types.ts";
|
|
13
|
+
import { executeToolCalls } from "./tool-executor.ts";
|
|
14
|
+
import type {
|
|
15
|
+
Message,
|
|
16
|
+
ContentBlock,
|
|
17
|
+
StreamEvent,
|
|
18
|
+
ToolCall,
|
|
19
|
+
ToolDefinition,
|
|
20
|
+
} from "../providers/types.ts";
|
|
21
|
+
|
|
22
|
+
export interface AgentConfig {
|
|
23
|
+
systemPrompt: string;
|
|
24
|
+
router: ProviderRouter;
|
|
25
|
+
toolRegistry: ToolRegistry;
|
|
26
|
+
toolContext: ToolContext;
|
|
27
|
+
maxIterations?: number;
|
|
28
|
+
/** If true, only allow read-only tools (plan mode) */
|
|
29
|
+
readOnly?: boolean;
|
|
30
|
+
/** Callback for streaming text to the UI */
|
|
31
|
+
onText?: (text: string) => void;
|
|
32
|
+
/** Callback when a tool is being called */
|
|
33
|
+
onToolStart?: (name: string, input: Record<string, unknown>) => void;
|
|
34
|
+
/** Callback when a tool completes */
|
|
35
|
+
onToolEnd?: (name: string, result: string, isError: boolean) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AgentResult {
|
|
39
|
+
messages: Message[];
|
|
40
|
+
finalText: string;
|
|
41
|
+
toolCalls: Array<{ name: string; input: Record<string, unknown>; result: string }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ── Streaming event types for streamAgentLoop() ───────────────── */
|
|
45
|
+
|
|
46
|
+
export type AgentEvent =
|
|
47
|
+
| { type: "text_delta"; text: string }
|
|
48
|
+
| { type: "tool_start"; name: string; input: Record<string, unknown> }
|
|
49
|
+
| { type: "tool_end"; name: string; result: string; isError: boolean }
|
|
50
|
+
| { type: "turn_end"; finalText: string; toolCalls: AgentResult["toolCalls"] };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run the agent loop for a single user turn.
|
|
54
|
+
* Streams responses, executes tools, and loops until the model stops.
|
|
55
|
+
*/
|
|
56
|
+
export async function runAgentLoop(
|
|
57
|
+
userMessage: string,
|
|
58
|
+
history: Message[],
|
|
59
|
+
config: AgentConfig
|
|
60
|
+
): Promise<AgentResult> {
|
|
61
|
+
const messages: Message[] = [
|
|
62
|
+
...history,
|
|
63
|
+
{ role: "user", content: userMessage },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const tools = config.readOnly
|
|
67
|
+
? config.toolRegistry.getReadOnlyDefinitions()
|
|
68
|
+
: config.toolRegistry.getDefinitions();
|
|
69
|
+
|
|
70
|
+
const maxIterations = config.maxIterations ?? 25;
|
|
71
|
+
const allToolCalls: AgentResult["toolCalls"] = [];
|
|
72
|
+
let finalText = "";
|
|
73
|
+
|
|
74
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
75
|
+
const { text, toolCalls, stopReason } = await streamResponse(
|
|
76
|
+
messages,
|
|
77
|
+
tools,
|
|
78
|
+
config
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
finalText = text;
|
|
82
|
+
|
|
83
|
+
// Build assistant message with content blocks
|
|
84
|
+
const contentBlocks: ContentBlock[] = [];
|
|
85
|
+
if (text) {
|
|
86
|
+
contentBlocks.push({ type: "text", text });
|
|
87
|
+
}
|
|
88
|
+
for (const tc of toolCalls) {
|
|
89
|
+
contentBlocks.push({
|
|
90
|
+
type: "tool_use",
|
|
91
|
+
id: tc.id,
|
|
92
|
+
name: tc.name,
|
|
93
|
+
input: tc.input,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
messages.push({
|
|
98
|
+
role: "assistant",
|
|
99
|
+
content: contentBlocks.length === 1 && contentBlocks[0]!.type === "text"
|
|
100
|
+
? text
|
|
101
|
+
: contentBlocks,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// If no tool calls, we're done
|
|
105
|
+
if (stopReason !== "tool_use" || toolCalls.length === 0) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Execute tool calls (parallel for safe tools, sequential for unsafe)
|
|
110
|
+
const executionResults = await executeToolCalls(
|
|
111
|
+
toolCalls,
|
|
112
|
+
config.toolRegistry,
|
|
113
|
+
config.toolContext,
|
|
114
|
+
{
|
|
115
|
+
onToolStart: config.onToolStart,
|
|
116
|
+
onToolEnd: config.onToolEnd,
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const resultBlocks: ContentBlock[] = [];
|
|
121
|
+
for (const er of executionResults) {
|
|
122
|
+
allToolCalls.push({ name: er.name, input: er.input, result: er.result });
|
|
123
|
+
resultBlocks.push({
|
|
124
|
+
type: "tool_result",
|
|
125
|
+
tool_use_id: er.toolCallId,
|
|
126
|
+
content: er.result,
|
|
127
|
+
is_error: er.isError,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
messages.push({ role: "user", content: resultBlocks });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// If we hit max iterations with no final text, add a fallback
|
|
135
|
+
if (!finalText && allToolCalls.length > 0) {
|
|
136
|
+
finalText = `[Reached maximum iterations (${maxIterations}). ${allToolCalls.length} tool calls were executed.]`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { messages, finalText, toolCalls: allToolCalls };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Streaming agent loop — yields incremental events as an AsyncGenerator.
|
|
144
|
+
*
|
|
145
|
+
* Unlike runAgentLoop() which buffers everything and returns a Promise<AgentResult>,
|
|
146
|
+
* this lets callers react to each event as it arrives (text deltas, tool
|
|
147
|
+
* invocations, etc.). Useful for streaming UIs and programmatic consumers.
|
|
148
|
+
*
|
|
149
|
+
* The existing runAgentLoop() is intentionally left unchanged so current
|
|
150
|
+
* callers keep working — this is an additive, opt-in API.
|
|
151
|
+
*/
|
|
152
|
+
export async function* streamAgentLoop(
|
|
153
|
+
userMessage: string,
|
|
154
|
+
history: Message[],
|
|
155
|
+
config: AgentConfig
|
|
156
|
+
): AsyncGenerator<AgentEvent> {
|
|
157
|
+
const messages: Message[] = [
|
|
158
|
+
...history,
|
|
159
|
+
{ role: "user", content: userMessage },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const tools = config.readOnly
|
|
163
|
+
? config.toolRegistry.getReadOnlyDefinitions()
|
|
164
|
+
: config.toolRegistry.getDefinitions();
|
|
165
|
+
|
|
166
|
+
const maxIterations = config.maxIterations ?? 25;
|
|
167
|
+
const allToolCalls: AgentResult["toolCalls"] = [];
|
|
168
|
+
let finalText = "";
|
|
169
|
+
|
|
170
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
171
|
+
// Stream API response, yielding text deltas as they arrive
|
|
172
|
+
let text = "";
|
|
173
|
+
const toolCalls: ToolCall[] = [];
|
|
174
|
+
let stopReason = "end_turn";
|
|
175
|
+
|
|
176
|
+
const stream = config.router.stream({
|
|
177
|
+
systemPrompt: config.systemPrompt,
|
|
178
|
+
messages,
|
|
179
|
+
tools,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
for await (const event of stream) {
|
|
183
|
+
switch (event.type) {
|
|
184
|
+
case "text_delta":
|
|
185
|
+
if (event.text) {
|
|
186
|
+
text += event.text;
|
|
187
|
+
yield { type: "text_delta", text: event.text };
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case "tool_call_end":
|
|
192
|
+
if (event.toolCall?.id && event.toolCall?.name && event.toolCall?.input) {
|
|
193
|
+
toolCalls.push(event.toolCall as ToolCall);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case "message_end":
|
|
198
|
+
if (event.stopReason) {
|
|
199
|
+
stopReason = event.stopReason;
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
finalText = text;
|
|
206
|
+
|
|
207
|
+
// Build assistant message with content blocks
|
|
208
|
+
const contentBlocks: ContentBlock[] = [];
|
|
209
|
+
if (text) {
|
|
210
|
+
contentBlocks.push({ type: "text", text });
|
|
211
|
+
}
|
|
212
|
+
for (const tc of toolCalls) {
|
|
213
|
+
contentBlocks.push({
|
|
214
|
+
type: "tool_use",
|
|
215
|
+
id: tc.id,
|
|
216
|
+
name: tc.name,
|
|
217
|
+
input: tc.input,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
messages.push({
|
|
222
|
+
role: "assistant",
|
|
223
|
+
content: contentBlocks.length === 1 && contentBlocks[0]!.type === "text"
|
|
224
|
+
? text
|
|
225
|
+
: contentBlocks,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// If no tool calls, we're done
|
|
229
|
+
if (stopReason !== "tool_use" || toolCalls.length === 0) {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Execute tool calls, yielding start/end events for each
|
|
234
|
+
const executionResults = await executeToolCalls(
|
|
235
|
+
toolCalls,
|
|
236
|
+
config.toolRegistry,
|
|
237
|
+
config.toolContext,
|
|
238
|
+
{
|
|
239
|
+
onToolStart: config.onToolStart,
|
|
240
|
+
onToolEnd: config.onToolEnd,
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
for (const er of executionResults) {
|
|
245
|
+
yield { type: "tool_start", name: er.name, input: er.input };
|
|
246
|
+
yield { type: "tool_end", name: er.name, result: er.result, isError: er.isError };
|
|
247
|
+
allToolCalls.push({ name: er.name, input: er.input, result: er.result });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Add tool results to messages for the next iteration
|
|
251
|
+
const resultBlocks: ContentBlock[] = executionResults.map((er) => ({
|
|
252
|
+
type: "tool_result" as const,
|
|
253
|
+
tool_use_id: er.toolCallId,
|
|
254
|
+
content: er.result,
|
|
255
|
+
is_error: er.isError,
|
|
256
|
+
}));
|
|
257
|
+
messages.push({ role: "user", content: resultBlocks });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// If we hit max iterations with no final text, add a fallback
|
|
261
|
+
if (!finalText && allToolCalls.length > 0) {
|
|
262
|
+
finalText = `[Reached maximum iterations (${maxIterations}). ${allToolCalls.length} tool calls were executed.]`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
yield { type: "turn_end", finalText, toolCalls: allToolCalls };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Stream a single API response, collecting text and tool calls.
|
|
270
|
+
*/
|
|
271
|
+
async function streamResponse(
|
|
272
|
+
messages: Message[],
|
|
273
|
+
tools: ToolDefinition[],
|
|
274
|
+
config: AgentConfig
|
|
275
|
+
): Promise<{
|
|
276
|
+
text: string;
|
|
277
|
+
toolCalls: ToolCall[];
|
|
278
|
+
stopReason: string;
|
|
279
|
+
}> {
|
|
280
|
+
let text = "";
|
|
281
|
+
const toolCalls: ToolCall[] = [];
|
|
282
|
+
let stopReason = "end_turn";
|
|
283
|
+
|
|
284
|
+
const stream = config.router.stream({
|
|
285
|
+
systemPrompt: config.systemPrompt,
|
|
286
|
+
messages,
|
|
287
|
+
tools,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
for await (const event of stream) {
|
|
291
|
+
switch (event.type) {
|
|
292
|
+
case "text_delta":
|
|
293
|
+
if (event.text) {
|
|
294
|
+
text += event.text;
|
|
295
|
+
config.onText?.(event.text);
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
|
|
299
|
+
case "tool_call_end":
|
|
300
|
+
if (event.toolCall?.id && event.toolCall?.name && event.toolCall?.input) {
|
|
301
|
+
toolCalls.push(event.toolCall as ToolCall);
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
|
|
305
|
+
case "message_end":
|
|
306
|
+
if (event.stopReason) {
|
|
307
|
+
stopReason = event.stopReason;
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { text, toolCalls, stopReason };
|
|
314
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model behavior patches — provider/model-specific prompt adjustments.
|
|
3
|
+
*
|
|
4
|
+
* Different models have known issues. These patches add targeted
|
|
5
|
+
* instructions to mitigate them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ModelPatch {
|
|
9
|
+
pattern: string; // Regex to match model name
|
|
10
|
+
name: string; // Human-readable patch name
|
|
11
|
+
promptSuffix: string; // Added to system prompt
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PATCHES: ModelPatch[] = [
|
|
15
|
+
{
|
|
16
|
+
pattern: "^grok(?!-4-1-fast)",
|
|
17
|
+
name: "Grok verbosity control",
|
|
18
|
+
promptSuffix: "\n\nIMPORTANT: Be concise. Avoid unnecessary preamble. Lead with the answer or action, not reasoning. If you can say it in one sentence, don't use three.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pattern: "^grok-4-1-fast",
|
|
22
|
+
name: "Grok fast mode",
|
|
23
|
+
promptSuffix: "\n\nYou are running in fast mode. Prioritize speed. Use fewer tool calls. Give direct answers.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
pattern: "^claude.*sonnet",
|
|
27
|
+
name: "Sonnet conciseness",
|
|
28
|
+
promptSuffix: "\n\nBe extremely concise. Skip filler words and preamble. No trailing summaries.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pattern: "^claude.*opus",
|
|
32
|
+
name: "Opus thoroughness",
|
|
33
|
+
promptSuffix: "\n\nBe thorough and precise. Verify your work. Check edge cases.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: "^o1(-mini)?$",
|
|
37
|
+
name: "OpenAI reasoning",
|
|
38
|
+
promptSuffix: "\n\nYou have reasoning capabilities. Use them for complex problems. Think step by step when the problem requires it.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
pattern: "^deepseek",
|
|
42
|
+
name: "DeepSeek format control",
|
|
43
|
+
promptSuffix: "\n\nAvoid over-commenting code. Keep code changes minimal and focused. Don't add docstrings unless asked.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pattern: "^(llama|local)",
|
|
47
|
+
name: "Local model constraints",
|
|
48
|
+
promptSuffix: "\n\nKeep tool calls simple. Avoid deeply nested or complex operations. You have limited context — be efficient with tokens.",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get applicable patches for the current model.
|
|
54
|
+
*/
|
|
55
|
+
export function getModelPatches(modelName: string): { names: string[]; combinedSuffix: string } {
|
|
56
|
+
const applicable = PATCHES.filter(p => new RegExp(p.pattern, "i").test(modelName));
|
|
57
|
+
return {
|
|
58
|
+
names: applicable.map(p => p.name),
|
|
59
|
+
combinedSuffix: applicable.map(p => p.promptSuffix).join(""),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List all available patches (for /patches command).
|
|
65
|
+
*/
|
|
66
|
+
export function listPatches(): ModelPatch[] {
|
|
67
|
+
return [...PATCHES];
|
|
68
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speculation — pre-fetch likely tool results while the model streams.
|
|
3
|
+
*
|
|
4
|
+
* When the model starts generating a tool_use block, we can sometimes
|
|
5
|
+
* predict the full call and start executing early. This hides latency
|
|
6
|
+
* for read-only tools like Read, Glob, Grep.
|
|
7
|
+
*
|
|
8
|
+
* Only read-only tools are eligible — we never speculatively execute
|
|
9
|
+
* writes, edits, or shell commands.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { readFile, stat } from "fs/promises";
|
|
14
|
+
import { dirname } from "path";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Cache entry
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface CacheEntry {
|
|
21
|
+
key: string;
|
|
22
|
+
result: string;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
hitCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// SpeculationCache
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export class SpeculationCache {
|
|
32
|
+
private cache = new Map<string, CacheEntry>();
|
|
33
|
+
private missCount = 0;
|
|
34
|
+
private maxSize: number;
|
|
35
|
+
private ttlMs: number;
|
|
36
|
+
|
|
37
|
+
constructor(maxSize = 100, ttlMs = 30_000) {
|
|
38
|
+
this.maxSize = maxSize;
|
|
39
|
+
this.ttlMs = ttlMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// -------------------------------------------------------------------------
|
|
43
|
+
// Core get / set
|
|
44
|
+
// -------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Check if we have a valid cached result for a tool call. */
|
|
47
|
+
get(toolName: string, input: Record<string, unknown>): string | null {
|
|
48
|
+
const key = this.makeKey(toolName, input);
|
|
49
|
+
const entry = this.cache.get(key);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
this.missCount++;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// TTL check
|
|
56
|
+
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
57
|
+
this.cache.delete(key);
|
|
58
|
+
this.missCount++;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
entry.hitCount++;
|
|
63
|
+
return entry.result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Store a tool result in the cache. */
|
|
67
|
+
set(toolName: string, input: Record<string, unknown>, result: string): void {
|
|
68
|
+
const key = this.makeKey(toolName, input);
|
|
69
|
+
|
|
70
|
+
// Evict oldest entry if at capacity
|
|
71
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
72
|
+
let oldestKey: string | null = null;
|
|
73
|
+
let oldestTs = Infinity;
|
|
74
|
+
for (const [k, v] of this.cache) {
|
|
75
|
+
if (v.timestamp < oldestTs) {
|
|
76
|
+
oldestTs = v.timestamp;
|
|
77
|
+
oldestKey = k;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (oldestKey) this.cache.delete(oldestKey);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.cache.set(key, {
|
|
84
|
+
key,
|
|
85
|
+
result,
|
|
86
|
+
timestamp: Date.now(),
|
|
87
|
+
hitCount: 0,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
// Speculative pre-fetching
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Speculatively pre-read a file into the cache.
|
|
97
|
+
* Skips files that don't exist or are too large (>1 MB).
|
|
98
|
+
*/
|
|
99
|
+
async prefetchRead(filePath: string): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
if (!existsSync(filePath)) return;
|
|
102
|
+
const stats = await stat(filePath);
|
|
103
|
+
if (stats.size > 1_000_000) return; // skip large files
|
|
104
|
+
|
|
105
|
+
const content = await readFile(filePath, "utf-8");
|
|
106
|
+
this.set("Read", { file_path: filePath }, content);
|
|
107
|
+
} catch {
|
|
108
|
+
// Silently ignore — speculation failures are harmless
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* After each tool execution, look at the recent history and
|
|
114
|
+
* speculatively pre-fetch results that the model is likely to
|
|
115
|
+
* request next.
|
|
116
|
+
*
|
|
117
|
+
* Heuristics:
|
|
118
|
+
* 1. After Glob → pre-read the first few matched files.
|
|
119
|
+
* 2. After Read of file X → pre-read sibling files with same ext.
|
|
120
|
+
* 3. After Grep returning file matches → pre-read those files.
|
|
121
|
+
*/
|
|
122
|
+
async speculateFromHistory(
|
|
123
|
+
recentToolCalls: Array<{
|
|
124
|
+
name: string;
|
|
125
|
+
input: Record<string, unknown>;
|
|
126
|
+
result?: string;
|
|
127
|
+
}>
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (recentToolCalls.length === 0) return;
|
|
130
|
+
|
|
131
|
+
const last = recentToolCalls[recentToolCalls.length - 1]!;
|
|
132
|
+
|
|
133
|
+
// Heuristic 1: After Glob, pre-read the first few matched files
|
|
134
|
+
if (last.name === "Glob" && typeof last.result === "string") {
|
|
135
|
+
const paths = last.result
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.slice(0, 5); // limit to first 5
|
|
140
|
+
|
|
141
|
+
await Promise.allSettled(paths.map((p) => this.prefetchRead(p)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Heuristic 2: After Grep returning file paths, pre-read them
|
|
145
|
+
if (last.name === "Grep" && typeof last.result === "string") {
|
|
146
|
+
const paths = last.result
|
|
147
|
+
.split("\n")
|
|
148
|
+
.map((l) => l.trim())
|
|
149
|
+
.filter((l) => l.startsWith("/"))
|
|
150
|
+
.slice(0, 5);
|
|
151
|
+
|
|
152
|
+
await Promise.allSettled(paths.map((p) => this.prefetchRead(p)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Heuristic 3: After Read, pre-read sibling files with same extension
|
|
156
|
+
if (last.name === "Read" && typeof last.input.file_path === "string") {
|
|
157
|
+
const filePath = last.input.file_path;
|
|
158
|
+
const dir = dirname(filePath);
|
|
159
|
+
try {
|
|
160
|
+
const { readdir } = await import("fs/promises");
|
|
161
|
+
const { extname, join } = await import("path");
|
|
162
|
+
const ext = extname(filePath);
|
|
163
|
+
const entries = await readdir(dir);
|
|
164
|
+
const siblings = entries
|
|
165
|
+
.filter((e) => extname(e) === ext && join(dir, e) !== filePath)
|
|
166
|
+
.slice(0, 3) // limit to 3 siblings
|
|
167
|
+
.map((e) => join(dir, e));
|
|
168
|
+
|
|
169
|
+
await Promise.allSettled(siblings.map((p) => this.prefetchRead(p)));
|
|
170
|
+
} catch {
|
|
171
|
+
// Ignore — dir may not exist or may not be readable
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// Invalidation
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/** Invalidate cache entries that might be stale after a write/edit. */
|
|
181
|
+
invalidateForFile(filePath: string): void {
|
|
182
|
+
// Remove any Read cache for this exact file
|
|
183
|
+
const readKey = this.makeKey("Read", { file_path: filePath });
|
|
184
|
+
this.cache.delete(readKey);
|
|
185
|
+
|
|
186
|
+
// Remove any Grep/Glob results that might reference this file
|
|
187
|
+
// (conservative: remove all Grep/Glob entries since results may change)
|
|
188
|
+
for (const [key] of this.cache) {
|
|
189
|
+
if (key.startsWith("Grep:") || key.startsWith("Glob:")) {
|
|
190
|
+
this.cache.delete(key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
// Diagnostics
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** Return cache statistics for debugging. */
|
|
200
|
+
getStats(): { size: number; hits: number; misses: number } {
|
|
201
|
+
let hits = 0;
|
|
202
|
+
for (const entry of this.cache.values()) hits += entry.hitCount;
|
|
203
|
+
return { size: this.cache.size, hits, misses: this.missCount };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Clear the entire cache. */
|
|
207
|
+
clear(): void {
|
|
208
|
+
this.cache.clear();
|
|
209
|
+
this.missCount = 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -------------------------------------------------------------------------
|
|
213
|
+
// Internals
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
private makeKey(toolName: string, input: Record<string, unknown>): string {
|
|
217
|
+
return `${toolName}:${JSON.stringify(input)}`;
|
|
218
|
+
}
|
|
219
|
+
}
|