@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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Manager — tracks token usage and compacts conversation history.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Proper token estimation (4 chars ~ 1 token for English)
|
|
6
|
+
* - Micro-compaction (remove stale tool results older than 5 turns)
|
|
7
|
+
* - Keep system prompt and recent 3 turns intact during compaction
|
|
8
|
+
* - Track pre/post compaction token counts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_TOKENS = 180000; // ~200k model limit with buffer
|
|
12
|
+
const COMPACT_THRESHOLD = 0.80;
|
|
13
|
+
const CHARS_PER_TOKEN = 4; // rough estimate for English text
|
|
14
|
+
const STALE_TOOL_RESULT_TURNS = 5; // tool results older than this are micro-compacted
|
|
15
|
+
|
|
16
|
+
export class ContextManager {
|
|
17
|
+
/**
|
|
18
|
+
* @param {number} maxTokens - Maximum tokens for context window
|
|
19
|
+
*/
|
|
20
|
+
constructor(maxTokens = DEFAULT_MAX_TOKENS) {
|
|
21
|
+
this.maxTokens = maxTokens;
|
|
22
|
+
this.threshold = COMPACT_THRESHOLD;
|
|
23
|
+
this.compactionCount = 0;
|
|
24
|
+
this.lastPreCompactTokens = 0;
|
|
25
|
+
this.lastPostCompactTokens = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Estimate token count for a message array.
|
|
30
|
+
* Uses character-based heuristic (no external tokenizer dependency).
|
|
31
|
+
* @param {Array} messages - conversation messages
|
|
32
|
+
* @returns {number} estimated token count
|
|
33
|
+
*/
|
|
34
|
+
getTokenCount(messages) {
|
|
35
|
+
let chars = 0;
|
|
36
|
+
for (const msg of messages) {
|
|
37
|
+
// Role overhead (~4 tokens)
|
|
38
|
+
chars += 16;
|
|
39
|
+
|
|
40
|
+
if (typeof msg.content === 'string') {
|
|
41
|
+
chars += msg.content.length;
|
|
42
|
+
} else if (Array.isArray(msg.content)) {
|
|
43
|
+
for (const block of msg.content) {
|
|
44
|
+
if (block.type === 'text') chars += (block.text || '').length;
|
|
45
|
+
else if (block.type === 'tool_result') chars += (block.content || '').length;
|
|
46
|
+
else if (block.type === 'tool_use') chars += JSON.stringify(block.input || {}).length + 20;
|
|
47
|
+
else if (block.type === 'thinking') chars += (block.thinking || '').length;
|
|
48
|
+
else chars += JSON.stringify(block).length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if compaction is needed.
|
|
57
|
+
* @param {Array} messages - current conversation messages
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
shouldCompact(messages) {
|
|
61
|
+
const tokenCount = this.getTokenCount(messages);
|
|
62
|
+
return tokenCount >= this.maxTokens * this.threshold;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Micro-compact: remove verbose tool results from messages older than N turns.
|
|
67
|
+
* Keeps the tool call reference but truncates result content.
|
|
68
|
+
* @param {Array} messages
|
|
69
|
+
* @param {number} recentTurns - number of recent user/assistant pairs to preserve
|
|
70
|
+
* @returns {Array}
|
|
71
|
+
*/
|
|
72
|
+
microCompact(messages, recentTurns = STALE_TOOL_RESULT_TURNS) {
|
|
73
|
+
// Count turns (each user message is roughly one turn)
|
|
74
|
+
let turnCount = 0;
|
|
75
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
76
|
+
if (messages[i].role === 'user') turnCount++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (turnCount <= recentTurns) return messages;
|
|
80
|
+
|
|
81
|
+
// Mark the boundary: keep last recentTurns user messages intact
|
|
82
|
+
let usersSeen = 0;
|
|
83
|
+
let boundary = messages.length;
|
|
84
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
85
|
+
if (messages[i].role === 'user') {
|
|
86
|
+
usersSeen++;
|
|
87
|
+
if (usersSeen >= recentTurns) {
|
|
88
|
+
boundary = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Truncate tool results before the boundary
|
|
95
|
+
const result = messages.map((msg, idx) => {
|
|
96
|
+
if (idx >= boundary) return msg;
|
|
97
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
98
|
+
|
|
99
|
+
const newContent = msg.content.map(block => {
|
|
100
|
+
if (block.type === 'tool_result' && typeof block.content === 'string' && block.content.length > 200) {
|
|
101
|
+
return {
|
|
102
|
+
...block,
|
|
103
|
+
content: block.content.slice(0, 100) + '...[truncated]',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return block;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return { ...msg, content: newContent };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compact messages by summarizing older history.
|
|
117
|
+
* Keeps the most recent N messages intact and replaces older ones
|
|
118
|
+
* with a summary message.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array} messages - current conversation messages
|
|
121
|
+
* @param {number} keepRecent - number of recent messages to preserve (default 6 = ~3 turns)
|
|
122
|
+
* @returns {Array} compacted message array
|
|
123
|
+
*/
|
|
124
|
+
compact(messages, keepRecent = 6) {
|
|
125
|
+
if (messages.length <= keepRecent) return messages;
|
|
126
|
+
|
|
127
|
+
this.lastPreCompactTokens = this.getTokenCount(messages);
|
|
128
|
+
this.compactionCount++;
|
|
129
|
+
|
|
130
|
+
// First try micro-compaction
|
|
131
|
+
let working = this.microCompact(messages);
|
|
132
|
+
if (!this.shouldCompact(working)) {
|
|
133
|
+
this.lastPostCompactTokens = this.getTokenCount(working);
|
|
134
|
+
return working;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Full compaction
|
|
138
|
+
const oldMessages = messages.slice(0, -keepRecent);
|
|
139
|
+
const recentMessages = messages.slice(-keepRecent);
|
|
140
|
+
|
|
141
|
+
// Build a summary of old messages
|
|
142
|
+
const summaryParts = [];
|
|
143
|
+
for (const msg of oldMessages) {
|
|
144
|
+
const role = msg.role;
|
|
145
|
+
let text = '';
|
|
146
|
+
if (typeof msg.content === 'string') {
|
|
147
|
+
text = msg.content.slice(0, 200);
|
|
148
|
+
} else if (Array.isArray(msg.content)) {
|
|
149
|
+
text = msg.content
|
|
150
|
+
.map(b => {
|
|
151
|
+
if (b.type === 'text') return b.text?.slice(0, 100);
|
|
152
|
+
if (b.type === 'tool_use') return `[tool:${b.name}]`;
|
|
153
|
+
if (b.type === 'tool_result') return `[result:${String(b.content).slice(0, 80)}]`;
|
|
154
|
+
return `[${b.type}]`;
|
|
155
|
+
})
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join(' ');
|
|
158
|
+
}
|
|
159
|
+
if (text) summaryParts.push(`${role}: ${text}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const summary = {
|
|
163
|
+
role: 'user',
|
|
164
|
+
content: `[Context compacted — summary of ${oldMessages.length} earlier messages]\n` +
|
|
165
|
+
summaryParts.join('\n').slice(0, 2000),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const compacted = [summary, ...recentMessages];
|
|
169
|
+
this.lastPostCompactTokens = this.getTokenCount(compacted);
|
|
170
|
+
return compacted;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Add a message and auto-compact if needed.
|
|
175
|
+
* @param {Array} messages - mutable message array
|
|
176
|
+
* @param {object} msg - new message to add
|
|
177
|
+
* @returns {Array} possibly compacted array with new message
|
|
178
|
+
*/
|
|
179
|
+
addMessage(messages, msg) {
|
|
180
|
+
messages.push(msg);
|
|
181
|
+
if (this.shouldCompact(messages)) {
|
|
182
|
+
return this.compact(messages);
|
|
183
|
+
}
|
|
184
|
+
return messages;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get compaction statistics.
|
|
189
|
+
* @returns {object}
|
|
190
|
+
*/
|
|
191
|
+
getStats() {
|
|
192
|
+
return {
|
|
193
|
+
compactionCount: this.compactionCount,
|
|
194
|
+
lastPreCompactTokens: this.lastPreCompactTokens,
|
|
195
|
+
lastPostCompactTokens: this.lastPostCompactTokens,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Provider — unified provider config and request/response transforms.
|
|
3
|
+
*
|
|
4
|
+
* Supports: Anthropic, OpenAI, Google, Bedrock (stub), Vertex (stub).
|
|
5
|
+
* Each provider defines endpoint, auth headers, and optional transforms.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PROVIDERS = {
|
|
9
|
+
anthropic: {
|
|
10
|
+
name: 'Anthropic',
|
|
11
|
+
endpoint: 'https://api.anthropic.com/v1/messages',
|
|
12
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
13
|
+
authHeader(key) {
|
|
14
|
+
return {
|
|
15
|
+
'x-api-key': key,
|
|
16
|
+
'anthropic-version': '2023-06-01',
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
models: ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-6'],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
openai: {
|
|
24
|
+
name: 'OpenAI',
|
|
25
|
+
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
26
|
+
envKey: 'OPENAI_API_KEY',
|
|
27
|
+
authHeader(key) {
|
|
28
|
+
return {
|
|
29
|
+
'Authorization': `Bearer ${key}`,
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
models: ['gpt-4o', 'gpt-4o-mini', 'o1-preview', 'o1-mini', 'o3-mini'],
|
|
34
|
+
transformRequest(body) {
|
|
35
|
+
const messages = [];
|
|
36
|
+
if (body.system) {
|
|
37
|
+
messages.push({ role: 'system', content: body.system });
|
|
38
|
+
}
|
|
39
|
+
for (const msg of body.messages || []) {
|
|
40
|
+
if (typeof msg.content === 'string') {
|
|
41
|
+
messages.push({ role: msg.role, content: msg.content });
|
|
42
|
+
} else if (Array.isArray(msg.content)) {
|
|
43
|
+
for (const block of msg.content) {
|
|
44
|
+
if (block.type === 'tool_result') {
|
|
45
|
+
messages.push({
|
|
46
|
+
role: 'tool',
|
|
47
|
+
tool_call_id: block.tool_use_id,
|
|
48
|
+
content: block.content,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tools = (body.tools || []).map(t => ({
|
|
56
|
+
type: 'function',
|
|
57
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
model: body.model,
|
|
62
|
+
messages,
|
|
63
|
+
...(tools.length > 0 && { tools }),
|
|
64
|
+
...(body.max_tokens && { max_tokens: body.max_tokens }),
|
|
65
|
+
...(body.stream && { stream: true }),
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
transformResponse(data) {
|
|
69
|
+
const choice = data.choices?.[0];
|
|
70
|
+
if (!choice) throw new Error('No choices in OpenAI response');
|
|
71
|
+
|
|
72
|
+
const content = [];
|
|
73
|
+
if (choice.message?.content) {
|
|
74
|
+
content.push({ type: 'text', text: choice.message.content });
|
|
75
|
+
}
|
|
76
|
+
if (choice.message?.tool_calls) {
|
|
77
|
+
for (const tc of choice.message.tool_calls) {
|
|
78
|
+
content.push({
|
|
79
|
+
type: 'tool_use',
|
|
80
|
+
id: tc.id,
|
|
81
|
+
name: tc.function.name,
|
|
82
|
+
input: JSON.parse(tc.function.arguments || '{}'),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
content,
|
|
89
|
+
stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
|
|
90
|
+
usage: {
|
|
91
|
+
input_tokens: data.usage?.prompt_tokens || 0,
|
|
92
|
+
output_tokens: data.usage?.completion_tokens || 0,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
google: {
|
|
99
|
+
name: 'Google',
|
|
100
|
+
endpoint: 'https://generativelanguage.googleapis.com/v1beta/models',
|
|
101
|
+
envKey: 'GOOGLE_API_KEY',
|
|
102
|
+
altEnvKey: 'GEMINI_API_KEY',
|
|
103
|
+
authHeader(key) {
|
|
104
|
+
return { 'Content-Type': 'application/json' };
|
|
105
|
+
},
|
|
106
|
+
models: ['gemini-2.0-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
|
|
107
|
+
transformRequest(body) {
|
|
108
|
+
const contents = [];
|
|
109
|
+
for (const msg of body.messages || []) {
|
|
110
|
+
const role = msg.role === 'assistant' ? 'model' : 'user';
|
|
111
|
+
if (typeof msg.content === 'string') {
|
|
112
|
+
contents.push({ role, parts: [{ text: msg.content }] });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
contents,
|
|
118
|
+
...(body.system && {
|
|
119
|
+
systemInstruction: { parts: [{ text: body.system }] },
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
transformResponse(data) {
|
|
124
|
+
const candidate = data.candidates?.[0];
|
|
125
|
+
if (!candidate) throw new Error('No candidates in Google response');
|
|
126
|
+
|
|
127
|
+
const content = [];
|
|
128
|
+
for (const part of candidate.content?.parts || []) {
|
|
129
|
+
if (part.text) content.push({ type: 'text', text: part.text });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
content,
|
|
134
|
+
stop_reason: 'end_turn',
|
|
135
|
+
usage: {
|
|
136
|
+
input_tokens: data.usageMetadata?.promptTokenCount || 0,
|
|
137
|
+
output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
bedrock: {
|
|
144
|
+
name: 'AWS Bedrock',
|
|
145
|
+
endpoint: null, // Dynamic based on region
|
|
146
|
+
envKey: 'AWS_ACCESS_KEY_ID',
|
|
147
|
+
models: ['anthropic.claude-3-sonnet', 'anthropic.claude-3-haiku'],
|
|
148
|
+
authHeader() {
|
|
149
|
+
// AWS SigV4 signing would go here
|
|
150
|
+
return { 'Content-Type': 'application/json' };
|
|
151
|
+
},
|
|
152
|
+
getEndpoint(model, region = 'us-east-1') {
|
|
153
|
+
return `https://bedrock-runtime.${region}.amazonaws.com/model/${model}/invoke`;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
vertex: {
|
|
158
|
+
name: 'Google Vertex AI',
|
|
159
|
+
endpoint: null, // Dynamic based on project/region
|
|
160
|
+
envKey: 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
161
|
+
models: ['claude-sonnet-4-6@anthropic'],
|
|
162
|
+
authHeader() {
|
|
163
|
+
// GCP bearer token would go here
|
|
164
|
+
return { 'Content-Type': 'application/json' };
|
|
165
|
+
},
|
|
166
|
+
getEndpoint(model, project, region = 'us-central1') {
|
|
167
|
+
return `https://${region}-aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/anthropic/models/${model}:rawPredict`;
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the provider configuration for a given model.
|
|
174
|
+
* @param {string} model - model name
|
|
175
|
+
* @returns {object} provider config
|
|
176
|
+
*/
|
|
177
|
+
export function getProvider(model) {
|
|
178
|
+
if (model.startsWith('claude') || model.startsWith('anthropic')) return PROVIDERS.anthropic;
|
|
179
|
+
if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return PROVIDERS.openai;
|
|
180
|
+
if (model.startsWith('gemini')) return PROVIDERS.google;
|
|
181
|
+
return PROVIDERS.anthropic; // default
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get a provider by name.
|
|
186
|
+
* @param {string} name
|
|
187
|
+
* @returns {object|undefined}
|
|
188
|
+
*/
|
|
189
|
+
export function getProviderByName(name) {
|
|
190
|
+
return PROVIDERS[name];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* List all supported providers.
|
|
195
|
+
* @returns {Array<{ name: string, envKey: string, models: string[] }>}
|
|
196
|
+
*/
|
|
197
|
+
export function listProviders() {
|
|
198
|
+
return Object.entries(PROVIDERS).map(([key, p]) => ({
|
|
199
|
+
id: key,
|
|
200
|
+
name: p.name,
|
|
201
|
+
envKey: p.envKey,
|
|
202
|
+
models: p.models || [],
|
|
203
|
+
hasEndpoint: !!p.endpoint,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check which providers have API keys configured.
|
|
209
|
+
* @returns {Array<{ id: string, name: string, configured: boolean }>}
|
|
210
|
+
*/
|
|
211
|
+
export function checkProviderKeys() {
|
|
212
|
+
return Object.entries(PROVIDERS).map(([key, p]) => ({
|
|
213
|
+
id: key,
|
|
214
|
+
name: p.name,
|
|
215
|
+
configured: !!(process.env[p.envKey] || (p.altEnvKey && process.env[p.altEnvKey])),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { PROVIDERS };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter — handle 429 and 529 API responses.
|
|
3
|
+
*
|
|
4
|
+
* Implements exponential backoff with jitter for rate-limited
|
|
5
|
+
* and overloaded API responses. Tracks retry state per-instance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class RateLimiter {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {number} [options.maxRetries] - max number of retries (default: 5)
|
|
12
|
+
* @param {number} [options.baseDelay] - base delay in ms (default: 1000)
|
|
13
|
+
* @param {number} [options.maxDelay] - max delay in ms (default: 60000)
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.maxRetries = options.maxRetries ?? 5;
|
|
17
|
+
this.baseDelay = options.baseDelay ?? 1000;
|
|
18
|
+
this.maxDelay = options.maxDelay ?? 60000;
|
|
19
|
+
this.retryAfter = 0;
|
|
20
|
+
this.retryCount = 0;
|
|
21
|
+
this.lastRetryAt = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handle an API response and determine whether to retry.
|
|
26
|
+
* @param {{ status: number, headers: { get: (name: string) => string|null } }} response
|
|
27
|
+
* @returns {Promise<'ok'|'retry'|'fail'>}
|
|
28
|
+
*/
|
|
29
|
+
async handleResponse(response) {
|
|
30
|
+
if (response.status === 429) {
|
|
31
|
+
// Rate limited
|
|
32
|
+
if (this.retryCount >= this.maxRetries) return 'fail';
|
|
33
|
+
|
|
34
|
+
const retryAfter = parseInt(response.headers?.get?.('retry-after') || '10', 10);
|
|
35
|
+
const delayMs = Math.min(retryAfter * 1000, this.maxDelay);
|
|
36
|
+
this.retryAfter = Date.now() + delayMs;
|
|
37
|
+
this.retryCount++;
|
|
38
|
+
this.lastRetryAt = new Date().toISOString();
|
|
39
|
+
|
|
40
|
+
await this.wait(delayMs);
|
|
41
|
+
return 'retry';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (response.status === 529) {
|
|
45
|
+
// API overloaded
|
|
46
|
+
if (this.retryCount >= this.maxRetries) return 'fail';
|
|
47
|
+
|
|
48
|
+
const delay = this.calculateBackoff();
|
|
49
|
+
this.retryAfter = Date.now() + delay;
|
|
50
|
+
this.retryCount++;
|
|
51
|
+
this.lastRetryAt = new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
await this.wait(delay);
|
|
54
|
+
return 'retry';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Success — reset retry count
|
|
58
|
+
this.retryCount = 0;
|
|
59
|
+
return 'ok';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculate exponential backoff with jitter.
|
|
64
|
+
* @returns {number} delay in milliseconds
|
|
65
|
+
*/
|
|
66
|
+
calculateBackoff() {
|
|
67
|
+
const exponential = this.baseDelay * Math.pow(2, this.retryCount);
|
|
68
|
+
const jitter = Math.random() * this.baseDelay;
|
|
69
|
+
return Math.min(exponential + jitter, this.maxDelay);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if we should wait before making a request.
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
shouldWait() {
|
|
77
|
+
return Date.now() < this.retryAfter;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get remaining wait time in ms.
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
remainingWait() {
|
|
85
|
+
return Math.max(0, this.retryAfter - Date.now());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reset all retry state.
|
|
90
|
+
*/
|
|
91
|
+
reset() {
|
|
92
|
+
this.retryAfter = 0;
|
|
93
|
+
this.retryCount = 0;
|
|
94
|
+
this.lastRetryAt = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get current limiter status.
|
|
99
|
+
*/
|
|
100
|
+
status() {
|
|
101
|
+
return {
|
|
102
|
+
retryCount: this.retryCount,
|
|
103
|
+
maxRetries: this.maxRetries,
|
|
104
|
+
retryAfter: this.retryAfter,
|
|
105
|
+
lastRetryAt: this.lastRetryAt,
|
|
106
|
+
isWaiting: this.shouldWait(),
|
|
107
|
+
remainingMs: this.remainingWait(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Wait for the specified duration.
|
|
113
|
+
* @param {number} ms
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
wait(ms) {
|
|
117
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — cron-based task scheduling.
|
|
3
|
+
*
|
|
4
|
+
* Stores scheduled tasks in ~/.claude/scheduled_tasks.json.
|
|
5
|
+
* Tasks have a cron expression, prompt, and optional environment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
|
|
13
|
+
export class Scheduler {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} [tasksFile] - path to scheduled tasks JSON
|
|
16
|
+
*/
|
|
17
|
+
constructor(tasksFile) {
|
|
18
|
+
this.tasksFile = tasksFile ||
|
|
19
|
+
path.join(os.homedir(), '.claude', 'scheduled_tasks.json');
|
|
20
|
+
this.timers = new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a new scheduled task.
|
|
25
|
+
* @param {string} cron - cron expression or interval shorthand (e.g., "5m", "1h")
|
|
26
|
+
* @param {string} prompt - prompt to execute
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {string} [options.name] - human-readable name
|
|
29
|
+
* @param {string} [options.model] - model to use
|
|
30
|
+
* @param {boolean} [options.enabled] - whether task is enabled (default: true)
|
|
31
|
+
* @returns {object} created task
|
|
32
|
+
*/
|
|
33
|
+
async create(cron, prompt, options = {}) {
|
|
34
|
+
const tasks = this._loadTasks();
|
|
35
|
+
const task = {
|
|
36
|
+
id: `task_${crypto.randomBytes(4).toString('hex')}`,
|
|
37
|
+
name: options.name || `Task ${tasks.length + 1}`,
|
|
38
|
+
cron,
|
|
39
|
+
prompt,
|
|
40
|
+
model: options.model || null,
|
|
41
|
+
enabled: options.enabled !== false,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
lastRun: null,
|
|
44
|
+
runCount: 0,
|
|
45
|
+
intervalMs: parseCronInterval(cron),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
tasks.push(task);
|
|
49
|
+
this._saveTasks(tasks);
|
|
50
|
+
return task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Delete a scheduled task by ID.
|
|
55
|
+
* @param {string} taskId
|
|
56
|
+
* @returns {boolean} true if deleted
|
|
57
|
+
*/
|
|
58
|
+
async delete(taskId) {
|
|
59
|
+
const tasks = this._loadTasks();
|
|
60
|
+
const idx = tasks.findIndex(t => t.id === taskId);
|
|
61
|
+
if (idx === -1) return false;
|
|
62
|
+
|
|
63
|
+
tasks.splice(idx, 1);
|
|
64
|
+
this._saveTasks(tasks);
|
|
65
|
+
|
|
66
|
+
// Clear timer if running
|
|
67
|
+
if (this.timers.has(taskId)) {
|
|
68
|
+
clearInterval(this.timers.get(taskId));
|
|
69
|
+
this.timers.delete(taskId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* List all scheduled tasks.
|
|
77
|
+
* @returns {Array<object>}
|
|
78
|
+
*/
|
|
79
|
+
async list() {
|
|
80
|
+
return this._loadTasks();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check and return tasks that are due to run.
|
|
85
|
+
* @returns {Array<object>} tasks that are due
|
|
86
|
+
*/
|
|
87
|
+
async runDue() {
|
|
88
|
+
const tasks = this._loadTasks();
|
|
89
|
+
const due = [];
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
for (const task of tasks) {
|
|
93
|
+
if (!task.enabled) continue;
|
|
94
|
+
if (!task.intervalMs) continue;
|
|
95
|
+
|
|
96
|
+
const lastRun = task.lastRun ? new Date(task.lastRun).getTime() : 0;
|
|
97
|
+
if (now - lastRun >= task.intervalMs) {
|
|
98
|
+
task.lastRun = new Date().toISOString();
|
|
99
|
+
task.runCount = (task.runCount || 0) + 1;
|
|
100
|
+
due.push(task);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (due.length > 0) {
|
|
105
|
+
this._saveTasks(tasks);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return due;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Enable or disable a task.
|
|
113
|
+
* @param {string} taskId
|
|
114
|
+
* @param {boolean} enabled
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
async setEnabled(taskId, enabled) {
|
|
118
|
+
const tasks = this._loadTasks();
|
|
119
|
+
const task = tasks.find(t => t.id === taskId);
|
|
120
|
+
if (!task) return false;
|
|
121
|
+
|
|
122
|
+
task.enabled = enabled;
|
|
123
|
+
this._saveTasks(tasks);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Load tasks from disk.
|
|
129
|
+
* @returns {Array<object>}
|
|
130
|
+
*/
|
|
131
|
+
_loadTasks() {
|
|
132
|
+
try {
|
|
133
|
+
const raw = fs.readFileSync(this.tasksFile, 'utf-8');
|
|
134
|
+
return JSON.parse(raw);
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save tasks to disk.
|
|
142
|
+
* @param {Array<object>} tasks
|
|
143
|
+
*/
|
|
144
|
+
_saveTasks(tasks) {
|
|
145
|
+
try {
|
|
146
|
+
const dir = path.dirname(this.tasksFile);
|
|
147
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
148
|
+
fs.writeFileSync(this.tasksFile, JSON.stringify(tasks, null, 2));
|
|
149
|
+
} catch {
|
|
150
|
+
// Best effort
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a cron expression or interval shorthand into milliseconds.
|
|
157
|
+
* @param {string} cron
|
|
158
|
+
* @returns {number}
|
|
159
|
+
*/
|
|
160
|
+
export function parseCronInterval(cron) {
|
|
161
|
+
const match = cron.match(/^(\d+)(s|m|h|d)$/);
|
|
162
|
+
if (match) {
|
|
163
|
+
const value = parseInt(match[1], 10);
|
|
164
|
+
const units = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
|
165
|
+
return value * (units[match[2]] || 60000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const num = parseInt(cron, 10);
|
|
169
|
+
if (!isNaN(num)) return num * 60000;
|
|
170
|
+
|
|
171
|
+
// Full cron expression — default to 5 minutes
|
|
172
|
+
return 300000;
|
|
173
|
+
}
|