ucode-agent 1.6.0 → 1.7.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.
@@ -1,529 +1,634 @@
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, editFiles } 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 { lookAtApp } from './browser.js';
12
- import { createApp } from './scaffold.js';
13
- import { deploy } from './deploy.js';
14
- import { clip, READ_LINES } from './shared.js';
15
-
16
- export { setRoot, setConfirm, getRoot } from './shared.js';
17
-
18
- const str = (description) => ({ type: 'string', description });
19
- const int = (description) => ({ type: 'integer', description });
20
- const bool = (description) => ({ type: 'boolean', description });
21
-
22
- export const tools = [
23
- {
24
- name: 'create_app',
25
- description:
26
- 'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
27
- 'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
28
- 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
29
- 'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
30
- 'starts installing its packages in the background so you can write components at once. ' +
31
- 'The result lists everything included.',
32
- parameters: {
33
- type: 'object',
34
- properties: {
35
- folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
36
- name: str('The display name of the app, e.g. "Stride".'),
37
- description: str('One line about the app, used in the page metadata.'),
38
- design: {
39
- type: 'string',
40
- enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
41
- description:
42
- 'The look: colours and fonts, light and dark. Pick the one that fits the app. ' +
43
- 'ocean - calm blue, for dashboards, finance, productivity (default). ' +
44
- 'grove - fresh green, for health, habits, food, nature. ' +
45
- 'sunset - warm coral with a serif, for travel, recipes, journaling, lifestyle. ' +
46
- 'graphite - monochrome and crisp, for developer tools, docs, portfolios. ' +
47
- 'violet - vivid violet, for AI tools, creative apps, music, learning. ' +
48
- 'citrus - bright lime and bold, for games, sport, kids, social.',
49
- },
50
- },
51
- required: ['folder', 'name'],
52
- },
53
- },
54
- {
55
- name: 'deploy',
56
- description:
57
- 'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
58
- 'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
59
- '.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
60
- '(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
61
- 'Run the local build first so errors show up here. Deploying again updates the same link.',
62
- parameters: {
63
- type: 'object',
64
- properties: {
65
- folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
66
- name: str('Optional: a project name to use instead of the one ucode would choose.'),
67
- },
68
- required: [],
69
- },
70
- },
71
- {
72
- name: 'read_file',
73
- description:
74
- 'Read one text file - for two or more, use read_files instead. Comes back as numbered lines — the numbers are for you to ' +
75
- 'refer to and must never appear in an edit_file argument. Long files arrive in ' +
76
- 'pages; pass offset to keep going.',
77
- parameters: {
78
- type: 'object',
79
- properties: {
80
- path: str('File path, relative to the project root.'),
81
- offset: int('First line to read, 1-based. Defaults to 1.'),
82
- limit: int(`How many lines. Defaults to ${READ_LINES}.`),
83
- },
84
- required: ['path'],
85
- },
86
- },
87
- {
88
- name: 'read_files',
89
- description:
90
- 'Read several text files in one call. Use this whenever you need more than one ' +
91
- 'file - it is one round trip instead of one per file, so it is much faster than ' +
92
- 'calling read_file repeatedly. Same numbered-line output as read_file, one block ' +
93
- 'per file. A missing file is reported in its place without failing the others.',
94
- parameters: {
95
- type: 'object',
96
- properties: {
97
- paths: {
98
- type: 'array',
99
- description: 'File paths, relative to the project root. Up to 20.',
100
- items: { type: 'string' },
101
- },
102
- limit: int(`Lines per file. Defaults to ${READ_LINES}.`),
103
- },
104
- required: ['paths'],
105
- },
106
- },
107
- {
108
- name: 'write_file',
109
- description:
110
- 'Create a file, or replace all of its contents. For a change to part of an ' +
111
- 'existing file use edit_file instead — this one throws away everything that was ' +
112
- 'there. Missing parent directories are created.',
113
- parameters: {
114
- type: 'object',
115
- properties: {
116
- path: str('File path, relative to the project root.'),
117
- content: str('The complete text of the file.'),
118
- },
119
- required: ['path', 'content'],
120
- },
121
- },
122
- {
123
- name: 'batch_write',
124
- description:
125
- 'Create or replace several files in one call. Use this to lay out a whole ' +
126
- 'project at once instead of calling write_file over and over — it is the ' +
127
- 'difference between one round trip and twenty.',
128
- parameters: {
129
- type: 'object',
130
- properties: {
131
- files: {
132
- type: 'array',
133
- description: 'The files to write.',
134
- items: {
135
- type: 'object',
136
- properties: {
137
- path: str('File path, relative to the project root.'),
138
- content: str('The complete text of the file.'),
139
- },
140
- required: ['path', 'content'],
141
- },
142
- },
143
- },
144
- required: ['files'],
145
- },
146
- },
147
- {
148
- name: 'edit_file',
149
- description:
150
- 'Replace one exact piece of text in a file. old_string must match the file ' +
151
- 'character for character, including indentation, and must occur exactly once — ' +
152
- 'the edit is refused on zero matches and on two. This is the normal way to ' +
153
- 'change existing code. The result shows the file as it now stands, so do not ' +
154
- 'read it again afterwards.',
155
- parameters: {
156
- type: 'object',
157
- properties: {
158
- path: str('File path, relative to the project root.'),
159
- old_string: str('The exact text to replace. Must be unique in the file.'),
160
- new_string: str('What to put there instead.'),
161
- },
162
- required: ['path', 'old_string', 'new_string'],
163
- },
164
- },
165
- {
166
- name: 'multi_edit',
167
- description:
168
- 'Several exact replacements in one file, applied in order, each seeing the ' +
169
- 'result of the last. Same rules as edit_file for each one. If any of them is ' +
170
- 'ambiguous or missing, none are written at all. Prefer this to calling ' +
171
- 'edit_file repeatedly on the same file.',
172
- parameters: {
173
- type: 'object',
174
- properties: {
175
- path: str('File path, relative to the project root.'),
176
- edits: {
177
- type: 'array',
178
- description: 'The replacements, in the order they should be applied.',
179
- items: {
180
- type: 'object',
181
- properties: {
182
- old_string: str('The exact text to replace. Must be unique at that point.'),
183
- new_string: str('What to put there instead.'),
184
- },
185
- required: ['old_string', 'new_string'],
186
- },
187
- },
188
- },
189
- required: ['path', 'edits'],
190
- },
191
- },
192
- {
193
- name: 'edit_files',
194
- description:
195
- 'Exact replacements across several files in one call - the fastest way to make ' +
196
- 'a change that touches a route, a component and a type together. Same matching ' +
197
- 'rules as edit_file for every edit. If any edit in any file fails, nothing is ' +
198
- 'written anywhere.',
199
- parameters: {
200
- type: 'object',
201
- properties: {
202
- files: {
203
- type: 'array',
204
- description: 'One entry per file, each listed once.',
205
- items: {
206
- type: 'object',
207
- properties: {
208
- path: str('File path, relative to the project root.'),
209
- edits: {
210
- type: 'array',
211
- description: 'Replacements for this file, in order.',
212
- items: {
213
- type: 'object',
214
- properties: {
215
- old_string: str('The exact text to replace.'),
216
- new_string: str('What to put there instead.'),
217
- },
218
- required: ['old_string', 'new_string'],
219
- },
220
- },
221
- },
222
- required: ['path', 'edits'],
223
- },
224
- },
225
- },
226
- required: ['files'],
227
- },
228
- },
229
- {
230
- name: 'list_dir',
231
- description: 'List what is in one directory, with file sizes.',
232
- parameters: {
233
- type: 'object',
234
- properties: { path: str('Directory path. Defaults to the project root.') },
235
- required: [],
236
- },
237
- },
238
- {
239
- name: 'glob',
240
- description:
241
- 'Find files by name pattern, most recently changed first. Understands **, *, ? ' +
242
- 'and {a,b}. node_modules, .git, dist and similar are skipped unless the pattern ' +
243
- 'names one of them.',
244
- parameters: {
245
- type: 'object',
246
- properties: {
247
- pattern: str('Glob pattern, e.g. "src/**/*.{ts,tsx}".'),
248
- path: str('Directory to look under. Defaults to the project root.'),
249
- },
250
- required: ['pattern'],
251
- },
252
- },
253
- {
254
- name: 'grep',
255
- description:
256
- 'Search inside files with a regular expression. Returns file:line: text for ' +
257
- 'every match. Pass glob to limit which files get read.',
258
- parameters: {
259
- type: 'object',
260
- properties: {
261
- pattern: str('A JavaScript regular expression.'),
262
- path: str('File or directory to search. Defaults to the project root.'),
263
- glob: str('Optional filename filter, e.g. "**/*.js".'),
264
- ignore_case: bool('Match case-insensitively. Defaults to false.'),
265
- },
266
- required: ['pattern'],
267
- },
268
- },
269
- {
270
- name: 'run_command',
271
- description:
272
- 'Run a shell command and get back its output and exit code. It runs without ' +
273
- 'asking, so never run something destructive the user did not ask for. There is ' +
274
- 'no keyboard: pass the non-interactive flag to anything that would ask a question. ' +
275
- 'Dev servers (npm run dev, vite, next dev, uvicorn...) are started in the ' +
276
- 'background automatically and the result comes back as soon as the server says ' +
277
- 'it is ready, with the URL it is listening on - do not start one twice.',
278
- parameters: {
279
- type: 'object',
280
- properties: {
281
- command: str('The whole command line.'),
282
- cwd: str('Directory to run it in. Defaults to the project root.'),
283
- timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
284
- background: bool('Start it detached and return its PID. For servers.'),
285
- },
286
- required: ['command'],
287
- },
288
- },
289
- {
290
- name: 'run_commands',
291
- description:
292
- 'Run several shell commands at once, up to max_parallel at a time. Good for ' +
293
- 'independent work install, lint and test together rather than one after ' +
294
- 'another. Each entry takes the same fields as run_command.',
295
- parameters: {
296
- type: 'object',
297
- properties: {
298
- commands: {
299
- type: 'array',
300
- description: 'The commands to run.',
301
- items: {
302
- type: 'object',
303
- properties: {
304
- command: str('The whole command line.'),
305
- cwd: str('Directory to run it in. Defaults to the project root.'),
306
- timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
307
- background: bool('Start it detached and return its PID.'),
308
- },
309
- required: ['command'],
310
- },
311
- },
312
- max_parallel: {
313
- type: 'integer',
314
- description: 'How many may run at once. Default 3.',
315
- minimum: 1,
316
- maximum: 10,
317
- },
318
- },
319
- required: ['commands'],
320
- },
321
- },
322
- {
323
- name: 'look_at_app',
324
- description:
325
- 'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
326
- '(1440px) and report what a person would run into: console errors, failed requests, ' +
327
- 'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
328
- 'fields. The first look at an app also brings a designer-style review of the ' +
329
- 'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
330
- 'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
331
- 'under .ucode/screenshots.',
332
- parameters: {
333
- type: 'object',
334
- properties: {
335
- url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
336
- paths: {
337
- type: 'array',
338
- description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
339
- items: { type: 'string' },
340
- },
341
- },
342
- required: ['url'],
343
- },
344
- },
345
- {
346
- name: 'web_search',
347
- description:
348
- 'Search the web and get back titles, links and summaries. For anything the ' +
349
- 'project files and your own knowledge cannot settle: current versions, recent ' +
350
- 'releases, an unfamiliar error, documentation for an API you do not know. Cite ' +
351
- 'the URLs you actually used.',
352
- parameters: {
353
- type: 'object',
354
- properties: {
355
- query: str('What to look up.'),
356
- max_results: int('How many results, 1-10. Defaults to 5.'),
357
- },
358
- required: ['query'],
359
- },
360
- },
361
- ];
362
-
363
- const run = {
364
- read_file: readFile,
365
- read_files: readFiles,
366
- write_file: writeFile,
367
- batch_write: batchWrite,
368
- edit_file: editFile,
369
- multi_edit: multiEdit,
370
- edit_files: editFiles,
371
- list_dir: listDir,
372
- glob,
373
- grep,
374
- run_command: runCommand,
375
- run_commands: runCommands,
376
- web_search: webSearch,
377
- look_at_app: lookAtApp,
378
- create_app: createApp,
379
- deploy,
380
- };
381
-
382
- /** Tools that change the project or execute code. */
383
- export const MUTATING = new Set([
384
- 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands', 'deploy',
385
- ]);
386
-
387
- /** Tools with no side effects, so several may run at the same time. */
388
- export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search']);
389
-
390
- /** Tools withheld in plan mode. Withholding beats asking a model not to. */
391
- export const WRITES = new Set([
392
- 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
393
- 'delegate', 'create_app', 'deploy',
394
- ]);
395
-
396
- /** Tools that change files on disk, which parallel workers take turns at. */
397
- export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files']);
398
-
399
- // ---------------------------------------------------------------------------
400
- // Argument checking
401
- // ---------------------------------------------------------------------------
402
-
403
- /**
404
- * Check the model's arguments against the schema before anything runs.
405
- *
406
- * Catching it here means the model gets a precise sentence about what it got
407
- * wrong and can correct itself, instead of a TypeError thrown from somewhere
408
- * inside fs that means nothing to anybody.
409
- */
410
- function check(name, args) {
411
- const schema = tools.find((t) => t.name === name).parameters;
412
- const problems = [];
413
-
414
- if (args === null || typeof args !== 'object' || Array.isArray(args)) {
415
- return ['the arguments must be a JSON object'];
416
- }
417
-
418
- for (const key of schema.required ?? []) {
419
- if (args[key] === undefined || args[key] === null) problems.push(`"${key}" is required and missing`);
420
- }
421
-
422
- for (const [key, value] of Object.entries(args)) {
423
- const spec = schema.properties[key];
424
- if (!spec) {
425
- problems.push(`"${key}" is not an argument of ${name} (it takes: ${Object.keys(schema.properties).join(', ')})`);
426
- continue;
427
- }
428
- if (value === undefined || value === null) continue;
429
-
430
- const actual = Array.isArray(value) ? 'array' : typeof value;
431
- const wanted = spec.type === 'integer' ? 'number' : spec.type;
432
- // A number sent as a string is close enough — the tool coerces it anyway.
433
- if (wanted === 'number' && actual === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) continue;
434
- if (actual !== wanted) problems.push(`"${key}" should be ${spec.type} but was ${actual}`);
435
- }
436
-
437
- return problems;
438
- }
439
-
440
- export async function runTool(name, args = {}, opts = {}) {
441
- const impl = run[name];
442
- if (!impl) {
443
- throw new ToolFailure({
444
- kind: 'no_such_tool',
445
- attempted: `calling ${name}`,
446
- failed: `There is no tool called "${name}".`,
447
- fix: `The tools you have are: ${tools.map((t) => t.name).join(', ')}.`,
448
- });
449
- }
450
-
451
- const problems = check(name, args);
452
- if (problems.length) {
453
- throw new ToolFailure({
454
- kind: 'bad_args',
455
- attempted: `calling ${name}`,
456
- failed: `The arguments were wrong: ${problems.join('; ')}.`,
457
- fix: `Call ${name} again with them corrected. Its schema is: ${JSON.stringify(
458
- tools.find((t) => t.name === name).parameters
459
- )}`,
460
- detail: { problems },
461
- });
462
- }
463
-
464
- return impl(args, opts);
465
- }
466
-
467
- /**
468
- * The line shown while a call runs: "Listing src", "Running npm test".
469
- *
470
- * Present tense, no trailing full stop — it is a label on something happening
471
- * now, not a sentence about something that happened. It is built from the call
472
- * itself rather than from what the model said it would do, so it is always an
473
- * account of the real work.
474
- */
475
- export function describe(name, args = {}) {
476
- switch (name) {
477
- case 'read_file':
478
- return `Reading ${clip(args.path)}${args.offset > 1 ? ` from line ${args.offset}` : ''}`;
479
- case 'read_files': {
480
- const names = (args.paths ?? []).map((p) => String(p));
481
- const joined = names.join(', ');
482
- return names.length && joined.length <= 60 ? `Reading ${joined}` : `Reading ${names.length} files`;
483
- }
484
- case 'write_file':
485
- return `Writing ${clip(args.path)}`;
486
- case 'batch_write': {
487
- const n = args.files?.length ?? 0;
488
- const first = args.files?.[0]?.path;
489
- return n === 1 && first ? `Writing ${clip(first)}` : `Writing ${n} files`;
490
- }
491
- case 'edit_file':
492
- return `Editing ${clip(args.path)}`;
493
- case 'multi_edit':
494
- return `Editing ${clip(args.path)}, ${args.edits?.length ?? 0} changes`;
495
- case 'edit_files': {
496
- const n = args.files?.length ?? 0;
497
- const first = args.files?.[0]?.path;
498
- return n === 1 && first ? `Editing ${clip(first)}` : `Editing ${n} files`;
499
- }
500
- case 'update_plan':
501
- return 'Updating the plan';
502
- case 'delegate':
503
- return `Starting ${args.tasks?.length ?? 0} workers in parallel`;
504
- case 'list_dir':
505
- return !args.path || args.path === '.'
506
- ? 'Listing the project root'
507
- : `Listing ${clip(args.path)}`;
508
- case 'glob':
509
- return `Finding ${clip(args.pattern)}`;
510
- case 'grep':
511
- return `Searching for ${clip(args.pattern, 40)}${args.glob ? ` in ${clip(args.glob, 20)}` : ''}`;
512
- case 'run_command':
513
- return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
514
- case 'run_commands':
515
- return `Running ${args.commands?.length ?? 0} commands together`;
516
- case 'deploy':
517
- return `Deploying ${clip(args.folder || '.', 30)} to Vercel`;
518
- case 'create_app':
519
- return `Creating ${clip(args.name || args.folder, 30)} from the Next.js starter`;
520
- case 'look_at_app':
521
- return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
522
- case 'web_search':
523
- return `Searching the web for ${clip(args.query, 60)}`;
524
- case 'load_skill':
525
- return `Loading the ${clip(args.name, 40)} skill`;
526
- default:
527
- return `${name} ${clip(JSON.stringify(args), 60)}`;
528
- }
529
- }
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, editFiles } from './files.js';
8
+ import { listDir, glob, grep } from './search.js';
9
+ import { findSymbol, outline } from './symbols.js';
10
+ import { renameSymbol } from './rename.js';
11
+ import { addBlock, BLOCK_NAMES } from './blocks.js';
12
+ import { typeOf } from './types.js';
13
+ import { runCommand, runCommands } from './shell.js';
14
+ import { webSearch } from './web.js';
15
+ import { lookAtApp } from './browser.js';
16
+ import { createApp } from './scaffold.js';
17
+ import { deploy } from './deploy.js';
18
+ import { clip, READ_LINES } from './shared.js';
19
+
20
+ export { setRoot, setConfirm, getRoot } from './shared.js';
21
+
22
+ const str = (description) => ({ type: 'string', description });
23
+ const int = (description) => ({ type: 'integer', description });
24
+ const bool = (description) => ({ type: 'boolean', description });
25
+
26
+ export const tools = [
27
+ {
28
+ name: 'create_app',
29
+ description:
30
+ 'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
31
+ 'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
32
+ 'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
33
+ 'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
34
+ 'starts installing its packages in the background so you can write components at once. ' +
35
+ 'The result lists everything included.',
36
+ parameters: {
37
+ type: 'object',
38
+ properties: {
39
+ folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
40
+ name: str('The display name of the app, e.g. "Stride".'),
41
+ description: str('One line about the app, used in the page metadata.'),
42
+ design: {
43
+ type: 'string',
44
+ enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
45
+ description:
46
+ 'The look: colours and fonts, light and dark. Pick the one that fits the app. ' +
47
+ 'ocean - calm blue, for dashboards, finance, productivity (default). ' +
48
+ 'grove - fresh green, for health, habits, food, nature. ' +
49
+ 'sunset - warm coral with a serif, for travel, recipes, journaling, lifestyle. ' +
50
+ 'graphite - monochrome and crisp, for developer tools, docs, portfolios. ' +
51
+ 'violet - vivid violet, for AI tools, creative apps, music, learning. ' +
52
+ 'citrus - bright lime and bold, for games, sport, kids, social.',
53
+ },
54
+ },
55
+ required: ['folder', 'name'],
56
+ },
57
+ },
58
+ {
59
+ name: 'deploy',
60
+ description:
61
+ 'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
62
+ 'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
63
+ '.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
64
+ '(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
65
+ 'Run the local build first so errors show up here. Deploying again updates the same link.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
70
+ name: str('Optional: a project name to use instead of the one ucode would choose.'),
71
+ },
72
+ required: [],
73
+ },
74
+ },
75
+ {
76
+ name: 'read_file',
77
+ description:
78
+ 'Read one text file - for two or more, use read_files instead. Comes back as numbered lines — the numbers are for you to ' +
79
+ 'refer to and must never appear in an edit_file argument. Long files arrive in ' +
80
+ 'pages; pass offset to keep going.',
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ path: str('File path, relative to the project root.'),
85
+ offset: int('First line to read, 1-based. Defaults to 1.'),
86
+ limit: int(`How many lines. Defaults to ${READ_LINES}.`),
87
+ },
88
+ required: ['path'],
89
+ },
90
+ },
91
+ {
92
+ name: 'read_files',
93
+ description:
94
+ 'Read several text files in one call. Use this whenever you need more than one ' +
95
+ 'file - it is one round trip instead of one per file, so it is much faster than ' +
96
+ 'calling read_file repeatedly. Same numbered-line output as read_file, one block ' +
97
+ 'per file. A missing file is reported in its place without failing the others.',
98
+ parameters: {
99
+ type: 'object',
100
+ properties: {
101
+ paths: {
102
+ type: 'array',
103
+ description: 'File paths, relative to the project root. Up to 20.',
104
+ items: { type: 'string' },
105
+ },
106
+ limit: int(`Lines per file. Defaults to ${READ_LINES}.`),
107
+ },
108
+ required: ['paths'],
109
+ },
110
+ },
111
+ {
112
+ name: 'write_file',
113
+ description:
114
+ 'Create a file, or replace all of its contents. For a change to part of an ' +
115
+ 'existing file use edit_file instead — this one throws away everything that was ' +
116
+ 'there. Missing parent directories are created.',
117
+ parameters: {
118
+ type: 'object',
119
+ properties: {
120
+ path: str('File path, relative to the project root.'),
121
+ content: str('The complete text of the file.'),
122
+ },
123
+ required: ['path', 'content'],
124
+ },
125
+ },
126
+ {
127
+ name: 'batch_write',
128
+ description:
129
+ 'Create or replace several files in one call. Use this to lay out a whole ' +
130
+ 'project at once instead of calling write_file over and over — it is the ' +
131
+ 'difference between one round trip and twenty.',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ files: {
136
+ type: 'array',
137
+ description: 'The files to write.',
138
+ items: {
139
+ type: 'object',
140
+ properties: {
141
+ path: str('File path, relative to the project root.'),
142
+ content: str('The complete text of the file.'),
143
+ },
144
+ required: ['path', 'content'],
145
+ },
146
+ },
147
+ },
148
+ required: ['files'],
149
+ },
150
+ },
151
+ {
152
+ name: 'edit_file',
153
+ description:
154
+ 'Replace one exact piece of text in a file. old_string must match the file ' +
155
+ 'character for character, including indentation, and must occur exactly once — ' +
156
+ 'the edit is refused on zero matches and on two. This is the normal way to ' +
157
+ 'change existing code. The result shows the file as it now stands, so do not ' +
158
+ 'read it again afterwards.',
159
+ parameters: {
160
+ type: 'object',
161
+ properties: {
162
+ path: str('File path, relative to the project root.'),
163
+ old_string: str('The exact text to replace. Must be unique in the file.'),
164
+ new_string: str('What to put there instead.'),
165
+ },
166
+ required: ['path', 'old_string', 'new_string'],
167
+ },
168
+ },
169
+ {
170
+ name: 'multi_edit',
171
+ description:
172
+ 'Several exact replacements in one file, applied in order, each seeing the ' +
173
+ 'result of the last. Same rules as edit_file for each one. If any of them is ' +
174
+ 'ambiguous or missing, none are written at all. Prefer this to calling ' +
175
+ 'edit_file repeatedly on the same file.',
176
+ parameters: {
177
+ type: 'object',
178
+ properties: {
179
+ path: str('File path, relative to the project root.'),
180
+ edits: {
181
+ type: 'array',
182
+ description: 'The replacements, in the order they should be applied.',
183
+ items: {
184
+ type: 'object',
185
+ properties: {
186
+ old_string: str('The exact text to replace. Must be unique at that point.'),
187
+ new_string: str('What to put there instead.'),
188
+ },
189
+ required: ['old_string', 'new_string'],
190
+ },
191
+ },
192
+ },
193
+ required: ['path', 'edits'],
194
+ },
195
+ },
196
+ {
197
+ name: 'edit_files',
198
+ description:
199
+ 'Exact replacements across several files in one call - the fastest way to make ' +
200
+ 'a change that touches a route, a component and a type together. Same matching ' +
201
+ 'rules as edit_file for every edit. If any edit in any file fails, nothing is ' +
202
+ 'written anywhere.',
203
+ parameters: {
204
+ type: 'object',
205
+ properties: {
206
+ files: {
207
+ type: 'array',
208
+ description: 'One entry per file, each listed once.',
209
+ items: {
210
+ type: 'object',
211
+ properties: {
212
+ path: str('File path, relative to the project root.'),
213
+ edits: {
214
+ type: 'array',
215
+ description: 'Replacements for this file, in order.',
216
+ items: {
217
+ type: 'object',
218
+ properties: {
219
+ old_string: str('The exact text to replace.'),
220
+ new_string: str('What to put there instead.'),
221
+ },
222
+ required: ['old_string', 'new_string'],
223
+ },
224
+ },
225
+ },
226
+ required: ['path', 'edits'],
227
+ },
228
+ },
229
+ },
230
+ required: ['files'],
231
+ },
232
+ },
233
+ {
234
+ name: 'list_dir',
235
+ description: 'List what is in one directory, with file sizes.',
236
+ parameters: {
237
+ type: 'object',
238
+ properties: { path: str('Directory path. Defaults to the project root.') },
239
+ required: [],
240
+ },
241
+ },
242
+ {
243
+ name: 'glob',
244
+ description:
245
+ 'Find files by name pattern, most recently changed first. Understands **, *, ? ' +
246
+ 'and {a,b}. node_modules, .git, dist and similar are skipped unless the pattern ' +
247
+ 'names one of them.',
248
+ parameters: {
249
+ type: 'object',
250
+ properties: {
251
+ pattern: str('Glob pattern, e.g. "src/**/*.{ts,tsx}".'),
252
+ path: str('Directory to look under. Defaults to the project root.'),
253
+ },
254
+ required: ['pattern'],
255
+ },
256
+ },
257
+ {
258
+ name: 'grep',
259
+ description:
260
+ 'Search inside files with a regular expression. Returns file:line: text for ' +
261
+ 'every match. Pass glob to limit which files get read.',
262
+ parameters: {
263
+ type: 'object',
264
+ properties: {
265
+ pattern: str('A JavaScript regular expression.'),
266
+ path: str('File or directory to search. Defaults to the project root.'),
267
+ glob: str('Optional filename filter, e.g. "**/*.js".'),
268
+ ignore_case: bool('Match case-insensitively. Defaults to false.'),
269
+ },
270
+ required: ['pattern'],
271
+ },
272
+ },
273
+ {
274
+ name: 'find_symbol',
275
+ description:
276
+ 'Find where a function, component, class or type is declared. Use this instead of ' +
277
+ 'grep when you want the definition: grep returns every line that mentions a name, ' +
278
+ 'nearly all of which are uses. Returns file:line, what kind of thing it is, and the ' +
279
+ 'declaring line. Falls back to near matches when the exact name is not found.',
280
+ parameters: {
281
+ type: 'object',
282
+ properties: {
283
+ name: str('The name to look for, e.g. "calculateTip" or "Button".'),
284
+ kind: str('Optional filter: function, component, class or type.'),
285
+ path: str('Directory to look under. Defaults to the project root.'),
286
+ },
287
+ required: ['name'],
288
+ },
289
+ },
290
+ {
291
+ name: 'outline',
292
+ description:
293
+ 'The shape of the code without reading all of it: what each file declares, and the ' +
294
+ 'URL any Next.js page answers on. Pass a file for its declarations in order, or a ' +
295
+ 'folder for a map of it. Good for getting your bearings in an unfamiliar project.',
296
+ parameters: {
297
+ type: 'object',
298
+ properties: {
299
+ path: str('File or directory. Defaults to the project root.'),
300
+ },
301
+ },
302
+ },
303
+ {
304
+ name: 'rename_symbol',
305
+ description:
306
+ 'Rename a function, component, variable, prop or type everywhere it appears as ' +
307
+ 'that name. Understands where code ends and strings and comments begin, so it will ' +
308
+ 'not rewrite a word inside a message, and matches whole names only — renaming "id" ' +
309
+ 'leaves "width" and "idle" alone. Prefer this over edit_file for a rename: a ' +
310
+ 'find-and-replace that matched too much is the most common broken edit.',
311
+ parameters: {
312
+ type: 'object',
313
+ properties: {
314
+ name: str('The name as it is now, e.g. "userId".'),
315
+ to: str('What it should become, e.g. "accountId".'),
316
+ path: str('File or directory to rename within. Defaults to the project root.'),
317
+ },
318
+ required: ['name', 'to'],
319
+ },
320
+ },
321
+ {
322
+ name: 'add_block',
323
+ description:
324
+ 'Add a ready-made, polished piece of an app — ' + BLOCK_NAMES.join(', ') + '. Each is ' +
325
+ 'copied in as an ordinary source file you can then edit, built on the shadcn ' +
326
+ 'components already in the starter, so nothing needs installing. Call it with no ' +
327
+ 'name to see what each one is for. Prefer these over writing a table or an empty ' +
328
+ 'state from scratch: they already handle sorting, empty and loading states, ' +
329
+ 'alignment and small screens.',
330
+ parameters: {
331
+ type: 'object',
332
+ properties: {
333
+ name: str('Which block, e.g. "data-table". Omit to list them.'),
334
+ folder: str('The app folder to add it to. Defaults to the project root.'),
335
+ },
336
+ },
337
+ },
338
+ {
339
+ name: 'type_of',
340
+ description:
341
+ 'Ask the TypeScript this project has installed what something actually is: the exact ' +
342
+ 'type or signature of a function, prop, variable or import, the docs written on it, ' +
343
+ 'and where it is defined. Use this instead of guessing at an API or reading the ' +
344
+ 'source of a package — the answer comes from the same compiler and tsconfig the ' +
345
+ 'build uses, so it is what the build will say. Costs milliseconds.',
346
+ parameters: {
347
+ type: 'object',
348
+ properties: {
349
+ path: str('The file the name appears in, e.g. "src/app/page.tsx".'),
350
+ symbol: str('The name to ask about, e.g. "useRouter" or "user".'),
351
+ line: int('Optional: which line it is on, when the name appears more than once.'),
352
+ },
353
+ required: ['path', 'symbol'],
354
+ },
355
+ },
356
+ {
357
+ name: 'run_command',
358
+ description:
359
+ 'Run a shell command and get back its output and exit code. It runs without ' +
360
+ 'asking, so never run something destructive the user did not ask for. There is ' +
361
+ 'no keyboard: pass the non-interactive flag to anything that would ask a question. ' +
362
+ 'Dev servers (npm run dev, vite, next dev, uvicorn...) are started in the ' +
363
+ 'background automatically and the result comes back as soon as the server says ' +
364
+ 'it is ready, with the URL it is listening on - do not start one twice.',
365
+ parameters: {
366
+ type: 'object',
367
+ properties: {
368
+ command: str('The whole command line.'),
369
+ cwd: str('Directory to run it in. Defaults to the project root.'),
370
+ timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
371
+ background: bool('Start it detached and return its PID. For servers.'),
372
+ },
373
+ required: ['command'],
374
+ },
375
+ },
376
+ {
377
+ name: 'run_commands',
378
+ description:
379
+ 'Run several shell commands at once, up to max_parallel at a time. Good for ' +
380
+ 'independent work — install, lint and test together rather than one after ' +
381
+ 'another. Each entry takes the same fields as run_command.',
382
+ parameters: {
383
+ type: 'object',
384
+ properties: {
385
+ commands: {
386
+ type: 'array',
387
+ description: 'The commands to run.',
388
+ items: {
389
+ type: 'object',
390
+ properties: {
391
+ command: str('The whole command line.'),
392
+ cwd: str('Directory to run it in. Defaults to the project root.'),
393
+ timeout_ms: int('Kill it after this many milliseconds. Default 120000.'),
394
+ background: bool('Start it detached and return its PID.'),
395
+ },
396
+ required: ['command'],
397
+ },
398
+ },
399
+ max_parallel: {
400
+ type: 'integer',
401
+ description: 'How many may run at once. Default 3.',
402
+ minimum: 1,
403
+ maximum: 10,
404
+ },
405
+ },
406
+ required: ['commands'],
407
+ },
408
+ },
409
+ {
410
+ name: 'look_at_app',
411
+ description:
412
+ 'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
413
+ '(1440px) and report what a person would run into: console errors, failed requests, ' +
414
+ 'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
415
+ 'fields. The first look at an app also brings a designer-style review of the ' +
416
+ 'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
417
+ 'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
418
+ 'under .ucode/screenshots.',
419
+ parameters: {
420
+ type: 'object',
421
+ properties: {
422
+ url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
423
+ paths: {
424
+ type: 'array',
425
+ description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
426
+ items: { type: 'string' },
427
+ },
428
+ },
429
+ required: ['url'],
430
+ },
431
+ },
432
+ {
433
+ name: 'web_search',
434
+ description:
435
+ 'Search the web and get back titles, links and summaries. For anything the ' +
436
+ 'project files and your own knowledge cannot settle: current versions, recent ' +
437
+ 'releases, an unfamiliar error, documentation for an API you do not know. Cite ' +
438
+ 'the URLs you actually used.',
439
+ parameters: {
440
+ type: 'object',
441
+ properties: {
442
+ query: str('What to look up.'),
443
+ max_results: int('How many results, 1-10. Defaults to 5.'),
444
+ },
445
+ required: ['query'],
446
+ },
447
+ },
448
+ ];
449
+
450
+ const run = {
451
+ read_file: readFile,
452
+ read_files: readFiles,
453
+ write_file: writeFile,
454
+ batch_write: batchWrite,
455
+ edit_file: editFile,
456
+ multi_edit: multiEdit,
457
+ edit_files: editFiles,
458
+ list_dir: listDir,
459
+ glob,
460
+ grep,
461
+ find_symbol: findSymbol,
462
+ outline,
463
+ rename_symbol: renameSymbol,
464
+ add_block: addBlock,
465
+ type_of: typeOf,
466
+ run_command: runCommand,
467
+ run_commands: runCommands,
468
+ web_search: webSearch,
469
+ look_at_app: lookAtApp,
470
+ create_app: createApp,
471
+ deploy,
472
+ };
473
+
474
+ /** Tools that change the project or execute code. */
475
+ export const MUTATING = new Set([
476
+ 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'add_block',
477
+ 'run_command', 'run_commands', 'deploy',
478
+ ]);
479
+
480
+ /** Tools with no side effects, so several may run at the same time. */
481
+ export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'find_symbol', 'outline', 'type_of']);
482
+
483
+ /** Tools withheld in plan mode. Withholding beats asking a model not to. */
484
+ export const WRITES = new Set([
485
+ 'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'add_block',
486
+ 'run_command', 'run_commands', 'delegate', 'create_app', 'deploy',
487
+ ]);
488
+
489
+ /** Tools that change files on disk, which parallel workers take turns at. */
490
+ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol']);
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Argument checking
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * Check the model's arguments against the schema before anything runs.
498
+ *
499
+ * Catching it here means the model gets a precise sentence about what it got
500
+ * wrong and can correct itself, instead of a TypeError thrown from somewhere
501
+ * inside fs that means nothing to anybody.
502
+ */
503
+ function check(name, args) {
504
+ const schema = tools.find((t) => t.name === name).parameters;
505
+ const problems = [];
506
+
507
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
508
+ return ['the arguments must be a JSON object'];
509
+ }
510
+
511
+ for (const key of schema.required ?? []) {
512
+ if (args[key] === undefined || args[key] === null) problems.push(`"${key}" is required and missing`);
513
+ }
514
+
515
+ for (const [key, value] of Object.entries(args)) {
516
+ const spec = schema.properties[key];
517
+ if (!spec) {
518
+ problems.push(`"${key}" is not an argument of ${name} (it takes: ${Object.keys(schema.properties).join(', ')})`);
519
+ continue;
520
+ }
521
+ if (value === undefined || value === null) continue;
522
+
523
+ const actual = Array.isArray(value) ? 'array' : typeof value;
524
+ const wanted = spec.type === 'integer' ? 'number' : spec.type;
525
+ // A number sent as a string is close enough — the tool coerces it anyway.
526
+ if (wanted === 'number' && actual === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) continue;
527
+ if (actual !== wanted) problems.push(`"${key}" should be ${spec.type} but was ${actual}`);
528
+ }
529
+
530
+ return problems;
531
+ }
532
+
533
+ export async function runTool(name, args = {}, opts = {}) {
534
+ const impl = run[name];
535
+ if (!impl) {
536
+ throw new ToolFailure({
537
+ kind: 'no_such_tool',
538
+ attempted: `calling ${name}`,
539
+ failed: `There is no tool called "${name}".`,
540
+ fix: `The tools you have are: ${tools.map((t) => t.name).join(', ')}.`,
541
+ });
542
+ }
543
+
544
+ const problems = check(name, args);
545
+ if (problems.length) {
546
+ throw new ToolFailure({
547
+ kind: 'bad_args',
548
+ attempted: `calling ${name}`,
549
+ failed: `The arguments were wrong: ${problems.join('; ')}.`,
550
+ fix: `Call ${name} again with them corrected. Its schema is: ${JSON.stringify(
551
+ tools.find((t) => t.name === name).parameters
552
+ )}`,
553
+ detail: { problems },
554
+ });
555
+ }
556
+
557
+ return impl(args, opts);
558
+ }
559
+
560
+ /**
561
+ * The line shown while a call runs: "Listing src", "Running npm test".
562
+ *
563
+ * Present tense, no trailing full stop — it is a label on something happening
564
+ * now, not a sentence about something that happened. It is built from the call
565
+ * itself rather than from what the model said it would do, so it is always an
566
+ * account of the real work.
567
+ */
568
+ export function describe(name, args = {}) {
569
+ switch (name) {
570
+ case 'read_file':
571
+ return `Reading ${clip(args.path)}${args.offset > 1 ? ` from line ${args.offset}` : ''}`;
572
+ case 'read_files': {
573
+ const names = (args.paths ?? []).map((p) => String(p));
574
+ const joined = names.join(', ');
575
+ return names.length && joined.length <= 60 ? `Reading ${joined}` : `Reading ${names.length} files`;
576
+ }
577
+ case 'write_file':
578
+ return `Writing ${clip(args.path)}`;
579
+ case 'batch_write': {
580
+ const n = args.files?.length ?? 0;
581
+ const first = args.files?.[0]?.path;
582
+ return n === 1 && first ? `Writing ${clip(first)}` : `Writing ${n} files`;
583
+ }
584
+ case 'edit_file':
585
+ return `Editing ${clip(args.path)}`;
586
+ case 'multi_edit':
587
+ return `Editing ${clip(args.path)}, ${args.edits?.length ?? 0} changes`;
588
+ case 'edit_files': {
589
+ const n = args.files?.length ?? 0;
590
+ const first = args.files?.[0]?.path;
591
+ return n === 1 && first ? `Editing ${clip(first)}` : `Editing ${n} files`;
592
+ }
593
+ case 'update_plan':
594
+ return 'Updating the plan';
595
+ case 'delegate':
596
+ return `Starting ${args.tasks?.length ?? 0} workers in parallel`;
597
+ case 'list_dir':
598
+ return !args.path || args.path === '.'
599
+ ? 'Listing the project root'
600
+ : `Listing ${clip(args.path)}`;
601
+ case 'glob':
602
+ return `Finding ${clip(args.pattern)}`;
603
+ case 'type_of':
604
+ return `Asking what ${clip(args.symbol, 30)} is`;
605
+ case 'add_block':
606
+ return args.name ? `Adding the ${clip(args.name, 30)} block` : 'Listing the ready-made blocks';
607
+ case 'rename_symbol':
608
+ return `Renaming ${clip(args.name, 30)} to ${clip(args.to, 30)}`;
609
+ case 'find_symbol':
610
+ return `Looking up ${clip(args.name, 40)}`;
611
+ case 'outline':
612
+ return !args.path || args.path === '.'
613
+ ? 'Mapping the project'
614
+ : `Mapping ${clip(args.path)}`;
615
+ case 'grep':
616
+ return `Searching for ${clip(args.pattern, 40)}${args.glob ? ` in ${clip(args.glob, 20)}` : ''}`;
617
+ case 'run_command':
618
+ return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
619
+ case 'run_commands':
620
+ return `Running ${args.commands?.length ?? 0} commands together`;
621
+ case 'deploy':
622
+ return `Deploying ${clip(args.folder || '.', 30)} to Vercel`;
623
+ case 'create_app':
624
+ return `Creating ${clip(args.name || args.folder, 30)} from the Next.js starter`;
625
+ case 'look_at_app':
626
+ return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
627
+ case 'web_search':
628
+ return `Searching the web for ${clip(args.query, 60)}`;
629
+ case 'load_skill':
630
+ return `Loading the ${clip(args.name, 40)} skill`;
631
+ default:
632
+ return `${name} ${clip(JSON.stringify(args), 60)}`;
633
+ }
634
+ }