markov-cli 1.0.10 → 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 +348 -599
- 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/src/interactive.js
CHANGED
|
@@ -1,550 +1,86 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
3
|
import gradient from 'gradient-string';
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
4
5
|
import { homedir } from 'os';
|
|
5
6
|
import { resolve } from 'path';
|
|
6
|
-
import { mkdirSync, writeFileSync } from 'fs';
|
|
7
7
|
import { printLogo } from './ui/logo.js';
|
|
8
|
-
import { chatWithTools, streamChat, MODEL,
|
|
8
|
+
import { chatWithTools, streamChat, streamChatWithTools, MODEL, MODEL_OPTIONS, setModelAndProvider, getModelDisplayName } from './ollama.js';
|
|
9
9
|
import { resolveFileRefs } from './files.js';
|
|
10
|
-
import {
|
|
11
|
-
import { parseEdits, renderDiff, applyEdit } from './editor.js';
|
|
10
|
+
import { RUN_TERMINAL_COMMAND_TOOL, WEB_SEARCH_TOOL, runTool } from './tools.js';
|
|
12
11
|
import { chatPrompt } from './input.js';
|
|
13
|
-
import {
|
|
14
|
-
import { getToken, login, clearToken } from './auth.js';
|
|
12
|
+
import { getFilesAndDirs } from './ui/picker.js';
|
|
13
|
+
import { getToken, login, clearToken, getClaudeKey, setClaudeKey, getOpenAIKey, setOpenAIKey } from './auth.js';
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return new Promise((resolve) => {
|
|
21
|
-
let idx = 0;
|
|
22
|
-
|
|
23
|
-
const draw = () => {
|
|
24
|
-
process.stdout.write('\r\x1b[0J');
|
|
25
|
-
process.stdout.write(chalk.dim(label) + '\n');
|
|
26
|
-
options.forEach((o, i) => {
|
|
27
|
-
process.stdout.write(
|
|
28
|
-
i === idx
|
|
29
|
-
? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
|
|
30
|
-
: ' ' + chalk.dim(o) + '\n'
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
// Move cursor back up to keep it stable
|
|
34
|
-
process.stdout.write(`\x1b[${options.length + 1}A`);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const cleanup = () => {
|
|
38
|
-
process.stdin.removeListener('data', onKey);
|
|
39
|
-
process.stdin.setRawMode(false);
|
|
40
|
-
process.stdin.pause();
|
|
41
|
-
// Clear the drawn lines
|
|
42
|
-
process.stdout.write('\r\x1b[0J');
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const onKey = (data) => {
|
|
46
|
-
const key = data.toString();
|
|
47
|
-
if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
|
|
48
|
-
if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
|
|
49
|
-
if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
|
|
50
|
-
if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
process.stdin.setRawMode(true);
|
|
54
|
-
process.stdin.resume();
|
|
55
|
-
process.stdin.setEncoding('utf8');
|
|
56
|
-
process.stdin.on('data', onKey);
|
|
57
|
-
draw();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Prompt y/n in raw mode, returns true for y/Y. */
|
|
62
|
-
function confirm(question) {
|
|
63
|
-
return new Promise((resolve) => {
|
|
64
|
-
process.stdout.write(question);
|
|
65
|
-
process.stdin.setRawMode(true);
|
|
66
|
-
process.stdin.resume();
|
|
67
|
-
process.stdin.setEncoding('utf8');
|
|
68
|
-
const onKey = (key) => {
|
|
69
|
-
process.stdin.removeListener('data', onKey);
|
|
70
|
-
process.stdin.setRawMode(false);
|
|
71
|
-
process.stdin.pause();
|
|
72
|
-
const answer = key.toLowerCase() === 'y';
|
|
73
|
-
process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
|
|
74
|
-
resolve(answer);
|
|
75
|
-
};
|
|
76
|
-
process.stdin.on('data', onKey);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Read a visible line of input. */
|
|
81
|
-
function promptLine(label) {
|
|
82
|
-
return new Promise((resolve) => {
|
|
83
|
-
process.stdout.write(label);
|
|
84
|
-
let buf = '';
|
|
85
|
-
const onData = (data) => {
|
|
86
|
-
const key = data.toString();
|
|
87
|
-
if (key === '\r' || key === '\n') {
|
|
88
|
-
process.stdin.removeListener('data', onData);
|
|
89
|
-
process.stdin.setRawMode(false);
|
|
90
|
-
process.stdin.pause();
|
|
91
|
-
process.stdout.write('\n');
|
|
92
|
-
resolve(buf);
|
|
93
|
-
} else if (key === '\x7f' || key === '\b') {
|
|
94
|
-
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
|
|
95
|
-
} else if (key >= ' ') {
|
|
96
|
-
buf += key;
|
|
97
|
-
process.stdout.write(key);
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
process.stdin.setRawMode(true);
|
|
101
|
-
process.stdin.resume();
|
|
102
|
-
process.stdin.setEncoding('utf8');
|
|
103
|
-
process.stdin.on('data', onData);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Read a line of input without echo (for passwords). */
|
|
108
|
-
function promptSecret(label) {
|
|
109
|
-
return new Promise((resolve) => {
|
|
110
|
-
process.stdout.write(label);
|
|
111
|
-
let buf = '';
|
|
112
|
-
const onData = (data) => {
|
|
113
|
-
const key = data.toString();
|
|
114
|
-
if (key === '\r' || key === '\n') {
|
|
115
|
-
process.stdin.removeListener('data', onData);
|
|
116
|
-
process.stdin.setRawMode(false);
|
|
117
|
-
process.stdin.pause();
|
|
118
|
-
process.stdout.write('\n');
|
|
119
|
-
resolve(buf);
|
|
120
|
-
} else if (key === '\x7f' || key === '\b') {
|
|
121
|
-
if (buf.length > 0) buf = buf.slice(0, -1);
|
|
122
|
-
} else if (key >= ' ') {
|
|
123
|
-
buf += key;
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
process.stdin.setRawMode(true);
|
|
127
|
-
process.stdin.resume();
|
|
128
|
-
process.stdin.setEncoding('utf8');
|
|
129
|
-
process.stdin.on('data', onData);
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const TERM_WIDTH = 80;
|
|
134
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
135
|
-
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
136
|
-
|
|
137
|
-
/** Word-wrap plain text to a given column width, preserving existing newlines. */
|
|
138
|
-
function wrapText(text, width) {
|
|
139
|
-
return text.split('\n').map(line => {
|
|
140
|
-
if (visLen(line) <= width) return line;
|
|
141
|
-
const words = line.split(' ');
|
|
142
|
-
const wrapped = [];
|
|
143
|
-
let current = '';
|
|
144
|
-
for (const word of words) {
|
|
145
|
-
const test = current ? current + ' ' + word : word;
|
|
146
|
-
if (visLen(test) <= width) {
|
|
147
|
-
current = test;
|
|
148
|
-
} else {
|
|
149
|
-
if (current) wrapped.push(current);
|
|
150
|
-
current = word;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (current) wrapped.push(current);
|
|
154
|
-
return wrapped.join('\n');
|
|
155
|
-
}).join('\n');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Extract numbered options (1. foo 2. bar 3. baz) from AI response text. Returns display strings or []. */
|
|
159
|
-
function extractNumberedOptions(text) {
|
|
160
|
-
if (!text) return [];
|
|
161
|
-
const re = /^\s*(\d+)\.\s+(.+)$/gm;
|
|
162
|
-
const items = [];
|
|
163
|
-
let match;
|
|
164
|
-
while ((match = re.exec(text)) !== null) {
|
|
165
|
-
items.push({ num: parseInt(match[1], 10), label: match[2].trim() });
|
|
166
|
-
}
|
|
167
|
-
let best = [];
|
|
168
|
-
for (let i = 0; i < items.length; i++) {
|
|
169
|
-
if (items[i].num !== 1) continue;
|
|
170
|
-
const run = [items[i]];
|
|
171
|
-
for (let j = i + 1; j < items.length; j++) {
|
|
172
|
-
if (items[j].num === run.length + 1) run.push(items[j]);
|
|
173
|
-
else break;
|
|
174
|
-
}
|
|
175
|
-
if (run.length > best.length) best = run;
|
|
176
|
-
}
|
|
177
|
-
return best.length >= 2 ? best.map(r => `${r.num}. ${r.label}`) : [];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
|
|
181
|
-
function formatResponseWithCodeBlocks(text, width) {
|
|
182
|
-
if (!text || typeof text !== 'string') return '';
|
|
183
|
-
const re = /```(\w*)\n([\s\S]*?)```/g;
|
|
184
|
-
const parts = [];
|
|
185
|
-
let lastIndex = 0;
|
|
186
|
-
let m;
|
|
187
|
-
while ((m = re.exec(text)) !== null) {
|
|
188
|
-
if (m.index > lastIndex) {
|
|
189
|
-
const textSegment = text.slice(lastIndex, m.index);
|
|
190
|
-
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
191
|
-
}
|
|
192
|
-
parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
|
|
193
|
-
lastIndex = re.lastIndex;
|
|
194
|
-
}
|
|
195
|
-
if (lastIndex < text.length) {
|
|
196
|
-
const textSegment = text.slice(lastIndex);
|
|
197
|
-
if (textSegment) parts.push({ type: 'text', content: textSegment });
|
|
198
|
-
}
|
|
199
|
-
if (parts.length === 0) return wrapText(text, width);
|
|
200
|
-
return parts.map((p) => {
|
|
201
|
-
if (p.type === 'text') return wrapText(p.content, width);
|
|
202
|
-
const label = p.lang ? p.lang : 'code';
|
|
203
|
-
const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
|
|
204
|
-
const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
|
|
205
|
-
return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
|
|
206
|
-
}).join('\n\n');
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Scan AI response text for fenced code blocks that reference files.
|
|
211
|
-
* Show a diff preview for each and apply on user confirmation.
|
|
212
|
-
* @param {string} responseText - The AI's response content
|
|
213
|
-
* @param {string[]} loadedFiles - Files attached via @ref
|
|
214
|
-
*/
|
|
215
|
-
async function applyCodeBlockEdits(responseText, loadedFiles = []) {
|
|
216
|
-
const edits = parseEdits(responseText, loadedFiles);
|
|
217
|
-
if (edits.length === 0) return;
|
|
218
|
-
|
|
219
|
-
console.log(chalk.dim(`\n📝 Detected ${edits.length} file edit(s) in response:\n`));
|
|
220
|
-
|
|
221
|
-
for (const edit of edits) {
|
|
222
|
-
renderDiff(edit.filepath, edit.content);
|
|
223
|
-
const ok = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(edit.filepath)}? [y/N] `));
|
|
224
|
-
if (ok) {
|
|
225
|
-
applyEdit(edit.filepath, edit.content);
|
|
226
|
-
console.log(chalk.green(` ✓ ${edit.filepath} updated\n`));
|
|
227
|
-
} else {
|
|
228
|
-
console.log(chalk.dim(` ✗ skipped ${edit.filepath}\n`));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Run `ls -la` in cwd and return a context string to prepend to user messages. */
|
|
234
|
-
async function getLsContext(cwd = process.cwd()) {
|
|
235
|
-
try {
|
|
236
|
-
const { stdout, stderr, exitCode } = await execCommand('ls -la', cwd);
|
|
237
|
-
const out = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
238
|
-
if (exitCode === 0 && out) {
|
|
239
|
-
return `[Current directory: ${cwd}]\n$ ls -la\n${out}\n\n`;
|
|
240
|
-
}
|
|
241
|
-
} catch (_) {}
|
|
242
|
-
return `[Current directory: ${cwd}]\n(listing unavailable)\n\n`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const GREP_DEFAULT_PATTERN = 'function |class |export |def ';
|
|
246
|
-
const GREP_MAX_LINES_DEFAULT = 400;
|
|
247
|
-
const GREP_INCLUDE = "--include='*.js' --include='*.ts' --include='*.jsx' --include='*.tsx' --include='*.py'";
|
|
248
|
-
/** Portable: filter paths with grep -v (BSD grep may not support --exclude-dir). */
|
|
249
|
-
const GREP_FILTER_PATHS = "| grep -v '/node_modules/' | grep -v '/.git/' | grep -v '/.next/' | grep -v '/dist/' | grep -v '/build/' | grep -v '/coverage/'";
|
|
250
|
-
|
|
251
|
-
/** Safe chars for MARKOV_GREP_PATTERN (no shell metacharacters). */
|
|
252
|
-
function isValidGrepPattern(p) {
|
|
253
|
-
if (typeof p !== 'string' || p.length > 200) return false;
|
|
254
|
-
return /^[a-zA-Z0-9 \|\.\-_\\\[\]\(\)\?\+\*]+$/.test(p);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Run grep for key definitions; return context block or empty string. Only runs when MARKOV_GREP_CONTEXT=1. */
|
|
258
|
-
async function getGrepContext(cwd = process.cwd()) {
|
|
259
|
-
if (!process.env.MARKOV_GREP_CONTEXT) return '';
|
|
260
|
-
|
|
261
|
-
const rawPattern = process.env.MARKOV_GREP_PATTERN;
|
|
262
|
-
const pattern = (rawPattern && isValidGrepPattern(rawPattern.trim())) ? rawPattern.trim() : GREP_DEFAULT_PATTERN;
|
|
263
|
-
const maxLines = Math.min(2000, Math.max(1, parseInt(process.env.MARKOV_GREP_MAX_LINES, 10) || GREP_MAX_LINES_DEFAULT));
|
|
264
|
-
const escaped = pattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
265
|
-
const cmd = `grep -rn -E "${escaped}" ${GREP_INCLUDE} . 2>/dev/null ${GREP_FILTER_PATHS} | head -n ${maxLines}`;
|
|
266
|
-
|
|
267
|
-
try {
|
|
268
|
-
const { stdout, exitCode } = await execCommand(cmd, cwd);
|
|
269
|
-
const out = (stdout || '').trim();
|
|
270
|
-
if (exitCode === 0 && out) {
|
|
271
|
-
return `[Grep: key definitions in repo]\n$ grep -rn -E '${pattern}' ...\n${out}\n\n`;
|
|
272
|
-
}
|
|
273
|
-
} catch (_) {}
|
|
274
|
-
return `[Grep: key definitions in repo]\n(grep context unavailable)\n\n`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Shared system message base: Markov intro, cwd, file list. */
|
|
278
|
-
function getSystemMessageBase() {
|
|
279
|
-
const files = getFiles();
|
|
280
|
-
const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
|
|
281
|
-
return `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** System message for /plan: output a step-by-step plan only (no tools). */
|
|
285
|
-
function buildPlanSystemMessage() {
|
|
286
|
-
return {
|
|
287
|
-
role: 'system',
|
|
288
|
-
content: getSystemMessageBase() +
|
|
289
|
-
'\nOutput a clear, step-by-step plan only. Do not run any commands or edit files—just describe the steps. No tools.',
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** System message for agent mode: tool-only instructions (no !!run / !!write). */
|
|
294
|
-
function buildAgentSystemMessage() {
|
|
295
|
-
const toolInstructions =
|
|
296
|
-
`\nTOOL MODE — you have tools; use them. \n` +
|
|
297
|
-
`- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). One command per call.\n` +
|
|
298
|
-
`- create_folder: create directories.\n` +
|
|
299
|
-
`- read_file: read file contents before editing.\n` +
|
|
300
|
-
`- write_file: create or overwrite a file with full content.\n` +
|
|
301
|
-
`- search_replace: replace first occurrence of text in a file.\n` +
|
|
302
|
-
`- delete_file: delete a file.\n` +
|
|
303
|
-
`- list_dir: list directory contents (path optional, defaults to current dir).\n` +
|
|
304
|
-
`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` +
|
|
305
|
-
`You MUST ALWAYS use write_file or search_replace for any file modification, creation, or fix. Never paste full file contents directly in your reply.\n` +
|
|
306
|
-
`All file operations must use RELATIVE paths.\n` +
|
|
307
|
-
`Do not output modified file contents in chat — apply changes through tool calls only.\n`;
|
|
308
|
-
return { role: 'system', content: getSystemMessageBase() + toolInstructions };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const AGENT_LOOP_MAX_ITERATIONS = 20;
|
|
312
|
-
|
|
313
|
-
/** If MARKOV_DEBUG is set, print the full message payload (system + conversation) before sending. */
|
|
314
|
-
function maybePrintFullPayload(messages) {
|
|
315
|
-
if (!process.env.MARKOV_DEBUG) return;
|
|
316
|
-
const payload = messages.map((m) => ({
|
|
317
|
-
role: m.role,
|
|
318
|
-
...(m.tool_calls && { tool_calls: m.tool_calls.length }),
|
|
319
|
-
...(m.tool_name && { tool_name: m.tool_name }),
|
|
320
|
-
content: typeof m.content === 'string' ? m.content : '(binary/object)',
|
|
321
|
-
}));
|
|
322
|
-
console.log(chalk.dim('\n--- MARKOV_DEBUG: full payload sent to API ---'));
|
|
323
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
324
|
-
console.log(chalk.dim('--- end payload ---\n'));
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** Preview of a file edit for confirmation (write_file / search_replace) */
|
|
328
|
-
function formatFileEditPreview(name, args) {
|
|
329
|
-
const path = args?.path ?? '(no path)';
|
|
330
|
-
if (name === 'search_replace') {
|
|
331
|
-
const oldStr = String(args?.old_string ?? '');
|
|
332
|
-
const newStr = String(args?.new_string ?? '');
|
|
333
|
-
const max = 120;
|
|
334
|
-
const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
|
|
335
|
-
const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
|
|
336
|
-
return (
|
|
337
|
-
chalk.cyan(path) + '\n' +
|
|
338
|
-
chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
|
|
339
|
-
chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
if (name === 'write_file') {
|
|
343
|
-
const content = String(args?.content ?? '');
|
|
344
|
-
const lines = content.split('\n');
|
|
345
|
-
const previewLines = lines.slice(0, 25);
|
|
346
|
-
const more = lines.length > 25 ? chalk.dim(` ... ${lines.length - 25} more lines`) : '';
|
|
347
|
-
return chalk.cyan(path) + '\n' + previewLines.map(l => ' ' + l).join('\n') + (more ? '\n' + more : '');
|
|
348
|
-
}
|
|
349
|
-
return path;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** One-line summary of tool args for display */
|
|
353
|
-
function formatToolCallSummary(name, args) {
|
|
354
|
-
const a = args ?? {};
|
|
355
|
-
if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
|
|
356
|
-
if (name === 'write_file') return a.path ?? '(no path)';
|
|
357
|
-
if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
|
|
358
|
-
if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
|
|
359
|
-
if (name === 'create_folder') return a.path ?? '(no path)';
|
|
360
|
-
if (name === 'list_dir') return (a.path ?? '.') || '.';
|
|
361
|
-
return JSON.stringify(a).slice(0, 50);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** One-line summary of tool result for display */
|
|
365
|
-
function formatToolResultSummary(name, resultJson) {
|
|
366
|
-
let obj;
|
|
367
|
-
try {
|
|
368
|
-
obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
|
|
369
|
-
} catch {
|
|
370
|
-
return resultJson?.slice(0, 60) ?? '—';
|
|
371
|
-
}
|
|
372
|
-
if (obj.error) return chalk.red('✗ ' + obj.error);
|
|
373
|
-
if (obj.declined) return chalk.yellow('✗ declined');
|
|
374
|
-
if (name === 'run_terminal_command') {
|
|
375
|
-
const code = obj.exitCode ?? obj.exit_code;
|
|
376
|
-
if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
|
|
377
|
-
return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
|
|
378
|
-
}
|
|
379
|
-
if (name === 'write_file' || name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
|
|
380
|
-
return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
|
|
381
|
-
}
|
|
382
|
-
if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
|
|
383
|
-
if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
|
|
384
|
-
return chalk.dim(JSON.stringify(obj).slice(0, 60));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
|
|
389
|
-
* For write_file and search_replace, confirmFileEdit is called first; if it returns false, the change is skipped and the model is told the user declined.
|
|
390
|
-
* @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
|
|
391
|
-
* @param {{ signal?: AbortSignal; cwd?: 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
|
|
392
|
-
* @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
|
|
393
|
-
*/
|
|
394
|
-
async function runAgentLoop(messages, opts = {}) {
|
|
395
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
396
|
-
const confirmFn = opts.confirmFn;
|
|
397
|
-
const confirmFileEdit = opts.confirmFileEdit;
|
|
398
|
-
const onBeforeToolRun = opts.onBeforeToolRun;
|
|
399
|
-
const onToolCall = opts.onToolCall;
|
|
400
|
-
const onToolResult = opts.onToolResult;
|
|
401
|
-
const onIteration = opts.onIteration;
|
|
402
|
-
const onThinking = opts.onThinking;
|
|
403
|
-
let iteration = 0;
|
|
404
|
-
|
|
405
|
-
while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
|
|
406
|
-
iteration += 1;
|
|
407
|
-
onThinking?.(iteration);
|
|
408
|
-
const data = await chatWithTools(messages, AGENT_TOOLS, MODEL, opts.signal ?? null);
|
|
15
|
+
// Extracted UI modules
|
|
16
|
+
import { selectFrom, confirm, promptLine, promptSecret } from './ui/prompts.js';
|
|
17
|
+
import { formatResponseWithCodeBlocks, formatFileEditPreview, formatToolCallSummary, formatToolResultSummary, printTokenUsage } from './ui/formatting.js';
|
|
18
|
+
import { createSpinner } from './ui/spinner.js';
|
|
409
19
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const toolCalls = message.tool_calls;
|
|
416
|
-
if (!toolCalls || toolCalls.length === 0) {
|
|
417
|
-
return { content: message.content ?? '', finalMessage: message };
|
|
418
|
-
}
|
|
20
|
+
// Extracted agent modules
|
|
21
|
+
import { buildPlanSystemMessage, buildAgentSystemMessage, buildInitSystemMessage, getLsContext, getGrepContext } from './agent/context.js';
|
|
22
|
+
import { runAgentLoop, maybePrintFullPayload, AGENT_LOOP_MAX_ITERATIONS } from './agent/agentLoop.js';
|
|
419
23
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
role: 'assistant',
|
|
423
|
-
content: message.content ?? '',
|
|
424
|
-
tool_calls: toolCalls,
|
|
425
|
-
});
|
|
24
|
+
// Extracted editor modules
|
|
25
|
+
import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
|
|
426
26
|
|
|
427
|
-
|
|
428
|
-
|
|
27
|
+
// Extracted command modules
|
|
28
|
+
import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS } from './commands/setup.js';
|
|
429
29
|
|
|
430
|
-
|
|
431
|
-
const name = tc?.function?.name;
|
|
432
|
-
const rawArgs = tc?.function?.arguments;
|
|
433
|
-
let args = rawArgs;
|
|
434
|
-
if (typeof rawArgs === 'string') {
|
|
435
|
-
try {
|
|
436
|
-
args = JSON.parse(rawArgs);
|
|
437
|
-
} catch {
|
|
438
|
-
messages.push({
|
|
439
|
-
role: 'tool',
|
|
440
|
-
tool_name: name ?? 'unknown',
|
|
441
|
-
content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
|
|
442
|
-
});
|
|
443
|
-
if (onToolCall) onToolCall(name ?? 'unknown', {});
|
|
444
|
-
if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
30
|
+
const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
|
|
448
31
|
|
|
449
|
-
|
|
32
|
+
const PLAN_FILE = 'plan.md';
|
|
33
|
+
const getPlanPath = () => resolve(process.cwd(), PLAN_FILE);
|
|
450
34
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (isFileEdit && confirmFileEdit) {
|
|
454
|
-
const ok = await confirmFileEdit(name, args ?? {});
|
|
455
|
-
if (!ok) {
|
|
456
|
-
result = JSON.stringify({ declined: true, message: 'User declined the change' });
|
|
457
|
-
if (onToolResult) onToolResult(name ?? 'unknown', result);
|
|
458
|
-
messages.push({
|
|
459
|
-
role: 'tool',
|
|
460
|
-
tool_name: name ?? 'unknown',
|
|
461
|
-
content: result,
|
|
462
|
-
});
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
35
|
+
const MARKOV_FILE = 'markov.md';
|
|
36
|
+
const getMarkovPath = () => resolve(process.cwd(), MARKOV_FILE);
|
|
466
37
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
messages.push({
|
|
470
|
-
role: 'tool',
|
|
471
|
-
tool_name: name ?? 'unknown',
|
|
472
|
-
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
}
|
|
38
|
+
/** Tools allowed during /plan and /yolo plan phase: web search + run command for research only (e.g. ls, cat). */
|
|
39
|
+
const PLAN_YOLO_TOOLS = [WEB_SEARCH_TOOL, RUN_TERMINAL_COMMAND_TOOL];
|
|
476
40
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
41
|
+
/** Short intro shown on first load; /intro re-displays this. */
|
|
42
|
+
const INTRO_TEXT =
|
|
43
|
+
'\n' +
|
|
44
|
+
chalk.bold('Quick start:\n') +
|
|
45
|
+
chalk.cyan(' /help') + chalk.dim(' show all commands\n') +
|
|
46
|
+
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
47
|
+
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
48
|
+
chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
|
|
49
|
+
chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
|
|
50
|
+
chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
|
|
51
|
+
chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
|
|
52
|
+
chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
|
|
482
53
|
|
|
483
54
|
const HELP_TEXT =
|
|
484
55
|
'\n' +
|
|
485
56
|
chalk.bold('Commands:\n') +
|
|
57
|
+
chalk.cyan(' /intro') + chalk.dim(' show quick start (same as on first load)\n') +
|
|
486
58
|
chalk.cyan(' /help') + chalk.dim(' show this help\n') +
|
|
487
59
|
chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
|
|
60
|
+
chalk.cyan(' /setup-tanstack') + chalk.dim(' scaffold a TanStack Start app\n') +
|
|
488
61
|
chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
|
|
489
62
|
chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
|
|
490
63
|
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
491
64
|
chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
|
|
492
65
|
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
493
66
|
chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
|
|
67
|
+
chalk.cyan(' /clear') + chalk.dim(' clear chat history and stored plan\n') +
|
|
68
|
+
chalk.cyan(' /env') + chalk.dim(' show which .env vars are loaded (for debugging)\n') +
|
|
494
69
|
chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n') +
|
|
495
|
-
chalk.cyan(' /
|
|
496
|
-
chalk.cyan(' /
|
|
70
|
+
chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
|
|
71
|
+
chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
|
|
72
|
+
chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
|
|
497
73
|
chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
|
|
498
74
|
chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
|
|
499
75
|
|
|
500
|
-
/**
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
try {
|
|
509
|
-
mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
|
|
510
|
-
console.log(chalk.green(` ✓ created ${step.path}\n`));
|
|
511
|
-
} catch (err) {
|
|
512
|
-
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
513
|
-
return false;
|
|
514
|
-
}
|
|
515
|
-
} else if (step.type === 'cd') {
|
|
516
|
-
try {
|
|
517
|
-
process.chdir(resolve(process.cwd(), step.path));
|
|
518
|
-
console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
|
|
519
|
-
} catch (err) {
|
|
520
|
-
console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
|
|
521
|
-
return false;
|
|
522
|
-
}
|
|
523
|
-
} else if (step.type === 'run') {
|
|
524
|
-
process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
|
|
525
|
-
const { stdout, stderr, exitCode } = await execCommand(step.cmd);
|
|
526
|
-
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
527
|
-
if (output) console.log(chalk.dim(output));
|
|
528
|
-
if (exitCode !== 0) {
|
|
529
|
-
console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
532
|
-
console.log(chalk.green(` ✓ done\n`));
|
|
533
|
-
} else if (step.type === 'write') {
|
|
534
|
-
process.stdout.write(chalk.dim(` write: ${step.path}\n`));
|
|
535
|
-
try {
|
|
536
|
-
const abs = resolve(process.cwd(), step.path);
|
|
537
|
-
const dir = abs.split('/').slice(0, -1).join('/');
|
|
538
|
-
mkdirSync(dir, { recursive: true });
|
|
539
|
-
writeFileSync(abs, step.content);
|
|
540
|
-
console.log(chalk.green(` ✓ wrote ${step.path}\n`));
|
|
541
|
-
} catch (err) {
|
|
542
|
-
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return true;
|
|
76
|
+
/** If MARKOV_DEBUG is set, print the raw model output after completion. */
|
|
77
|
+
function maybePrintRawModelOutput(rawText) {
|
|
78
|
+
if (!process.env.MARKOV_DEBUG || rawText == null) return;
|
|
79
|
+
const text = typeof rawText === 'string' ? rawText : String(rawText);
|
|
80
|
+
if (!text.trim()) return;
|
|
81
|
+
console.log(chalk.dim('\n--- MARKOV_DEBUG: raw model output ---'));
|
|
82
|
+
console.log(text);
|
|
83
|
+
console.log(chalk.dim('--- end raw output ---\n'));
|
|
548
84
|
}
|
|
549
85
|
|
|
550
86
|
export async function startInteractive() {
|
|
@@ -553,8 +89,8 @@ export async function startInteractive() {
|
|
|
553
89
|
let allFiles = getFilesAndDirs();
|
|
554
90
|
const chatMessages = [];
|
|
555
91
|
|
|
556
|
-
console.log(chalk.dim(`Chat with Markov (${
|
|
557
|
-
console.log(
|
|
92
|
+
console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
|
|
93
|
+
console.log(INTRO_TEXT);
|
|
558
94
|
|
|
559
95
|
if (!getToken()) {
|
|
560
96
|
console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
|
|
@@ -562,6 +98,7 @@ export async function startInteractive() {
|
|
|
562
98
|
|
|
563
99
|
let pendingMessage = null;
|
|
564
100
|
let lastPlan = null;
|
|
101
|
+
const inputHistory = [];
|
|
565
102
|
|
|
566
103
|
while (true) {
|
|
567
104
|
let raw;
|
|
@@ -570,7 +107,7 @@ export async function startInteractive() {
|
|
|
570
107
|
pendingMessage = null;
|
|
571
108
|
console.log(chalk.magenta('you> ') + raw + '\n');
|
|
572
109
|
} else {
|
|
573
|
-
raw = await chatPrompt(chalk.magenta('you> '), allFiles);
|
|
110
|
+
raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
|
|
574
111
|
}
|
|
575
112
|
if (raw === null) continue;
|
|
576
113
|
const trimmed = raw.trim();
|
|
@@ -597,6 +134,20 @@ export async function startInteractive() {
|
|
|
597
134
|
continue;
|
|
598
135
|
}
|
|
599
136
|
|
|
137
|
+
// /clear — clear chat history and stored plan
|
|
138
|
+
if (trimmed === '/clear') {
|
|
139
|
+
chatMessages.length = 0;
|
|
140
|
+
lastPlan = null;
|
|
141
|
+
console.log(chalk.green('✓ Chat and context cleared.\n'));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// /intro — show quick start (same as on first load)
|
|
146
|
+
if (trimmed === '/intro') {
|
|
147
|
+
console.log(INTRO_TEXT);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
600
151
|
// /help — list all commands
|
|
601
152
|
if (trimmed === '/help') {
|
|
602
153
|
console.log(HELP_TEXT);
|
|
@@ -615,6 +166,27 @@ export async function startInteractive() {
|
|
|
615
166
|
continue;
|
|
616
167
|
}
|
|
617
168
|
|
|
169
|
+
// /env — show which .env vars are loaded (for debugging)
|
|
170
|
+
if (trimmed === '/env') {
|
|
171
|
+
const mask = (v) => (v && v.length > 8 ? v.slice(0, 8) + '…' + v.slice(-4) : v ? '***' : null);
|
|
172
|
+
const vars = [
|
|
173
|
+
['ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY],
|
|
174
|
+
['ANTHROPIC_MODEL', process.env.ANTHROPIC_MODEL],
|
|
175
|
+
['OPENAI_API_KEY', process.env.OPENAI_API_KEY],
|
|
176
|
+
['OPENAI_MODEL', process.env.OPENAI_MODEL],
|
|
177
|
+
['MARKOV_SEARCH_API_KEY', process.env.MARKOV_SEARCH_API_KEY],
|
|
178
|
+
['MARKOV_DEBUG', process.env.MARKOV_DEBUG],
|
|
179
|
+
];
|
|
180
|
+
console.log(chalk.dim('\nEnvironment (from .env or shell):\n'));
|
|
181
|
+
for (const [name, val] of vars) {
|
|
182
|
+
const status = val ? chalk.green('set') : chalk.red('not set');
|
|
183
|
+
const preview = val ? chalk.dim(' ' + mask(val)) : '';
|
|
184
|
+
console.log(chalk.dim(' ') + name + chalk.dim(': ') + status + preview);
|
|
185
|
+
}
|
|
186
|
+
console.log('');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
618
190
|
// /plan [prompt] — stream a plan (no tools), store as lastPlan
|
|
619
191
|
if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
|
|
620
192
|
const rawUserContent = trimmed.startsWith('/plan ')
|
|
@@ -647,31 +219,69 @@ export async function startInteractive() {
|
|
|
647
219
|
}
|
|
648
220
|
};
|
|
649
221
|
try {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
process.stdout.write(
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
222
|
+
let currentPlanMessages = planMessages;
|
|
223
|
+
let fullPlanText = '';
|
|
224
|
+
const planMaxIter = 10;
|
|
225
|
+
for (let planIter = 0; planIter < planMaxIter; planIter++) {
|
|
226
|
+
const { content, toolCalls, usage } = await streamChatWithTools(
|
|
227
|
+
currentPlanMessages,
|
|
228
|
+
PLAN_YOLO_TOOLS,
|
|
229
|
+
MODEL,
|
|
230
|
+
{
|
|
231
|
+
think: true, // plan mode only: request thinking from backend
|
|
232
|
+
onContent: (token) => {
|
|
233
|
+
if (firstContent) {
|
|
234
|
+
clearPlanSpinner();
|
|
235
|
+
firstContent = false;
|
|
236
|
+
}
|
|
237
|
+
process.stdout.write(token);
|
|
238
|
+
},
|
|
239
|
+
onThinking: (token) => {
|
|
240
|
+
if (!thinkingStarted) {
|
|
241
|
+
clearPlanSpinner();
|
|
242
|
+
process.stdout.write(chalk.dim('Thinking: '));
|
|
243
|
+
thinkingStarted = true;
|
|
244
|
+
}
|
|
245
|
+
process.stdout.write(chalk.dim(token));
|
|
246
|
+
},
|
|
669
247
|
},
|
|
248
|
+
planAbort.signal,
|
|
249
|
+
null
|
|
250
|
+
);
|
|
251
|
+
fullPlanText = content ?? '';
|
|
252
|
+
if (!toolCalls?.length) {
|
|
253
|
+
printTokenUsage(usage);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
|
|
257
|
+
for (const tc of toolCalls) {
|
|
258
|
+
const name = tc?.function?.name ?? 'unknown';
|
|
259
|
+
let args = tc?.function?.arguments;
|
|
260
|
+
if (typeof args === 'string') {
|
|
261
|
+
try {
|
|
262
|
+
args = JSON.parse(args);
|
|
263
|
+
} catch {
|
|
264
|
+
currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
|
|
269
|
+
const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
|
|
270
|
+
currentPlanMessages.push({
|
|
271
|
+
role: 'tool',
|
|
272
|
+
tool_name: name,
|
|
273
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
274
|
+
});
|
|
670
275
|
}
|
|
671
|
-
|
|
276
|
+
}
|
|
672
277
|
chatMessages.push({ role: 'assistant', content: fullPlanText });
|
|
673
|
-
|
|
674
|
-
|
|
278
|
+
// Store only plan (stream content), not thinking; write to plan.md for /build.
|
|
279
|
+
if ((fullPlanText ?? '').trim()) {
|
|
280
|
+
lastPlan = fullPlanText;
|
|
281
|
+
writeFileSync(getPlanPath(), fullPlanText.trim(), 'utf-8');
|
|
282
|
+
}
|
|
283
|
+
console.log('\n' + chalk.dim('Plan saved to plan.md. Use ') + chalk.green('/build') + chalk.dim(' to execute.\n'));
|
|
284
|
+
maybePrintRawModelOutput(fullPlanText);
|
|
675
285
|
} catch (err) {
|
|
676
286
|
if (planSpinner) {
|
|
677
287
|
clearInterval(planSpinner);
|
|
@@ -700,31 +310,65 @@ export async function startInteractive() {
|
|
|
700
310
|
let thinkingStarted = false;
|
|
701
311
|
let fullPlanText = '';
|
|
702
312
|
try {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
313
|
+
let currentPlanMessages = planMessages;
|
|
314
|
+
const yoloPlanMaxIter = 10;
|
|
315
|
+
for (let planIter = 0; planIter < yoloPlanMaxIter; planIter++) {
|
|
316
|
+
const { content: yoloPlanContent, toolCalls, usage } = await streamChatWithTools(
|
|
317
|
+
currentPlanMessages,
|
|
318
|
+
PLAN_YOLO_TOOLS,
|
|
319
|
+
MODEL,
|
|
320
|
+
{
|
|
321
|
+
think: true, // plan phase: request thinking from backend
|
|
322
|
+
onContent: (token) => process.stdout.write(token),
|
|
323
|
+
onThinking: (token) => {
|
|
324
|
+
if (!thinkingStarted) {
|
|
325
|
+
process.stdout.write(chalk.dim('Thinking: '));
|
|
326
|
+
thinkingStarted = true;
|
|
327
|
+
}
|
|
328
|
+
process.stdout.write(chalk.dim(token));
|
|
329
|
+
},
|
|
715
330
|
},
|
|
331
|
+
yoloAbort.signal,
|
|
332
|
+
null
|
|
333
|
+
);
|
|
334
|
+
fullPlanText = yoloPlanContent ?? '';
|
|
335
|
+
if (!toolCalls?.length) {
|
|
336
|
+
printTokenUsage(usage);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
currentPlanMessages = [...currentPlanMessages, { role: 'assistant', content: yoloPlanContent ?? '', tool_calls: toolCalls }];
|
|
340
|
+
for (const tc of toolCalls) {
|
|
341
|
+
const name = tc?.function?.name ?? 'unknown';
|
|
342
|
+
let args = tc?.function?.arguments;
|
|
343
|
+
if (typeof args === 'string') {
|
|
344
|
+
try {
|
|
345
|
+
args = JSON.parse(args);
|
|
346
|
+
} catch {
|
|
347
|
+
currentPlanMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
|
|
352
|
+
const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
|
|
353
|
+
currentPlanMessages.push({
|
|
354
|
+
role: 'tool',
|
|
355
|
+
tool_name: name,
|
|
356
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
357
|
+
});
|
|
716
358
|
}
|
|
717
|
-
|
|
359
|
+
}
|
|
718
360
|
chatMessages.push({ role: 'assistant', content: fullPlanText });
|
|
719
|
-
|
|
361
|
+
// Store only plan (stream content), not thinking, so build phase uses exactly this.
|
|
362
|
+
if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
|
|
363
|
+
maybePrintRawModelOutput(fullPlanText);
|
|
720
364
|
} catch (err) {
|
|
721
365
|
if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
722
366
|
continue;
|
|
723
367
|
}
|
|
724
368
|
const buildContent = (await getLsContext()) + (await getGrepContext()) +
|
|
725
|
-
'
|
|
369
|
+
'\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
|
|
726
370
|
chatMessages.push({ role: 'user', content: buildContent });
|
|
727
|
-
const agentMessages = [buildAgentSystemMessage(),
|
|
371
|
+
const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
|
|
728
372
|
maybePrintFullPayload(agentMessages);
|
|
729
373
|
const abortController = new AbortController();
|
|
730
374
|
const confirmFn = () => Promise.resolve(true);
|
|
@@ -778,11 +422,7 @@ export async function startInteractive() {
|
|
|
778
422
|
await applyCodeBlockEdits(result.content, []);
|
|
779
423
|
allFiles = getFilesAndDirs();
|
|
780
424
|
console.log(chalk.green(`✓ Yolo done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
781
|
-
|
|
782
|
-
if (opts1.length >= 2) {
|
|
783
|
-
const chosen = await selectFrom(opts1, 'Select an option:');
|
|
784
|
-
if (chosen) pendingMessage = chosen;
|
|
785
|
-
}
|
|
425
|
+
maybePrintRawModelOutput(result.content);
|
|
786
426
|
}
|
|
787
427
|
} catch (err) {
|
|
788
428
|
stopSpinner();
|
|
@@ -791,16 +431,119 @@ export async function startInteractive() {
|
|
|
791
431
|
continue;
|
|
792
432
|
}
|
|
793
433
|
|
|
794
|
-
// /
|
|
434
|
+
// /init [prompt] — create markov.md with project summary (agent writes file via tools)
|
|
435
|
+
if (trimmed === '/init' || trimmed.startsWith('/init ')) {
|
|
436
|
+
const rawUserContent = trimmed.startsWith('/init ')
|
|
437
|
+
? trimmed.slice(6).trim()
|
|
438
|
+
: (await promptLine(chalk.bold('Describe the project to summarize (optional): '))).trim();
|
|
439
|
+
const userContent = (await getLsContext()) + (await getGrepContext()) +
|
|
440
|
+
(rawUserContent ? `Create markov.md with a project summary. Focus on: ${rawUserContent}` : 'Create markov.md with a concise project summary.');
|
|
441
|
+
const initMessages = [buildInitSystemMessage(), { role: 'user', content: userContent }];
|
|
442
|
+
const initAbort = new AbortController();
|
|
443
|
+
process.stdout.write(chalk.dim('\nInit › '));
|
|
444
|
+
const DOTS = ['.', '..', '...'];
|
|
445
|
+
let dotIdx = 0;
|
|
446
|
+
const initStartTime = Date.now();
|
|
447
|
+
let initSpinner = setInterval(() => {
|
|
448
|
+
const elapsed = ((Date.now() - initStartTime) / 1000).toFixed(1);
|
|
449
|
+
process.stdout.write('\r' + chalk.dim('Init › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
|
|
450
|
+
dotIdx++;
|
|
451
|
+
}, 400);
|
|
452
|
+
let thinkingStarted = false;
|
|
453
|
+
let firstContent = true;
|
|
454
|
+
const clearInitSpinner = () => {
|
|
455
|
+
if (initSpinner) {
|
|
456
|
+
clearInterval(initSpinner);
|
|
457
|
+
initSpinner = null;
|
|
458
|
+
process.stdout.write('\r\x1b[0J');
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
try {
|
|
462
|
+
let currentInitMessages = initMessages;
|
|
463
|
+
const initMaxIter = 10;
|
|
464
|
+
for (let initIter = 0; initIter < initMaxIter; initIter++) {
|
|
465
|
+
const { content, toolCalls, usage } = await streamChatWithTools(
|
|
466
|
+
currentInitMessages,
|
|
467
|
+
PLAN_YOLO_TOOLS,
|
|
468
|
+
MODEL,
|
|
469
|
+
{
|
|
470
|
+
think: true,
|
|
471
|
+
onContent: (token) => {
|
|
472
|
+
if (firstContent) {
|
|
473
|
+
clearInitSpinner();
|
|
474
|
+
firstContent = false;
|
|
475
|
+
}
|
|
476
|
+
process.stdout.write(token);
|
|
477
|
+
},
|
|
478
|
+
onThinking: (token) => {
|
|
479
|
+
if (!thinkingStarted) {
|
|
480
|
+
clearInitSpinner();
|
|
481
|
+
process.stdout.write(chalk.dim('Thinking: '));
|
|
482
|
+
thinkingStarted = true;
|
|
483
|
+
}
|
|
484
|
+
process.stdout.write(chalk.dim(token));
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
initAbort.signal,
|
|
488
|
+
null
|
|
489
|
+
);
|
|
490
|
+
if (!toolCalls?.length) {
|
|
491
|
+
printTokenUsage(usage);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
currentInitMessages = [...currentInitMessages, { role: 'assistant', content: content ?? '', tool_calls: toolCalls }];
|
|
495
|
+
for (const tc of toolCalls) {
|
|
496
|
+
const name = tc?.function?.name ?? 'unknown';
|
|
497
|
+
let args = tc?.function?.arguments;
|
|
498
|
+
if (typeof args === 'string') {
|
|
499
|
+
try {
|
|
500
|
+
args = JSON.parse(args);
|
|
501
|
+
} catch {
|
|
502
|
+
currentInitMessages.push({ role: 'tool', tool_name: name, content: JSON.stringify({ error: 'Invalid JSON in arguments' }) });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
|
|
507
|
+
const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
|
|
508
|
+
currentInitMessages.push({
|
|
509
|
+
role: 'tool',
|
|
510
|
+
tool_name: name,
|
|
511
|
+
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (existsSync(getMarkovPath())) {
|
|
516
|
+
console.log('\n' + chalk.green('✓ markov.md created. It will be included in the system message from now on.\n'));
|
|
517
|
+
} else {
|
|
518
|
+
console.log('\n' + chalk.yellow('Init finished but markov.md was not created. The agent may need another run or a clearer prompt.\n'));
|
|
519
|
+
}
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (initSpinner) {
|
|
522
|
+
clearInterval(initSpinner);
|
|
523
|
+
initSpinner = null;
|
|
524
|
+
process.stdout.write('\r\x1b[0J');
|
|
525
|
+
}
|
|
526
|
+
if (!initAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// /build — execute plan from plan.md only
|
|
795
532
|
if (trimmed === '/build') {
|
|
796
|
-
|
|
797
|
-
|
|
533
|
+
const planPath = getPlanPath();
|
|
534
|
+
if (!existsSync(planPath)) {
|
|
535
|
+
console.log(chalk.yellow('plan.md not found. Run /plan first to create a plan.\n'));
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const planToUse = readFileSync(planPath, 'utf-8').trim();
|
|
539
|
+
if (!planToUse) {
|
|
540
|
+
console.log(chalk.yellow('plan.md is empty. Run /plan first to create a plan.\n'));
|
|
798
541
|
continue;
|
|
799
542
|
}
|
|
800
543
|
const buildContent = (await getLsContext()) + (await getGrepContext()) +
|
|
801
|
-
'
|
|
544
|
+
'\n\nPlan:\n' + planToUse + '\n\nExecute this plan using your tools. Run commands and edit files as needed. ';
|
|
802
545
|
chatMessages.push({ role: 'user', content: buildContent });
|
|
803
|
-
const agentMessages = [buildAgentSystemMessage(),
|
|
546
|
+
const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
|
|
804
547
|
maybePrintFullPayload(agentMessages);
|
|
805
548
|
const abortController = new AbortController();
|
|
806
549
|
const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
|
|
@@ -859,11 +602,7 @@ export async function startInteractive() {
|
|
|
859
602
|
await applyCodeBlockEdits(result.content, []);
|
|
860
603
|
allFiles = getFilesAndDirs();
|
|
861
604
|
console.log(chalk.green(`✓ Build done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
862
|
-
|
|
863
|
-
if (opts1.length >= 2) {
|
|
864
|
-
const chosen = await selectFrom(opts1, 'Select an option:');
|
|
865
|
-
if (chosen) pendingMessage = chosen;
|
|
866
|
-
}
|
|
605
|
+
maybePrintRawModelOutput(result.content);
|
|
867
606
|
}
|
|
868
607
|
} catch (err) {
|
|
869
608
|
stopSpinner();
|
|
@@ -872,12 +611,32 @@ export async function startInteractive() {
|
|
|
872
611
|
continue;
|
|
873
612
|
}
|
|
874
613
|
|
|
875
|
-
// /models — pick active model
|
|
614
|
+
// /models — pick active model (Claude or Ollama)
|
|
876
615
|
if (trimmed === '/models') {
|
|
877
|
-
const
|
|
616
|
+
const labels = MODEL_OPTIONS.map((o) => o.label);
|
|
617
|
+
const chosen = await selectFrom(labels, 'Select model:');
|
|
878
618
|
if (chosen) {
|
|
879
|
-
|
|
880
|
-
|
|
619
|
+
const opt = MODEL_OPTIONS.find((o) => o.label === chosen);
|
|
620
|
+
if (opt) {
|
|
621
|
+
if (opt.provider === 'claude' && !getClaudeKey()) {
|
|
622
|
+
const enteredKey = (await promptSecret('Claude API key (paste then Enter): ')).trim();
|
|
623
|
+
if (!enteredKey) {
|
|
624
|
+
console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
setClaudeKey(enteredKey);
|
|
628
|
+
}
|
|
629
|
+
if (opt.provider === 'openai' && !getOpenAIKey()) {
|
|
630
|
+
const enteredKey = (await promptSecret('OpenAI API key (paste then Enter): ')).trim();
|
|
631
|
+
if (!enteredKey) {
|
|
632
|
+
console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
setOpenAIKey(enteredKey);
|
|
636
|
+
}
|
|
637
|
+
setModelAndProvider(opt.provider, opt.model);
|
|
638
|
+
console.log(chalk.dim(`\n🤖 switched to ${chalk.cyan(getModelDisplayName())}\n`));
|
|
639
|
+
}
|
|
881
640
|
}
|
|
882
641
|
continue;
|
|
883
642
|
}
|
|
@@ -923,33 +682,27 @@ export async function startInteractive() {
|
|
|
923
682
|
continue;
|
|
924
683
|
}
|
|
925
684
|
|
|
926
|
-
// /setup-nextjs — scaffold a Next.js app
|
|
685
|
+
// /setup-nextjs — scaffold a Next.js app
|
|
927
686
|
if (trimmed === '/setup-nextjs') {
|
|
928
|
-
const
|
|
929
|
-
const steps = [
|
|
930
|
-
{ type: 'mkdir', path: name },
|
|
931
|
-
{ type: 'cd', path: name },
|
|
932
|
-
{ type: 'run', cmd: 'npx create-next-app@latest . --yes' },
|
|
933
|
-
{ type: 'run', cmd: 'npm install sass' },
|
|
934
|
-
];
|
|
935
|
-
const ok = await runSetupSteps(steps);
|
|
687
|
+
const ok = await runSetupSteps(NEXTJS_STEPS);
|
|
936
688
|
allFiles = getFilesAndDirs();
|
|
937
|
-
if (ok) console.log(chalk.green(
|
|
689
|
+
if (ok) console.log(chalk.green('✓ Next.js app created.\n'));
|
|
938
690
|
continue;
|
|
939
691
|
}
|
|
940
692
|
|
|
941
|
-
// /setup-
|
|
693
|
+
// /setup-tanstack — scaffold a TanStack Start app
|
|
694
|
+
if (trimmed === '/setup-tanstack') {
|
|
695
|
+
const ok = await runSetupSteps(TANSTACK_STEPS);
|
|
696
|
+
allFiles = getFilesAndDirs();
|
|
697
|
+
if (ok) console.log(chalk.green('✓ TanStack Start app created.\n'));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// /setup-laravel — scaffold a Laravel API
|
|
942
702
|
if (trimmed === '/setup-laravel') {
|
|
943
|
-
const
|
|
944
|
-
const steps = [
|
|
945
|
-
{ type: 'mkdir', path: name },
|
|
946
|
-
{ type: 'cd', path: name },
|
|
947
|
-
{ type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
|
|
948
|
-
{ type: 'run', cmd: 'php artisan serve' },
|
|
949
|
-
];
|
|
950
|
-
const ok = await runSetupSteps(steps);
|
|
703
|
+
const ok = await runSetupSteps(LARAVEL_STEPS);
|
|
951
704
|
allFiles = getFilesAndDirs();
|
|
952
|
-
if (ok) console.log(chalk.green(
|
|
705
|
+
if (ok) console.log(chalk.green('✓ Laravel API created.\n'));
|
|
953
706
|
continue;
|
|
954
707
|
}
|
|
955
708
|
|
|
@@ -1030,11 +783,7 @@ export async function startInteractive() {
|
|
|
1030
783
|
await applyCodeBlockEdits(result.content, loaded);
|
|
1031
784
|
allFiles = getFilesAndDirs();
|
|
1032
785
|
console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
1033
|
-
|
|
1034
|
-
if (opts2.length >= 2) {
|
|
1035
|
-
const chosen = await selectFrom(opts2, 'Select an option:');
|
|
1036
|
-
if (chosen) pendingMessage = chosen;
|
|
1037
|
-
}
|
|
786
|
+
maybePrintRawModelOutput(result.content);
|
|
1038
787
|
}
|
|
1039
788
|
} catch (err) {
|
|
1040
789
|
stopSpinner();
|