ucode-agent 1.0.0

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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * index.js — the tool registry: schemas, argument checking, dispatch, and the
3
+ * line the user reads while each one runs.
4
+ */
5
+
6
+ import { ToolFailure } from '../core/failure.js';
7
+ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit } from './files.js';
8
+ import { listDir, glob, grep } from './search.js';
9
+ import { runCommand, runCommands } from './shell.js';
10
+ import { webSearch } from './web.js';
11
+ import { clip, READ_LINES } from './shared.js';
12
+
13
+ export { setRoot, setConfirm, getRoot } from './shared.js';
14
+
15
+ const str = (description) => ({ type: 'string', description });
16
+ const int = (description) => ({ type: 'integer', description });
17
+ const bool = (description) => ({ type: 'boolean', description });
18
+
19
+ export const tools = [
20
+ {
21
+ name: 'read_file',
22
+ description:
23
+ 'Read a text file. Comes back as numbered lines — the numbers are for you to ' +
24
+ 'refer to and must never appear in an edit_file argument. Long files arrive in ' +
25
+ 'pages; pass offset to keep going.',
26
+ parameters: {
27
+ type: 'object',
28
+ properties: {
29
+ path: str('File path, relative to the project root.'),
30
+ offset: int('First line to read, 1-based. Defaults to 1.'),
31
+ limit: int(`How many lines. Defaults to ${READ_LINES}.`),
32
+ },
33
+ required: ['path'],
34
+ },
35
+ },
36
+ {
37
+ name: 'read_files',
38
+ description:
39
+ 'Read several text files in one call. Use this whenever you need more than one ' +
40
+ 'file - it is one round trip instead of one per file, so it is much faster than ' +
41
+ 'calling read_file repeatedly. Same numbered-line output as read_file, one block ' +
42
+ 'per file. A missing file is reported in its place without failing the others.',
43
+ parameters: {
44
+ type: 'object',
45
+ properties: {
46
+ paths: {
47
+ type: 'array',
48
+ description: 'File paths, relative to the project root. Up to 20.',
49
+ items: { type: 'string' },
50
+ },
51
+ limit: int(`Lines per file. Defaults to ${READ_LINES}.`),
52
+ },
53
+ required: ['paths'],
54
+ },
55
+ },
56
+ {
57
+ name: 'write_file',
58
+ description:
59
+ 'Create a file, or replace all of its contents. For a change to part of an ' +
60
+ 'existing file use edit_file instead — this one throws away everything that was ' +
61
+ 'there. Missing parent directories are created.',
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ path: str('File path, relative to the project root.'),
66
+ content: str('The complete text of the file.'),
67
+ },
68
+ required: ['path', 'content'],
69
+ },
70
+ },
71
+ {
72
+ name: 'batch_write',
73
+ description:
74
+ 'Create or replace several files in one call. Use this to lay out a whole ' +
75
+ 'project at once instead of calling write_file over and over — it is the ' +
76
+ 'difference between one round trip and twenty.',
77
+ parameters: {
78
+ type: 'object',
79
+ properties: {
80
+ files: {
81
+ type: 'array',
82
+ description: 'The files to write.',
83
+ items: {
84
+ type: 'object',
85
+ properties: {
86
+ path: str('File path, relative to the project root.'),
87
+ content: str('The complete text of the file.'),
88
+ },
89
+ required: ['path', 'content'],
90
+ },
91
+ },
92
+ },
93
+ required: ['files'],
94
+ },
95
+ },
96
+ {
97
+ name: 'edit_file',
98
+ description:
99
+ 'Replace one exact piece of text in a file. old_string must match the file ' +
100
+ 'character for character, including indentation, and must occur exactly once — ' +
101
+ 'the edit is refused on zero matches and on two. This is the normal way to ' +
102
+ 'change existing code.',
103
+ parameters: {
104
+ type: 'object',
105
+ properties: {
106
+ path: str('File path, relative to the project root.'),
107
+ old_string: str('The exact text to replace. Must be unique in the file.'),
108
+ new_string: str('What to put there instead.'),
109
+ },
110
+ required: ['path', 'old_string', 'new_string'],
111
+ },
112
+ },
113
+ {
114
+ name: 'multi_edit',
115
+ description:
116
+ 'Several exact replacements in one file, applied in order, each seeing the ' +
117
+ 'result of the last. Same rules as edit_file for each one. If any of them is ' +
118
+ 'ambiguous or missing, none are written at all. Prefer this to calling ' +
119
+ 'edit_file repeatedly on the same file.',
120
+ parameters: {
121
+ type: 'object',
122
+ properties: {
123
+ path: str('File path, relative to the project root.'),
124
+ edits: {
125
+ type: 'array',
126
+ description: 'The replacements, in the order they should be applied.',
127
+ items: {
128
+ type: 'object',
129
+ properties: {
130
+ old_string: str('The exact text to replace. Must be unique at that point.'),
131
+ new_string: str('What to put there instead.'),
132
+ },
133
+ required: ['old_string', 'new_string'],
134
+ },
135
+ },
136
+ },
137
+ required: ['path', 'edits'],
138
+ },
139
+ },
140
+ {
141
+ name: 'list_dir',
142
+ description: 'List what is in one directory, with file sizes.',
143
+ parameters: {
144
+ type: 'object',
145
+ properties: { path: str('Directory path. Defaults to the project root.') },
146
+ required: [],
147
+ },
148
+ },
149
+ {
150
+ name: 'glob',
151
+ description:
152
+ 'Find files by name pattern, most recently changed first. Understands **, *, ? ' +
153
+ 'and {a,b}. node_modules, .git, dist and similar are skipped unless the pattern ' +
154
+ 'names one of them.',
155
+ parameters: {
156
+ type: 'object',
157
+ properties: {
158
+ pattern: str('Glob pattern, e.g. "src/**/*.{ts,tsx}".'),
159
+ path: str('Directory to look under. Defaults to the project root.'),
160
+ },
161
+ required: ['pattern'],
162
+ },
163
+ },
164
+ {
165
+ name: 'grep',
166
+ description:
167
+ 'Search inside files with a regular expression. Returns file:line: text for ' +
168
+ 'every match. Pass glob to limit which files get read.',
169
+ parameters: {
170
+ type: 'object',
171
+ properties: {
172
+ pattern: str('A JavaScript regular expression.'),
173
+ path: str('File or directory to search. Defaults to the project root.'),
174
+ glob: str('Optional filename filter, e.g. "**/*.js".'),
175
+ ignore_case: bool('Match case-insensitively. Defaults to false.'),
176
+ },
177
+ required: ['pattern'],
178
+ },
179
+ },
180
+ {
181
+ name: 'run_command',
182
+ description:
183
+ 'Run a shell command and get back its output and exit code. It runs without ' +
184
+ 'asking, so never run something destructive the user did not ask for. There is ' +
185
+ 'no keyboard: pass the non-interactive flag to anything that would ask a question. ' +
186
+ 'Dev servers (npm run dev, vite, next dev, uvicorn...) are started in the ' +
187
+ 'background automatically and the result comes back as soon as the server says ' +
188
+ 'it is ready, with the URL it is listening on - do not start one twice.',
189
+ parameters: {
190
+ type: 'object',
191
+ properties: {
192
+ command: str('The whole command line.'),
193
+ cwd: str('Directory to run it in. Defaults to the project root.'),
194
+ timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
195
+ background: bool('Start it detached and return its PID. For servers.'),
196
+ },
197
+ required: ['command'],
198
+ },
199
+ },
200
+ {
201
+ name: 'run_commands',
202
+ description:
203
+ 'Run several shell commands at once, up to max_parallel at a time. Good for ' +
204
+ 'independent work — install, lint and test together rather than one after ' +
205
+ 'another. Each entry takes the same fields as run_command.',
206
+ parameters: {
207
+ type: 'object',
208
+ properties: {
209
+ commands: {
210
+ type: 'array',
211
+ description: 'The commands to run.',
212
+ items: {
213
+ type: 'object',
214
+ properties: {
215
+ command: str('The whole command line.'),
216
+ cwd: str('Directory to run it in. Defaults to the project root.'),
217
+ timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
218
+ background: bool('Start it detached and return its PID.'),
219
+ },
220
+ required: ['command'],
221
+ },
222
+ },
223
+ max_parallel: {
224
+ type: 'integer',
225
+ description: 'How many may run at once. Default 3.',
226
+ minimum: 1,
227
+ maximum: 10,
228
+ },
229
+ },
230
+ required: ['commands'],
231
+ },
232
+ },
233
+ {
234
+ name: 'web_search',
235
+ description:
236
+ 'Search the web and get back titles, links and summaries. For anything the ' +
237
+ 'project files and your own knowledge cannot settle: current versions, recent ' +
238
+ 'releases, an unfamiliar error, documentation for an API you do not know. Cite ' +
239
+ 'the URLs you actually used.',
240
+ parameters: {
241
+ type: 'object',
242
+ properties: {
243
+ query: str('What to look up.'),
244
+ max_results: int('How many results, 1-10. Defaults to 5.'),
245
+ },
246
+ required: ['query'],
247
+ },
248
+ },
249
+ ];
250
+
251
+ const run = {
252
+ read_file: readFile,
253
+ read_files: readFiles,
254
+ write_file: writeFile,
255
+ batch_write: batchWrite,
256
+ edit_file: editFile,
257
+ multi_edit: multiEdit,
258
+ list_dir: listDir,
259
+ glob,
260
+ grep,
261
+ run_command: runCommand,
262
+ run_commands: runCommands,
263
+ web_search: webSearch,
264
+ };
265
+
266
+ /** Tools that change the project or execute code. */
267
+ export const MUTATING = new Set([
268
+ 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'run_command', 'run_commands',
269
+ ]);
270
+
271
+ /** Tools with no side effects, so several may run at the same time. */
272
+ export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search']);
273
+
274
+ /** Tools withheld in plan mode. Withholding beats asking a model not to. */
275
+ export const WRITES = new Set([
276
+ 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'run_command', 'run_commands',
277
+ ]);
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Argument checking
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Check the model's arguments against the schema before anything runs.
285
+ *
286
+ * Catching it here means the model gets a precise sentence about what it got
287
+ * wrong and can correct itself, instead of a TypeError thrown from somewhere
288
+ * inside fs that means nothing to anybody.
289
+ */
290
+ function check(name, args) {
291
+ const schema = tools.find((t) => t.name === name).parameters;
292
+ const problems = [];
293
+
294
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
295
+ return ['the arguments must be a JSON object'];
296
+ }
297
+
298
+ for (const key of schema.required ?? []) {
299
+ if (args[key] === undefined || args[key] === null) problems.push(`"${key}" is required and missing`);
300
+ }
301
+
302
+ for (const [key, value] of Object.entries(args)) {
303
+ const spec = schema.properties[key];
304
+ if (!spec) {
305
+ problems.push(`"${key}" is not an argument of ${name} (it takes: ${Object.keys(schema.properties).join(', ')})`);
306
+ continue;
307
+ }
308
+ if (value === undefined || value === null) continue;
309
+
310
+ const actual = Array.isArray(value) ? 'array' : typeof value;
311
+ const wanted = spec.type === 'integer' ? 'number' : spec.type;
312
+ // A number sent as a string is close enough — the tool coerces it anyway.
313
+ if (wanted === 'number' && actual === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) continue;
314
+ if (actual !== wanted) problems.push(`"${key}" should be ${spec.type} but was ${actual}`);
315
+ }
316
+
317
+ return problems;
318
+ }
319
+
320
+ export async function runTool(name, args = {}, opts = {}) {
321
+ const impl = run[name];
322
+ if (!impl) {
323
+ throw new ToolFailure({
324
+ kind: 'no_such_tool',
325
+ attempted: `calling ${name}`,
326
+ failed: `There is no tool called "${name}".`,
327
+ fix: `The tools you have are: ${tools.map((t) => t.name).join(', ')}.`,
328
+ });
329
+ }
330
+
331
+ const problems = check(name, args);
332
+ if (problems.length) {
333
+ throw new ToolFailure({
334
+ kind: 'bad_args',
335
+ attempted: `calling ${name}`,
336
+ failed: `The arguments were wrong: ${problems.join('; ')}.`,
337
+ fix: `Call ${name} again with them corrected. Its schema is: ${JSON.stringify(
338
+ tools.find((t) => t.name === name).parameters
339
+ )}`,
340
+ detail: { problems },
341
+ });
342
+ }
343
+
344
+ return impl(args, opts);
345
+ }
346
+
347
+ /**
348
+ * The line shown while a call runs: "Listing src", "Running npm test".
349
+ *
350
+ * Present tense, no trailing full stop — it is a label on something happening
351
+ * now, not a sentence about something that happened. It is built from the call
352
+ * itself rather than from what the model said it would do, so it is always an
353
+ * account of the real work.
354
+ */
355
+ export function describe(name, args = {}) {
356
+ switch (name) {
357
+ case 'read_file':
358
+ return `Reading ${clip(args.path)}${args.offset > 1 ? ` from line ${args.offset}` : ''}`;
359
+ case 'read_files': {
360
+ const names = (args.paths ?? []).map((p) => String(p));
361
+ const joined = names.join(', ');
362
+ return names.length && joined.length <= 60 ? `Reading ${joined}` : `Reading ${names.length} files`;
363
+ }
364
+ case 'write_file':
365
+ return `Writing ${clip(args.path)}`;
366
+ case 'batch_write': {
367
+ const n = args.files?.length ?? 0;
368
+ const first = args.files?.[0]?.path;
369
+ return n === 1 && first ? `Writing ${clip(first)}` : `Writing ${n} files`;
370
+ }
371
+ case 'edit_file':
372
+ return `Editing ${clip(args.path)}`;
373
+ case 'multi_edit':
374
+ return `Editing ${clip(args.path)}, ${args.edits?.length ?? 0} changes`;
375
+ case 'list_dir':
376
+ return !args.path || args.path === '.'
377
+ ? 'Listing the project root'
378
+ : `Listing ${clip(args.path)}`;
379
+ case 'glob':
380
+ return `Finding ${clip(args.pattern)}`;
381
+ case 'grep':
382
+ return `Searching for ${clip(args.pattern, 40)}${args.glob ? ` in ${clip(args.glob, 20)}` : ''}`;
383
+ case 'run_command':
384
+ return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
385
+ case 'run_commands':
386
+ return `Running ${args.commands?.length ?? 0} commands together`;
387
+ case 'web_search':
388
+ return `Searching the web for ${clip(args.query, 60)}`;
389
+ case 'load_skill':
390
+ return `Loading the ${clip(args.name, 40)} skill`;
391
+ default:
392
+ return `${name} ${clip(JSON.stringify(args), 60)}`;
393
+ }
394
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * search.js — finding things: what is in a directory, which files match a
3
+ * name pattern, and which lines match a regular expression.
4
+ */
5
+
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { ToolFailure } from '../core/failure.js';
9
+ import {
10
+ resolveIn, guard, result, fsFailure, looksBinary, bytes, walk, globToRegExp,
11
+ SKIP, MAX_GLOB_HITS, MAX_GREP_HITS, WALK_WIDTH,
12
+ } from './shared.js';
13
+
14
+ export async function listDir({ path: p = '.' }) {
15
+ const target = resolveIn(p || '.', 'list_dir');
16
+ await guard(target, `list ${target.abs}`);
17
+ const attempted = `listing ${target.show}`;
18
+
19
+ let entries;
20
+ try {
21
+ entries = await fs.readdir(target.abs, { withFileTypes: true });
22
+ } catch (err) {
23
+ throw fsFailure(err, attempted, target.show);
24
+ }
25
+
26
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => `${e.name}/`);
27
+
28
+ // Every size looked up at once rather than one stat after another.
29
+ const files = await Promise.all(
30
+ entries
31
+ .filter((e) => !e.isDirectory())
32
+ .map(async (entry) => {
33
+ if (!entry.isFile()) return `${entry.name} (link or device)`;
34
+ try {
35
+ return `${entry.name} (${bytes((await fs.stat(path.join(target.abs, entry.name))).size)})`;
36
+ } catch {
37
+ // A file that disappeared between the listing and the stat is not
38
+ // worth failing the whole call over.
39
+ return entry.name;
40
+ }
41
+ })
42
+ );
43
+
44
+ dirs.sort();
45
+ files.sort();
46
+
47
+ return result(
48
+ `${target.show}/\n${[...dirs, ...files].join('\n') || '(empty)'}`,
49
+ `${dirs.length} dir${dirs.length === 1 ? '' : 's'}, ${files.length} file${files.length === 1 ? '' : 's'}`
50
+ );
51
+ }
52
+
53
+ export async function glob({ pattern, path: p = '.' }) {
54
+ if (typeof pattern !== 'string' || !pattern.trim()) {
55
+ throw new ToolFailure({
56
+ kind: 'bad_args',
57
+ attempted: 'matching files by name',
58
+ failed: 'The "pattern" argument was missing or empty.',
59
+ fix: 'Pass something like "**/*.js" or "src/**/*.{ts,tsx}".',
60
+ });
61
+ }
62
+
63
+ const target = resolveIn(p || '.', 'glob');
64
+ await guard(target, `search ${target.abs}`);
65
+
66
+ const re = globToRegExp(pattern.trim());
67
+ // If the pattern names an ignored folder outright, the user meant it.
68
+ const includeSkipped = [...SKIP].some((d) => pattern.includes(d));
69
+ const all = await walk(target.abs, { includeSkipped });
70
+ const matched = all.filter((rel) => re.test(rel));
71
+
72
+ if (matched.length === 0) {
73
+ return result(
74
+ `Nothing matched "${pattern}" under ${target.show}. Looked at ${all.length} files; ` +
75
+ 'build and vendor folders are skipped unless the pattern names one.',
76
+ 'no matches'
77
+ );
78
+ }
79
+
80
+ // Newest first: when hunting through an unfamiliar codebase, the files
81
+ // somebody touched recently are nearly always the ones that matter.
82
+ const dated = await Promise.all(
83
+ matched.slice(0, 2000).map(async (rel) => {
84
+ try {
85
+ return { rel, at: (await fs.stat(path.join(target.abs, rel))).mtimeMs };
86
+ } catch {
87
+ return { rel, at: 0 };
88
+ }
89
+ })
90
+ );
91
+ dated.sort((a, b) => b.at - a.at);
92
+
93
+ const shown = dated.slice(0, MAX_GLOB_HITS).map((f) => f.rel);
94
+ const extra = matched.length > shown.length
95
+ ? `\n[${matched.length - shown.length} more not shown]`
96
+ : '';
97
+
98
+ return result(
99
+ shown.join('\n') + extra,
100
+ `${matched.length} match${matched.length === 1 ? '' : 'es'}`
101
+ );
102
+ }
103
+
104
+ export async function grep({ pattern, path: p = '.', glob: filter, ignore_case = false }) {
105
+ if (typeof pattern !== 'string' || pattern === '') {
106
+ throw new ToolFailure({
107
+ kind: 'bad_args',
108
+ attempted: 'searching file contents',
109
+ failed: 'The "pattern" argument was missing or empty.',
110
+ fix: 'Pass a regular expression, for example "function\\\\s+\\\\w+".',
111
+ });
112
+ }
113
+
114
+ let re;
115
+ try {
116
+ re = new RegExp(pattern, ignore_case ? 'i' : '');
117
+ } catch (err) {
118
+ throw new ToolFailure({
119
+ kind: 'bad_args',
120
+ attempted: 'searching file contents',
121
+ failed: `"${pattern}" is not a valid regular expression: ${err.message}`,
122
+ fix: 'Escape the metacharacters ( . * + ? [ ] ( ) { } | \\ ) or use a simpler pattern.',
123
+ cause: err,
124
+ });
125
+ }
126
+
127
+ const target = resolveIn(p || '.', 'grep');
128
+ await guard(target, `search ${target.abs}`);
129
+
130
+ const stat = await fs.stat(target.abs).catch((err) => {
131
+ throw fsFailure(err, 'searching file contents', target.show);
132
+ });
133
+
134
+ let base = target.abs;
135
+ let candidates;
136
+ if (stat.isFile()) {
137
+ base = path.dirname(target.abs);
138
+ candidates = [path.basename(target.abs)];
139
+ } else {
140
+ candidates = await walk(target.abs);
141
+ if (filter) {
142
+ const fre = globToRegExp(filter);
143
+ candidates = candidates.filter((rel) => fre.test(rel));
144
+ }
145
+ }
146
+
147
+ const hits = [];
148
+ const inFiles = new Set();
149
+
150
+ // Files are read a batch at a time rather than one after another — the
151
+ // search itself is instant, it is the waiting on each read that adds up. The
152
+ // batch is scanned in its original order, so the same search always lists
153
+ // its matches the same way.
154
+ for (let at = 0; at < candidates.length && hits.length < MAX_GREP_HITS; at += WALK_WIDTH) {
155
+ const batch = candidates.slice(at, at + WALK_WIDTH);
156
+ const read = await Promise.all(batch.map((rel) =>
157
+ fs.readFile(path.join(base, rel)).then((buf) => ({ rel, buf }), () => ({ rel, buf: null }))
158
+ ));
159
+
160
+ for (const { rel, buf } of read) {
161
+ if (hits.length >= MAX_GREP_HITS) break;
162
+ if (!buf || looksBinary(buf.subarray(0, 4096))) continue;
163
+
164
+ const lines = buf.toString('utf8').split(/\r?\n/);
165
+ for (let i = 0; i < lines.length && hits.length < MAX_GREP_HITS; i++) {
166
+ re.lastIndex = 0;
167
+ if (!re.test(lines[i])) continue;
168
+ inFiles.add(rel);
169
+ const text = lines[i].trim();
170
+ hits.push(`${rel}:${i + 1}: ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
171
+ }
172
+ }
173
+ }
174
+
175
+ if (hits.length === 0) {
176
+ return result(
177
+ `No line matched /${pattern}/ under ${target.show}` +
178
+ `${filter ? ` (limited to ${filter})` : ''}. Read ${candidates.length} files.`,
179
+ 'no matches'
180
+ );
181
+ }
182
+
183
+ const capped = hits.length >= MAX_GREP_HITS
184
+ ? `\n[stopped at ${MAX_GREP_HITS} matches — narrow the pattern, or pass a glob]`
185
+ : '';
186
+
187
+ return result(
188
+ hits.join('\n') + capped,
189
+ `${hits.length} match${hits.length === 1 ? '' : 'es'} in ` +
190
+ `${inFiles.size} file${inFiles.size === 1 ? '' : 's'}`
191
+ );
192
+ }