markov-cli 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js CHANGED
@@ -2,6 +2,7 @@ import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs';
4
4
  import { resolve, dirname } from 'path';
5
+ import { chatWithTools } from './ollama.js';
5
6
 
6
7
  const execAsync = promisify(exec);
7
8
 
@@ -29,23 +30,23 @@ export const RUN_TERMINAL_COMMAND_TOOL = {
29
30
  };
30
31
 
31
32
  /** 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
- };
33
+ // export const CREATE_FOLDER_TOOL = {
34
+ // type: 'function',
35
+ // function: {
36
+ // name: 'create_folder',
37
+ // description: 'Create a folder (and any necessary parent folders) in the current working directory.',
38
+ // parameters: {
39
+ // type: 'object',
40
+ // required: ['path'],
41
+ // properties: {
42
+ // path: {
43
+ // type: 'string',
44
+ // description: 'Relative path of the folder to create (e.g. "src/components").',
45
+ // },
46
+ // },
47
+ // },
48
+ // },
49
+ // };
49
50
 
50
51
  /** Ollama-format tool definition for reading a file */
51
52
  export const READ_FILE_TOOL = {
@@ -66,7 +67,7 @@ export const READ_FILE_TOOL = {
66
67
  },
67
68
  };
68
69
 
69
- /** Ollama-format tool definition for writing or overwriting a file */
70
+ /* WRITE_FILE_TOOL disabled: model uses run_terminal_command for file creation/overwrite
70
71
  export const WRITE_FILE_TOOL = {
71
72
  type: 'function',
72
73
  function: {
@@ -88,6 +89,7 @@ export const WRITE_FILE_TOOL = {
88
89
  },
89
90
  },
90
91
  };
92
+ */
91
93
 
92
94
  /** Ollama-format tool definition for search-and-replace in a file */
