ucode-agent 1.26.2 → 1.28.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.
- package/README.md +438 -399
- package/package.json +1 -1
- package/skills/build-app/DIGEST.md +94 -0
- package/skills/build-app/SKILL.md +222 -181
- package/skills/ui-ux/DIGEST.md +135 -0
- package/src/core/context.js +164 -151
- package/src/core/loop.js +189 -24
- package/src/core/provider.js +6 -0
- package/src/core/skills.js +189 -165
- package/src/core/window.js +27 -2
- package/src/tools/blocks.js +117 -27
- package/src/tools/index.js +71 -23
- package/src/tools/scaffold.js +104 -14
- package/src/ui/plain.js +358 -351
- package/src/ui/screen.js +1574 -1475
- package/src/ui/theme.js +587 -412
- package/templates/blocks/plain/filter-bar.js +133 -0
- package/templates/blocks/plain/item-list.js +249 -0
- package/templates/blocks/plain/modal.js +141 -0
- package/templates/blocks/plain/store.js +93 -0
- package/templates/blocks/plain/theme-toggle.js +116 -0
- package/templates/blocks/plain/toast.js +107 -0
- package/templates/plain-html/styles.css +4 -0
package/src/tools/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFi
|
|
|
8
8
|
import { listDir, glob, grep } from './search.js';
|
|
9
9
|
import { findSymbol, outline } from './symbols.js';
|
|
10
10
|
import { renameSymbol } from './rename.js';
|
|
11
|
-
import { addBlock, BLOCK_NAMES } from './blocks.js';
|
|
11
|
+
import { addBlock, BLOCK_NAMES, PLAIN_BLOCK_NAMES, ALL_BLOCK_NAMES } from './blocks.js';
|
|
12
12
|
import { typeOf } from './types.js';
|
|
13
13
|
import { runCommand, runCommands } from './shell.js';
|
|
14
14
|
import { webSearch } from './web.js';
|
|
@@ -82,27 +82,45 @@ export const tools = [
|
|
|
82
82
|
{
|
|
83
83
|
name: 'create_app',
|
|
84
84
|
description:
|
|
85
|
-
'Start a new app
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
93
|
-
'
|
|
85
|
+
'Start a new app AND write it, in one call. Pass "files" with the whole app and this ' +
|
|
86
|
+
'is the only call the build needs: the starter lands, your files are written over it, ' +
|
|
87
|
+
'and the result comes back with everything. Two starters. "plain-html" (the default): ' +
|
|
88
|
+
'one index.html, one stylesheet, one ES module — nothing to install, nothing to build, ' +
|
|
89
|
+
'opens straight in a browser, and its three files come back inside this result so there ' +
|
|
90
|
+
'is never a reason to read them. Use it for anything that is one page: a tasks app, a ' +
|
|
91
|
+
'toy, a game, a visualisation, a calculator, a timer. "next-shadcn": Next.js 16, ' +
|
|
92
|
+
'TypeScript, Tailwind 4 and shadcn with 33 components — only when the app genuinely ' +
|
|
93
|
+
'needs routes, a database or many screens, because it costs an install and a build. ' +
|
|
94
|
+
'This is how every Next.js app begins - never run create-next-app or shadcn init.',
|
|
94
95
|
parameters: {
|
|
95
96
|
type: 'object',
|
|
96
97
|
properties: {
|
|
97
98
|
folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
|
|
98
99
|
name: str('The display name of the app, e.g. "Stride".'),
|
|
99
100
|
description: str('One line about the app, used in the page metadata.'),
|
|
101
|
+
files: {
|
|
102
|
+
type: 'array',
|
|
103
|
+
description:
|
|
104
|
+
'The app itself, written in this same call, straight over the starter\'s files. ' +
|
|
105
|
+
'Pass the whole app here rather than following up with batch_write - it saves a ' +
|
|
106
|
+
'round trip, which is most of the time a build takes. Paths are relative to the ' +
|
|
107
|
+
'project root and so include the app folder, e.g. "stride/index.html".',
|
|
108
|
+
items: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
path: str('Path relative to the project root, e.g. "stride/index.html".'),
|
|
112
|
+
content: str('The complete contents of the file.'),
|
|
113
|
+
},
|
|
114
|
+
required: ['path', 'content'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
100
117
|
template: {
|
|
101
118
|
type: 'string',
|
|
102
119
|
enum: ['next-shadcn', 'plain-html'],
|
|
103
120
|
description:
|
|
104
|
-
'Which starter. "plain-html" for
|
|
105
|
-
'no install, no build
|
|
121
|
+
'Which starter. "plain-html" (the default) for one page, a toy, a game, or any ' +
|
|
122
|
+
'app that does not need a server: no install, no build, nothing to wait for. ' +
|
|
123
|
+
'"next-shadcn" only for routes, a database or many screens.',
|
|
106
124
|
},
|
|
107
125
|
design: {
|
|
108
126
|
type: 'string',
|
|
@@ -369,16 +387,20 @@ export const tools = [
|
|
|
369
387
|
{
|
|
370
388
|
name: 'add_block',
|
|
371
389
|
description:
|
|
372
|
-
'Add a ready-made, polished piece of an app
|
|
373
|
-
'
|
|
374
|
-
'
|
|
375
|
-
'
|
|
376
|
-
'
|
|
377
|
-
'
|
|
390
|
+
'Add a ready-made, polished piece of an app, copied in as an ordinary source file ' +
|
|
391
|
+
'you can then edit. Which set you get is decided by the app itself, so you never ' +
|
|
392
|
+
'pick wrong. For a plain page: ' + PLAIN_BLOCK_NAMES.join(', ') + ' — plain ES ' +
|
|
393
|
+
'modules that import nothing and style themselves from the CSS variables already ' +
|
|
394
|
+
'in styles.css. For a React app: ' + BLOCK_NAMES.join(', ') + ' — built on the ' +
|
|
395
|
+
'shadcn components already in the starter. ALWAYS reach for these before writing a ' +
|
|
396
|
+
'list, a filter row, a store, a dialog or a table by hand: they already handle the ' +
|
|
397
|
+
'keyboard, the empty state, small screens and the cases that get skipped, and every ' +
|
|
398
|
+
'one you use is a hundred lines you do not have to type. Call it with no name to ' +
|
|
399
|
+
'see what each is for.',
|
|
378
400
|
parameters: {
|
|
379
401
|
type: 'object',
|
|
380
402
|
properties: {
|
|
381
|
-
name: str(
|
|
403
|
+
name: str(`Which block, e.g. "${ALL_BLOCK_NAMES[0]}". Omit to list the ones this app can use.`),
|
|
382
404
|
folder: str('The app folder to add it to. Defaults to the project root.'),
|
|
383
405
|
},
|
|
384
406
|
},
|
|
@@ -506,7 +528,7 @@ const run = {
|
|
|
506
528
|
/** Tools that change the project or execute code. */
|
|
507
529
|
export const MUTATING = new Set([
|
|
508
530
|
'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'add_block',
|
|
509
|
-
'run_command', 'run_commands', 'deploy',
|
|
531
|
+
'run_command', 'run_commands', 'create_app', 'deploy',
|
|
510
532
|
]);
|
|
511
533
|
|
|
512
534
|
/** Tools with no side effects, so several may run at the same time. */
|
|
@@ -519,7 +541,7 @@ export const WRITES = new Set([
|
|
|
519
541
|
]);
|
|
520
542
|
|
|
521
543
|
/** Tools that change files on disk, which parallel workers take turns at. */
|
|
522
|
-
export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol']);
|
|
544
|
+
export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'create_app']);
|
|
523
545
|
|
|
524
546
|
// ---------------------------------------------------------------------------
|
|
525
547
|
// Argument checking
|
|
@@ -532,6 +554,14 @@ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'm
|
|
|
532
554
|
* wrong and can correct itself, instead of a TypeError thrown from somewhere
|
|
533
555
|
* inside fs that means nothing to anybody.
|
|
534
556
|
*/
|
|
557
|
+
/**
|
|
558
|
+
* Arguments whose tool reads more shapes than the schema advertises.
|
|
559
|
+
*
|
|
560
|
+
* Kept here rather than in the schema so the wire format stays exactly what
|
|
561
|
+
* the model is asked for — the leniency is ucode's, not part of the contract.
|
|
562
|
+
*/
|
|
563
|
+
const LENIENT = new Set(['create_app.files']);
|
|
564
|
+
|
|
535
565
|
function check(name, args) {
|
|
536
566
|
const schema = tools.find((t) => t.name === name).parameters;
|
|
537
567
|
const problems = [];
|
|
@@ -552,10 +582,27 @@ function check(name, args) {
|
|
|
552
582
|
}
|
|
553
583
|
if (value === undefined || value === null) continue;
|
|
554
584
|
|
|
555
|
-
|
|
585
|
+
let actual = Array.isArray(value) ? 'array' : typeof value;
|
|
556
586
|
const wanted = spec.type === 'integer' ? 'number' : spec.type;
|
|
557
587
|
// A number sent as a string is close enough — the tool coerces it anyway.
|
|
558
588
|
if (wanted === 'number' && actual === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) continue;
|
|
589
|
+
|
|
590
|
+
// An array or object sent as a JSON string is the single most common way
|
|
591
|
+
// a model gets a nested argument wrong, and it is one every model makes
|
|
592
|
+
// sometimes. Rejecting it costs a whole round trip to be told something
|
|
593
|
+
// that could simply be read: parse it and carry on.
|
|
594
|
+
if ((wanted === 'array' || wanted === 'object') && actual === 'string') {
|
|
595
|
+
try {
|
|
596
|
+
const parsed = JSON.parse(value);
|
|
597
|
+
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
598
|
+
if (kind === wanted || LENIENT.has(`${name}.${key}`)) { args[key] = parsed; actual = kind; }
|
|
599
|
+
} catch { /* not JSON either — the message below is the right answer */ }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// A list of files written as a { path: contents } map. The tool reads it
|
|
603
|
+
// either way, so refusing it here would be a round trip spent on nothing.
|
|
604
|
+
if (wanted === 'array' && actual === 'object' && LENIENT.has(`${name}.${key}`)) continue;
|
|
605
|
+
|
|
559
606
|
if (actual !== wanted) problems.push(`"${key}" should be ${spec.type} but was ${actual}`);
|
|
560
607
|
}
|
|
561
608
|
|
|
@@ -649,7 +696,8 @@ export function describe(name, args = {}) {
|
|
|
649
696
|
// HTML app announced itself as Next.js, which is a line that is simply
|
|
650
697
|
// untrue on screen while the opposite happens on disk.
|
|
651
698
|
return `Creating ${clip(args.name || args.folder, 30)} from the ` +
|
|
652
|
-
`${args.template === '
|
|
699
|
+
`${args.template === 'next-shadcn' ? 'Next.js' : 'HTML'} starter` +
|
|
700
|
+
`${args.files?.length ? ` with ${args.files.length} file${args.files.length === 1 ? '' : 's'}` : ''}`;
|
|
653
701
|
case 'look_at_app':
|
|
654
702
|
return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
|
|
655
703
|
case 'web_search':
|
package/src/tools/scaffold.js
CHANGED
|
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { ToolFailure } from '../core/failure.js';
|
|
17
17
|
import { resolveIn, guard, result } from './shared.js';
|
|
18
|
+
import { batchWrite } from './files.js';
|
|
18
19
|
import { packageJsonWritten, installIn } from './shell.js';
|
|
19
20
|
import { restore, populate } from './cache.js';
|
|
20
21
|
|
|
@@ -28,6 +29,44 @@ const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
|
|
|
28
29
|
|
|
29
30
|
export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Which of a starter's files come back inside the result, in full.
|
|
34
|
+
*
|
|
35
|
+
* Reading a file ucode just copied is a whole round trip spent learning what
|
|
36
|
+
* it already had on disk, and a round trip is ten to forty seconds. The
|
|
37
|
+
* three-file starter is small enough to hand over outright; the Next.js one
|
|
38
|
+
* is a hundred files and its guide has to do that job instead.
|
|
39
|
+
*/
|
|
40
|
+
const SHOW_BACK = { 'plain-html': ['index.html', 'styles.css', 'app.js'] };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The files argument, however it was written.
|
|
44
|
+
*
|
|
45
|
+
* A list of `{ path, content }` is what the schema asks for, and it is what
|
|
46
|
+
* arrives most of the time. The rest of the time it is a JSON string, or a
|
|
47
|
+
* `{ "todo/app.js": "..." }` map, or the same list with the keys named
|
|
48
|
+
* something adjacent. Each of those, refused, is a round trip spent being
|
|
49
|
+
* told what could have been read — so they are all read.
|
|
50
|
+
*/
|
|
51
|
+
function normaliseFiles(files) {
|
|
52
|
+
let value = files;
|
|
53
|
+
if (typeof value === 'string') {
|
|
54
|
+
try { value = JSON.parse(value); } catch { return []; }
|
|
55
|
+
}
|
|
56
|
+
if (!value || typeof value !== 'object') return [];
|
|
57
|
+
|
|
58
|
+
const entries = Array.isArray(value)
|
|
59
|
+
? value
|
|
60
|
+
: Object.entries(value).map(([path, content]) => ({ path, content }));
|
|
61
|
+
|
|
62
|
+
return entries.map((entry) => {
|
|
63
|
+
if (typeof entry !== 'object' || entry === null) return entry;
|
|
64
|
+
const path = entry.path ?? entry.file ?? entry.filename ?? entry.name;
|
|
65
|
+
const content = entry.content ?? entry.contents ?? entry.text ?? entry.body ?? entry.source;
|
|
66
|
+
return { ...entry, path, content };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/** What each starter is for, so the choice is made on purpose. */
|
|
32
71
|
export const TEMPLATE_NOTES = {
|
|
33
72
|
'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
|
|
@@ -63,14 +102,6 @@ async function copyTree(from, to, fill) {
|
|
|
63
102
|
return copied;
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
/**
|
|
67
|
-
* @param {object} o
|
|
68
|
-
* @param {string} o.folder new, empty folder for the app
|
|
69
|
-
* @param {string} o.name display name, e.g. "Stride"
|
|
70
|
-
* @param {string} [o.description]
|
|
71
|
-
* @param {string} [o.template]
|
|
72
|
-
* @param {boolean} [o.install] start the background install (tests turn it off)
|
|
73
|
-
*/
|
|
74
105
|
/**
|
|
75
106
|
* Give the new app its look: one of the hand-picked presets in the starter's
|
|
76
107
|
* presets/ folder — a full light and dark palette and a font — written into
|
|
@@ -129,7 +160,21 @@ export async function applyDesign(appDir, design) {
|
|
|
129
160
|
return preset;
|
|
130
161
|
}
|
|
131
162
|
|
|
132
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Start an app, and — when the model passes them — write its files in the
|
|
165
|
+
* same call.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} o
|
|
168
|
+
* @param {string} o.folder new, empty folder for the app
|
|
169
|
+
* @param {string} o.name display name, e.g. "Stride"
|
|
170
|
+
* @param {string} [o.description]
|
|
171
|
+
* @param {string} [o.template] defaults to plain-html: nothing to install
|
|
172
|
+
* @param {string} [o.design]
|
|
173
|
+
* @param {{path: string, content: string}[]} [o.files] the app itself, paths
|
|
174
|
+
* relative to the project root, written straight over the starter's
|
|
175
|
+
* @param {boolean} [o.install] start the background install (tests turn it off)
|
|
176
|
+
*/
|
|
177
|
+
export async function createApp({ folder, name, description, template = 'plain-html', design, files, install = true }) {
|
|
133
178
|
if (!TEMPLATE_NAMES.includes(template)) {
|
|
134
179
|
throw new ToolFailure({
|
|
135
180
|
kind: 'bad_args',
|
|
@@ -139,6 +184,26 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
139
184
|
});
|
|
140
185
|
}
|
|
141
186
|
|
|
187
|
+
// Every shape a model reaches for when handing over a set of files. Reading
|
|
188
|
+
// them all costs nothing; refusing them costs a round trip each, which is
|
|
189
|
+
// the whole reason this argument exists.
|
|
190
|
+
const given = normaliseFiles(files);
|
|
191
|
+
|
|
192
|
+
// Checked before anything is copied: a bad entry found halfway through
|
|
193
|
+
// would leave the folder created, and the retry would then be refused for
|
|
194
|
+
// already having files in it.
|
|
195
|
+
const bad = given.findIndex(
|
|
196
|
+
(f) => typeof f?.path !== 'string' || typeof f?.content !== 'string'
|
|
197
|
+
);
|
|
198
|
+
if (bad !== -1) {
|
|
199
|
+
throw new ToolFailure({
|
|
200
|
+
kind: 'bad_args',
|
|
201
|
+
attempted: 'creating an app',
|
|
202
|
+
failed: `Entry ${bad + 1} of "files" is missing "path" or "content" — both must be strings.`,
|
|
203
|
+
fix: 'Fix that entry and call create_app again. Nothing has been created yet.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
142
207
|
const target = resolveIn(folder, 'create_app', 'folder');
|
|
143
208
|
const attempted = `creating an app in ${target.show}`;
|
|
144
209
|
if (target.show === '.') {
|
|
@@ -173,7 +238,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
173
238
|
__APP_DESCRIPTION__: plain(description) || display,
|
|
174
239
|
};
|
|
175
240
|
|
|
176
|
-
const
|
|
241
|
+
const copied = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
|
|
177
242
|
// Next.js serves static files from public/; a plain page has no such place
|
|
178
243
|
// and an empty folder in a three-file app is clutter.
|
|
179
244
|
if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
|
|
@@ -202,9 +267,27 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
202
267
|
|
|
203
268
|
const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
|
|
204
269
|
|
|
205
|
-
|
|
206
|
-
|
|
270
|
+
// The app's own files, written in this same call. Two round trips become
|
|
271
|
+
// one, and round trips are nearly all of the time a build takes.
|
|
272
|
+
const mine = given;
|
|
273
|
+
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
274
|
+
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
|
275
|
+
|
|
276
|
+
// The starter's own files, in full, so there is never a reason to read them
|
|
277
|
+
// back — and only the ones this call did not already write over. A read is
|
|
278
|
+
// another round trip to learn what ucode already knows.
|
|
279
|
+
const starter = [];
|
|
280
|
+
for (const rel of SHOW_BACK[template] ?? []) {
|
|
281
|
+
const abs = path.join(target.abs, rel);
|
|
282
|
+
if (written.has(abs)) continue;
|
|
283
|
+
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
284
|
+
if (text !== null) starter.push(`=== ${target.show}/${rel} ===\n${text}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const out = result(
|
|
288
|
+
`Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
|
|
207
289
|
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
290
|
+
(wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
|
|
208
291
|
(linked
|
|
209
292
|
? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
|
|
210
293
|
'nothing to install: build and run straight away.\n'
|
|
@@ -215,7 +298,14 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
215
298
|
(needsInstall
|
|
216
299
|
? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
|
|
217
300
|
: `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
|
|
218
|
-
`folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`)
|
|
219
|
-
|
|
301
|
+
`folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`) +
|
|
302
|
+
(starter.length
|
|
303
|
+
? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
|
|
304
|
+
: ''),
|
|
305
|
+
`${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
|
|
306
|
+
`${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`,
|
|
307
|
+
24_000
|
|
220
308
|
);
|
|
309
|
+
if (wrote?.diff?.length) out.diff = wrote.diff;
|
|
310
|
+
return out;
|
|
221
311
|
}
|