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,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
+ }