markov-cli 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/editor.js +6 -4
- package/src/files.js +36 -5
- package/src/interactive.js +307 -226
- package/src/ollama.js +21 -1
- package/src/tools.js +129 -0
- package/src/ui/picker.js +20 -0
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markov-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "LivingCloud's AI Agent",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "LivingCloud's CLI AI Agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"markov": "./bin/markov.js"
|
|
8
|
-
"markov-local": "./bin/markov.js"
|
|
7
|
+
"markov": "./bin/markov.js"
|
|
9
8
|
},
|
|
10
9
|
"scripts": {
|
|
11
10
|
"start": "node bin/markov.js"
|
|
12
11
|
},
|
|
13
12
|
"keywords": [
|
|
14
13
|
"cli",
|
|
14
|
+
"livingcloud",
|
|
15
|
+
"livingcloud-cli",
|
|
15
16
|
"markov",
|
|
16
17
|
"markov-cli",
|
|
17
18
|
"agent",
|
package/src/editor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
2
|
-
import { join, extname } from 'path';
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join, extname, dirname } from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
|
|
5
5
|
// Maps common language identifiers (including short aliases) to file extensions.
|
|
@@ -141,9 +141,9 @@ export function renderDiff(filepath, newContent, cwd = process.cwd()) {
|
|
|
141
141
|
|
|
142
142
|
export function parseEdits(responseText, loadedFiles = []) {
|
|
143
143
|
const edits = [];
|
|
144
|
+
// File writes use !!write: in interactive.js; here we only handle fenced blocks with path/language or single attached file
|
|
144
145
|
const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
|
|
145
146
|
let match;
|
|
146
|
-
|
|
147
147
|
while ((match = regex.exec(responseText)) !== null) {
|
|
148
148
|
const tag = match[1].trim();
|
|
149
149
|
const content = match[2];
|
|
@@ -169,5 +169,7 @@ export function parseEdits(responseText, loadedFiles = []) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
export function applyEdit(filepath, content, cwd = process.cwd()) {
|
|
172
|
-
|
|
172
|
+
const abs = join(cwd, filepath);
|
|
173
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
174
|
+
writeFileSync(abs, content, 'utf-8');
|
|
173
175
|
}
|
package/src/files.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
import { getFiles } from './ui/picker.js';
|
|
3
4
|
|
|
4
5
|
const FILE_REF_REGEX = /@([\w./\-]+)/g;
|
|
5
6
|
|
|
7
|
+
/** Infer language for fenced block from file extension */
|
|
8
|
+
function langForPath(path) {
|
|
9
|
+
const ext = path.split('.').pop()?.toLowerCase();
|
|
10
|
+
const map = {
|
|
11
|
+
js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
|
|
12
|
+
html: 'html', css: 'css', scss: 'scss', json: 'json',
|
|
13
|
+
md: 'markdown', py: 'python', sh: 'shell', yaml: 'yaml', yml: 'yaml',
|
|
14
|
+
};
|
|
15
|
+
return map[ext] ?? 'text';
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Finds all @ref patterns in a string and returns the unique file paths.
|
|
8
20
|
*/
|
|
@@ -29,15 +41,34 @@ export function resolveFileRefs(input, cwd = process.cwd()) {
|
|
|
29
41
|
const blocks = [];
|
|
30
42
|
|
|
31
43
|
for (const ref of refs) {
|
|
32
|
-
const
|
|
44
|
+
const cleanRef = ref.endsWith('/') ? ref.slice(0, -1) : ref;
|
|
45
|
+
const fullPath = join(cwd, cleanRef);
|
|
33
46
|
if (!existsSync(fullPath)) {
|
|
34
47
|
failed.push(ref);
|
|
35
48
|
continue;
|
|
36
49
|
}
|
|
50
|
+
|
|
51
|
+
// If it's a directory, expand to all files inside
|
|
52
|
+
if (statSync(fullPath).isDirectory()) {
|
|
53
|
+
const dirFiles = getFiles(fullPath, cwd);
|
|
54
|
+
for (const filePath of dirFiles) {
|
|
55
|
+
try {
|
|
56
|
+
const text = readFileSync(join(cwd, filePath), 'utf-8');
|
|
57
|
+
const lang = langForPath(filePath);
|
|
58
|
+
blocks.push(`FILE: ${filePath}\n\`\`\`${lang}\n${text}\n\`\`\``);
|
|
59
|
+
loaded.push(filePath);
|
|
60
|
+
} catch {
|
|
61
|
+
failed.push(filePath);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
try {
|
|
38
68
|
const text = readFileSync(fullPath, 'utf-8');
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
const lang = langForPath(cleanRef);
|
|
70
|
+
blocks.push(`FILE: ${cleanRef}\n\`\`\`${lang}\n${text}\n\`\`\``);
|
|
71
|
+
loaded.push(cleanRef);
|
|
41
72
|
} catch {
|
|
42
73
|
failed.push(ref);
|
|
43
74
|
}
|
package/src/interactive.js
CHANGED
|
@@ -1,18 +1,264 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import gradient from 'gradient-string';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
-
import { resolve,
|
|
5
|
-
import {
|
|
4
|
+
import { resolve, relative, isAbsolute } from 'path';
|
|
5
|
+
import { mkdirSync, rmSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
6
7
|
import { printLogo } from './ui/logo.js';
|
|
7
8
|
import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
|
|
8
9
|
import { resolveFileRefs } from './files.js';
|
|
10
|
+
import { execCommand } from './tools.js';
|
|
9
11
|
import { parseEdits, applyEdit, renderDiff } from './editor.js';
|
|
10
12
|
import { chatPrompt } from './input.js';
|
|
11
|
-
import { getFiles } from './ui/picker.js';
|
|
12
|
-
import { getToken, login } from './auth.js';
|
|
13
|
+
import { getFiles, getFilesAndDirs } from './ui/picker.js';
|
|
13
14
|
|
|
14
15
|
const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
|
|
15
16
|
|
|
17
|
+
/** If path is absolute and under cwd, return relative path; otherwise return as-is. */
|
|
18
|
+
function toRelativePath(p) {
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
if (!isAbsolute(p)) return p;
|
|
21
|
+
const rel = relative(cwd, p);
|
|
22
|
+
if (!rel.startsWith('..') && !isAbsolute(rel)) return rel;
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Extract every !!run: command from a model response. */
|
|
27
|
+
function parseRunCommands(text) {
|
|
28
|
+
const commands = [];
|
|
29
|
+
for (const line of text.split('\n')) {
|
|
30
|
+
const match = line.match(/^!!run:\s*(.+)$/);
|
|
31
|
+
if (match) commands.push(match[1].trim());
|
|
32
|
+
}
|
|
33
|
+
return commands;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract every !!mkdir: path from a model response. */
|
|
37
|
+
function parseMkdirCommands(text) {
|
|
38
|
+
const paths = [];
|
|
39
|
+
for (const line of text.split('\n')) {
|
|
40
|
+
const match = line.match(/^!!mkdir:\s*(.+)$/);
|
|
41
|
+
if (match) paths.push(match[1].trim());
|
|
42
|
+
}
|
|
43
|
+
return paths;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract every !!rmdir: path from a model response. */
|
|
47
|
+
function parseRmdirCommands(text) {
|
|
48
|
+
const paths = [];
|
|
49
|
+
for (const line of text.split('\n')) {
|
|
50
|
+
const match = line.match(/^!!rmdir:\s*(.+)$/);
|
|
51
|
+
if (match) paths.push(match[1].trim());
|
|
52
|
+
}
|
|
53
|
+
return paths;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extract every !!touch: path from a model response. */
|
|
57
|
+
function parseTouchCommands(text) {
|
|
58
|
+
const paths = [];
|
|
59
|
+
for (const line of text.split('\n')) {
|
|
60
|
+
const match = line.match(/^!!touch:\s*(.+)$/);
|
|
61
|
+
if (match) paths.push(match[1].trim());
|
|
62
|
+
}
|
|
63
|
+
return paths;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Extract every !!delete: path from a model response. */
|
|
67
|
+
function parseDeleteCommands(text) {
|
|
68
|
+
const paths = [];
|
|
69
|
+
for (const line of text.split('\n')) {
|
|
70
|
+
const match = line.match(/^!!delete:\s*(.+)$/);
|
|
71
|
+
if (match) paths.push(match[1].trim());
|
|
72
|
+
}
|
|
73
|
+
return paths;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract every !!write: path and its following fenced code block from a model response. */
|
|
77
|
+
function parseWriteCommands(text) {
|
|
78
|
+
const edits = [];
|
|
79
|
+
const lines = text.split('\n');
|
|
80
|
+
let i = 0;
|
|
81
|
+
const blockRegex = /```(?:[\w./\-]*)\n([\s\S]*?)```/;
|
|
82
|
+
while (i < lines.length) {
|
|
83
|
+
const line = lines[i];
|
|
84
|
+
const writeMatch = line.match(/^!!write:\s*(.+)$/);
|
|
85
|
+
if (writeMatch) {
|
|
86
|
+
const filepath = writeMatch[1].trim();
|
|
87
|
+
if (filepath) {
|
|
88
|
+
const rest = lines.slice(i + 1).join('\n');
|
|
89
|
+
const blockMatch = rest.match(blockRegex);
|
|
90
|
+
if (blockMatch) {
|
|
91
|
+
edits.push({ filepath, content: blockMatch[1] });
|
|
92
|
+
const blockText = blockMatch[0];
|
|
93
|
+
const beforeBlock = rest.substring(0, rest.indexOf(blockText));
|
|
94
|
+
const linesBeforeBlock = beforeBlock.split('\n').length;
|
|
95
|
+
const linesInBlock = blockText.split('\n').length;
|
|
96
|
+
i += 1 + linesBeforeBlock + linesInBlock;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
return edits;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse and apply all file operations from a model reply.
|
|
108
|
+
* Shows diffs/confirmations for each op. Returns updated allFiles list.
|
|
109
|
+
*/
|
|
110
|
+
async function handleFileOps(reply, loadedFiles) {
|
|
111
|
+
let allFiles = getFilesAndDirs();
|
|
112
|
+
|
|
113
|
+
// Create folders first (so !!run: cd <folder> can succeed later)
|
|
114
|
+
for (const folderPath of parseMkdirCommands(reply)) {
|
|
115
|
+
const path = toRelativePath(folderPath);
|
|
116
|
+
process.stdout.write(chalk.dim(` mkdir: ${path} — `));
|
|
117
|
+
const confirmed = await confirm(chalk.bold(`Create folder ${chalk.cyan(path)}? [y/N] `));
|
|
118
|
+
if (confirmed) {
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(resolve(process.cwd(), path), { recursive: true });
|
|
121
|
+
allFiles = getFilesAndDirs();
|
|
122
|
+
console.log(chalk.green(`✓ created ${path}\n`));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
console.log(chalk.dim('skipped\n'));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create empty files
|
|
132
|
+
for (const filePath of parseTouchCommands(reply)) {
|
|
133
|
+
const path = toRelativePath(filePath);
|
|
134
|
+
const confirmed = await confirm(chalk.bold(`Create file ${chalk.cyan(path)}? [y/N] `));
|
|
135
|
+
if (confirmed) {
|
|
136
|
+
try {
|
|
137
|
+
const abs = resolve(process.cwd(), path);
|
|
138
|
+
const parentDir = abs.split('/').slice(0, -1).join('/');
|
|
139
|
+
mkdirSync(parentDir, { recursive: true });
|
|
140
|
+
writeFileSync(abs, '', { flag: 'wx' });
|
|
141
|
+
allFiles = getFilesAndDirs();
|
|
142
|
+
console.log(chalk.green(`✓ created ${path}\n`));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
console.log(chalk.dim('skipped\n'));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Write files via !!write: path + fenced block
|
|
152
|
+
for (const { filepath, content } of parseWriteCommands(reply)) {
|
|
153
|
+
const path = toRelativePath(filepath);
|
|
154
|
+
renderDiff(path, content);
|
|
155
|
+
const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
|
|
156
|
+
if (confirmed) {
|
|
157
|
+
try {
|
|
158
|
+
applyEdit(path, content);
|
|
159
|
+
allFiles = getFilesAndDirs();
|
|
160
|
+
console.log(chalk.green(`✓ wrote ${path}\n`));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
console.log(chalk.dim('skipped\n'));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Write / edit files (fenced blocks with path/language or single attached file)
|
|
170
|
+
const edits = parseEdits(reply, loadedFiles);
|
|
171
|
+
for (const { filepath, content } of edits) {
|
|
172
|
+
const path = toRelativePath(filepath);
|
|
173
|
+
renderDiff(path, content);
|
|
174
|
+
const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
|
|
175
|
+
if (confirmed) {
|
|
176
|
+
try {
|
|
177
|
+
applyEdit(path, content);
|
|
178
|
+
allFiles = getFilesAndDirs();
|
|
179
|
+
console.log(chalk.green(`✓ wrote ${path}\n`));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
console.log(chalk.dim('skipped\n'));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Run terminal commands (after folders/files exist, so cd works)
|
|
189
|
+
for (const cmd of parseRunCommands(reply)) {
|
|
190
|
+
const ok = await confirm(chalk.bold(`Run: ${chalk.cyan(cmd)}? [y/N] `));
|
|
191
|
+
if (ok) {
|
|
192
|
+
// cd must be handled in-process — child processes can't change the parent's cwd
|
|
193
|
+
const cdMatch = cmd.match(/^cd\s+(.+)$/);
|
|
194
|
+
if (cdMatch) {
|
|
195
|
+
const target = resolve(process.cwd(), cdMatch[1].trim().replace(/^~/, homedir()));
|
|
196
|
+
try {
|
|
197
|
+
process.chdir(target);
|
|
198
|
+
allFiles = getFilesAndDirs();
|
|
199
|
+
console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.log(chalk.red(` no such directory: ${target}\n`));
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
process.stdout.write(chalk.dim(` running: ${cmd}\n`));
|
|
206
|
+
const { stdout, stderr, exitCode } = await execCommand(cmd);
|
|
207
|
+
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
208
|
+
console.log(output ? chalk.dim(output) + '\n' : chalk.dim(` (exit ${exitCode})\n`));
|
|
209
|
+
allFiles = getFilesAndDirs();
|
|
210
|
+
} else {
|
|
211
|
+
console.log(chalk.dim('skipped\n'));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Remove directories
|
|
216
|
+
for (const dirPath of parseRmdirCommands(reply)) {
|
|
217
|
+
const path = toRelativePath(dirPath);
|
|
218
|
+
const confirmed = await confirm(chalk.bold(`Remove directory ${chalk.cyan(path)}? [y/N] `));
|
|
219
|
+
if (confirmed) {
|
|
220
|
+
const abs = resolve(process.cwd(), path);
|
|
221
|
+
if (existsSync(abs)) {
|
|
222
|
+
try {
|
|
223
|
+
rmSync(abs, { recursive: true, force: true });
|
|
224
|
+
allFiles = getFilesAndDirs();
|
|
225
|
+
console.log(chalk.green(`✓ removed ${path}\n`));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.log(chalk.red(`✗ could not remove ${path}: ${err.message}\n`));
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
console.log(chalk.yellow(`⚠ not found: ${path}\n`));
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
console.log(chalk.dim('skipped\n'));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Delete files
|
|
238
|
+
for (const filePath of parseDeleteCommands(reply)) {
|
|
239
|
+
const path = toRelativePath(filePath);
|
|
240
|
+
const confirmed = await confirm(chalk.bold(`Delete ${chalk.cyan(path)}? [y/N] `));
|
|
241
|
+
if (confirmed) {
|
|
242
|
+
const abs = resolve(process.cwd(), path);
|
|
243
|
+
if (existsSync(abs)) {
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(abs);
|
|
246
|
+
allFiles = getFilesAndDirs();
|
|
247
|
+
console.log(chalk.green(`✓ deleted ${path}\n`));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.log(chalk.red(`✗ could not delete ${path}: ${err.message}\n`));
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
console.log(chalk.yellow(`⚠ not found: ${path}\n`));
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
console.log(chalk.dim('skipped\n'));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return allFiles;
|
|
260
|
+
}
|
|
261
|
+
|
|
16
262
|
/** Arrow-key selector. Returns the chosen string or null if cancelled. */
|
|
17
263
|
function selectFrom(options, label) {
|
|
18
264
|
return new Promise((resolve) => {
|
|
@@ -165,7 +411,40 @@ function countRows(lines, w) {
|
|
|
165
411
|
* After streaming finishes, the viewport is replaced with the full response.
|
|
166
412
|
* Returns the full reply string.
|
|
167
413
|
*/
|
|
414
|
+
function buildSystemMessage() {
|
|
415
|
+
const files = getFiles();
|
|
416
|
+
const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
|
|
417
|
+
const fileOpsInstructions =
|
|
418
|
+
`\nFILE OPERATIONS — use these exact syntaxes when needed:\n` +
|
|
419
|
+
`- Always use RELATIVE paths (e.g. path/to/file or markov/next-app/README.md), never absolute paths like /Users/...\n` +
|
|
420
|
+
`- Run a terminal command: output exactly on its own line: !!run: <command>\n` +
|
|
421
|
+
`- Write or edit a file: output exactly on its own line !!write: path/to/file, then a fenced code block with the full file content.\n` +
|
|
422
|
+
`- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
|
|
423
|
+
`- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
|
|
424
|
+
`- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
|
|
425
|
+
`- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
|
|
426
|
+
`- You may combine multiple operations in one response.\n` +
|
|
427
|
+
`- NEVER put commands in fenced code blocks — always use !!run: syntax for commands.\n` +
|
|
428
|
+
`- NEVER use && inside !!run: — use one !!run: per command. For "cd then do X", output !!run: cd <path> on one line, then !!run: <command> on the next (cd changes the working directory for all following commands).\n` +
|
|
429
|
+
`\nSETUP NEXT.JS APP (or any new project in a subfolder):\n` +
|
|
430
|
+
`1. Create the folder first: !!mkdir: next-app (or the requested name).\n` +
|
|
431
|
+
`2. Change into it on its own line: !!run: cd next-app (nothing after the path).\n` +
|
|
432
|
+
`3. Run each following command on its own !!run: line: e.g. !!run: npx create-next-app@latest . --yes, then !!run: git init, !!run: git add ., !!run: git commit -m "Initial commit".\n` +
|
|
433
|
+
`\nSETUP LARAVEL API (output exactly this):\n` +
|
|
434
|
+
`!!mkdir: laravel-api then !!run: cd laravel-api then !!run: composer create-project --prefer-dist laravel/laravel . then !!run: php artisan serve. One per line; nothing after the dot in composer; no composer run dev or dev-server. No custom routes.\n` +
|
|
435
|
+
`\nSETUP ROUTES (API only — use when user asks to add routes, /health, /users, or API endpoints):\n` +
|
|
436
|
+
`Only after the Laravel app exists. Add in routes/api.php only (not web.php): GET /api/health returning response()->json(['status' => 'ok']), and /api/users as a resource (index, show, store, update, destroy) using User model and a controller. Register api in bootstrap/app.php if needed. Use !!write: for routes/api.php and the controller; prefix with laravel-api/ if cwd is parent. Do not use routes/web.php.\n`;
|
|
437
|
+
return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
|
|
438
|
+
}
|
|
439
|
+
|
|
168
440
|
async function streamWithViewport(chatMessages, signal) {
|
|
441
|
+
// Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
|
|
442
|
+
const resolvedMessages = chatMessages.map(msg => {
|
|
443
|
+
if (msg.role !== 'user') return msg;
|
|
444
|
+
const { content } = resolveFileRefs(msg.content);
|
|
445
|
+
return { ...msg, content };
|
|
446
|
+
});
|
|
447
|
+
|
|
169
448
|
const DOTS = ['.', '..', '...'];
|
|
170
449
|
let dotIdx = 0;
|
|
171
450
|
let firstToken = true;
|
|
@@ -185,7 +464,7 @@ async function streamWithViewport(chatMessages, signal) {
|
|
|
185
464
|
process.stdin.on('data', onCancel);
|
|
186
465
|
|
|
187
466
|
try {
|
|
188
|
-
const reply = await streamChat(
|
|
467
|
+
const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
|
|
189
468
|
if (firstToken) {
|
|
190
469
|
clearInterval(spinner);
|
|
191
470
|
firstToken = false;
|
|
@@ -198,7 +477,7 @@ async function streamWithViewport(chatMessages, signal) {
|
|
|
198
477
|
const w = process.stdout.columns || TERM_WIDTH;
|
|
199
478
|
const displayWidth = Math.min(w, TERM_WIDTH);
|
|
200
479
|
const lines = wrapText(fullText, displayWidth).split('\n');
|
|
201
|
-
const viewLines = lines
|
|
480
|
+
const viewLines = lines;
|
|
202
481
|
const rendered = viewLines.join('\n');
|
|
203
482
|
|
|
204
483
|
// Count actual terminal rows rendered (accounts for line wrapping).
|
|
@@ -245,28 +524,20 @@ const HELP_TEXT =
|
|
|
245
524
|
chalk.cyan(' /help') + chalk.dim(' show this help\n') +
|
|
246
525
|
chalk.cyan(' /plan <message>') + chalk.dim(' ask AI to create a plan (no files written)\n') +
|
|
247
526
|
chalk.cyan(' /build') + chalk.dim(' execute the stored plan\n') +
|
|
248
|
-
chalk.cyan(' /yolo <message>') + chalk.dim(' AI can create/edit any file in cwd\n') +
|
|
249
|
-
chalk.cyan(' /edit <message>') + chalk.dim(' AI can edit files in @-referenced directories\n') +
|
|
250
|
-
chalk.cyan(' /new <file> [desc]') + chalk.dim(' create a new file with AI\n') +
|
|
251
527
|
chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
|
|
252
528
|
chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
|
|
253
|
-
chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
|
|
254
529
|
chalk.dim('\nTips: use ') + chalk.cyan('@filename') + chalk.dim(' to attach a file · ctrl+q to cancel a response\n');
|
|
255
530
|
|
|
256
531
|
export async function startInteractive() {
|
|
257
532
|
printLogo();
|
|
258
533
|
|
|
259
|
-
let allFiles =
|
|
534
|
+
let allFiles = getFilesAndDirs();
|
|
260
535
|
const chatMessages = [];
|
|
261
536
|
let currentPlan = null; // { text: string } | null
|
|
262
537
|
|
|
263
538
|
console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
|
|
264
539
|
console.log(HELP_TEXT);
|
|
265
540
|
|
|
266
|
-
if (!getToken()) {
|
|
267
|
-
console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
541
|
while (true) {
|
|
271
542
|
const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
|
|
272
543
|
if (raw === null) continue;
|
|
@@ -274,19 +545,6 @@ export async function startInteractive() {
|
|
|
274
545
|
|
|
275
546
|
if (!trimmed) continue;
|
|
276
547
|
|
|
277
|
-
// /login — authenticate and save token
|
|
278
|
-
if (trimmed === '/login') {
|
|
279
|
-
const email = await promptLine('Email: ');
|
|
280
|
-
const password = await promptSecret('Password: ');
|
|
281
|
-
try {
|
|
282
|
-
await login(email, password);
|
|
283
|
-
console.log(chalk.green('✓ logged in\n'));
|
|
284
|
-
} catch (err) {
|
|
285
|
-
console.log(chalk.red(`✗ ${err.message}\n`));
|
|
286
|
-
}
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
548
|
// /help — list all commands
|
|
291
549
|
if (trimmed === '/help') {
|
|
292
550
|
console.log(HELP_TEXT);
|
|
@@ -303,58 +561,6 @@ export async function startInteractive() {
|
|
|
303
561
|
continue;
|
|
304
562
|
}
|
|
305
563
|
|
|
306
|
-
// /new <filename> [description] — create a new file with AI
|
|
307
|
-
if (trimmed.startsWith('/new ')) {
|
|
308
|
-
const args = trimmed.slice(5).trim();
|
|
309
|
-
const spaceIdx = args.indexOf(' ');
|
|
310
|
-
const filename = spaceIdx === -1 ? args : args.slice(0, spaceIdx);
|
|
311
|
-
const description = spaceIdx === -1 ? '' : args.slice(spaceIdx + 1).trim();
|
|
312
|
-
|
|
313
|
-
if (!filename) {
|
|
314
|
-
console.log(chalk.yellow('\nUsage: /new <filename> [description]\n'));
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const newFilePrompt = description
|
|
319
|
-
? `Create a new file called \`${filename}\` with the following requirements:\n${description}\n\nOutput the complete file content in a fenced code block tagged with the exact filename like this:\n\`\`\`${filename}\n// content here\n\`\`\``
|
|
320
|
-
: `Create a new file called \`${filename}\`. Output the complete file content in a fenced code block tagged with the exact filename like this:\n\`\`\`${filename}\n// content here\n\`\`\``;
|
|
321
|
-
|
|
322
|
-
chatMessages.push({ role: 'user', content: newFilePrompt });
|
|
323
|
-
|
|
324
|
-
const abortController = new AbortController();
|
|
325
|
-
try {
|
|
326
|
-
const reply = await streamWithViewport(chatMessages, abortController.signal);
|
|
327
|
-
|
|
328
|
-
if (reply === null) {
|
|
329
|
-
chatMessages.pop();
|
|
330
|
-
} else {
|
|
331
|
-
chatMessages.push({ role: 'assistant', content: reply });
|
|
332
|
-
|
|
333
|
-
const edits = parseEdits(reply, [filename]);
|
|
334
|
-
for (const { filepath, content } of edits) {
|
|
335
|
-
renderDiff(filepath, content);
|
|
336
|
-
const confirmed = await confirm(chalk.bold(`Create ${chalk.cyan(filepath)}? [y/N] `));
|
|
337
|
-
if (confirmed) {
|
|
338
|
-
try {
|
|
339
|
-
applyEdit(filepath, content);
|
|
340
|
-
allFiles = getFiles();
|
|
341
|
-
console.log(chalk.green(`✓ created ${filepath}\n`));
|
|
342
|
-
} catch (err) {
|
|
343
|
-
console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
console.log(chalk.dim('skipped\n'));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
} catch (err) {
|
|
351
|
-
if (!abortController.signal.aborted) {
|
|
352
|
-
console.log(chalk.red(`\n${err.message}\n`));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
564
|
// /cd [path] — change working directory within this session
|
|
359
565
|
if (trimmed.startsWith('/cd')) {
|
|
360
566
|
const arg = trimmed.slice(3).trim();
|
|
@@ -363,7 +569,7 @@ export async function startInteractive() {
|
|
|
363
569
|
: homedir();
|
|
364
570
|
try {
|
|
365
571
|
process.chdir(target);
|
|
366
|
-
allFiles =
|
|
572
|
+
allFiles = getFilesAndDirs();
|
|
367
573
|
console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
|
|
368
574
|
} catch {
|
|
369
575
|
console.log(chalk.red(`\nno such directory: ${target}\n`));
|
|
@@ -371,14 +577,18 @@ export async function startInteractive() {
|
|
|
371
577
|
continue;
|
|
372
578
|
}
|
|
373
579
|
|
|
374
|
-
// /plan <message> — ask LLM to produce a plan, store it
|
|
580
|
+
// /plan <message> — ask LLM to produce a plan as a normal chat message, store it
|
|
375
581
|
if (trimmed.startsWith('/plan ')) {
|
|
376
582
|
const planRequest = trimmed.slice(6).trim();
|
|
377
583
|
const planPrompt =
|
|
378
|
-
`
|
|
379
|
-
`For each step specify what
|
|
380
|
-
|
|
381
|
-
|
|
584
|
+
`Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
|
|
585
|
+
`For each step, specify exactly what will happen and which syntax will be used:\n` +
|
|
586
|
+
`- Writing or editing a file → !!write: path/to/file then fenced code block\n` +
|
|
587
|
+
`- Creating an empty file → !!touch: path/to/file\n` +
|
|
588
|
+
`- Creating a folder → !!mkdir: path/to/folder\n` +
|
|
589
|
+
`- Removing a folder → !!rmdir: path/to/folder\n` +
|
|
590
|
+
`- Deleting a file → !!delete: path/to/file\n\n` +
|
|
591
|
+
`Do NOT output any actual file contents or commands yet — only the plan.`;
|
|
382
592
|
chatMessages.push({ role: 'user', content: planPrompt });
|
|
383
593
|
const abortController = new AbortController();
|
|
384
594
|
try {
|
|
@@ -396,7 +606,7 @@ export async function startInteractive() {
|
|
|
396
606
|
continue;
|
|
397
607
|
}
|
|
398
608
|
|
|
399
|
-
// /build — execute the stored plan
|
|
609
|
+
// /build — execute the stored plan with full file ops
|
|
400
610
|
if (trimmed === '/build') {
|
|
401
611
|
if (!currentPlan) {
|
|
402
612
|
console.log(chalk.yellow('\n⚠ No plan stored. Use /plan <message> first.\n'));
|
|
@@ -404,9 +614,8 @@ export async function startInteractive() {
|
|
|
404
614
|
}
|
|
405
615
|
|
|
406
616
|
const buildPrompt =
|
|
407
|
-
`Execute the following plan
|
|
408
|
-
`Plan:\n${currentPlan.text}
|
|
409
|
-
`Output each file completely in a fenced code block like:\n\`\`\`path/to/file.ext\n// full content\n\`\`\``;
|
|
617
|
+
`Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
|
|
618
|
+
`Plan:\n${currentPlan.text}`;
|
|
410
619
|
|
|
411
620
|
chatMessages.push({ role: 'user', content: buildPrompt });
|
|
412
621
|
const abortController = new AbortController();
|
|
@@ -416,87 +625,9 @@ export async function startInteractive() {
|
|
|
416
625
|
chatMessages.pop();
|
|
417
626
|
} else {
|
|
418
627
|
chatMessages.push({ role: 'assistant', content: reply });
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
renderDiff(filepath, content);
|
|
423
|
-
const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(filepath)}? [y/N] `));
|
|
424
|
-
if (confirmed) {
|
|
425
|
-
try {
|
|
426
|
-
applyEdit(filepath, content);
|
|
427
|
-
allFiles = getFiles();
|
|
428
|
-
console.log(chalk.green(`✓ wrote ${filepath}\n`));
|
|
429
|
-
appliedEdits.push({ filepath, content });
|
|
430
|
-
} catch (err) {
|
|
431
|
-
console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
|
|
432
|
-
}
|
|
433
|
-
} else {
|
|
434
|
-
console.log(chalk.dim('skipped\n'));
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (appliedEdits.length > 0) {
|
|
438
|
-
currentPlan = null;
|
|
439
|
-
console.log(chalk.green('✓ Plan executed.\n'));
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
} catch (err) {
|
|
443
|
-
if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
444
|
-
}
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// /yolo <message> — AI can create/edit any file in cwd; @refs loaded for context
|
|
449
|
-
if (trimmed.startsWith('/yolo ')) {
|
|
450
|
-
const yoloMessage = trimmed.slice(6).trim();
|
|
451
|
-
const { content: yoloContent, loaded: yoloLoaded, failed: yoloFailed } = resolveFileRefs(yoloMessage);
|
|
452
|
-
|
|
453
|
-
if (yoloLoaded.length > 0) {
|
|
454
|
-
console.log(chalk.dim(`\n📎 attached: ${yoloLoaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
|
|
455
|
-
}
|
|
456
|
-
if (yoloFailed.length > 0) {
|
|
457
|
-
console.log(chalk.yellow(`\n⚠ not found: ${yoloFailed.map(f => `@${f}`).join(', ')}`));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const yoloInstruction =
|
|
461
|
-
`You have permission to create or modify ANY file in the current working directory.\n` +
|
|
462
|
-
`Output every file you create or modify as a fenced code block tagged with its exact relative path:\n` +
|
|
463
|
-
`\`\`\`path/to/file.ext\n// full content\n\`\`\`\n\n`;
|
|
464
|
-
|
|
465
|
-
const yoloFull = yoloContent.startsWith('The user referenced')
|
|
466
|
-
? yoloContent.replace('The user referenced', yoloInstruction + 'The user referenced')
|
|
467
|
-
: yoloInstruction + yoloContent;
|
|
468
|
-
|
|
469
|
-
chatMessages.push({ role: 'user', content: yoloFull });
|
|
470
|
-
const abortController = new AbortController();
|
|
471
|
-
try {
|
|
472
|
-
const reply = await streamWithViewport(chatMessages, abortController.signal);
|
|
473
|
-
if (reply === null) {
|
|
474
|
-
chatMessages.pop();
|
|
475
|
-
} else {
|
|
476
|
-
chatMessages.push({ role: 'assistant', content: reply });
|
|
477
|
-
const edits = parseEdits(reply, yoloLoaded);
|
|
478
|
-
const appliedEdits = [];
|
|
479
|
-
for (const { filepath, content } of edits) {
|
|
480
|
-
renderDiff(filepath, content);
|
|
481
|
-
const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(filepath)}? [y/N] `));
|
|
482
|
-
if (confirmed) {
|
|
483
|
-
try {
|
|
484
|
-
applyEdit(filepath, content);
|
|
485
|
-
allFiles = getFiles();
|
|
486
|
-
console.log(chalk.green(`✓ wrote ${filepath}\n`));
|
|
487
|
-
appliedEdits.push({ filepath, content });
|
|
488
|
-
} catch (err) {
|
|
489
|
-
console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
|
|
490
|
-
}
|
|
491
|
-
} else {
|
|
492
|
-
console.log(chalk.dim('skipped\n'));
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
if (appliedEdits.length > 0) {
|
|
496
|
-
const blocks = appliedEdits.map(({ filepath, content }) => `--- @${filepath} ---\n${content}\n---`);
|
|
497
|
-
chatMessages.push({ role: 'user', content: `Files saved:\n\n${blocks.join('\n\n')}` });
|
|
498
|
-
chatMessages.push({ role: 'assistant', content: 'Got it, I have the updated file contents.' });
|
|
499
|
-
}
|
|
628
|
+
allFiles = await handleFileOps(reply, []);
|
|
629
|
+
currentPlan = null;
|
|
630
|
+
console.log(chalk.green('✓ Plan executed.\n'));
|
|
500
631
|
}
|
|
501
632
|
} catch (err) {
|
|
502
633
|
if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
|
|
@@ -504,11 +635,8 @@ export async function startInteractive() {
|
|
|
504
635
|
continue;
|
|
505
636
|
}
|
|
506
637
|
|
|
507
|
-
//
|
|
508
|
-
const
|
|
509
|
-
const message = isEditMode ? trimmed.slice(6).trim() : trimmed;
|
|
510
|
-
|
|
511
|
-
const { content, loaded, failed } = resolveFileRefs(message);
|
|
638
|
+
// Normal chat — file ops always available
|
|
639
|
+
const { loaded, failed } = resolveFileRefs(trimmed);
|
|
512
640
|
|
|
513
641
|
if (loaded.length > 0) {
|
|
514
642
|
console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
|
|
@@ -517,29 +645,8 @@ export async function startInteractive() {
|
|
|
517
645
|
console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
|
|
518
646
|
}
|
|
519
647
|
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
let editDirInstruction = '';
|
|
523
|
-
if (isEditMode && loaded.length > 0) {
|
|
524
|
-
const dirs = [...new Set(loaded.map(f => dirname(f) || '.'))];
|
|
525
|
-
for (const dir of dirs) {
|
|
526
|
-
try {
|
|
527
|
-
const entries = readdirSync(join(process.cwd(), dir), { withFileTypes: true });
|
|
528
|
-
for (const e of entries) {
|
|
529
|
-
if (e.isFile()) editDirFiles.push(dir === '.' ? e.name : `${dir}/${e.name}`);
|
|
530
|
-
}
|
|
531
|
-
} catch { /* ignore unreadable dirs */ }
|
|
532
|
-
}
|
|
533
|
-
editDirInstruction =
|
|
534
|
-
`\nYou may edit any file in the following director${dirs.length > 1 ? 'ies' : 'y'}: ${dirs.join(', ')}.\n` +
|
|
535
|
-
`Output modified files as fenced code blocks tagged with their exact relative path.\n`;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const fullContent = isEditMode && editDirInstruction
|
|
539
|
-
? content + editDirInstruction
|
|
540
|
-
: content;
|
|
541
|
-
|
|
542
|
-
chatMessages.push({ role: 'user', content: fullContent });
|
|
648
|
+
// Store raw message — @refs are re-resolved fresh on every API call
|
|
649
|
+
chatMessages.push({ role: 'user', content: trimmed });
|
|
543
650
|
|
|
544
651
|
const abortController = new AbortController();
|
|
545
652
|
try {
|
|
@@ -549,33 +656,7 @@ export async function startInteractive() {
|
|
|
549
656
|
chatMessages.pop();
|
|
550
657
|
} else {
|
|
551
658
|
chatMessages.push({ role: 'assistant', content: reply });
|
|
552
|
-
|
|
553
|
-
const edits = isEditMode ? parseEdits(reply, [...loaded, ...editDirFiles]) : [];
|
|
554
|
-
const appliedEdits = [];
|
|
555
|
-
for (const { filepath, content } of edits) {
|
|
556
|
-
renderDiff(filepath, content);
|
|
557
|
-
const confirmed = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(filepath)}? [y/N] `));
|
|
558
|
-
if (confirmed) {
|
|
559
|
-
try {
|
|
560
|
-
applyEdit(filepath, content);
|
|
561
|
-
console.log(chalk.green(`✓ saved ${filepath}\n`));
|
|
562
|
-
appliedEdits.push({ filepath, content });
|
|
563
|
-
} catch (err) {
|
|
564
|
-
console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
|
|
565
|
-
}
|
|
566
|
-
} else {
|
|
567
|
-
console.log(chalk.dim('skipped\n'));
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (appliedEdits.length > 0) {
|
|
572
|
-
const blocks = appliedEdits.map(({ filepath, content }) => `--- @${filepath} ---\n${content}\n---`);
|
|
573
|
-
chatMessages.push({
|
|
574
|
-
role: 'user',
|
|
575
|
-
content: `The following file(s) were just saved with the applied changes:\n\n${blocks.join('\n\n')}`,
|
|
576
|
-
});
|
|
577
|
-
chatMessages.push({ role: 'assistant', content: 'Got it, I have the updated file contents.' });
|
|
578
|
-
}
|
|
659
|
+
allFiles = await handleFileOps(reply, loaded);
|
|
579
660
|
}
|
|
580
661
|
} catch (err) {
|
|
581
662
|
if (!abortController.signal.aborted) {
|
package/src/ollama.js
CHANGED
|
@@ -4,7 +4,7 @@ const getHeaders = () => {
|
|
|
4
4
|
const token = getToken();
|
|
5
5
|
return { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) };
|
|
6
6
|
};
|
|
7
|
-
export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen3:14b'];
|
|
7
|
+
export const MODELS = ['gemma3:4b', 'qwen2.5-coder:0.5b', 'qwen2.5-coder:7b', 'qwen2.5:14b-instruct', 'qwen3:14b'];
|
|
8
8
|
export let MODEL = 'qwen3:14b';
|
|
9
9
|
export function setModel(m) { MODEL = m; }
|
|
10
10
|
|
|
@@ -69,3 +69,23 @@ export async function streamChat(messages, onToken, _model = MODEL, signal = nul
|
|
|
69
69
|
|
|
70
70
|
return fullText;
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Chat with tools (function calling). Non-streaming; returns full response with optional tool_calls.
|
|
75
|
+
* Use for agent loop: if message.tool_calls present, run tools locally and call again with updated messages.
|
|
76
|
+
*/
|
|
77
|
+
export async function chatWithTools(messages, tools, model = MODEL, signal = null) {
|
|
78
|
+
const controller = signal ? { signal } : {};
|
|
79
|
+
const res = await fetch(`${API_URL}/ai/chat/tools`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: getHeaders(),
|
|
82
|
+
body: JSON.stringify({ messages, tools, model, temperature: 0.2 }),
|
|
83
|
+
...controller,
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.text().catch(() => '');
|
|
87
|
+
throw new Error(`API error ${res.status} ${res.statusText}${body ? ': ' + body : ''}`);
|
|
88
|
+
}
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
return data;
|
|
91
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
/** Ollama-format tool definition for running a shell command on the user's machine */
|
|
9
|
+
export const RUN_TERMINAL_COMMAND_TOOL = {
|
|
10
|
+
type: 'function',
|
|
11
|
+
function: {
|
|
12
|
+
name: 'run_terminal_command',
|
|
13
|
+
description: 'Run a single shell command in the user\'s terminal. You MUST call this tool (do not only list commands in your reply) when the user asks to set up a project, install dependencies (npm install, pip install, etc.), run init/build commands (npm init, npx create-react-app, etc.), or execute any shell command. Use for listing files (ls), checking status (git status), and running scripts. Commands run in the current working directory. Do not run interactive or long-running daemons. Run one command per tool call; for multiple commands, make multiple tool calls.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['command'],
|
|
17
|
+
properties: {
|
|
18
|
+
command: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'The shell command to run (e.g. "ls -la", "git status"). Single command only, no pipes or multiple commands unless the shell supports it.',
|
|
21
|
+
},
|
|
22
|
+
cwd: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Working directory for the command. Defaults to current directory if omitted.',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Ollama-format tool definition for creating a directory */
|
|
32
|
+
export const CREATE_FOLDER_TOOL = {
|
|
33
|
+
type: 'function',
|
|
34
|
+
function: {
|
|
35
|
+
name: 'create_folder',
|
|
36
|
+
description: 'Create a folder (and any necessary parent folders) in the current working directory.',
|
|
37
|
+
parameters: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['path'],
|
|
40
|
+
properties: {
|
|
41
|
+
path: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Relative path of the folder to create (e.g. "src/components").',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const TOOLS_MAP = {
|
|
51
|
+
create_folder: async (args, opts = {}) => {
|
|
52
|
+
const folderPath = typeof args.path === 'string' ? args.path : String(args?.path ?? '');
|
|
53
|
+
if (!folderPath.trim()) {
|
|
54
|
+
return { success: false, error: 'Error: empty path' };
|
|
55
|
+
}
|
|
56
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
57
|
+
const absPath = resolve(cwd, folderPath);
|
|
58
|
+
try {
|
|
59
|
+
mkdirSync(absPath, { recursive: true });
|
|
60
|
+
return { success: true, path: folderPath };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { success: false, error: err.message };
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
run_terminal_command: async (args, opts = {}) => {
|
|
66
|
+
const command = typeof args.command === 'string' ? args.command : String(args?.command ?? '');
|
|
67
|
+
const cwd = typeof args.cwd === 'string' ? args.cwd : opts.cwd ?? process.cwd();
|
|
68
|
+
if (!command.trim()) {
|
|
69
|
+
return { stdout: '', stderr: 'Error: empty command', exitCode: 1 };
|
|
70
|
+
}
|
|
71
|
+
if (opts.confirmFn) {
|
|
72
|
+
const ok = await opts.confirmFn(command);
|
|
73
|
+
if (!ok) {
|
|
74
|
+
return { stdout: '', stderr: 'Command cancelled by user', exitCode: -1 };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const timeout = opts.timeout ?? 30_000;
|
|
78
|
+
|
|
79
|
+
const maxBuffer = opts.maxBuffer ?? 1024 * 1024;
|
|
80
|
+
try {
|
|
81
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
82
|
+
cwd,
|
|
83
|
+
timeout,
|
|
84
|
+
maxBuffer,
|
|
85
|
+
shell: true,
|
|
86
|
+
});
|
|
87
|
+
return { stdout: stdout || '', stderr: stderr || '', exitCode: 0 };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const stderr = err.stderr ?? err.message ?? String(err);
|
|
90
|
+
const stdout = err.stdout ?? '';
|
|
91
|
+
const exitCode = err.code ?? 1;
|
|
92
|
+
return { stdout, stderr, exitCode };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Execute a shell command directly and return its output.
|
|
99
|
+
* @param {string} command
|
|
100
|
+
* @param {string} [cwd]
|
|
101
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
|
|
102
|
+
*/
|
|
103
|
+
export async function execCommand(command, cwd = process.cwd()) {
|
|
104
|
+
if (!command.trim()) return { stdout: '', stderr: 'Error: empty command', exitCode: 1 };
|
|
105
|
+
const timeout = 30_000;
|
|
106
|
+
const maxBuffer = 1024 * 1024;
|
|
107
|
+
try {
|
|
108
|
+
const { stdout, stderr } = await execAsync(command, { cwd, timeout, maxBuffer, shell: true });
|
|
109
|
+
return { stdout: stdout || '', stderr: stderr || '', exitCode: 0 };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { stdout: err.stdout ?? '', stderr: err.stderr ?? err.message ?? String(err), exitCode: err.code ?? 1 };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run a tool by name with the given arguments.
|
|
117
|
+
* @param {string} name - Tool name (e.g. 'run_terminal_command')
|
|
118
|
+
* @param {Record<string, unknown>} args - Arguments object (e.g. { command: 'ls -la' })
|
|
119
|
+
* @param {{ cwd?: string, confirmFn?: (command: string) => Promise<boolean>, timeout?: number }} opts - Optional cwd, confirmation callback for terminal commands, timeout
|
|
120
|
+
* @returns {Promise<string>} - Result string to send back to the model (e.g. JSON or plain text)
|
|
121
|
+
*/
|
|
122
|
+
export async function runTool(name, args, opts = {}) {
|
|
123
|
+
const fn = TOOLS_MAP[name];
|
|
124
|
+
if (!fn) {
|
|
125
|
+
return JSON.stringify({ error: `Unknown tool: ${name}` });
|
|
126
|
+
}
|
|
127
|
+
const result = await fn(args ?? {}, opts);
|
|
128
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
129
|
+
}
|
package/src/ui/picker.js
CHANGED
|
@@ -24,6 +24,26 @@ export function getFiles(dir = process.cwd(), base = dir, depth = 0, acc = []) {
|
|
|
24
24
|
return acc;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Returns files AND directories (dirs have a trailing /). */
|
|
28
|
+
export function getFilesAndDirs(dir = process.cwd(), base = dir, depth = 0, acc = []) {
|
|
29
|
+
if (depth > 5 || acc.length >= MAX_FILES) return acc;
|
|
30
|
+
let entries;
|
|
31
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (IGNORE.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
34
|
+
const full = join(dir, entry.name);
|
|
35
|
+
const rel = relative(base, full);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
acc.push(rel + '/');
|
|
38
|
+
getFilesAndDirs(full, base, depth + 1, acc);
|
|
39
|
+
} else {
|
|
40
|
+
acc.push(rel);
|
|
41
|
+
}
|
|
42
|
+
if (acc.length >= MAX_FILES) break;
|
|
43
|
+
}
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
export function filterFiles(files, query) {
|
|
28
48
|
if (!query) return files;
|
|
29
49
|
const q = query.toLowerCase();
|