markov-cli 1.0.2 → 1.0.3

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 CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "markov-cli",
3
- "version": "1.0.2",
4
- "description": "LivingCloud's AI Agent",
3
+ "version": "1.0.3",
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,16 @@ export function renderDiff(filepath, newContent, cwd = process.cwd()) {
141
141
 
142
142
  export function parseEdits(responseText, loadedFiles = []) {
143
143
  const edits = [];
144
- const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
144
+ // Match "FILE: path" followed by a fenced code block (so model can use FILE: path + ```lang)
145
+ const filePrefixRegex = /FILE:\s*([^\n]+)\s*\n\s*```(?:[\w./\-]*)\n([\s\S]*?)```/g;
145
146
  let match;
147
+ while ((match = filePrefixRegex.exec(responseText)) !== null) {
148
+ const filepath = match[1].trim();
149
+ const content = match[2];
150
+ if (filepath) edits.push({ filepath, content });
151
+ }
146
152
 
153
+ const regex = /```([\w./\-]*)\n([\s\S]*?)```/g;
147
154
  while ((match = regex.exec(responseText)) !== null) {
148
155
  const tag = match[1].trim();
149
156
  const content = match[2];
@@ -169,5 +176,7 @@ export function parseEdits(responseText, loadedFiles = []) {
169
176
  }
170
177
 
171
178
  export function applyEdit(filepath, content, cwd = process.cwd()) {
172
- writeFileSync(join(cwd, filepath), content, 'utf-8');
179
+ const abs = join(cwd, filepath);
180
+ mkdirSync(dirname(abs), { recursive: true });
181
+ writeFileSync(abs, content, 'utf-8');
173
182
  }
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 fullPath = join(cwd, ref);
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
- blocks.push(`--- @${ref} ---\n${text}\n---`);
40
- loaded.push(ref);
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
  }
@@ -1,18 +1,202 @@
1
1
  import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
  import { homedir } from 'os';
4
- import { resolve, dirname, join } from 'path';
5
- import { readdirSync } from 'fs';
4
+ import { resolve } 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
+ /** Extract every !!run: command from a model response. */
18
+ function parseRunCommands(text) {
19
+ const commands = [];
20
+ for (const line of text.split('\n')) {
21
+ const match = line.match(/^!!run:\s*(.+)$/);
22
+ if (match) commands.push(match[1].trim());
23
+ }
24
+ return commands;
25
+ }
26
+
27
+ /** Extract every !!mkdir: path from a model response. */
28
+ function parseMkdirCommands(text) {
29
+ const paths = [];
30
+ for (const line of text.split('\n')) {
31
+ const match = line.match(/^!!mkdir:\s*(.+)$/);
32
+ if (match) paths.push(match[1].trim());
33
+ }
34
+ return paths;
35
+ }
36
+
37
+ /** Extract every !!rmdir: path from a model response. */
38
+ function parseRmdirCommands(text) {
39
+ const paths = [];
40
+ for (const line of text.split('\n')) {
41
+ const match = line.match(/^!!rmdir:\s*(.+)$/);
42
+ if (match) paths.push(match[1].trim());
43
+ }
44
+ return paths;
45
+ }
46
+
47
+ /** Extract every !!touch: path from a model response. */
48
+ function parseTouchCommands(text) {
49
+ const paths = [];
50
+ for (const line of text.split('\n')) {
51
+ const match = line.match(/^!!touch:\s*(.+)$/);
52
+ if (match) paths.push(match[1].trim());
53
+ }
54
+ return paths;
55
+ }
56
+
57
+ /** Extract every !!delete: path from a model response. */
58
+ function parseDeleteCommands(text) {
59
+ const paths = [];
60
+ for (const line of text.split('\n')) {
61
+ const match = line.match(/^!!delete:\s*(.+)$/);
62
+ if (match) paths.push(match[1].trim());
63
+ }
64
+ return paths;
65
+ }
66
+
67
+ /**
68
+ * Parse and apply all file operations from a model reply.
69
+ * Shows diffs/confirmations for each op. Returns updated allFiles list.
70
+ */
71
+ async function handleFileOps(reply, loadedFiles) {
72
+ let allFiles = getFilesAndDirs();
73
+
74
+ // Run terminal commands
75
+ for (const cmd of parseRunCommands(reply)) {
76
+ const ok = await confirm(chalk.bold(`Run: ${chalk.cyan(cmd)}? [y/N] `));
77
+ if (ok) {
78
+ // cd must be handled in-process — child processes can't change the parent's cwd
79
+ const cdMatch = cmd.match(/^cd\s+(.+)$/);
80
+ if (cdMatch) {
81
+ const target = resolve(process.cwd(), cdMatch[1].trim().replace(/^~/, homedir()));
82
+ try {
83
+ process.chdir(target);
84
+ allFiles = getFilesAndDirs();
85
+ console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
86
+ } catch (err) {
87
+ console.log(chalk.red(` no such directory: ${target}\n`));
88
+ }
89
+ continue;
90
+ }
91
+ process.stdout.write(chalk.dim(` running: ${cmd}\n`));
92
+ const { stdout, stderr, exitCode } = await execCommand(cmd);
93
+ const output = [stdout, stderr].filter(Boolean).join('\n').trim();
94
+ console.log(output ? chalk.dim(output) + '\n' : chalk.dim(` (exit ${exitCode})\n`));
95
+ allFiles = getFilesAndDirs();
96
+ } else {
97
+ console.log(chalk.dim('skipped\n'));
98
+ }
99
+ }
100
+
101
+ // Write / edit files
102
+ const edits = parseEdits(reply, loadedFiles);
103
+ for (const { filepath, content } of edits) {
104
+ renderDiff(filepath, content);
105
+ const confirmed = await confirm(chalk.bold(`Write ${chalk.cyan(filepath)}? [y/N] `));
106
+ if (confirmed) {
107
+ try {
108
+ applyEdit(filepath, content);
109
+ allFiles = getFilesAndDirs();
110
+ console.log(chalk.green(`✓ wrote ${filepath}\n`));
111
+ } catch (err) {
112
+ console.log(chalk.red(`✗ could not write ${filepath}: ${err.message}\n`));
113
+ }
114
+ } else {
115
+ console.log(chalk.dim('skipped\n'));
116
+ }
117
+ }
118
+
119
+ // Create folders
120
+ for (const folderPath of parseMkdirCommands(reply)) {
121
+ process.stdout.write(chalk.dim(` mkdir: ${folderPath} — `));
122
+ const confirmed = await confirm(chalk.bold(`Create folder ${chalk.cyan(folderPath)}? [y/N] `));
123
+ if (confirmed) {
124
+ try {
125
+ mkdirSync(resolve(process.cwd(), folderPath), { recursive: true });
126
+ allFiles = getFilesAndDirs();
127
+ console.log(chalk.green(`✓ created ${folderPath}\n`));
128
+ } catch (err) {
129
+ console.log(chalk.red(`✗ could not create ${folderPath}: ${err.message}\n`));
130
+ }
131
+ } else {
132
+ console.log(chalk.dim('skipped\n'));
133
+ }
134
+ }
135
+
136
+ // Create empty files
137
+ for (const filePath of parseTouchCommands(reply)) {
138
+ const confirmed = await confirm(chalk.bold(`Create file ${chalk.cyan(filePath)}? [y/N] `));
139
+ if (confirmed) {
140
+ try {
141
+ const abs = resolve(process.cwd(), filePath);
142
+ const parentDir = abs.split('/').slice(0, -1).join('/');
143
+ mkdirSync(parentDir, { recursive: true });
144
+ writeFileSync(abs, '', { flag: 'wx' });
145
+ allFiles = getFilesAndDirs();
146
+ console.log(chalk.green(`✓ created ${filePath}\n`));
147
+ } catch (err) {
148
+ console.log(chalk.red(`✗ could not create ${filePath}: ${err.message}\n`));
149
+ }
150
+ } else {
151
+ console.log(chalk.dim('skipped\n'));
152
+ }
153
+ }
154
+
155
+ // Remove directories
156
+ for (const dirPath of parseRmdirCommands(reply)) {
157
+ const confirmed = await confirm(chalk.bold(`Remove directory ${chalk.cyan(dirPath)}? [y/N] `));
158
+ if (confirmed) {
159
+ const abs = resolve(process.cwd(), dirPath);
160
+ if (existsSync(abs)) {
161
+ try {
162
+ rmSync(abs, { recursive: true, force: true });
163
+ allFiles = getFilesAndDirs();
164
+ console.log(chalk.green(`✓ removed ${dirPath}\n`));
165
+ } catch (err) {
166
+ console.log(chalk.red(`✗ could not remove ${dirPath}: ${err.message}\n`));
167
+ }
168
+ } else {
169
+ console.log(chalk.yellow(`⚠ not found: ${dirPath}\n`));
170
+ }
171
+ } else {
172
+ console.log(chalk.dim('skipped\n'));
173
+ }
174
+ }
175
+
176
+ // Delete files
177
+ for (const filePath of parseDeleteCommands(reply)) {
178
+ const confirmed = await confirm(chalk.bold(`Delete ${chalk.cyan(filePath)}? [y/N] `));
179
+ if (confirmed) {
180
+ const abs = resolve(process.cwd(), filePath);
181
+ if (existsSync(abs)) {
182
+ try {
183
+ unlinkSync(abs);
184
+ allFiles = getFilesAndDirs();
185
+ console.log(chalk.green(`✓ deleted ${filePath}\n`));
186
+ } catch (err) {
187
+ console.log(chalk.red(`✗ could not delete ${filePath}: ${err.message}\n`));
188
+ }
189
+ } else {
190
+ console.log(chalk.yellow(`⚠ not found: ${filePath}\n`));
191
+ }
192
+ } else {
193
+ console.log(chalk.dim('skipped\n'));
194
+ }
195
+ }
196
+
197
+ return allFiles;
198
+ }
199
+
16
200
  /** Arrow-key selector. Returns the chosen string or null if cancelled. */
17
201
  function selectFrom(options, label) {
18
202
  return new Promise((resolve) => {
@@ -165,7 +349,30 @@ function countRows(lines, w) {
165
349
  * After streaming finishes, the viewport is replaced with the full response.
166
350
  * Returns the full reply string.
167
351
  */
352
+ function buildSystemMessage() {
353
+ const files = getFiles();
354
+ const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
355
+ const fileOpsInstructions =
356
+ `\nFILE OPERATIONS — use these exact syntaxes when needed:\n` +
357
+ `- Run a terminal command: output exactly on its own line: !!run: <command>\n` +
358
+ `- Write or edit a file: output "FILE: path/to/file" followed by a fenced code block with the full file content.\n` +
359
+ `- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
360
+ `- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
361
+ `- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
362
+ `- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
363
+ `- You may combine multiple operations in one response.\n` +
364
+ `- NEVER put commands in fenced code blocks — always use !!run: syntax for commands.\n`;
365
+ return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
366
+ }
367
+
168
368
  async function streamWithViewport(chatMessages, signal) {
369
+ // Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
370
+ const resolvedMessages = chatMessages.map(msg => {
371
+ if (msg.role !== 'user') return msg;
372
+ const { content } = resolveFileRefs(msg.content);
373
+ return { ...msg, content };
374
+ });
375
+
169
376
  const DOTS = ['.', '..', '...'];
170
377
  let dotIdx = 0;
171
378
  let firstToken = true;
@@ -185,7 +392,7 @@ async function streamWithViewport(chatMessages, signal) {
185
392
  process.stdin.on('data', onCancel);
186
393
 
187
394
  try {
188
- const reply = await streamChat(chatMessages, (token) => {
395
+ const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
189
396
  if (firstToken) {
190
397
  clearInterval(spinner);
191
398
  firstToken = false;
@@ -198,7 +405,7 @@ async function streamWithViewport(chatMessages, signal) {
198
405
  const w = process.stdout.columns || TERM_WIDTH;
199
406
  const displayWidth = Math.min(w, TERM_WIDTH);
200
407
  const lines = wrapText(fullText, displayWidth).split('\n');
201
- const viewLines = lines.slice(-VIEWPORT_LINES);
408
+ const viewLines = lines;
202
409
  const rendered = viewLines.join('\n');
203
410
 
204
411
  // Count actual terminal rows rendered (accounts for line wrapping).
@@ -245,28 +452,20 @@ const HELP_TEXT =
245
452
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
246
453
  chalk.cyan(' /plan <message>') + chalk.dim(' ask AI to create a plan (no files written)\n') +
247
454
  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
455
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
252
456
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
253
- chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
254
457
  chalk.dim('\nTips: use ') + chalk.cyan('@filename') + chalk.dim(' to attach a file · ctrl+q to cancel a response\n');
255
458
 
256
459
  export async function startInteractive() {
257
460
  printLogo();
258
461
 
259
- let allFiles = getFiles();
462
+ let allFiles = getFilesAndDirs();
260
463
  const chatMessages = [];
261
464
  let currentPlan = null; // { text: string } | null
262
465
 
263
466
  console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
264
467
  console.log(HELP_TEXT);
265
468
 
266
- if (!getToken()) {
267
- console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
268
- }
269
-
270
469
  while (true) {
271
470
  const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
272
471
  if (raw === null) continue;
@@ -274,19 +473,6 @@ export async function startInteractive() {
274
473
 
275
474
  if (!trimmed) continue;
276
475
 
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
476
  // /help — list all commands
291
477
  if (trimmed === '/help') {
292
478
  console.log(HELP_TEXT);
@@ -303,58 +489,6 @@ export async function startInteractive() {
303
489
  continue;
304
490
  }
305
491
 
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
492
  // /cd [path] — change working directory within this session
359
493
  if (trimmed.startsWith('/cd')) {
360
494
  const arg = trimmed.slice(3).trim();
@@ -363,7 +497,7 @@ export async function startInteractive() {
363
497
  : homedir();
364
498
  try {
365
499
  process.chdir(target);
366
- allFiles = getFiles();
500
+ allFiles = getFilesAndDirs();
367
501
  console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
368
502
  } catch {
369
503
  console.log(chalk.red(`\nno such directory: ${target}\n`));
@@ -371,14 +505,18 @@ export async function startInteractive() {
371
505
  continue;
372
506
  }
373
507
 
374
- // /plan <message> — ask LLM to produce a plan, store it (no files written)
508
+ // /plan <message> — ask LLM to produce a plan as a normal chat message, store it
375
509
  if (trimmed.startsWith('/plan ')) {
376
510
  const planRequest = trimmed.slice(6).trim();
377
511
  const planPrompt =
378
- `You are a planning assistant. Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
379
- `For each step specify what to do and which files to create or modify (with exact relative paths).\n` +
380
- `Do NOT write any code yet only the plan.`;
381
-
512
+ `Create a detailed, numbered plan for the following task:\n\n${planRequest}\n\n` +
513
+ `For each step, specify exactly what will happen and which syntax will be used:\n` +
514
+ `- Writing or editing a file FILE: path/to/file + fenced code block\n` +
515
+ `- Creating an empty file → !!touch: path/to/file\n` +
516
+ `- Creating a folder → !!mkdir: path/to/folder\n` +
517
+ `- Removing a folder → !!rmdir: path/to/folder\n` +
518
+ `- Deleting a file → !!delete: path/to/file\n\n` +
519
+ `Do NOT output any actual file contents or commands yet — only the plan.`;
382
520
  chatMessages.push({ role: 'user', content: planPrompt });
383
521
  const abortController = new AbortController();
384
522
  try {
@@ -396,7 +534,7 @@ export async function startInteractive() {
396
534
  continue;
397
535
  }
398
536
 
399
- // /build — execute the stored plan
537
+ // /build — execute the stored plan with full file ops
400
538
  if (trimmed === '/build') {
401
539
  if (!currentPlan) {
402
540
  console.log(chalk.yellow('\n⚠ No plan stored. Use /plan <message> first.\n'));
@@ -404,9 +542,8 @@ export async function startInteractive() {
404
542
  }
405
543
 
406
544
  const buildPrompt =
407
- `Execute the following plan by outputting ALL files to create or modify as fenced code blocks tagged with their exact relative filenames.\n\n` +
408
- `Plan:\n${currentPlan.text}\n\n` +
409
- `Output each file completely in a fenced code block like:\n\`\`\`path/to/file.ext\n// full content\n\`\`\``;
545
+ `Execute the following plan. Use FILE: syntax for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
546
+ `Plan:\n${currentPlan.text}`;
410
547
 
411
548
  chatMessages.push({ role: 'user', content: buildPrompt });
412
549
  const abortController = new AbortController();
@@ -416,28 +553,9 @@ export async function startInteractive() {
416
553
  chatMessages.pop();
417
554
  } else {
418
555
  chatMessages.push({ role: 'assistant', content: reply });
419
- const edits = parseEdits(reply, []);
420
- const appliedEdits = [];
421
- for (const { filepath, content } of edits) {
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
- }
556
+ allFiles = await handleFileOps(reply, []);
557
+ currentPlan = null;
558
+ console.log(chalk.green('✓ Plan executed.\n'));
441
559
  }
442
560
  } catch (err) {
443
561
  if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
@@ -445,70 +563,8 @@ export async function startInteractive() {
445
563
  continue;
446
564
  }
447
565
 
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
- }
500
- }
501
- } catch (err) {
502
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
503
- }
504
- continue;
505
- }
506
-
507
- // Determine mode
508
- const isEditMode = trimmed.startsWith('/edit ');
509
- const message = isEditMode ? trimmed.slice(6).trim() : trimmed;
510
-
511
- const { content, loaded, failed } = resolveFileRefs(message);
566
+ // Normal chat — file ops always available
567
+ const { loaded, failed } = resolveFileRefs(trimmed);
512
568
 
513
569
  if (loaded.length > 0) {
514
570
  console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
@@ -517,29 +573,8 @@ export async function startInteractive() {
517
573
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
518
574
  }
519
575
 
520
- // For /edit, expand scope to all files in the directories of @-referenced files.
521
- let editDirFiles = [];
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 });
576
+ // Store raw message @refs are re-resolved fresh on every API call
577
+ chatMessages.push({ role: 'user', content: trimmed });
543
578
 
544
579
  const abortController = new AbortController();
545
580
  try {
@@ -549,33 +584,7 @@ export async function startInteractive() {
549
584
  chatMessages.pop();
550
585
  } else {
551
586
  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
- }
587
+ allFiles = await handleFileOps(reply, loaded);
579
588
  }
580
589
  } catch (err) {
581
590
  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();