ucode-agent 1.37.0 → 1.39.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 +1 -1
- package/src/core/loop.js +21 -4
- package/src/core/provider.js +75 -0
- package/src/core/relink.js +62 -0
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -19,6 +19,7 @@ import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
|
|
|
19
19
|
import { LogWatch } from './livelog.js';
|
|
20
20
|
import { checkHtml } from './htmlcheck.js';
|
|
21
21
|
import { runningServers } from '../tools/shell.js';
|
|
22
|
+
import { unprefixOwnFolder } from './relink.js';
|
|
22
23
|
import { beginTurn, undoTurn, changedCount } from './undo.js';
|
|
23
24
|
import { spawn } from 'node:child_process';
|
|
24
25
|
|
|
@@ -1912,6 +1913,17 @@ export class Agent {
|
|
|
1912
1913
|
if (!changed.length) return null;
|
|
1913
1914
|
|
|
1914
1915
|
const root = path.resolve(this.cwd);
|
|
1916
|
+
|
|
1917
|
+
// A page that links to its own folder, whoever wrote it. create_app fixes
|
|
1918
|
+
// the files it is handed, but an app written across several calls — a
|
|
1919
|
+
// scaffold, then two edits — reintroduces the prefix on the next write,
|
|
1920
|
+
// and nothing was watching after the first one. The page then loads no
|
|
1921
|
+
// stylesheet and no script, which is the difference between an app and a
|
|
1922
|
+
// wall of unstyled markup, so it is corrected wherever it turns up.
|
|
1923
|
+
const relinked = await unprefixOwnFolder(root, changed);
|
|
1924
|
+
if (relinked.length) {
|
|
1925
|
+
this.ui.toolCall(`Fixing self-referencing links in ${relinked.join(', ')}`);
|
|
1926
|
+
}
|
|
1915
1927
|
const tsRoots = new Set();
|
|
1916
1928
|
const singles = [];
|
|
1917
1929
|
const problems = [];
|
|
@@ -1996,10 +2008,15 @@ export class Agent {
|
|
|
1996
2008
|
// nothing on exactly the builds that needed it, and this is the only one
|
|
1997
2009
|
// that opens the page, presses its buttons and finds out whether any of it
|
|
1998
2010
|
// actually works.
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2011
|
+
// Whatever else is wrong. It used to wait until everything else was clean,
|
|
2012
|
+
// which meant the one check that opens the page and presses its buttons
|
|
2013
|
+
// was skipped on exactly the builds going badly — and a type error and a
|
|
2014
|
+
// dead button are two separate things to fix, so finding them in one pass
|
|
2015
|
+
// costs a round trip less than finding them in two. The visual review
|
|
2016
|
+
// inside the look still stands itself down on a page that is broken;
|
|
2017
|
+
// reviewing the typography of an error overlay helps nobody.
|
|
2018
|
+
const seen = await this.lookOnceThisTurn(root, changed);
|
|
2019
|
+
if (seen) problems.push(seen);
|
|
2003
2020
|
|
|
2004
2021
|
return problems.length ? problems.join('\n\n') : null;
|
|
2005
2022
|
}
|
package/src/core/provider.js
CHANGED
|
@@ -795,6 +795,69 @@ function scalarArgs(text) {
|
|
|
795
795
|
return out;
|
|
796
796
|
}
|
|
797
797
|
|
|
798
|
+
/**
|
|
799
|
+
* Rebuild the edits out of a broken edit_file or edit_files call.
|
|
800
|
+
*
|
|
801
|
+
* The same problem as a broken write, one level down: an edit carries two
|
|
802
|
+
* slabs of somebody's source as escaped JSON strings, and one stray character
|
|
803
|
+
* loses the call. edit_files failed this way three times in a row in a traced
|
|
804
|
+
* build, at the end of a turn, which is a turn that ends having undone nothing
|
|
805
|
+
* and fixed nothing.
|
|
806
|
+
*
|
|
807
|
+
* Safer than it sounds. Every edit is applied by replaceOnce, which requires
|
|
808
|
+
* old_string to appear exactly once and refuses otherwise — so an edit
|
|
809
|
+
* recovered wrongly does not corrupt a file, it fails to match and says so.
|
|
810
|
+
* The risk of guessing is a clear error; the cost of not guessing is the whole
|
|
811
|
+
* call. Truncated replies are still refused upstream, where half a string
|
|
812
|
+
* would mean half a file.
|
|
813
|
+
*
|
|
814
|
+
* Each edit is attributed to the nearest "path" before it, which is how
|
|
815
|
+
* edit_files nests them; edit_file has exactly one of each.
|
|
816
|
+
*/
|
|
817
|
+
export function salvageEdits(text) {
|
|
818
|
+
const heads = [...text.matchAll(/"old_string"\s*:\s*"/g)];
|
|
819
|
+
if (!heads.length) return null;
|
|
820
|
+
|
|
821
|
+
const paths = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
822
|
+
const NEW = '"new_string"';
|
|
823
|
+
const out = [];
|
|
824
|
+
|
|
825
|
+
for (let i = 0; i < heads.length; i++) {
|
|
826
|
+
const oldFrom = heads[i].index + heads[i][0].length;
|
|
827
|
+
const newKey = text.indexOf(NEW, oldFrom);
|
|
828
|
+
if (newKey < 0) return null;
|
|
829
|
+
|
|
830
|
+
const opens = /^\s*:\s*"/.exec(text.slice(newKey + NEW.length));
|
|
831
|
+
if (!opens) return null;
|
|
832
|
+
const newFrom = newKey + NEW.length + opens[0].length;
|
|
833
|
+
|
|
834
|
+
// The value runs until whatever structure comes next — the following edit,
|
|
835
|
+
// or the following file — and the trailing JSON punctuation comes off.
|
|
836
|
+
const nextEdit = i + 1 < heads.length ? heads[i + 1].index : text.length;
|
|
837
|
+
const nextPath = paths.find((m) => m.index > newFrom)?.index ?? text.length;
|
|
838
|
+
const strip = (v) => v.replace(/"[\s,{}[\]]*$/, '');
|
|
839
|
+
|
|
840
|
+
const old_string = unescapeLoose(strip(text.slice(oldFrom, newKey)));
|
|
841
|
+
const new_string = unescapeLoose(strip(text.slice(newFrom, Math.min(nextEdit, nextPath))));
|
|
842
|
+
const owner = paths.filter((m) => m.index < heads[i].index).pop();
|
|
843
|
+
|
|
844
|
+
if (!owner || !old_string) return null;
|
|
845
|
+
out.push({ path: unescapeLoose(owner[1]), old_string, new_string });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return out.length ? out : null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The same edits, grouped under their file, which is edit_files' own shape. */
|
|
852
|
+
export function groupEdits(edits) {
|
|
853
|
+
const byPath = new Map();
|
|
854
|
+
for (const { path, old_string, new_string } of edits) {
|
|
855
|
+
if (!byPath.has(path)) byPath.set(path, []);
|
|
856
|
+
byPath.get(path).push({ old_string, new_string });
|
|
857
|
+
}
|
|
858
|
+
return [...byPath].map(([path, list]) => ({ path, edits: list }));
|
|
859
|
+
}
|
|
860
|
+
|
|
798
861
|
export function salvageWrites(text) {
|
|
799
862
|
const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
|
|
800
863
|
if (!heads.length) return null;
|
|
@@ -867,6 +930,18 @@ export function readCall({ id, name, raw, cutOff = false }) {
|
|
|
867
930
|
}
|
|
868
931
|
} catch { /* beyond general repair — try the file-write shape next */ }
|
|
869
932
|
|
|
933
|
+
if (name === 'edit_file' || name === 'edit_files' || name === 'multi_edit') {
|
|
934
|
+
const edits = salvageEdits(text);
|
|
935
|
+
if (edits) {
|
|
936
|
+
if (name === 'edit_file') call.args = edits[0];
|
|
937
|
+
else if (name === 'multi_edit') {
|
|
938
|
+
call.args = { path: edits[0].path, edits: edits.map(({ old_string, new_string }) => ({ old_string, new_string })) };
|
|
939
|
+
} else call.args = { files: groupEdits(edits) };
|
|
940
|
+
call.repaired = true;
|
|
941
|
+
return call;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
870
945
|
const salvaged = SALVAGEABLE.has(name) ? salvageWrites(text) : null;
|
|
871
946
|
if (salvaged) {
|
|
872
947
|
if (name === 'write_file') call.args = salvaged[0];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relink.js — taking an app's own folder back out of its own links.
|
|
3
|
+
*
|
|
4
|
+
* create_app wants paths relative to the project root — "tide/index.html" —
|
|
5
|
+
* because that is where every file tool works from. The model then carries the
|
|
6
|
+
* same prefix into the markup and writes `<link href="tide/styles.css">` inside
|
|
7
|
+
* tide/index.html, where it resolves to tide/tide/styles.css and 404s. The page
|
|
8
|
+
* comes up as bare markup: no stylesheet, no script, dead buttons. It is the
|
|
9
|
+
* commonest way a finished build looks like nothing was built.
|
|
10
|
+
*
|
|
11
|
+
* It lived inside create_app first, which covered only the files that call was
|
|
12
|
+
* handed. An app written across a scaffold and two edits put the prefix back on
|
|
13
|
+
* the next write and nothing was watching, so it runs over every changed page
|
|
14
|
+
* now, on every turn, wherever the page came from.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** Relative href/src only — absolute paths, URLs, data: and anchors are left alone. */
|
|
21
|
+
const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip a page's own folder from its relative links.
|
|
25
|
+
*
|
|
26
|
+
* `files` are paths relative to `root`. A page at "tide/index.html" has "tide/"
|
|
27
|
+
* taken off its links, because inside that folder the prefix is always wrong.
|
|
28
|
+
* A page sitting directly in the root has no folder to strip and is skipped.
|
|
29
|
+
*
|
|
30
|
+
* Returns the pages it changed, for reporting. Never throws: a file that cannot
|
|
31
|
+
* be read is a file that is left exactly as it is.
|
|
32
|
+
*/
|
|
33
|
+
export async function unprefixOwnFolder(root, files) {
|
|
34
|
+
const fixed = [];
|
|
35
|
+
|
|
36
|
+
for (const rel of files) {
|
|
37
|
+
if (!/\.html?$/i.test(rel)) continue;
|
|
38
|
+
|
|
39
|
+
const abs = path.resolve(root, rel);
|
|
40
|
+
const folder = path.basename(path.dirname(abs));
|
|
41
|
+
if (!folder || path.dirname(abs) === path.resolve(root)) continue;
|
|
42
|
+
|
|
43
|
+
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
44
|
+
if (text === null) continue;
|
|
45
|
+
|
|
46
|
+
const prefix = `${folder}/`;
|
|
47
|
+
let hits = 0;
|
|
48
|
+
const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
|
|
49
|
+
if (!value.startsWith(prefix)) return all;
|
|
50
|
+
hits++;
|
|
51
|
+
return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!hits) continue;
|
|
55
|
+
try {
|
|
56
|
+
await fs.writeFile(abs, next, 'utf8');
|
|
57
|
+
fixed.push(`${rel} (${hits})`);
|
|
58
|
+
} catch { /* unwritable: leave it, and say nothing that is not true */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return fixed;
|
|
62
|
+
}
|