ucode-agent 1.38.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.38.0",
3
+ "version": "1.39.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",
package/src/core/loop.js CHANGED
@@ -2008,10 +2008,15 @@ export class Agent {
2008
2008
  // nothing on exactly the builds that needed it, and this is the only one
2009
2009
  // that opens the page, presses its buttons and finds out whether any of it
2010
2010
  // actually works.
2011
- if (!problems.length) {
2012
- const seen = await this.lookOnceThisTurn(root, changed);
2013
- if (seen) problems.push(seen);
2014
- }
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);
2015
2020
 
2016
2021
  return problems.length ? problems.join('\n\n') : null;
2017
2022
  }
@@ -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];