93
95
  export const SEARCH_REPLACE_TOOL = {
@@ -117,37 +119,83 @@ export const SEARCH_REPLACE_TOOL = {
117
119
  };
118
120
 
119
121
  /** Ollama-format tool definition for deleting a file */
120
- export const DELETE_FILE_TOOL = {
122
+ // export const DELETE_FILE_TOOL = {
123
+ // type: 'function',
124
+ // function: {
125
+ // name: 'delete_file',
126
+ // description: 'Delete a file. Does not remove directories.',
127
+ // parameters: {
128
+ // type: 'object',
129
+ // required: ['path'],
130
+ // properties: {
131
+ // path: {
132
+ // type: 'string',
133
+ // description: 'Relative path to the file to delete.',
134
+ // },
135
+ // },
136
+ // },
137
+ // },
138
+ // };
139
+
140
+ /** Ollama-format tool definition for listing directory contents */
141
+ // export const LIST_DIR_TOOL = {
142
+ // type: 'function',
143
+ // function: {
144
+ // name: 'list_dir',
145
+ // description: 'List files and folders in a directory. Use to discover paths before reading or editing.',
146
+ // parameters: {
147
+ // type: 'object',
148
+ // required: [],
149
+ // properties: {
150
+ // path: {
151
+ // type: 'string',
152
+ // description: 'Relative path to the directory (default "." for current directory).',
153
+ // },
154
+ // },
155
+ // },
156
+ // },
157
+ // };
158
+
159
+ /** Ollama-format tool definition for web search */
160
+ export const WEB_SEARCH_TOOL = {
121
161
  type: 'function',
122
162
  function: {
123
- name: 'delete_file',
124
- description: 'Delete a file. Does not remove directories.',
163
+ name: 'web_search',
164
+ description: 'Search the web for current information. Use when the user asks about recent events, documentation, or facts that may require up-to-date or external sources.',
125
165
  parameters: {
126
166
  type: 'object',
127
- required: ['path'],
167
+ required: ['query'],
128
168
  properties: {
129
- path: {
169
+ query: {
130
170
  type: 'string',
131
- description: 'Relative path to the file to delete.',
171
+ description: 'Search query (e.g. "Node.js 20 release date", "React useEffect docs").',
172
+ },
173
+ num_results: {
174
+ type: 'number',
175
+ description: 'Maximum number of results to return (default 5).',
132
176
  },
133
177
  },
134
178
  },
135
179
  },
136
180
  };
137
181
 
138
- /** Ollama-format tool definition for listing directory contents */
139
- export const LIST_DIR_TOOL = {
182
+ /** Ollama-format tool definition for delegating a task to a fast 0.8b subagent */
183
+ export const CREATE_SUBAGENT_TOOL = {
140
184
  type: 'function',
141
185
  function: {
142
- name: 'list_dir',
143
- description: 'List files and folders in a directory. Use to discover paths before reading or editing.',
186
+ name: 'create_subagent',
187
+ description: 'Delegate a focused, self-contained task to a fast 0.8b model. Use for isolated sub-tasks like summarizing text, generating a regex, writing a small helper function, or answering a specific question. The subagent has no tools and no prior conversation context — pass everything it needs in task/context. Its response is returned directly to you.',
144
188
  parameters: {
145
189
  type: 'object',
146
- required: [],
190
+ required: ['task'],
147
191
  properties: {
148
- path: {
192
+ task: {
193
+ type: 'string',
194
+ description: 'The specific task or question for the subagent to complete (be precise and self-contained).',
195
+ },
196
+ context: {
149
197
  type: 'string',
150
- description: 'Relative path to the directory (default "." for current directory).',
198
+ description: 'Optional extra context the subagent needs (e.g. a file snippet, a schema, relevant background). Keep it concise.',
151
199
  },
152
200
  },
153
201
  },
@@ -157,12 +205,14 @@ export const LIST_DIR_TOOL = {
157
205
  /** Tools array for agent loop (chatWithTools). */
158
206
  export const AGENT_TOOLS = [
159
207
  RUN_TERMINAL_COMMAND_TOOL,
160
- CREATE_FOLDER_TOOL,
208
+ // CREATE_FOLDER_TOOL,
161
209
  READ_FILE_TOOL,
162
- WRITE_FILE_TOOL,
210
+ // WRITE_FILE_TOOL, // disabled: model uses run_terminal_command for file creation/overwrite
163
211
  SEARCH_REPLACE_TOOL,
164
- DELETE_FILE_TOOL,
165
- LIST_DIR_TOOL,
212
+ // DELETE_FILE_TOOL,
213
+ // LIST_DIR_TOOL,
214
+ WEB_SEARCH_TOOL,
215
+ CREATE_SUBAGENT_TOOL,
166
216
  ];
167
217
 
168
218
  function getPath(args, opts) {
@@ -199,6 +249,7 @@ const TOOLS_MAP = {
199
249
  return { error: err.message };
200
250
  }
201
251
  },
252
+ /* write_file disabled: model uses run_terminal_command for file creation/overwrite
202
253
  write_file: async (args, opts = {}) => {
203
254
  const { path: relPath, absPath } = getPath(args, opts);
204
255
  const content = typeof args.content === 'string' ? args.content : String(args?.content ?? '');
@@ -211,6 +262,7 @@ const TOOLS_MAP = {
211
262
  return { success: false, error: err.message };
212
263
  }
213
264
  },
265
+ */
214
266
  search_replace: async (args, opts = {}) => {
215
267
  const { path: relPath, absPath } = getPath(args, opts);
216
268
  const oldStr = typeof args.old_string === 'string' ? args.old_string : String(args?.old_string ?? '');
@@ -287,6 +339,70 @@ const TOOLS_MAP = {
287
339
  return { stdout, stderr, exitCode };
288
340
  }
289
341
  },
342
+ web_search: async (args) => {
343
+ const apiKey = '763399c40c6e5025305aff6f12bbff36152c0830';
344
+ if (!apiKey || !apiKey.trim()) {
345
+ return { error: 'Web search not configured. Set MARKOV_SEARCH_API_KEY.' };
346
+ }
347
+ const query = typeof args.query === 'string' ? args.query.trim() : String(args?.query ?? '').trim();
348
+ if (!query) {
349
+ return { error: 'Query is required for web_search.' };
350
+ }
351
+ const numResults = Math.min(10, Math.max(1, parseInt(args?.num_results, 10) || 5));
352
+ try {
353
+ const res = await fetch('https://google.serper.dev/search', {
354
+ method: 'POST',
355
+ headers: {
356
+ 'X-API-KEY': apiKey,
357
+ 'Content-Type': 'application/json',
358
+ },
359
+ body: JSON.stringify({ q: query, num: numResults }),
360
+ });
361
+ if (!res.ok) {
362
+ const text = await res.text();
363
+ return { error: `Search API error ${res.status}: ${text.slice(0, 200)}` };
364
+ }
365
+ const data = await res.json();
366
+ const organic = data?.organic ?? [];
367
+ const results = organic.slice(0, numResults).map((item) => ({
368
+ title: item.title ?? '',
369
+ link: item.link ?? item.url ?? '',
370
+ snippet: item.snippet ?? '',
371
+ }));
372
+ return { results, query };
373
+ } catch (err) {
374
+ return { error: err?.message ?? 'Web search request failed.' };
375
+ }
376
+ },
377
+ create_subagent: async (args) => {
378
+ const task = typeof args.task === 'string' ? args.task.trim() : '';
379
+ const context = typeof args.context === 'string' ? args.context.trim() : '';
380
+ if (!task) return { error: 'task is required for create_subagent' };
381
+
382
+ const messages = [
383
+ {
384
+ role: 'system',
385
+ content: 'You are a focused AI subagent. Complete the assigned task concisely and return only the result. No preamble.',
386
+ },
387
+ {
388
+ role: 'user',
389
+ content: context ? `Context:\n${context}\n\nTask: ${task}` : task,
390
+ },
391
+ ];
392
+
393
+ try {
394
+ const data = await chatWithTools(messages, [RUN_TERMINAL_COMMAND_TOOL], 'qwen3.5:0.8b', null, 'ollama');
395
+ // Backend returns raw JSON; Claude path returns { message: { content } }
396
+ const content =
397
+ data?.message?.content ??
398
+ data?.choices?.[0]?.message?.content ??
399
+ data?.content ??
400
+ '';
401
+ return { result: content, model: 'qwen3.5:0.8b' };
402
+ } catch (err) {
403
+ return { error: err.message ?? 'Subagent call failed' };
404
+ }
405
+ },
290
406
  };
291
407
 
292
408
  /**
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk';
2
+
3
+ const TERM_WIDTH = 80;
4
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
5
+ const visLen = (s) => s.replace(ANSI_RE, '').length;
6
+
7
+ /** Print token usage when the provider returns it (OpenAI or Anthropic shape). */
8
+ export function printTokenUsage(usage) {
9
+ if (!usage) return;
10
+ // OpenAI: prompt_tokens, completion_tokens, total_tokens
11
+ if (typeof usage.prompt_tokens === 'number' && typeof usage.completion_tokens === 'number') {
12
+ const total = usage.total_tokens ?? usage.prompt_tokens + usage.completion_tokens;
13
+ console.log(chalk.dim(`Tokens: prompt ${usage.prompt_tokens}, completion ${usage.completion_tokens}, total ${total}`));
14
+ return;
15
+ }
16
+ // Anthropic: input_tokens, output_tokens
17
+ if (typeof usage.input_tokens === 'number' && typeof usage.output_tokens === 'number') {
18
+ const total = usage.input_tokens + usage.output_tokens;
19
+ console.log(chalk.dim(`Tokens: input ${usage.input_tokens}, output ${usage.output_tokens}, total ${total}`));
20
+ }
21
+ }
22
+
23
+ /** Word-wrap plain text to a given column width, preserving existing newlines. */
24
+ export function wrapText(text, width) {
25
+ return text.split('\n').map(line => {
26
+ if (visLen(line) <= width) return line;
27
+ const words = line.split(' ');
28
+ const wrapped = [];
29
+ let current = '';
30
+ for (const word of words) {
31
+ const test = current ? current + ' ' + word : word;
32
+ if (visLen(test) <= width) {
33
+ current = test;
34
+ } else {
35
+ if (current) wrapped.push(current);
36
+ current = word;
37
+ }
38
+ }
39
+ if (current) wrapped.push(current);
40
+ return wrapped.join('\n');
41
+ }).join('\n');
42
+ }
43
+
44
+ /** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
45
+ export function formatResponseWithCodeBlocks(text, width) {
46
+ if (!text || typeof text !== 'string') return '';
47
+ const re = /```(\w*)\n([\s\S]*?)```/g;
48
+ const parts = [];
49
+ let lastIndex = 0;
50
+ let m;
51
+ while ((m = re.exec(text)) !== null) {
52
+ if (m.index > lastIndex) {
53
+ const textSegment = text.slice(lastIndex, m.index);
54
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
55
+ }
56
+ parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
57
+ lastIndex = re.lastIndex;
58
+ }
59
+ if (lastIndex < text.length) {
60
+ const textSegment = text.slice(lastIndex);
61
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
62
+ }
63
+ if (parts.length === 0) return wrapText(text, width);
64
+ return parts.map((p) => {
65
+ if (p.type === 'text') return wrapText(p.content, width);
66
+ const label = p.lang ? p.lang : 'code';
67
+ const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
68
+ const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
69
+ return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
70
+ }).join('\n\n');
71
+ }
72
+
73
+ /** Preview of a file edit for confirmation (search_replace) */
74
+ export function formatFileEditPreview(name, args) {
75
+ const path = args?.path ?? '(no path)';
76
+ if (name === 'search_replace') {
77
+ const oldStr = String(args?.old_string ?? '');
78
+ const newStr = String(args?.new_string ?? '');
79
+ const max = 120;
80
+ const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
81
+ const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
82
+ return (
83
+ chalk.cyan(path) + '\n' +
84
+ chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
85
+ chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
86
+ );
87
+ }
88
+ return path;
89
+ }
90
+
91
+ /** One-line summary of tool args for display */
92
+ export function formatToolCallSummary(name, args) {
93
+ const a = args ?? {};
94
+ if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
95
+ if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
96
+ if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
97
+ if (name === 'create_folder') return a.path ?? '(no path)';
98
+ if (name === 'list_dir') return (a.path ?? '.') || '.';
99
+ if (name === 'web_search') return (a.query ?? '').trim() || '(empty query)';
100
+ return JSON.stringify(a).slice(0, 50);
101
+ }
102
+
103
+ /** One-line summary of tool result for display */
104
+ export function formatToolResultSummary(name, resultJson) {
105
+ let obj;
106
+ try {
107
+ obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
108
+ } catch {
109
+ return resultJson?.slice(0, 60) ?? '—';
110
+ }
111
+ if (obj.error) return chalk.red('✗ ' + obj.error);
112
+ if (obj.declined) return chalk.yellow('✗ declined');
113
+ if (name === 'run_terminal_command') {
114
+ const code = obj.exitCode ?? obj.exit_code;
115
+ if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
116
+ return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
117
+ }
118
+ if (name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
119
+ return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
120
+ }
121
+ if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
122
+ if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
123
+ if (name === 'web_search') return obj.error ? chalk.red('✗ ' + obj.error) : (obj.results ? chalk.green('✓ ' + (obj.results.length ?? 0) + ' results') : chalk.dim('—'));
124
+ return chalk.dim(JSON.stringify(obj).slice(0, 60));
125
+ }
@@ -0,0 +1,116 @@
1
+ import chalk from 'chalk';
2
+
3
+ /** Arrow-key selector. Returns the chosen string or null if cancelled. */
4
+ export function selectFrom(options, label) {
5
+ return new Promise((resolve) => {
6
+ let idx = 0;
7
+
8
+ const draw = () => {
9
+ process.stdout.write('\r\x1b[0J');
10
+ process.stdout.write(chalk.dim(label) + '\n');
11
+ options.forEach((o, i) => {
12
+ process.stdout.write(
13
+ i === idx
14
+ ? ' ' + chalk.bgCyan.black(` ${o} `) + '\n'
15
+ : ' ' + chalk.dim(o) + '\n'
16
+ );
17
+ });
18
+ // Move cursor back up to keep it stable
19
+ process.stdout.write(`\x1b[${options.length + 1}A`);
20
+ };
21
+
22
+ const cleanup = () => {
23
+ process.stdin.removeListener('data', onKey);
24
+ process.stdin.setRawMode(false);
25
+ process.stdin.pause();
26
+ // Clear the drawn lines
27
+ process.stdout.write('\r\x1b[0J');
28
+ };
29
+
30
+ const onKey = (data) => {
31
+ const key = data.toString();
32
+ if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
33
+ if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
34
+ if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
35
+ if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
36
+ };
37
+
38
+ process.stdin.setRawMode(true);
39
+ process.stdin.resume();
40
+ process.stdin.setEncoding('utf8');
41
+ process.stdin.on('data', onKey);
42
+ draw();
43
+ });
44
+ }
45
+
46
+ /** Prompt y/n in raw mode, returns true for y/Y. */
47
+ export function confirm(question) {
48
+ return new Promise((resolve) => {
49
+ process.stdout.write(question);
50
+ process.stdin.setRawMode(true);
51
+ process.stdin.resume();
52
+ process.stdin.setEncoding('utf8');
53
+ const onKey = (key) => {
54
+ process.stdin.removeListener('data', onKey);
55
+ process.stdin.setRawMode(false);
56
+ process.stdin.pause();
57
+ const answer = key.toLowerCase() === 'y';
58
+ process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
59
+ resolve(answer);
60
+ };
61
+ process.stdin.on('data', onKey);
62
+ });
63
+ }
64
+
65
+ /** Read a visible line of input. */
66
+ export function promptLine(label) {
67
+ return new Promise((resolve) => {
68
+ process.stdout.write(label);
69
+ let buf = '';
70
+ const onData = (data) => {
71
+ const key = data.toString();
72
+ if (key === '\r' || key === '\n') {
73
+ process.stdin.removeListener('data', onData);
74
+ process.stdin.setRawMode(false);
75
+ process.stdin.pause();
76
+ process.stdout.write('\n');
77
+ resolve(buf);
78
+ } else if (key === '\x7f' || key === '\b') {
79
+ if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
80
+ } else if (key >= ' ') {
81
+ buf += key;
82
+ process.stdout.write(key);
83
+ }
84
+ };
85
+ process.stdin.setRawMode(true);
86
+ process.stdin.resume();
87
+ process.stdin.setEncoding('utf8');
88
+ process.stdin.on('data', onData);
89
+ });
90
+ }
91
+
92
+ /** Read a line of input without echo (for passwords). */
93
+ export function promptSecret(label) {
94
+ return new Promise((resolve) => {
95
+ process.stdout.write(label);
96
+ let buf = '';
97
+ const onData = (data) => {
98
+ const key = data.toString();
99
+ if (key === '\r' || key === '\n') {
100
+ process.stdin.removeListener('data', onData);
101
+ process.stdin.setRawMode(false);
102
+ process.stdin.pause();
103
+ process.stdout.write('\n');
104
+ resolve(buf);
105
+ } else if (key === '\x7f' || key === '\b') {
106
+ if (buf.length > 0) buf = buf.slice(0, -1);
107
+ } else if (key >= ' ') {
108
+ buf += key;
109
+ }
110
+ };
111
+ process.stdin.setRawMode(true);
112
+ process.stdin.resume();
113
+ process.stdin.setEncoding('utf8');
114
+ process.stdin.on('data', onData);
115
+ });
116
+ }
@@ -0,0 +1,40 @@
1
+ import chalk from 'chalk';
2
+ import gradient from 'gradient-string';
3
+
4
+ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
5
+
6
+ /**
7
+ * Create a spinner with a given label.
8
+ * Returns an object with a stop() method.
9
+ * @param {string} label - The label to display before the spinner
10
+ * @returns {{ stop: () => void }} A spinner handle with a stop() method
11
+ */
12
+ export function createSpinner(label) {
13
+ const DOTS = ['.', '..', '...'];
14
+ let dotIdx = 0;
15
+ let interval = null;
16
+ const startTime = Date.now();
17
+
18
+ const start = () => {
19
+ if (interval) clearInterval(interval);
20
+ dotIdx = 0;
21
+ process.stdout.write(chalk.dim(`\n${label}`));
22
+ interval = setInterval(() => {
23
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
24
+ process.stdout.write('\r' + chalk.dim(label) + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
25
+ dotIdx++;
26
+ }, 400);
27
+ };
28
+
29
+ const stop = () => {
30
+ if (interval) {
31
+ clearInterval(interval);
32
+ interval = null;
33
+ }
34
+ process.stdout.write('\r\x1b[0J');
35
+ };
36
+
37
+ start();
38
+
39
+ return { stop };
40
+ }