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/src/interactive.js
CHANGED
|
@@ -1,484 +1,42 @@
|
|
|
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
|
-
};
|
|
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';
|
|
52
19
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
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';
|
|
79
23
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
}
|
|
24
|
+
// Extracted editor modules
|
|
25
|
+
import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
|
|
132
26
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
27
|
+
// Extracted command modules
|
|
28
|
+
import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS } from './commands/setup.js';
|
|
136
29
|
|
|
137
|
-
|
|
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);
|
|
409
|
-
|
|
410
|
-
const message = data?.message;
|
|
411
|
-
if (!message) {
|
|
412
|
-
return { content: '', finalMessage: { role: 'assistant', content: '' } };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const toolCalls = message.tool_calls;
|
|
416
|
-
if (!toolCalls || toolCalls.length === 0) {
|
|
417
|
-
return { content: message.content ?? '', finalMessage: message };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Append assistant message with tool_calls
|
|
421
|
-
messages.push({
|
|
422
|
-
role: 'assistant',
|
|
423
|
-
content: message.content ?? '',
|
|
424
|
-
tool_calls: toolCalls,
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
onBeforeToolRun?.();
|
|
428
|
-
onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, toolCalls.length);
|
|
429
|
-
|
|
430
|
-
for (const tc of toolCalls) {
|
|
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
|
-
}
|
|
476
|
-
|
|
477
|
-
return {
|
|
478
|
-
content: '(agent loop reached max iterations)',
|
|
479
|
-
finalMessage: { role: 'assistant', content: '(max iterations)' },
|
|
480
|
-
};
|
|
481
|
-
}
|
|
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];
|
|
482
40
|
|
|
483
41
|
/** Short intro shown on first load; /intro re-displays this. */
|
|
484
42
|
const INTRO_TEXT =
|
|
@@ -487,8 +45,9 @@ const INTRO_TEXT =
|
|
|
487
45
|
chalk.cyan(' /help') + chalk.dim(' show all commands\n') +
|
|
488
46
|
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
489
47
|
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
490
|
-
chalk.cyan(' /
|
|
491
|
-
chalk.cyan(' /
|
|
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') +
|
|
492
51
|
chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
|
|
493
52
|
chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
|
|
494
53
|
|
|
@@ -498,66 +57,30 @@ const HELP_TEXT =
|
|
|
498
57
|
chalk.cyan(' /intro') + chalk.dim(' show quick start (same as on first load)\n') +
|
|
499
58
|
chalk.cyan(' /help') + chalk.dim(' show this help\n') +
|
|
500
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') +
|
|
501
61
|
chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
|
|
502
62
|
chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
|
|
503
63
|
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
504
64
|
chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
|
|
505
65
|
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
506
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') +
|
|
507
69
|
chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n') +
|
|
508
|
-
chalk.cyan(' /
|
|
509
|
-
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') +
|
|
510
73
|
chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
|
|
511
74
|
chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
|
|
512
75
|
|
|
513
|
-
/**
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
try {
|
|
522
|
-
mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
|
|
523
|
-
console.log(chalk.green(` ✓ created ${step.path}\n`));
|
|
524
|
-
} catch (err) {
|
|
525
|
-
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
526
|
-
return false;
|
|
527
|
-
}
|
|
528
|
-
} else if (step.type === 'cd') {
|
|
529
|
-
try {
|
|
530
|
-
process.chdir(resolve(process.cwd(), step.path));
|
|
531
|
-
console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
|
|
532
|
-
} catch (err) {
|
|
533
|
-
console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
|
|
534
|
-
return false;
|
|
535
|
-
}
|
|
536
|
-
} else if (step.type === 'run') {
|
|
537
|
-
process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
|
|
538
|
-
const { stdout, stderr, exitCode } = await execCommand(step.cmd);
|
|
539
|
-
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
540
|
-
if (output) console.log(chalk.dim(output));
|
|
541
|
-
if (exitCode !== 0) {
|
|
542
|
-
console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
console.log(chalk.green(` ✓ done\n`));
|
|
546
|
-
} else if (step.type === 'write') {
|
|
547
|
-
process.stdout.write(chalk.dim(` write: ${step.path}\n`));
|
|
548
|
-
try {
|
|
549
|
-
const abs = resolve(process.cwd(), step.path);
|
|
550
|
-
const dir = abs.split('/').slice(0, -1).join('/');
|
|
551
|
-
mkdirSync(dir, { recursive: true });
|
|
552
|
-
writeFileSync(abs, step.content);
|
|
553
|
-
console.log(chalk.green(` ✓ wrote ${step.path}\n`));
|
|
554
|
-
} catch (err) {
|
|
555
|
-
console.log(chalk.red(` ✗ ${err.message}\n`));
|
|
556
|
-
return false;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
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'));
|
|
561
84
|
}
|
|
562
85
|
|
|
563
86
|
export async function startInteractive() {
|
|
@@ -566,7 +89,7 @@ export async function startInteractive() {
|
|
|
566
89
|
let allFiles = getFilesAndDirs();
|
|
567
90
|
const chatMessages = [];
|
|
568
91
|
|
|
569
|
-
console.log(chalk.dim(`Chat with Markov (${
|
|
92
|
+
console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
|
|
570
93
|
console.log(INTRO_TEXT);
|
|
571
94
|
|
|
572
95
|
if (!getToken()) {
|
|
@@ -575,6 +98,7 @@ export async function startInteractive() {
|
|
|
575
98
|
|
|
576
99
|
let pendingMessage = null;
|
|
577
100
|
let lastPlan = null;
|
|
101
|
+
const inputHistory = [];
|
|
578
102
|
|
|
579
103
|
while (true) {
|
|
580
104
|
let raw;
|
|
@@ -583,7 +107,7 @@ export async function startInteractive() {
|
|
|
583
107
|
pendingMessage = null;
|
|
584
108
|
console.log(chalk.magenta('you> ') + raw + '\n');
|
|
585
109
|
} else {
|
|
586
|
-
raw = await chatPrompt(chalk.magenta('you> '), allFiles);
|
|
110
|
+
raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
|
|
587
111
|
}
|
|
588
112
|
if (raw === null) continue;
|
|
589
113
|
const trimmed = raw.trim();
|
|
@@ -610,6 +134,14 @@ export async function startInteractive() {
|
|
|
610
134
|
continue;
|
|
611
135
|
}
|
|
612
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
|
+
|
|
613
145
|
// /intro — show quick start (same as on first load)
|
|
614
146
|
if (trimmed === '/intro') {
|
|
615
147
|
console.log(INTRO_TEXT);
|
|
@@ -634,6 +166,27 @@ export async function startInteractive() {
|
|
|
634
166
|
continue;
|
|
635
167
|
}
|
|
636
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
|
+
|
|
637
190
|
// /plan [prompt] — stream a plan (no tools), store as lastPlan
|
|
638
191
|
if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
|
|
639
192
|
const rawUserContent = trimmed.startsWith('/plan ')
|
|
@@ -666,31 +219,69 @@ export async function startInteractive() {
|
|
|
666
219
|
}
|
|
667
220
|
};
|
|
668
221
|
try {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
process.stdout.write(
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
+
},
|
|
688
247
|
},
|
|
248
|
+
planAbort.signal,
|
|
249
|
+
null
|
|
250
|
+
);
|
|
251
|
+
fullPlanText = content ?? '';
|
|
252
|
+
if (!toolCalls?.length) {
|
|
253
|
+
printTokenUsage(usage);
|
|
254
|
+
break;
|
|
689
255
|
}
|
|
690
|
-
|
|
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
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
691
277
|
chatMessages.push({ role: 'assistant', content: fullPlanText });
|
|
692
|
-
|
|
693
|
-
|
|
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);
|
|
694
285
|
} catch (err) {
|
|
695
286
|
if (planSpinner) {
|
|
696
287
|
clearInterval(planSpinner);
|
|
@@ -719,31 +310,65 @@ export async function startInteractive() {
|
|
|
719
310
|
let thinkingStarted = false;
|
|
720
311
|
let fullPlanText = '';
|
|
721
312
|
try {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
+
},
|
|
734
330
|
},
|
|
331
|
+
yoloAbort.signal,
|
|
332
|
+
null
|
|
333
|
+
);
|
|
334
|
+
fullPlanText = yoloPlanContent ?? '';
|
|
335
|
+
if (!toolCalls?.length) {
|
|
336
|
+
printTokenUsage(usage);
|
|
337
|
+
break;
|
|
735
338
|
}
|
|
736
|
-
|
|
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
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
737
360
|
chatMessages.push({ role: 'assistant', content: fullPlanText });
|
|
738
|
-
|
|
361
|
+
// Store only plan (stream content), not thinking, so build phase uses exactly this.
|
|
362
|
+
if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
|
|
363
|
+
maybePrintRawModelOutput(fullPlanText);
|
|
739
364
|
} catch (err) {
|
|
740
365
|
if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
741
366
|
continue;
|
|
742
367
|
}
|
|
743
368
|
const buildContent = (await getLsContext()) + (await getGrepContext()) +
|
|
744
|
-
'
|
|
369
|
+
'\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
|
|
745
370
|
chatMessages.push({ role: 'user', content: buildContent });
|
|
746
|
-
const agentMessages = [buildAgentSystemMessage(),
|
|
371
|
+
const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
|
|
747
372
|
maybePrintFullPayload(agentMessages);
|
|
748
373
|
const abortController = new AbortController();
|
|
749
374
|
const confirmFn = () => Promise.resolve(true);
|
|
@@ -797,11 +422,7 @@ export async function startInteractive() {
|
|
|
797
422
|
await applyCodeBlockEdits(result.content, []);
|
|
798
423
|
allFiles = getFilesAndDirs();
|
|
799
424
|
console.log(chalk.green(`✓ Yolo done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
800
|
-
|
|
801
|
-
if (opts1.length >= 2) {
|
|
802
|
-
const chosen = await selectFrom(opts1, 'Select an option:');
|
|
803
|
-
if (chosen) pendingMessage = chosen;
|
|
804
|
-
}
|
|
425
|
+
maybePrintRawModelOutput(result.content);
|
|
805
426
|
}
|
|
806
427
|
} catch (err) {
|
|
807
428
|
stopSpinner();
|
|
@@ -810,16 +431,119 @@ export async function startInteractive() {
|
|
|
810
431
|
continue;
|
|
811
432
|
}
|
|
812
433
|
|
|
813
|
-
// /
|
|
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
|
|
814
532
|
if (trimmed === '/build') {
|
|
815
|
-
|
|
816
|
-
|
|
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'));
|
|
817
541
|
continue;
|
|
818
542
|
}
|
|
819
543
|
const buildContent = (await getLsContext()) + (await getGrepContext()) +
|
|
820
|
-
'
|
|
544
|
+
'\n\nPlan:\n' + planToUse + '\n\nExecute this plan using your tools. Run commands and edit files as needed. ';
|
|
821
545
|
chatMessages.push({ role: 'user', content: buildContent });
|
|
822
|
-
const agentMessages = [buildAgentSystemMessage(),
|
|
546
|
+
const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
|
|
823
547
|
maybePrintFullPayload(agentMessages);
|
|
824
548
|
const abortController = new AbortController();
|
|
825
549
|
const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
|
|
@@ -878,11 +602,7 @@ export async function startInteractive() {
|
|
|
878
602
|
await applyCodeBlockEdits(result.content, []);
|
|
879
603
|
allFiles = getFilesAndDirs();
|
|
880
604
|
console.log(chalk.green(`✓ Build done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
881
|
-
|
|
882
|
-
if (opts1.length >= 2) {
|
|
883
|
-
const chosen = await selectFrom(opts1, 'Select an option:');
|
|
884
|
-
if (chosen) pendingMessage = chosen;
|
|
885
|
-
}
|
|
605
|
+
maybePrintRawModelOutput(result.content);
|
|
886
606
|
}
|
|
887
607
|
} catch (err) {
|
|
888
608
|
stopSpinner();
|
|
@@ -891,12 +611,32 @@ export async function startInteractive() {
|
|
|
891
611
|
continue;
|
|
892
612
|
}
|
|
893
613
|
|
|
894
|
-
// /models — pick active model
|
|
614
|
+
// /models — pick active model (Claude or Ollama)
|
|
895
615
|
if (trimmed === '/models') {
|
|
896
|
-
const
|
|
616
|
+
const labels = MODEL_OPTIONS.map((o) => o.label);
|
|
617
|
+
const chosen = await selectFrom(labels, 'Select model:');
|
|
897
618
|
if (chosen) {
|
|
898
|
-
|
|
899
|
-
|
|
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
|
+
}
|
|
900
640
|
}
|
|
901
641
|
continue;
|
|
902
642
|
}
|
|
@@ -942,33 +682,27 @@ export async function startInteractive() {
|
|
|
942
682
|
continue;
|
|
943
683
|
}
|
|
944
684
|
|
|
945
|
-
// /setup-nextjs — scaffold a Next.js app
|
|
685
|
+
// /setup-nextjs — scaffold a Next.js app
|
|
946
686
|
if (trimmed === '/setup-nextjs') {
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
687
|
+
const ok = await runSetupSteps(NEXTJS_STEPS);
|
|
688
|
+
allFiles = getFilesAndDirs();
|
|
689
|
+
if (ok) console.log(chalk.green('✓ Next.js app created.\n'));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// /setup-tanstack — scaffold a TanStack Start app
|
|
694
|
+
if (trimmed === '/setup-tanstack') {
|
|
695
|
+
const ok = await runSetupSteps(TANSTACK_STEPS);
|
|
955
696
|
allFiles = getFilesAndDirs();
|
|
956
|
-
if (ok) console.log(chalk.green(
|
|
697
|
+
if (ok) console.log(chalk.green('✓ TanStack Start app created.\n'));
|
|
957
698
|
continue;
|
|
958
699
|
}
|
|
959
700
|
|
|
960
|
-
// /setup-laravel — scaffold a Laravel API
|
|
701
|
+
// /setup-laravel — scaffold a Laravel API
|
|
961
702
|
if (trimmed === '/setup-laravel') {
|
|
962
|
-
const
|
|
963
|
-
const steps = [
|
|
964
|
-
{ type: 'mkdir', path: name },
|
|
965
|
-
{ type: 'cd', path: name },
|
|
966
|
-
{ type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
|
|
967
|
-
{ type: 'run', cmd: 'php artisan serve' },
|
|
968
|
-
];
|
|
969
|
-
const ok = await runSetupSteps(steps);
|
|
703
|
+
const ok = await runSetupSteps(LARAVEL_STEPS);
|
|
970
704
|
allFiles = getFilesAndDirs();
|
|
971
|
-
if (ok) console.log(chalk.green(
|
|
705
|
+
if (ok) console.log(chalk.green('✓ Laravel API created.\n'));
|
|
972
706
|
continue;
|
|
973
707
|
}
|
|
974
708
|
|
|
@@ -1049,11 +783,7 @@ export async function startInteractive() {
|
|
|
1049
783
|
await applyCodeBlockEdits(result.content, loaded);
|
|
1050
784
|
allFiles = getFilesAndDirs();
|
|
1051
785
|
console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
|
|
1052
|
-
|
|
1053
|
-
if (opts2.length >= 2) {
|
|
1054
|
-
const chosen = await selectFrom(opts2, 'Select an option:');
|
|
1055
|
-
if (chosen) pendingMessage = chosen;
|
|
1056
|
-
}
|
|
786
|
+
maybePrintRawModelOutput(result.content);
|
|
1057
787
|
}
|
|
1058
788
|
} catch (err) {
|
|
1059
789
|
stopSpinner();
|