ucode-agent 1.34.0 → 1.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
@@ -765,6 +765,36 @@ async function streamed(request, opts, id) {
765
765
  * "path", then "content", then either the next file or the end. Splitting on
766
766
  * that shape and escaping the stray quotes gets every file back.
767
767
  */
768
+ /**
769
+ * Which calls can be rebuilt out of broken JSON.
770
+ *
771
+ * All three carry file contents — hundreds of lines of HTML, CSS and
772
+ * JavaScript escaped into a JSON string — which is the one argument shape a
773
+ * model gets wrong often enough to matter. Everything else is short enough
774
+ * that a parse failure is a real mistake, worth reporting rather than guessing
775
+ * around.
776
+ */
777
+ const SALVAGEABLE = new Set(['write_file', 'batch_write', 'create_app']);
778
+
779
+ /**
780
+ * The plain string arguments sitting beside a files array, read off the raw
781
+ * text when the object as a whole will not parse.
782
+ *
783
+ * Only the keys create_app needs, and only from before the files begin, so a
784
+ * "name" belonging to something nested inside a file cannot be mistaken for
785
+ * the app's own.
786
+ */
787
+ function scalarArgs(text) {
788
+ const at = text.search(/"files"\s*:/);
789
+ const head = at > 0 ? text.slice(0, at) : text;
790
+ const out = {};
791
+ for (const key of ['folder', 'name', 'description', 'template', 'design']) {
792
+ const hit = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(head);
793
+ if (hit) out[key] = hit[1].replace(/\\(["\\/])/g, '$1');
794
+ }
795
+ return out;
796
+ }
797
+
768
798
  export function salvageWrites(text) {
769
799
  const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
770
800
  if (!heads.length) return null;
@@ -837,9 +867,17 @@ export function readCall({ id, name, raw, cutOff = false }) {
837
867
  }
838
868
  } catch { /* beyond general repair — try the file-write shape next */ }
839
869
 
840
- const salvaged = (name === 'write_file' || name === 'batch_write') ? salvageWrites(text) : null;
870
+ const salvaged = SALVAGEABLE.has(name) ? salvageWrites(text) : null;
841
871
  if (salvaged) {
842
- call.args = name === 'write_file' ? salvaged[0] : { files: salvaged };
872
+ if (name === 'write_file') call.args = salvaged[0];
873
+ // create_app carries the app in the same { path, content } shape, with
874
+ // a few plain strings beside it. Losing the entire call because one of
875
+ // several hundred lines of HTML held a raw newline is how a build ends
876
+ // with no files at all — which is what it did: refused three times,
877
+ // the folder never created, the turn over in sixty-six seconds having
878
+ // produced nothing. The scalars are read back off the same text.
879
+ else if (name === 'create_app') call.args = { ...scalarArgs(text), files: salvaged };
880
+ else call.args = { files: salvaged };
843
881
  call.repaired = true;
844
882
  return call;
845
883
  }
@@ -238,15 +238,32 @@ async function useTheApp(page) {
238
238
  let after = await snapshot().catch(() => before);
239
239
  if (tried.length && moved(before, after)) return { tried, worked: true };
240
240
 
241
- // Nothing moved, so try the other half of the same pattern.
242
- const button = page.locator('button:not([disabled]), input[type="submit"], [role="button"]').first();
243
- if (await button.count().catch(() => 0)) {
244
- const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
245
- const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
246
- if (ok) {
241
+ // Nothing moved, so try the other half of the same pattern — the button that
242
+ // submits, before any other. Taking simply the first button in the document
243
+ // finds the theme toggle in the header, clicks it, and reports a working app
244
+ // as dead because switching to dark mode adds no elements.
245
+ const candidates = [
246
+ page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
247
+ page.locator('form button:not([disabled])'),
248
+ page.locator('button:not([disabled]), [role="button"]'),
249
+ ];
250
+
251
+ const pressed = new Set();
252
+ for (const group of candidates) {
253
+ const count = Math.min(await group.count().catch(() => 0), 3);
254
+ for (let i = 0; i < count; i++) {
255
+ const button = group.nth(i);
256
+ const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
257
+ if (pressed.has(label || `#${i}`)) continue;
258
+ pressed.add(label || `#${i}`);
259
+
260
+ const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
261
+ if (!ok) continue;
247
262
  await page.waitForTimeout(300);
248
- tried.push(`clicked ${label ? `"${label}"` : 'the first button'}`);
263
+ tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
264
+
249
265
  after = await snapshot().catch(() => after);
266
+ if (moved(before, after)) return { tried, worked: true };
250
267
  }
251
268
  }
252
269
 
@@ -131,6 +131,48 @@ const TOKEN_FILE = { 'plain-html': 'styles.css' };
131
131
  * a stylesheet with no :root variables at all gets the starter's block put
132
132
  * back above it, where every rule underneath can reach it.
133
133
  */
134
+ const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
+
136
+ /**
137
+ * Take the app's own folder back out of its own links.
138
+ *
139
+ * create_app wants paths relative to the project root — "todo/index.html" —
140
+ * because that is where every other file tool works from. The model then
141
+ * carries the same prefix into the markup and writes
142
+ * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
+ * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
+ * stylesheet and no script: no design, no behaviour, nothing in the console
145
+ * but two failed requests.
146
+ *
147
+ * It is the tool's own convention leaking into the file, so the tool takes it
148
+ * back out. Only a leading "<folder>/" on a relative reference, which inside
149
+ * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
+ * left exactly as they are.
151
+ */
152
+ async function unprefixOwnFolder(appDir, written) {
153
+ const prefix = `${path.basename(appDir)}/`;
154
+ const fixed = [];
155
+
156
+ for (const abs of written) {
157
+ if (!/\.html?$/i.test(abs)) continue;
158
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
+ if (text === null) continue;
160
+
161
+ let hits = 0;
162
+ const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
+ if (!value.startsWith(prefix)) return all;
164
+ hits++;
165
+ return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
+ });
167
+
168
+ if (hits) {
169
+ await fs.writeFile(abs, next, 'utf8');
170
+ fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
+ }
172
+ }
173
+ return fixed;
174
+ }
175
+
134
176
  async function keepDesignTokens(appDir, template, written, starterCss) {
135
177
  const rel = TOKEN_FILE[template];
136
178
  if (!rel || !starterCss) return null;
@@ -318,7 +360,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
318
360
  const mine = given;
319
361
  const wrote = mine.length ? await batchWrite({ files: mine }) : null;
320
362
  const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
321
- const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
363
+ const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
364
+ const relinked = await unprefixOwnFolder(target.abs, written);
322
365
 
323
366
  // The starter's own files, in full, so there is never a reason to read them
324
367
  // back — and only the ones this call did not already write over. A read is
@@ -335,6 +378,13 @@ export async function createApp({ folder, name, description, template = 'plain-h
335
378
  `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
336
379
  (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
337
380
  (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
381
+ (relinked.length
382
+ ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
383
+ 'Paths in "files" are relative to the project root, but a link inside a page is ' +
384
+ 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
385
+ '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
386
+ 'script 404 and the page comes up as bare markup.\n'
387
+ : '') +
338
388
  (keptTokens
339
389
  ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
340
390
  'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +