@ruvnet/open-claude-code 2.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/package.json +27 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/config/cli-args.mjs +139 -0
- package/src/config/env.mjs +261 -0
- package/src/config/settings.mjs +119 -0
- package/src/core/agent-loop.mjs +462 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/checkpoints.mjs +141 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session.mjs +143 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +220 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/tools/agent.mjs +127 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +127 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +114 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/ink-app.mjs +231 -0
- package/src/ui/repl.mjs +144 -0
- package/test/test.mjs +1857 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Loop — async generator yielding 13 event types.
|
|
3
|
+
* Handles streaming, tool calls, thinking, auto-compaction, hooks, multi-provider.
|
|
4
|
+
*/
|
|
5
|
+
import { streamResponse, accumulateStream } from './streaming.mjs';
|
|
6
|
+
import { ContextManager } from './context-manager.mjs';
|
|
7
|
+
import { buildSystemPrompt } from './system-prompt.mjs';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
|
|
11
|
+
const contextManager = new ContextManager(settings.maxContextTokens || 180000);
|
|
12
|
+
|
|
13
|
+
// Build system prompt using the new builder
|
|
14
|
+
const promptResult = buildSystemPrompt({
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
tools: tools.list?.() || [],
|
|
17
|
+
override: settings.systemPromptOverride,
|
|
18
|
+
addDirs: settings.addDirs,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const state = {
|
|
22
|
+
messages: [],
|
|
23
|
+
systemPrompt: promptResult.full,
|
|
24
|
+
turnCount: 0,
|
|
25
|
+
tokenUsage: { input: 0, output: 0 },
|
|
26
|
+
model,
|
|
27
|
+
tools,
|
|
28
|
+
_contextManager: contextManager,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
async function* run(userMessage, options = {}) {
|
|
32
|
+
// Add user message (skip for continuation turns)
|
|
33
|
+
if (userMessage && !options.continuation) {
|
|
34
|
+
state.messages = contextManager.addMessage(state.messages, {
|
|
35
|
+
role: 'user',
|
|
36
|
+
content: userMessage,
|
|
37
|
+
});
|
|
38
|
+
state.turnCount++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check max turns
|
|
42
|
+
if (settings.maxTurns && state.turnCount > settings.maxTurns) {
|
|
43
|
+
yield { type: 'error', message: `Max turns (${settings.maxTurns}) reached.` };
|
|
44
|
+
yield { type: 'stop', reason: 'max_turns' };
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Auto-compact if needed
|
|
49
|
+
if (contextManager.shouldCompact(state.messages)) {
|
|
50
|
+
yield { type: 'compaction', count: contextManager.compactionCount + 1 };
|
|
51
|
+
state.messages = contextManager.compact(state.messages);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
yield { type: 'stream_request_start', turn: state.turnCount };
|
|
55
|
+
|
|
56
|
+
// Detect provider and call API
|
|
57
|
+
const provider = detectProvider(model);
|
|
58
|
+
let response;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (settings.stream !== false) {
|
|
62
|
+
// Streaming mode
|
|
63
|
+
response = await callApiStreaming(provider, model, state, tools.list(), settings);
|
|
64
|
+
const collectedContent = [];
|
|
65
|
+
let currentText = '';
|
|
66
|
+
let currentThinking = '';
|
|
67
|
+
|
|
68
|
+
for await (const event of response.events) {
|
|
69
|
+
if (event.type === 'content_block_start') {
|
|
70
|
+
if (event.content_block?.type === 'thinking') {
|
|
71
|
+
currentThinking = '';
|
|
72
|
+
}
|
|
73
|
+
} else if (event.type === 'content_block_delta') {
|
|
74
|
+
if (event.delta?.type === 'text_delta') {
|
|
75
|
+
currentText += event.delta.text;
|
|
76
|
+
yield { type: 'stream_event', text: event.delta.text };
|
|
77
|
+
} else if (event.delta?.type === 'thinking_delta') {
|
|
78
|
+
currentThinking += event.delta.thinking;
|
|
79
|
+
yield { type: 'thinking', text: event.delta.thinking };
|
|
80
|
+
}
|
|
81
|
+
} else if (event.type === 'ping') {
|
|
82
|
+
// Keepalive, ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Use the accumulated message
|
|
87
|
+
response = response.accumulated;
|
|
88
|
+
} else {
|
|
89
|
+
// Non-streaming mode
|
|
90
|
+
response = await callApi(provider, model, state, tools.list(), settings);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
yield { type: 'error', message: err.message };
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Track token usage
|
|
98
|
+
if (response.usage) {
|
|
99
|
+
state.tokenUsage.input += response.usage.input_tokens || 0;
|
|
100
|
+
state.tokenUsage.output += response.usage.output_tokens || 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build assistant message for history
|
|
104
|
+
const assistantMessage = { role: 'assistant', content: response.content };
|
|
105
|
+
state.messages.push(assistantMessage);
|
|
106
|
+
|
|
107
|
+
// Process content blocks
|
|
108
|
+
const toolUseBlocks = [];
|
|
109
|
+
|
|
110
|
+
for (const block of response.content || []) {
|
|
111
|
+
if (block.type === 'text') {
|
|
112
|
+
yield { type: 'assistant', content: block.text };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (block.type === 'thinking') {
|
|
116
|
+
yield { type: 'thinking_complete', thinking: block.thinking };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (block.type === 'tool_use') {
|
|
120
|
+
toolUseBlocks.push(block);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Process tool calls
|
|
125
|
+
if (toolUseBlocks.length > 0) {
|
|
126
|
+
const toolResults = [];
|
|
127
|
+
|
|
128
|
+
for (const block of toolUseBlocks) {
|
|
129
|
+
// Run pre-tool hooks
|
|
130
|
+
if (hooks) {
|
|
131
|
+
const hookResult = await hooks.runPreToolUse(block.name, block.input);
|
|
132
|
+
if (!hookResult.allow) {
|
|
133
|
+
yield { type: 'hookPermissionResult', tool: block.name, allowed: false, message: hookResult.message };
|
|
134
|
+
toolResults.push({
|
|
135
|
+
type: 'tool_result',
|
|
136
|
+
tool_use_id: block.id,
|
|
137
|
+
content: `Blocked by hook: ${hookResult.message}`,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check permission
|
|
144
|
+
const allowed = await permissions.check(block.name, block.input);
|
|
145
|
+
if (!allowed) {
|
|
146
|
+
yield { type: 'hookPermissionResult', tool: block.name, allowed: false };
|
|
147
|
+
toolResults.push({
|
|
148
|
+
type: 'tool_result',
|
|
149
|
+
tool_use_id: block.id,
|
|
150
|
+
content: 'Permission denied',
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Execute tool
|
|
156
|
+
yield { type: 'tool_progress', tool: block.name, status: 'running' };
|
|
157
|
+
|
|
158
|
+
let result;
|
|
159
|
+
try {
|
|
160
|
+
result = await tools.call(block.name, block.input);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
result = `Tool error: ${err.message}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run post-tool hooks
|
|
166
|
+
if (hooks) {
|
|
167
|
+
result = await hooks.runPostToolUse(block.name, result);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
yield { type: 'result', tool: block.name, result };
|
|
171
|
+
|
|
172
|
+
toolResults.push({
|
|
173
|
+
type: 'tool_result',
|
|
174
|
+
tool_use_id: block.id,
|
|
175
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Add tool results as a single user message
|
|
180
|
+
state.messages.push({ role: 'user', content: toolResults });
|
|
181
|
+
|
|
182
|
+
// Recursive: continue the loop after tool execution
|
|
183
|
+
yield* run(null, { continuation: true });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// No tool calls — check stop hooks
|
|
188
|
+
if (hooks) {
|
|
189
|
+
const allowStop = await hooks.runStop();
|
|
190
|
+
if (!allowStop) {
|
|
191
|
+
// Hook prevented stopping — continue with a nudge
|
|
192
|
+
state.messages = contextManager.addMessage(state.messages, {
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: '[System: A hook prevented stopping. Please continue with the task.]',
|
|
195
|
+
});
|
|
196
|
+
yield* run(null, { continuation: true });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { run, state };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function detectProvider(model) {
|
|
208
|
+
if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
|
|
209
|
+
if (model.startsWith('gemini')) return 'google';
|
|
210
|
+
return 'anthropic';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function callApi(provider, model, state, toolDefs, settings) {
|
|
214
|
+
const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
|
|
215
|
+
const caller = callers[provider] || callers.anthropic;
|
|
216
|
+
return caller(model, state, toolDefs, settings, false);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function callApiStreaming(provider, model, state, toolDefs, settings) {
|
|
220
|
+
const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
|
|
221
|
+
const caller = callers[provider] || callers.anthropic;
|
|
222
|
+
return caller(model, state, toolDefs, settings, true);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
226
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
227
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
228
|
+
|
|
229
|
+
const body = {
|
|
230
|
+
model,
|
|
231
|
+
max_tokens: settings.maxTokens || 16384,
|
|
232
|
+
messages: state.messages,
|
|
233
|
+
...(state.systemPrompt && { system: state.systemPrompt }),
|
|
234
|
+
...(toolDefs.length > 0 && { tools: toolDefs }),
|
|
235
|
+
...(stream && { stream: true }),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Enable extended thinking if model supports it
|
|
239
|
+
if (model.includes('opus') || settings.thinking) {
|
|
240
|
+
body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: {
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
'x-api-key': apiKey,
|
|
248
|
+
'anthropic-version': '2023-06-01',
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify(body),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
const err = await res.text();
|
|
255
|
+
throw new Error(`Anthropic API error ${res.status}: ${err}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (stream) {
|
|
259
|
+
const collected = [];
|
|
260
|
+
const eventGenerator = async function* () {
|
|
261
|
+
for await (const event of streamResponse(res)) {
|
|
262
|
+
collected.push(event);
|
|
263
|
+
yield event;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
events: eventGenerator(),
|
|
268
|
+
get accumulated() {
|
|
269
|
+
return accumulateFromCollected(collected);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return res.json();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
278
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
279
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
|
280
|
+
|
|
281
|
+
const messages = [];
|
|
282
|
+
if (state.systemPrompt) {
|
|
283
|
+
messages.push({ role: 'system', content: state.systemPrompt });
|
|
284
|
+
}
|
|
285
|
+
for (const msg of state.messages) {
|
|
286
|
+
if (typeof msg.content === 'string') {
|
|
287
|
+
messages.push({ role: msg.role, content: msg.content });
|
|
288
|
+
} else if (Array.isArray(msg.content)) {
|
|
289
|
+
for (const block of msg.content) {
|
|
290
|
+
if (block.type === 'tool_result') {
|
|
291
|
+
messages.push({
|
|
292
|
+
role: 'tool',
|
|
293
|
+
tool_call_id: block.tool_use_id,
|
|
294
|
+
content: block.content,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const tools = toolDefs.map(t => ({
|
|
302
|
+
type: 'function',
|
|
303
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
const body = {
|
|
307
|
+
model,
|
|
308
|
+
messages,
|
|
309
|
+
...(tools.length > 0 && { tools }),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
313
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
'Content-Type': 'application/json',
|
|
317
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
318
|
+
},
|
|
319
|
+
body: JSON.stringify(body),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
const err = await res.text();
|
|
324
|
+
throw new Error(`OpenAI API error ${res.status}: ${err}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const data = await res.json();
|
|
328
|
+
return convertOpenAIResponse(data);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function callGoogle(model, state, toolDefs, settings, stream) {
|
|
332
|
+
const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
|
|
333
|
+
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
|
|
334
|
+
|
|
335
|
+
const contents = [];
|
|
336
|
+
for (const msg of state.messages) {
|
|
337
|
+
const role = msg.role === 'assistant' ? 'model' : 'user';
|
|
338
|
+
if (typeof msg.content === 'string') {
|
|
339
|
+
contents.push({ role, parts: [{ text: msg.content }] });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const body = {
|
|
344
|
+
contents,
|
|
345
|
+
...(state.systemPrompt && {
|
|
346
|
+
systemInstruction: { parts: [{ text: state.systemPrompt }] },
|
|
347
|
+
}),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const res = await fetch(
|
|
351
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
352
|
+
{
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (!res.ok) {
|
|
360
|
+
const err = await res.text();
|
|
361
|
+
throw new Error(`Google API error ${res.status}: ${err}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const data = await res.json();
|
|
365
|
+
return convertGoogleResponse(data);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function convertOpenAIResponse(data) {
|
|
369
|
+
const choice = data.choices?.[0];
|
|
370
|
+
if (!choice) throw new Error('No choices in OpenAI response');
|
|
371
|
+
|
|
372
|
+
const content = [];
|
|
373
|
+
if (choice.message?.content) {
|
|
374
|
+
content.push({ type: 'text', text: choice.message.content });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (choice.message?.tool_calls) {
|
|
378
|
+
for (const tc of choice.message.tool_calls) {
|
|
379
|
+
content.push({
|
|
380
|
+
type: 'tool_use',
|
|
381
|
+
id: tc.id,
|
|
382
|
+
name: tc.function.name,
|
|
383
|
+
input: JSON.parse(tc.function.arguments || '{}'),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
content,
|
|
390
|
+
stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
|
|
391
|
+
usage: {
|
|
392
|
+
input_tokens: data.usage?.prompt_tokens || 0,
|
|
393
|
+
output_tokens: data.usage?.completion_tokens || 0,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function convertGoogleResponse(data) {
|
|
399
|
+
const candidate = data.candidates?.[0];
|
|
400
|
+
if (!candidate) throw new Error('No candidates in Google response');
|
|
401
|
+
|
|
402
|
+
const content = [];
|
|
403
|
+
for (const part of candidate.content?.parts || []) {
|
|
404
|
+
if (part.text) content.push({ type: 'text', text: part.text });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
content,
|
|
409
|
+
stop_reason: 'end_turn',
|
|
410
|
+
usage: {
|
|
411
|
+
input_tokens: data.usageMetadata?.promptTokenCount || 0,
|
|
412
|
+
output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function accumulateFromCollected(events) {
|
|
418
|
+
const message = {
|
|
419
|
+
content: [],
|
|
420
|
+
stop_reason: null,
|
|
421
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
let currentBlock = null;
|
|
425
|
+
|
|
426
|
+
for (const event of events) {
|
|
427
|
+
switch (event.type) {
|
|
428
|
+
case 'message_start':
|
|
429
|
+
if (event.message?.usage) {
|
|
430
|
+
message.usage.input_tokens = event.message.usage.input_tokens || 0;
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
case 'content_block_start':
|
|
434
|
+
currentBlock = { ...event.content_block };
|
|
435
|
+
if (currentBlock.type === 'text') currentBlock.text = '';
|
|
436
|
+
if (currentBlock.type === 'thinking') currentBlock.thinking = '';
|
|
437
|
+
if (currentBlock.type === 'tool_use') currentBlock.input = '';
|
|
438
|
+
message.content.push(currentBlock);
|
|
439
|
+
break;
|
|
440
|
+
case 'content_block_delta':
|
|
441
|
+
if (!currentBlock) break;
|
|
442
|
+
if (event.delta?.type === 'text_delta') currentBlock.text += event.delta.text;
|
|
443
|
+
else if (event.delta?.type === 'thinking_delta') currentBlock.thinking += event.delta.thinking;
|
|
444
|
+
else if (event.delta?.type === 'input_json_delta') currentBlock.input += event.delta.partial_json;
|
|
445
|
+
break;
|
|
446
|
+
case 'content_block_stop':
|
|
447
|
+
if (currentBlock?.type === 'tool_use' && typeof currentBlock.input === 'string') {
|
|
448
|
+
try { currentBlock.input = JSON.parse(currentBlock.input || '{}'); } catch { currentBlock.input = {}; }
|
|
449
|
+
}
|
|
450
|
+
currentBlock = null;
|
|
451
|
+
break;
|
|
452
|
+
case 'message_delta':
|
|
453
|
+
if (event.delta?.stop_reason) message.stop_reason = event.delta.stop_reason;
|
|
454
|
+
if (event.usage) message.usage.output_tokens = event.usage.output_tokens || 0;
|
|
455
|
+
break;
|
|
456
|
+
case 'ping':
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return message;
|
|
462
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Caching — implements cache_control for Anthropic API.
|
|
3
|
+
*
|
|
4
|
+
* Adds cache_control: { type: "ephemeral" } to system prompt blocks
|
|
5
|
+
* that are static (like CLAUDE.md content), allowing the API to
|
|
6
|
+
* cache them and reduce input token costs.
|
|
7
|
+
*
|
|
8
|
+
* Tracks cache_read_tokens and cache_creation_tokens.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class PromptCache {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.stats = {
|
|
14
|
+
cacheCreationTokens: 0,
|
|
15
|
+
cacheReadTokens: 0,
|
|
16
|
+
totalRequests: 0,
|
|
17
|
+
cacheHits: 0,
|
|
18
|
+
cacheMisses: 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apply cache control to system prompt blocks.
|
|
24
|
+
* Static content (CLAUDE.md, tool definitions) gets ephemeral cache markers.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|Array} systemPrompt - system prompt content
|
|
27
|
+
* @returns {Array} system prompt blocks with cache_control
|
|
28
|
+
*/
|
|
29
|
+
applyCacheControl(systemPrompt) {
|
|
30
|
+
if (typeof systemPrompt === 'string') {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: systemPrompt,
|
|
35
|
+
cache_control: { type: 'ephemeral' },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(systemPrompt)) {
|
|
41
|
+
return systemPrompt.map((block, i) => {
|
|
42
|
+
if (typeof block === 'string') {
|
|
43
|
+
return {
|
|
44
|
+
type: 'text',
|
|
45
|
+
text: block,
|
|
46
|
+
cache_control: { type: 'ephemeral' },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Only cache the first block (usually CLAUDE.md) and tool defs
|
|
50
|
+
if (i === 0 || block.cacheable) {
|
|
51
|
+
return { ...block, cache_control: { type: 'ephemeral' } };
|
|
52
|
+
}
|
|
53
|
+
return block;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return systemPrompt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update cache stats from API response usage data.
|
|
62
|
+
* @param {object} usage - API response usage object
|
|
63
|
+
*/
|
|
64
|
+
updateStats(usage) {
|
|
65
|
+
this.stats.totalRequests++;
|
|
66
|
+
if (usage) {
|
|
67
|
+
if (usage.cache_creation_input_tokens) {
|
|
68
|
+
this.stats.cacheCreationTokens += usage.cache_creation_input_tokens;
|
|
69
|
+
this.stats.cacheMisses++;
|
|
70
|
+
}
|
|
71
|
+
if (usage.cache_read_input_tokens) {
|
|
72
|
+
this.stats.cacheReadTokens += usage.cache_read_input_tokens;
|
|
73
|
+
this.stats.cacheHits++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get cache efficiency stats.
|
|
80
|
+
*/
|
|
81
|
+
getStats() {
|
|
82
|
+
const hitRate = this.stats.totalRequests > 0
|
|
83
|
+
? ((this.stats.cacheHits / this.stats.totalRequests) * 100).toFixed(1)
|
|
84
|
+
: '0.0';
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...this.stats,
|
|
88
|
+
hitRate: `${hitRate}%`,
|
|
89
|
+
tokensSaved: this.stats.cacheReadTokens,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reset stats.
|
|
95
|
+
*/
|
|
96
|
+
reset() {
|
|
97
|
+
this.stats = {
|
|
98
|
+
cacheCreationTokens: 0,
|
|
99
|
+
cacheReadTokens: 0,
|
|
100
|
+
totalRequests: 0,
|
|
101
|
+
cacheHits: 0,
|
|
102
|
+
cacheMisses: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Checkpointing — save and restore file state before edits.
|
|
3
|
+
*
|
|
4
|
+
* Before any file edit, a checkpoint is created containing the
|
|
5
|
+
* original file content. The /undo command restores the last checkpoint.
|
|
6
|
+
* Checkpoints are stored in .claude/checkpoints/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
|
|
13
|
+
export class CheckpointManager {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} [baseDir] - project root directory
|
|
16
|
+
*/
|
|
17
|
+
constructor(baseDir = process.cwd()) {
|
|
18
|
+
this.baseDir = baseDir;
|
|
19
|
+
this.checkpointDir = path.join(baseDir, '.claude', 'checkpoints');
|
|
20
|
+
this.history = []; // Stack of checkpoint IDs
|
|
21
|
+
this.maxCheckpoints = 50;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a checkpoint for a file before editing.
|
|
26
|
+
* @param {string} filePath - absolute path to the file
|
|
27
|
+
* @returns {string|null} checkpoint ID, or null if file doesn't exist
|
|
28
|
+
*/
|
|
29
|
+
save(filePath) {
|
|
30
|
+
const absPath = path.resolve(filePath);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
34
|
+
const id = `ckpt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
35
|
+
|
|
36
|
+
fs.mkdirSync(this.checkpointDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const checkpoint = {
|
|
39
|
+
id,
|
|
40
|
+
filePath: absPath,
|
|
41
|
+
relativePath: path.relative(this.baseDir, absPath),
|
|
42
|
+
content,
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
size: content.length,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ckptFile = path.join(this.checkpointDir, `${id}.json`);
|
|
48
|
+
fs.writeFileSync(ckptFile, JSON.stringify(checkpoint));
|
|
49
|
+
|
|
50
|
+
this.history.push(id);
|
|
51
|
+
|
|
52
|
+
// Trim old checkpoints
|
|
53
|
+
while (this.history.length > this.maxCheckpoints) {
|
|
54
|
+
const old = this.history.shift();
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(path.join(this.checkpointDir, `${old}.json`));
|
|
57
|
+
} catch {
|
|
58
|
+
// Already deleted
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return id;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Restore the most recent checkpoint (undo last edit).
|
|
70
|
+
* @returns {{ filePath: string, restored: boolean, id: string }|null}
|
|
71
|
+
*/
|
|
72
|
+
undo() {
|
|
73
|
+
if (this.history.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
const id = this.history.pop();
|
|
76
|
+
const ckptFile = path.join(this.checkpointDir, `${id}.json`);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const raw = fs.readFileSync(ckptFile, 'utf-8');
|
|
80
|
+
const checkpoint = JSON.parse(raw);
|
|
81
|
+
|
|
82
|
+
fs.writeFileSync(checkpoint.filePath, checkpoint.content);
|
|
83
|
+
fs.unlinkSync(ckptFile);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: checkpoint.id,
|
|
87
|
+
filePath: checkpoint.filePath,
|
|
88
|
+
restored: true,
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { id, filePath: null, restored: false, error: err.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* List recent checkpoints.
|
|
97
|
+
* @param {number} [limit=10]
|
|
98
|
+
* @returns {Array}
|
|
99
|
+
*/
|
|
100
|
+
list(limit = 10) {
|
|
101
|
+
const result = [];
|
|
102
|
+
const ids = this.history.slice(-limit).reverse();
|
|
103
|
+
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(
|
|
107
|
+
path.join(this.checkpointDir, `${id}.json`),
|
|
108
|
+
'utf-8'
|
|
109
|
+
);
|
|
110
|
+
const ckpt = JSON.parse(raw);
|
|
111
|
+
result.push({
|
|
112
|
+
id: ckpt.id,
|
|
113
|
+
file: ckpt.relativePath,
|
|
114
|
+
timestamp: ckpt.timestamp,
|
|
115
|
+
size: ckpt.size,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
result.push({ id, file: '?', timestamp: '?', size: 0 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clear all checkpoints.
|
|
127
|
+
*/
|
|
128
|
+
clear() {
|
|
129
|
+
this.history.length = 0;
|
|
130
|
+
try {
|
|
131
|
+
const entries = fs.readdirSync(this.checkpointDir);
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.startsWith('ckpt_') && entry.endsWith('.json')) {
|
|
134
|
+
fs.unlinkSync(path.join(this.checkpointDir, entry));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Directory doesn't exist
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|