markov-cli 1.0.11 → 1.0.12
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/.env.example +12 -0
- package/bin/markov.js +15 -0
- package/package.json +1 -1
- package/src/agent/agentLoop.js +131 -0
- package/src/agent/context.js +102 -0
- package/src/auth.js +46 -0
- package/src/claude.js +318 -0
- package/src/commands/setup.js +72 -0
- package/src/editor/codeBlockEdits.js +27 -0
- package/src/files.js +1 -1
- package/src/input.js +67 -13
- package/src/interactive.js +331 -601
- package/src/ollama.js +173 -6
- package/src/openai.js +258 -0
- package/src/tools.js +151 -35
- package/src/ui/formatting.js +125 -0
- package/src/ui/prompts.js +116 -0
- package/src/ui/spinner.js +40 -0
package/.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Optional: enable web search in the CLI AI (uses Serper by default).
|
|
2
|
+
# Get a key at https://serper.dev
|
|
3
|
+
# MARKOV_SEARCH_API_KEY=your_serper_api_key
|
|
4
|
+
|
|
5
|
+
# Optional: use Anthropic (Claude) instead of the backend. Takes priority over OpenAI when set.
|
|
6
|
+
# ANTHROPIC_API_KEY=sk-ant-your_anthropic_api_key
|
|
7
|
+
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
|
8
|
+
# Or Claude 3.5 Sonnet (if still available): ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
|
|
9
|
+
|
|
10
|
+
# Optional: use OpenAI (ChatGPT) instead of the backend. Used when ANTHROPIC_API_KEY is not set.
|
|
11
|
+
# OPENAI_API_KEY=sk-your_openai_api_key
|
|
12
|
+
# OPENAI_MODEL=gpt-4o-mini
|
package/bin/markov.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const packageEnv = path.resolve(__dirname, '../.env');
|
|
9
|
+
|
|
10
|
+
// Load from package dir first (for npm link / local dev), then cwd (project-specific overrides)
|
|
11
|
+
const r1 = dotenv.config({ path: packageEnv });
|
|
12
|
+
dotenv.config();
|
|
13
|
+
|
|
14
|
+
if (process.env.MARKOV_DEBUG && r1.error && r1.error.code === 'ENOENT') {
|
|
15
|
+
console.error(`[markov] .env not found at ${packageEnv} (run from CLI project dir or ensure .env exists)`);
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
const { startInteractive } = await import('../src/interactive.js');
|
|
4
19
|
startInteractive();
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { streamChatWithTools, MODEL } from '../ollama.js';
|
|
3
|
+
import { runTool, AGENT_TOOLS } from '../tools.js';
|
|
4
|
+
import { printTokenUsage, formatToolCallSummary, formatToolResultSummary, formatResponseWithCodeBlocks } from '../ui/formatting.js';
|
|
5
|
+
|
|
6
|
+
export const AGENT_LOOP_MAX_ITERATIONS = 20;
|
|
7
|
+
|
|
8
|
+
/** If MARKOV_DEBUG is set, print the full message payload (system + conversation) before sending. */
|
|
9
|
+
export function maybePrintFullPayload(messages) {
|
|
10
|
+
if (!process.env.MARKOV_DEBUG) return;
|
|
11
|
+
const payload = messages.map((m) => ({
|
|
12
|
+
role: m.role,
|
|
13
|
+
...(m.tool_calls && { tool_calls: m.tool_calls.length }),
|
|
14
|
+
...(m.tool_name && { tool_name: m.tool_name }),
|
|
15
|
+
content: typeof m.content === 'string' ? m.content : '(binary/object)',
|
|
16
|
+
}));
|
|
17
|
+
console.log(chalk.dim('\n--- MARKOV_DEBUG: full payload sent to API ---'));
|
|
18
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
19
|
+
console.log(chalk.dim('--- end payload ---\n'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
|
|
24
|
+
* For search_replace, confirmFileEdit is called first; if it returns false, the change is skipped and the model is told the user declined.
|
|
25
|
+
* @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
|
|
26
|
+
* @param {{ signal?: AbortSignal; cwd?: string; executionProvider?: string; confirmFn?: (command: string) => Promise<boolean>; confirmFileEdit?: (name: string, args: object) => Promise<boolean>; onBeforeToolRun?: () => void; onToolCall?: (name: string, args: object) => void; onToolResult?: (name: string, result: string) => void }} opts
|
|
27
|
+
* @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
|
|
28
|
+
*/
|
|
29
|
+
export async function runAgentLoop(messages, opts = {}) {
|
|
30
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
31
|
+
const confirmFn = opts.confirmFn;
|
|
32
|
+
const confirmFileEdit = opts.confirmFileEdit;
|
|
33
|
+
const onBeforeToolRun = opts.onBeforeToolRun;
|
|
34
|
+
const onToolCall = opts.onToolCall;
|
|
35
|
+
const onToolResult = opts.onToolResult;
|
|
36
|
+
const onIteration = opts.onIteration;
|
|
37
|
+
const onThinking = opts.onThinking;
|
|
38
|
+
let iteration = 0;
|
|
39
|
+
|
|
40
|
+
while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
|
|
41
|
+
iteration += 1;
|
|
42
|
+
onThinking?.(iteration);
|
|
43
|
+
const { content: streamContent, toolCalls, usage } = await streamChatWithTools(
|
|
44
|
+
messages,
|
|
45
|
+
AGENT_TOOLS,
|
|
46
|
+
MODEL,
|
|
47
|
+
{
|
|
48
|
+
onContent: opts.onStreamContent,
|
|
49
|
+
onThinking: opts.onStreamThinking,
|
|
50
|
+
},
|
|
51
|
+
opts.signal ?? null,
|
|
52
|
+
opts.executionProvider ?? null
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Ensure each tool call has an id (backend may omit it); needed for Anthropic tool_result matching
|
|
56
|
+
const normalizedToolCalls = toolCalls?.map((tc, i) => ({
|
|
57
|
+
...tc,
|
|
58
|
+
id: tc.id || `toolu_${i}_${Math.random().toString(36).slice(2)}`,
|
|
59
|
+
}));
|
|
60
|
+
const message = {
|
|
61
|
+
role: 'assistant',
|
|
62
|
+
content: streamContent ?? '',
|
|
63
|
+
...(normalizedToolCalls && normalizedToolCalls.length > 0 && { tool_calls: normalizedToolCalls }),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!toolCalls || toolCalls.length === 0) {
|
|
67
|
+
printTokenUsage(usage);
|
|
68
|
+
return { content: message.content ?? '', finalMessage: message };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Append assistant message with tool_calls
|
|
72
|
+
messages.push(message);
|
|
73
|
+
|
|
74
|
+
onBeforeToolRun?.();
|
|
75
|
+
onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, normalizedToolCalls.length);
|
|
76
|
+
|
|
77
|
+
for (const tc of normalizedToolCalls) {
|
|
78
|
+
const name = tc?.function?.name;
|
|
79
|
+
const rawArgs = tc?.function?.arguments;
|
|
80
|
+
let args = rawArgs;
|
|
81
|
+
if (typeof rawArgs === 'string') {
|
|
82
|
+
try {
|
|
83
|
+
args = JSON.parse(rawArgs);
|
|
84
|
+
} catch {
|
|
85
|
+
messages.push({
|
|
86
|
+
role: 'tool',
|
|
87
|
+
tool_name: name ?? 'unknown',
|
|
88
|
+
tool_call_id: tc.id,
|
|
89
|
+
content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
|
|
90
|
+
});
|
|
91
|
+
if (onToolCall) onToolCall(name ?? 'unknown', {});
|
|
92
|
+
if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
|
|
98
|
+
|
|
99
|
+
const isFileEdit = name === 'search_replace';
|
|
100
|
+
let result;
|
|
101
|
+
if (isFileEdit && confirmFileEdit) {
|
|
102
|
+
const ok = await confirmFileEdit(name, args ?? {});
|
|
103
|
+
if (!ok) {
|
|
104
|
+
result = JSON.stringify({ declined: true, message: 'User declined the change' });
|
|
105
|
+
if (onToolResult) onToolResult(name ?? 'unknown', result);
|
|
106
|
+
messages.push({
|
|
107
|
+
role: 'tool',
|
|
108
|
+
tool_name: name ?? 'unknown',
|
|
109
|
+
tool_call_id: tc.id,
|
|
110
|
+
content: result,
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
result = await runTool(name, args ?? {}, { cwd, confirmFn });
|
|
117
|
+
if (onToolResult) onToolResult(name ?? 'unknown', typeof result === 'string' ? result : JSON.stringify(result));
|
|
118
|
+
messages.push({
|
|
119
|
+
role: 'tool',
|
|
120
|
+
tool_name: name ?? 'unknown',
|
|
121
|
+
tool_call_id: tc.id,
|
|
122
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
content: '(agent loop reached max iterations)',
|
|
129
|
+
finalMessage: { role: 'assistant', content: '(max iterations)' },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { execCommand } from '../tools.js';
|
|
4
|
+
import { getFiles } from '../ui/picker.js';
|
|
5
|
+
|
|
6
|
+
const MARKOV_FILE = 'markov.md';
|
|
7
|
+
|
|
8
|
+
const GREP_DEFAULT_PATTERN = 'function |class |export |def ';
|
|
9
|
+
const GREP_MAX_LINES_DEFAULT = 400;
|
|
10
|
+
const GREP_INCLUDE = "--include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' --include='*.py'";
|
|
11
|
+
/** Portable: filter paths with grep -v (BSD grep may not support --exclude-dir). */
|
|
12
|
+
const GREP_FILTER_PATHS = "| grep -v '/node_modules/' | grep -v '/.git/' | grep -v '/.next/' | grep -v '/dist/' | grep -v '/build/' | grep -v '/coverage/'";
|
|
13
|
+
|
|
14
|
+
/** Safe chars for MARKOV_GREP_PATTERN (no shell metacharacters). */
|
|
15
|
+
function isValidGrepPattern(p) {
|
|
16
|
+
if (typeof p !== 'string' || p.length > 200) return false;
|
|
17
|
+
return /^[a-zA-Z0-9 \|\.\-_\\\[\]\(\)\?\+\*]+$/.test(p);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Run `ls -la` in cwd and return a context string to prepend to user messages. */
|
|
21
|
+
export async function getLsContext(cwd = process.cwd()) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout, stderr, exitCode } = await execCommand('ls -la', cwd);
|
|
24
|
+
const out = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
25
|
+
if (exitCode === 0 && out) {
|
|
26
|
+
return `[Current directory: ${cwd}]\n$ ls -la\n${out}\n\n`;
|
|
27
|
+
}
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
return `[Current directory: ${cwd}]\n(listing unavailable)\n\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Run grep for key definitions; return context block or empty string. Only runs when MARKOV_GREP_CONTEXT=1. */
|
|
33
|
+
export async function getGrepContext(cwd = process.cwd()) {
|
|
34
|
+
if (!process.env.MARKOV_GREP_CONTEXT) return '';
|
|
35
|
+
|
|
36
|
+
const rawPattern = process.env.MARKOV_GREP_PATTERN;
|
|
37
|
+
const pattern = (rawPattern && isValidGrepPattern(rawPattern.trim())) ? rawPattern.trim() : GREP_DEFAULT_PATTERN;
|
|
38
|
+
const maxLines = Math.min(2000, Math.max(1, parseInt(process.env.MARKOV_GREP_MAX_LINES, 10) || GREP_MAX_LINES_DEFAULT));
|
|
39
|
+
const escaped = pattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
40
|
+
const cmd = `grep -rn -E "${escaped}" ${GREP_INCLUDE} . 2>/dev/null ${GREP_FILTER_PATHS} | head -n ${maxLines}`;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const { stdout, exitCode } = await execCommand(cmd, cwd);
|
|
44
|
+
const out = (stdout || '').trim();
|
|
45
|
+
if (exitCode === 0 && out) {
|
|
46
|
+
return `[Grep: key definitions in repo]\n$ grep -rn -E '${pattern}' ...\n${out}\n\n`;
|
|
47
|
+
}
|
|
48
|
+
} catch (_) {}
|
|
49
|
+
return `[Grep: key definitions in repo]\n(grep context unavailable)\n\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Shared system message base: Markov intro, cwd, file list, and markov.md summary if present. */
|
|
53
|
+
export function getSystemMessageBase() {
|
|
54
|
+
const files = getFiles();
|
|
55
|
+
const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
|
|
56
|
+
let out = `You are Markov, an AI CLI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
|
|
57
|
+
const markovPath = resolve(process.cwd(), MARKOV_FILE);
|
|
58
|
+
if (existsSync(markovPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const summary = readFileSync(markovPath, 'utf-8').trim();
|
|
61
|
+
if (summary) out += `\n\nProject summary (markov.md):\n${summary}`;
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** System message for /plan: research with tools, then output a step-by-step plan (saved to plan.md; /build runs it). */
|
|
68
|
+
export function buildPlanSystemMessage() {
|
|
69
|
+
return {
|
|
70
|
+
role: 'system',
|
|
71
|
+
content: getSystemMessageBase() +
|
|
72
|
+
'\n\nPLAN MODE — two phases.\n' +
|
|
73
|
+
'1) RESEARCH (DONT OVERTHINK) (use tools now): Use web_search and run_terminal_command to gather facts, check docs, list files, inspect the codebase. You may use at most 3 web_search calls; use them sparingly for the most relevant queries. Do not describe research in your final plan.\n' +
|
|
74
|
+
'2) OUTPUT THE PLAN: After research, you MUST output a plan. Output a single plan as clear, numbered steps. Each step must be an actionable instruction (e.g. "Create src/foo.js with …", "Run npm install", "Add route X in file Y"). No vague steps like "Gather information" or "Investigate options"—do that in phase 1 with tools. In plan mode, plan.md is always created or overwritten with your plan output; the user then runs /build, which executes only the contents of plan.md.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** System message for /init: create markov.md with project summary using tools. */
|
|
79
|
+
export function buildInitSystemMessage() {
|
|
80
|
+
return {
|
|
81
|
+
role: 'system',
|
|
82
|
+
content: getSystemMessageBase() +
|
|
83
|
+
'\n\nINIT MODE — your only goal is to create or overwrite markov.md in the project root with a concise project summary.\n' +
|
|
84
|
+
'Use run_terminal_command to list files, read key files (e.g. README, package.json), and to write markov.md (e.g. cat > markov.md << \'EOF\' ... EOF). You may use web_search if needed. Use RELATIVE paths. When done, reply briefly that markov.md was created.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** System message for agent mode: tool-only instructions (no !!run / !!write). */
|
|
89
|
+
export function buildAgentSystemMessage() {
|
|
90
|
+
const toolInstructions =
|
|
91
|
+
`\nTOOL MODE — you have tools; use them. \n` +
|
|
92
|
+
`- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). Use this to create or overwrite files (e.g. echo 'content' > path, cat > path << 'EOF', tee). One command per call.\n` +
|
|
93
|
+
// `- create_folder: create directories.\n` +
|
|
94
|
+
`- read_file: read file contents before editing.\n` +
|
|
95
|
+
`- search_replace: replace first occurrence of text in a file (for small, targeted edits).\n` +
|
|
96
|
+
// `- delete_file: delete a file.\n` +
|
|
97
|
+
// `- list_dir: list directory contents (path optional, defaults to current dir).\n` +
|
|
98
|
+
`- web_search: search the web for current information; use when the user asks about recent events, docs, or facts.\n` +
|
|
99
|
+
`When the user asks to run commands, create/edit/delete files, scaffold projects, or to "plan" or "start" an app (e.g. npm run dev), you MUST call the appropriate tool. Do not only describe steps in your reply.\n` +
|
|
100
|
+
`For file creation or overwriting, use run_terminal_command only (e.g. echo, cat, heredocs, tee). For small edits use search_replace. Never paste full file contents directly in your reply. All file operations must use RELATIVE paths. Do not output modified file contents in chat — apply changes through tool calls only.\n`;
|
|
101
|
+
return { role: 'system', content: getSystemMessageBase() + toolInstructions };
|
|
102
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -4,9 +4,55 @@ import { homedir } from 'os';
|
|
|
4
4
|
|
|
5
5
|
const CONFIG_DIR = join(homedir(), '.markov');
|
|
6
6
|
const TOKEN_PATH = join(CONFIG_DIR, 'token');
|
|
7
|
+
const ANTHROPIC_KEY_PATH = join(CONFIG_DIR, 'anthropic_api_key');
|
|
8
|
+
const OPENAI_KEY_PATH = join(CONFIG_DIR, 'openai_api_key');
|
|
7
9
|
|
|
8
10
|
export const API_URL = 'https://api.livingcloud.app/api';
|
|
9
11
|
|
|
12
|
+
export function getOpenAIKey() {
|
|
13
|
+
const env = process.env.OPENAI_API_KEY?.trim();
|
|
14
|
+
if (env) return env;
|
|
15
|
+
if (!existsSync(OPENAI_KEY_PATH)) return null;
|
|
16
|
+
return readFileSync(OPENAI_KEY_PATH, 'utf-8').trim() || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setOpenAIKey(key) {
|
|
20
|
+
const trimmed = (key && String(key).trim()) || '';
|
|
21
|
+
if (!trimmed) return;
|
|
22
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
23
|
+
writeFileSync(OPENAI_KEY_PATH, trimmed, 'utf-8');
|
|
24
|
+
process.env.OPENAI_API_KEY = trimmed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearOpenAIKey() {
|
|
28
|
+
if (existsSync(OPENAI_KEY_PATH)) {
|
|
29
|
+
writeFileSync(OPENAI_KEY_PATH, '', 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
delete process.env.OPENAI_API_KEY;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getClaudeKey() {
|
|
35
|
+
const env = process.env.ANTHROPIC_API_KEY?.trim();
|
|
36
|
+
if (env) return env;
|
|
37
|
+
if (!existsSync(ANTHROPIC_KEY_PATH)) return null;
|
|
38
|
+
return readFileSync(ANTHROPIC_KEY_PATH, 'utf-8').trim() || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setClaudeKey(key) {
|
|
42
|
+
const trimmed = (key && String(key).trim()) || '';
|
|
43
|
+
if (!trimmed) return;
|
|
44
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
45
|
+
writeFileSync(ANTHROPIC_KEY_PATH, trimmed, 'utf-8');
|
|
46
|
+
process.env.ANTHROPIC_API_KEY = trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearClaudeKey() {
|
|
50
|
+
if (existsSync(ANTHROPIC_KEY_PATH)) {
|
|
51
|
+
writeFileSync(ANTHROPIC_KEY_PATH, '', 'utf-8');
|
|
52
|
+
}
|
|
53
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
54
|
+
}
|
|
55
|
+
|
|
10
56
|
export function getToken() {
|
|
11
57
|
if (!existsSync(TOKEN_PATH)) return null;
|
|
12
58
|
return readFileSync(TOKEN_PATH, 'utf-8').trim() || null;
|
package/src/claude.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic (Claude) client for the CLI.
|
|
3
|
+
* Used when ANTHROPIC_API_KEY is set (env or ~/.markov); same interface as openai.js.
|
|
4
|
+
*
|
|
5
|
+
* Converts between CLI/OpenAI message format and Anthropic API format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getClaudeKey } from './auth.js';
|
|
9
|
+
|
|
10
|
+
const ANTHROPIC_API = 'https://api.anthropic.com/v1';
|
|
11
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
12
|
+
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5-20251001';
|
|
13
|
+
|
|
14
|
+
const RATE_LIMIT_MAX_RETRIES = 3;
|
|
15
|
+
const RATE_LIMIT_DEFAULT_WAIT_MS = 60_000; // 1 minute (limit is per minute)
|
|
16
|
+
|
|
17
|
+
function getHeaders() {
|
|
18
|
+
const key = getClaudeKey();
|
|
19
|
+
if (!key || !key.trim()) throw new Error('Claude API key is not set');
|
|
20
|
+
return {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'x-api-key': key.trim(),
|
|
23
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch with retry on 429 (rate limit). Waits Retry-After seconds or default 60s, then retries up to RATE_LIMIT_MAX_RETRIES.
|
|
29
|
+
*/
|
|
30
|
+
async function fetchWithRetry(url, options, signal = null) {
|
|
31
|
+
let lastRes = null;
|
|
32
|
+
let lastErr = null;
|
|
33
|
+
for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
|
|
34
|
+
const res = await fetch(url, { ...options, signal: signal ?? undefined });
|
|
35
|
+
lastRes = res;
|
|
36
|
+
if (res.status !== 429) return res;
|
|
37
|
+
await res.text(); // drain body
|
|
38
|
+
if (attempt === RATE_LIMIT_MAX_RETRIES) break;
|
|
39
|
+
const retryAfter = res.headers.get('retry-after');
|
|
40
|
+
const waitMs = retryAfter ? Math.min(120, Math.max(1, parseInt(retryAfter, 10))) * 1000 : RATE_LIMIT_DEFAULT_WAIT_MS;
|
|
41
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
42
|
+
}
|
|
43
|
+
const errBody = await lastRes.text().catch(() => '');
|
|
44
|
+
lastErr = new Error(`Anthropic API error ${lastRes.status} ${lastRes.statusText} (rate limited; retried ${RATE_LIMIT_MAX_RETRIES} times)${errBody ? ': ' + errBody : ''}`);
|
|
45
|
+
throw lastErr;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert CLI messages (OpenAI-compatible format) to Anthropic format.
|
|
50
|
+
* - System messages are extracted into a top-level system string.
|
|
51
|
+
* - Assistant tool_calls become tool_use content blocks.
|
|
52
|
+
* - Tool role messages are grouped into user messages with tool_result blocks.
|
|
53
|
+
*/
|
|
54
|
+
function toClaudeMessages(messages) {
|
|
55
|
+
const systemParts = [];
|
|
56
|
+
const out = [];
|
|
57
|
+
let lastToolUseIds = [];
|
|
58
|
+
let toolResultBuffer = [];
|
|
59
|
+
|
|
60
|
+
function flushToolResults() {
|
|
61
|
+
if (toolResultBuffer.length === 0) return;
|
|
62
|
+
const results = toolResultBuffer.map((item, i) => {
|
|
63
|
+
const content = typeof item === 'string' ? item : item.content;
|
|
64
|
+
const id = (typeof item === 'object' && item?.tool_use_id) || lastToolUseIds[i] || lastToolUseIds[0];
|
|
65
|
+
if (!id) throw new Error(`Tool result ${i} has no tool_use_id and no matching tool_use from previous assistant message.`);
|
|
66
|
+
return { type: 'tool_result', tool_use_id: id, content };
|
|
67
|
+
});
|
|
68
|
+
out.push({ role: 'user', content: results });
|
|
69
|
+
toolResultBuffer = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const m of messages) {
|
|
73
|
+
if (m.role === 'tool') {
|
|
74
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '');
|
|
75
|
+
toolResultBuffer.push(m.tool_call_id ? { content, tool_use_id: m.tool_call_id } : content);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
flushToolResults();
|
|
80
|
+
|
|
81
|
+
if (m.role === 'system') {
|
|
82
|
+
systemParts.push(typeof m.content === 'string' ? m.content : '');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (m.role === 'user') {
|
|
86
|
+
lastToolUseIds = [];
|
|
87
|
+
out.push({ role: 'user', content: typeof m.content === 'string' ? m.content : '' });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (m.role === 'assistant') {
|
|
91
|
+
const content = [];
|
|
92
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
93
|
+
if (text) content.push({ type: 'text', text });
|
|
94
|
+
lastToolUseIds = [];
|
|
95
|
+
if (m.tool_calls && Array.isArray(m.tool_calls)) {
|
|
96
|
+
for (const tc of m.tool_calls) {
|
|
97
|
+
const id = tc.id || `toolu_${Math.random().toString(36).slice(2)}`;
|
|
98
|
+
let input = {};
|
|
99
|
+
try { input = JSON.parse(tc.function?.arguments || '{}'); } catch (_) {}
|
|
100
|
+
content.push({ type: 'tool_use', id, name: tc.function?.name ?? 'unknown', input });
|
|
101
|
+
lastToolUseIds.push(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
out.push({ role: 'assistant', content: content.length > 0 ? content : [{ type: 'text', text: '' }] });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
flushToolResults();
|
|
109
|
+
|
|
110
|
+
return { system: systemParts.join('\n\n') || undefined, messages: out };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert Ollama/OpenAI-format tools to Anthropic format.
|
|
115
|
+
* Renames `parameters` → `input_schema` and drops the outer `type: 'function'` wrapper.
|
|
116
|
+
*/
|
|
117
|
+
function toClaudeTools(tools) {
|
|
118
|
+
if (!tools || tools.length === 0) return undefined;
|
|
119
|
+
return tools.map((t) => {
|
|
120
|
+
const f = t.function ?? t;
|
|
121
|
+
return {
|
|
122
|
+
name: f.name,
|
|
123
|
+
description: f.description ?? '',
|
|
124
|
+
input_schema: f.parameters ?? { type: 'object', properties: {} },
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Convert an Anthropic content array back to CLI format: { text, toolCalls }.
|
|
131
|
+
* toolCalls uses OpenAI format ({ id, type, function: { name, arguments } }) for compatibility.
|
|
132
|
+
*/
|
|
133
|
+
function contentToCliFormat(content) {
|
|
134
|
+
if (!Array.isArray(content)) {
|
|
135
|
+
return { text: typeof content === 'string' ? content : '', toolCalls: null };
|
|
136
|
+
}
|
|
137
|
+
let text = '';
|
|
138
|
+
const toolCalls = [];
|
|
139
|
+
for (const block of content) {
|
|
140
|
+
if (block.type === 'text') text += block.text || '';
|
|
141
|
+
if (block.type === 'tool_use') {
|
|
142
|
+
toolCalls.push({
|
|
143
|
+
id: block.id,
|
|
144
|
+
type: 'function',
|
|
145
|
+
function: {
|
|
146
|
+
name: block.name,
|
|
147
|
+
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input ?? {}),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { text, toolCalls: toolCalls.length > 0 ? toolCalls : null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stream chat (no tools). Same signature as openai.js streamChat.
|
|
157
|
+
*/
|
|
158
|
+
export async function streamChat(messages, onToken, _model = DEFAULT_MODEL, signal = null, opts = {}) {
|
|
159
|
+
const { system, messages: claudeMessages } = toClaudeMessages(messages);
|
|
160
|
+
const body = { model: _model, max_tokens: 8096, messages: claudeMessages, stream: true, temperature: 0.2 };
|
|
161
|
+
if (system) body.system = system;
|
|
162
|
+
|
|
163
|
+
const res = await fetchWithRetry(
|
|
164
|
+
`${ANTHROPIC_API}/messages`,
|
|
165
|
+
{ method: 'POST', headers: getHeaders(), body: JSON.stringify(body) },
|
|
166
|
+
signal
|
|
167
|
+
);
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
const errBody = await res.text().catch(() => '');
|
|
170
|
+
throw new Error(`Anthropic API error ${res.status} ${res.statusText}${errBody ? ': ' + errBody : ''}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const reader = res.body.getReader();
|
|
174
|
+
const decoder = new TextDecoder();
|
|
175
|
+
let buffer = '';
|
|
176
|
+
let fullText = '';
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
while (true) {
|
|
180
|
+
if (signal?.aborted) { reader.cancel(); break; }
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done) break;
|
|
183
|
+
buffer += decoder.decode(value, { stream: true });
|
|
184
|
+
const lines = buffer.split('\n');
|
|
185
|
+
buffer = lines.pop() || '';
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (!line.startsWith('data: ')) continue;
|
|
188
|
+
const data = line.slice(6).trim();
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(data);
|
|
191
|
+
if (parsed.type === 'content_block_delta' && parsed.delta?.type === 'text_delta') {
|
|
192
|
+
fullText += parsed.delta.text;
|
|
193
|
+
onToken(parsed.delta.text);
|
|
194
|
+
}
|
|
195
|
+
} catch (_) {}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
reader.releaseLock();
|
|
200
|
+
}
|
|
201
|
+
return fullText;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Chat with tools (non-streaming). Returns { message } with message.content and optional message.tool_calls.
|
|
206
|
+
*/
|
|
207
|
+
export async function chatWithTools(messages, tools, model = DEFAULT_MODEL, signal = null) {
|
|
208
|
+
const { system, messages: claudeMessages } = toClaudeMessages(messages);
|
|
209
|
+
const claudeTools = toClaudeTools(tools);
|
|
210
|
+
const body = { model, max_tokens: 8096, messages: claudeMessages, temperature: 0.2 };
|
|
211
|
+
if (system) body.system = system;
|
|
212
|
+
if (claudeTools) body.tools = claudeTools;
|
|
213
|
+
|
|
214
|
+
const res = await fetchWithRetry(
|
|
215
|
+
`${ANTHROPIC_API}/messages`,
|
|
216
|
+
{ method: 'POST', headers: getHeaders(), body: JSON.stringify(body) },
|
|
217
|
+
signal
|
|
218
|
+
);
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
const errBody = await res.text().catch(() => '');
|
|
221
|
+
throw new Error(`Anthropic API error ${res.status} ${res.statusText}${errBody ? ': ' + errBody : ''}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const data = await res.json();
|
|
225
|
+
const { text, toolCalls } = contentToCliFormat(data.content);
|
|
226
|
+
return {
|
|
227
|
+
message: {
|
|
228
|
+
role: 'assistant',
|
|
229
|
+
content: text,
|
|
230
|
+
tool_calls: toolCalls ?? undefined,
|
|
231
|
+
},
|
|
232
|
+
usage: data.usage ?? null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Stream chat with tools. Returns { content, toolCalls } where toolCalls are in OpenAI format for compatibility.
|
|
238
|
+
*/
|
|
239
|
+
export async function streamChatWithTools(messages, tools, model = DEFAULT_MODEL, callbacks = {}, signal = null) {
|
|
240
|
+
const onContent = callbacks.onContent;
|
|
241
|
+
const { system, messages: claudeMessages } = toClaudeMessages(messages);
|
|
242
|
+
const claudeTools = toClaudeTools(tools);
|
|
243
|
+
const body = { model, max_tokens: 8096, messages: claudeMessages, stream: true, temperature: 0.2 };
|
|
244
|
+
if (system) body.system = system;
|
|
245
|
+
if (claudeTools) body.tools = claudeTools;
|
|
246
|
+
|
|
247
|
+
const res = await fetchWithRetry(
|
|
248
|
+
`${ANTHROPIC_API}/messages`,
|
|
249
|
+
{ method: 'POST', headers: getHeaders(), body: JSON.stringify(body) },
|
|
250
|
+
signal
|
|
251
|
+
);
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
const errBody = await res.text().catch(() => '');
|
|
254
|
+
throw new Error(`Anthropic API error ${res.status} ${res.statusText}${errBody ? ': ' + errBody : ''}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const reader = res.body.getReader();
|
|
258
|
+
const decoder = new TextDecoder();
|
|
259
|
+
let buffer = '';
|
|
260
|
+
let fullContent = '';
|
|
261
|
+
const blocks = {}; // index -> { type, id?, name?, text?, inputJson? }
|
|
262
|
+
let usage = null;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
while (true) {
|
|
266
|
+
if (signal?.aborted) { reader.cancel(); break; }
|
|
267
|
+
const { done, value } = await reader.read();
|
|
268
|
+
if (done) break;
|
|
269
|
+
buffer += decoder.decode(value, { stream: true });
|
|
270
|
+
const lines = buffer.split('\n');
|
|
271
|
+
buffer = lines.pop() || '';
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
if (!line.startsWith('data: ')) continue;
|
|
274
|
+
const data = line.slice(6).trim();
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(data);
|
|
277
|
+
if (parsed.type === 'message_delta' && parsed.usage) usage = parsed.usage;
|
|
278
|
+
if (parsed.type === 'content_block_start') {
|
|
279
|
+
const { index, content_block: cb } = parsed;
|
|
280
|
+
blocks[index] = { type: cb.type };
|
|
281
|
+
if (cb.type === 'tool_use') {
|
|
282
|
+
blocks[index].id = cb.id;
|
|
283
|
+
blocks[index].name = cb.name;
|
|
284
|
+
blocks[index].inputJson = '';
|
|
285
|
+
} else if (cb.type === 'text') {
|
|
286
|
+
blocks[index].text = '';
|
|
287
|
+
}
|
|
288
|
+
} else if (parsed.type === 'content_block_delta') {
|
|
289
|
+
const { index, delta } = parsed;
|
|
290
|
+
if (!blocks[index]) continue;
|
|
291
|
+
if (delta.type === 'text_delta') {
|
|
292
|
+
blocks[index].text = (blocks[index].text || '') + delta.text;
|
|
293
|
+
fullContent += delta.text;
|
|
294
|
+
if (onContent) onContent(delta.text);
|
|
295
|
+
} else if (delta.type === 'input_json_delta') {
|
|
296
|
+
blocks[index].inputJson = (blocks[index].inputJson || '') + delta.partial_json;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (_) {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
reader.releaseLock();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const toolCalls = Object.keys(blocks)
|
|
307
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
308
|
+
.filter((k) => blocks[k].type === 'tool_use')
|
|
309
|
+
.map((k) => {
|
|
310
|
+
const b = blocks[k];
|
|
311
|
+
return { id: b.id, type: 'function', function: { name: b.name, arguments: b.inputJson || '{}' } };
|
|
312
|
+
})
|
|
313
|
+
.filter((tc) => tc.id || tc.function?.name);
|
|
314
|
+
|
|
315
|
+
return { content: fullContent, toolCalls: toolCalls.length > 0 ? toolCalls : null, usage };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const ANTHROPIC_MODEL = DEFAULT_MODEL;
|