ucode-agent 1.54.0 → 1.57.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.
@@ -172,7 +172,7 @@ export function autoLoadFor(skills, text) {
172
172
  export function skillMessage(skill, { automatic = false, short = false } = {}) {
173
173
  const digest = short && skill.digest ? skill.digest : null;
174
174
  const why = automatic
175
- ? `The "${skill.name}" skill was loaded automatically because this request is the kind it covers.`
175
+ ? `The "${skill.name}" skill was loaded automatically because the request that follows is the kind it covers.`
176
176
  : `The "${skill.name}" skill was loaded for this task.`;
177
177
  return {
178
178
  role: 'system',
@@ -87,10 +87,12 @@ function cutPoint(messages, budget) {
87
87
  * @param {Array} messages
88
88
  * @param {object} o
89
89
  * @param {number} o.limit token budget
90
- * @param {Function} o.summarize async (older) => string
90
+ * @param {Function} o.summarize async (older, previousSummary) => string
91
+ * @param {boolean} [o.force] fold even below the threshold — the provider
92
+ * has already said the request is too big
91
93
  */
92
- export async function fold(messages, { limit, summarize }) {
93
- if (!tooBig(messages, limit)) return { messages, folded: false };
94
+ export async function fold(messages, { limit, summarize, force = false }) {
95
+ if (!force && !tooBig(messages, limit)) return { messages, folded: false };
94
96
 
95
97
  // Half the size that triggered the fold, so there is room to work before
96
98
  // the next one. Sized off the same absolute rule, or a fold on a
@@ -103,7 +105,10 @@ export async function fold(messages, { limit, summarize }) {
103
105
  // Nothing old enough to fold — the tail on its own is already oversized.
104
106
  if (older.length === 0) return { messages, folded: false };
105
107
 
106
- const summary = await summarize(older);
108
+ // A second fold must not summarize the first summary as if it were chat:
109
+ // it is handed over as the prior summary, to be merged rather than retold.
110
+ const previous = older.find((m) => m.folded)?.summary ?? null;
111
+ const summary = await summarize(older.filter((m) => !m.folded), previous);
107
112
 
108
113
  return {
109
114
  folded: true,
@@ -117,19 +122,87 @@ export async function fold(messages, { limit, summarize }) {
117
122
  `were folded away to stay inside the context window.\n\n${summary}\n\n` +
118
123
  'Treat all of that as settled context. Everything after this point is verbatim.',
119
124
  folded: true,
125
+ summary,
120
126
  },
121
127
  ...recent,
122
128
  ],
123
129
  };
124
130
  }
125
131
 
126
- /** What the summarizer is asked to do. */
127
- export const SUMMARY_PROMPT =
128
- 'Summarize this conversation so another engineer could pick it up mid-task with ' +
129
- 'nothing else to go on. Keep, in this order: (1) what the user is trying to achieve, ' +
130
- '(2) which files were read or changed and what is in them, (3) decisions taken and the ' +
131
- 'reasoning, (4) commands run and what they printed, (5) what is still unfinished. ' +
132
- 'Name exact paths, functions and error messages. No preamble, no commentary.';
132
+ /**
133
+ * What the summarizer is asked to do: opencode's anchored summary (MIT, see
134
+ * THIRD_PARTY_NOTICES.md). Fixed sections mean nothing gets dropped because
135
+ * the summarizer found it dull — the next step and the files that matter
136
+ * always have a place — and a second fold merges into the first instead of
137
+ * summarizing a summary.
138
+ */
139
+ export const SUMMARY_PROMPT = `You summarize a coding session so another coding agent can continue the work with nothing else to go on.
140
+
141
+ Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
142
+ <template>
143
+ ## Objective
144
+ - [one or two brief sentences describing what the user is trying to accomplish]
145
+
146
+ ## Important Details
147
+ - [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
148
+
149
+ ## Work State
150
+ ### Completed
151
+ - [finished work, verified facts, or changes made; otherwise "(none)"]
152
+
153
+ ### Active
154
+ - [current work, partial changes, or investigation state; otherwise "(none)"]
155
+
156
+ ### Blocked
157
+ - [blockers, failing commands, or unknowns; otherwise "(none)"]
158
+
159
+ ## Next Move
160
+ 1. [immediate concrete action, or "(none)"]
161
+ 2. [next action if known, or "(none)"]
162
+
163
+ ## Relevant Files
164
+ - [file or directory path: why it matters, or "(none)"]
165
+ </template>
166
+
167
+ Rules:
168
+ - Keep every section, even when empty.
169
+ - Use terse bullets, not prose paragraphs.
170
+ - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
171
+ - Do not mention the summary process or that context was compacted.`;
172
+
173
+ const MERGE = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
174
+
175
+ When combining:
176
+ - Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
177
+ - The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
178
+ - Add new progress, decisions, constraints, and context from the conversation.
179
+ - Move completed work from "Active" to "Completed".
180
+ - If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
181
+ - Update "Objective" and "Next Move" to reflect the current work state.`;
182
+
183
+ /** The summarizer's user message: the conversation, and the summary it extends if there is one. */
184
+ export function summaryRequest(conversation, previous = null) {
185
+ // File contents are in there too. One carrying "</conversation>" must not
186
+ // close the section early and speak to the summarizer as if it were us.
187
+ const fence = (s) => String(s).replace(/<\/?(?:conversation|prior-summary)>/gi, '');
188
+ conversation = fence(conversation);
189
+ if (previous) previous = fence(previous);
190
+ const parts = [`Here is the conversation so far:
191
+
192
+ <conversation>
193
+ ${conversation}
194
+ </conversation>`];
195
+ if (previous) {
196
+ parts.push(`Here is the summary of the conversation before the <conversation> above:
197
+
198
+ <prior-summary>
199
+ ${previous}
200
+ </prior-summary>`, MERGE);
201
+ } else {
202
+ parts.push('Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.');
203
+ }
204
+ return parts.join('\n\n');
205
+ }
133
206
 
134
207
  /**
135
208
  * Flatten the messages being folded into plain text for the summarizer.
@@ -7,18 +7,39 @@
7
7
  * coding agent can do, because everything after it is built on a lie.
8
8
  */
9
9
 
10
- import { promises as fs } from 'node:fs';
10
+ import { promises as fs, existsSync } from 'node:fs';
11
11
  import { remember } from '../core/undo.js';
12
12
  import path from 'node:path';
13
13
  import { ToolFailure } from '../core/failure.js';
14
14
  import {
15
15
  resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
16
16
  changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
17
- noteFile, writeTracked, assertUnchanged,
17
+ noteFile, writeTracked, assertUnchanged, getRoot,
18
18
  } from './shared.js';
19
19
  import { packageJsonWritten } from './shell.js';
20
+ import { fuzzyReplace } from './fuzzy.js';
20
21
  import { parse as parseSource } from '@babel/parser';
21
22
 
23
+ /**
24
+ * Up to three names beside a missing file that look like what was meant —
25
+ * "App.jsx" for "app.jsx", "index.html" for "index.htm". Named in the refusal
26
+ * (as opencode's read tool does), the next step is the right read instead of
27
+ * a list_dir to find out.
28
+ */
29
+ async function lookalikes(target) {
30
+ const dir = path.dirname(target.abs);
31
+ const base = path.basename(target.abs).toLowerCase();
32
+ const shown = path.dirname(target.show);
33
+ try {
34
+ return (await fs.readdir(dir))
35
+ .filter((n) => n.toLowerCase().includes(base) || (n.length > 2 && base.includes(n.toLowerCase())))
36
+ .slice(0, 3)
37
+ .map((n) => (shown === '.' ? n : `${shown}/${n}`));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
22
43
  export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
23
44
  const target = resolveIn(p, 'read_file');
24
45
  await guard(target, `read ${target.abs}`);
@@ -28,7 +49,12 @@ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
28
49
  try {
29
50
  stat = await fs.stat(target.abs);
30
51
  } catch (err) {
31
- throw fsFailure(err, attempted, target.show);
52
+ const failure = fsFailure(err, attempted, target.show);
53
+ // Outside the project the user said yes to one path, not to a listing of
54
+ // the folder around it — so no lookalikes there.
55
+ const near = err?.code === 'ENOENT' && target.inside ? await lookalikes(target) : [];
56
+ if (near.length) failure.fix = `Did you mean ${near.join(', ')}? ${failure.fix}`;
57
+ throw failure;
32
58
  }
33
59
 
34
60
  if (stat.isDirectory()) {
@@ -170,6 +196,44 @@ const brokenNote = (show, problem) => {
170
196
  `a structure you have lost track of.`;
171
197
  };
172
198
 
199
+ /**
200
+ * A module in a page with no build step that imports a stylesheet or an image.
201
+ *
202
+ * A bundler would take it; a browser refuses the whole module ("Failed to
203
+ * load module script ... MIME type text/css"), so none of the app's script
204
+ * runs and every button is dead. The syntax is fine, so nothing else notices
205
+ * until the page is opened — a live build shipped exactly this as done.
206
+ */
207
+ const ASSET_IMPORT = /^[ \t]*import\s+(?:[\w$*{}\s,]+?\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp))['"](?!\s*(?:with|assert)\s*\{)/m;
208
+
209
+ export function assetImport(abs, text) {
210
+ if (!/\.m?js$/i.test(abs)) return null;
211
+ // An import quoted inside a block comment is not an import.
212
+ const hit = ASSET_IMPORT.exec(String(text).replace(/\/\*[\s\S]*?\*\//g, ''));
213
+ if (!hit) return null;
214
+ // Anything with a package.json above it, up to the project root, may well be
215
+ // bundled. path.relative, not a string prefix: "ucode2" starts with "ucode",
216
+ // and Windows paths differ in case from one shell to the next.
217
+ const top = getRoot();
218
+ const within = (dir) => {
219
+ const rel = path.relative(top, dir);
220
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
221
+ };
222
+ for (let dir = path.dirname(abs); ; dir = path.dirname(dir)) {
223
+ if (existsSync(path.join(dir, 'package.json'))) return null;
224
+ if (!within(dir) || path.relative(top, dir) === '' || path.dirname(dir) === dir) break;
225
+ }
226
+ return `imports ${hit[1]}, which a page with no build step cannot do: the browser refuses the ` +
227
+ 'whole module, so none of its script runs. Remove that import and load it from the page ' +
228
+ 'instead — <link rel="stylesheet" href="…"> for a stylesheet, an <img> or a plain URL for an image.';
229
+ }
230
+
231
+ /** The note for that, appended to a write's result like a parse problem is. */
232
+ const pageNote = (target, text) => {
233
+ const problem = assetImport(target.abs, text);
234
+ return problem ? `\n\n⚠ ${target.show} ${problem}` : '';
235
+ };
236
+
173
237
  /** A file this short comes back whole after an edit; longer ones show the part around the change. */
174
238
  const SHOW_WHOLE = 250;
175
239
  const AROUND = 15;
@@ -323,6 +387,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
323
387
  lineCount,
324
388
  diff,
325
389
  problem: syntaxProblem(target.abs, content),
390
+ note: pageNote(target, content),
326
391
  line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
327
392
  `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
328
393
  };
@@ -342,7 +407,7 @@ export async function writeFile({ path: p, content }) {
342
407
 
343
408
  const written = await put(target, content);
344
409
  const out = result(
345
- `${written.line}.${parseNote(target.show, written.problem)}`,
410
+ `${written.line}.${parseNote(target.show, written.problem)}${written.note}`,
346
411
  `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
347
412
  );
348
413
  out.diff = written.diff;
@@ -388,7 +453,7 @@ export async function batchWrite({ files }) {
388
453
  // would bury the reply under three hundred lines of gutter.
389
454
  const written = await put(target, content, { diffMax: 6 });
390
455
  if (!written.existed) created++;
391
- lines.push(written.line + parseNote(target.show, written.problem));
456
+ lines.push(written.line + parseNote(target.show, written.problem) + written.note);
392
457
  if (written.problem) broken++;
393
458
  diff.push(`~${target.show}`, ...written.diff);
394
459
  }
@@ -444,7 +509,7 @@ function explainMiss(original, oldString, show) {
444
509
  }
445
510
 
446
511
  /** Apply one replacement to a string, or explain precisely why it cannot. */
447
- function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
512
+ function replaceOnce(text, { old_string, new_string, replace_all }, { show, attempted, label = '' }) {
448
513
  const prefix = label ? `${label}: ` : '';
449
514
 
450
515
  if (typeof old_string !== 'string' || typeof new_string !== 'string') {
@@ -467,7 +532,7 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
467
532
  // for exactly this loop. Saying "already done" ends it in one step.
468
533
  if (old_string === new_string) {
469
534
  const found = text.indexOf(old_string);
470
- return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, loose: false };
535
+ return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, how: '', count: 0 };
471
536
  }
472
537
 
473
538
  // Models write \n. A file checked out on Windows is often \r\n, and then an
@@ -486,21 +551,45 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
486
551
  detail: { hits },
487
552
  });
488
553
 
554
+ // replace_all is for renames: every copy changes, and many copies is the point.
555
+ const all = replace_all === true || replace_all === 'true';
489
556
  const hits = text.split(oldText).length - 1;
490
- if (hits > 1) throw ambiguous(hits);
491
- if (hits === 1) {
557
+ if (hits > 1 && !all) throw ambiguous(hits);
558
+ if (hits >= 1) {
492
559
  const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
493
- return { text: text.replace(oldText, () => newText), at, loose: false };
560
+ const out = all ? text.split(oldText).join(newText) : text.replace(oldText, () => newText);
561
+ return { text: out, at, how: '', count: hits };
494
562
  }
495
563
 
496
564
  // No exact match. The commonest reason by far is whitespace — tabs against
497
565
  // spaces, a different indent depth, trailing spaces — with every word right.
498
566
  // Match line by line ignoring that, and re-indent the replacement to fit.
499
567
  // Still unique or nothing: a loose match found twice is refused like any other.
500
- const loose = looseReplace(text, old_string, new_string);
501
- if (loose?.count === 1) return { text: loose.text, at: loose.at, loose: true };
568
+ const loose = all ? null : looseReplace(text, old_string, new_string);
569
+ if (loose?.count === 1) {
570
+ return { text: loose.text, at: loose.at, how: 'ignoring whitespace and re-indented to fit', count: 1 };
571
+ }
502
572
  if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
503
573
 
574
+ // Still nothing. The remaining slips — a middle line remembered slightly
575
+ // wrong, escapes written out, a blank line at either end — each have a
576
+ // matcher of their own (fuzzy.js). They are given the edit in the file's
577
+ // own line endings, and only the matched span changes: the rest of a file
578
+ // with mixed endings keeps every one it had.
579
+ const fuzzy = fuzzyReplace(text, oldText, newText, { all });
580
+ if (fuzzy?.ambiguous) throw ambiguous('several', ' once matched loosely');
581
+ if (fuzzy?.wide) {
582
+ throw new ToolFailure({
583
+ kind: 'no_match', attempted,
584
+ failed: `${prefix}old_string only matches ${show} loosely, across far more text than it contains. Refusing to replace that much.`,
585
+ fix: `Read ${show} again and copy the exact text you mean to replace.`,
586
+ });
587
+ }
588
+ if (fuzzy) {
589
+ const at = text.slice(0, fuzzy.index).split('\n').length;
590
+ return { text: fuzzy.text, at, how: fuzzy.how, count: fuzzy.count };
591
+ }
592
+
504
593
  const { failed, fix } = explainMiss(text, old_string, show);
505
594
  throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
506
595
  }
@@ -548,7 +637,7 @@ function looseReplace(text, oldString, newString) {
548
637
  return { count: 1, text: out.join(eol), at: start + 1 };
549
638
  }
550
639
 
551
- export async function editFile({ path: p, old_string, new_string }) {
640
+ export async function editFile({ path: p, old_string, new_string, replace_all }) {
552
641
  const target = resolveIn(p, 'edit_file');
553
642
  const attempted = `editing ${target.show}`;
554
643
  await guard(target, `edit ${target.abs}`);
@@ -560,7 +649,7 @@ export async function editFile({ path: p, old_string, new_string }) {
560
649
  throw fsFailure(err, attempted, target.show);
561
650
  }
562
651
 
563
- const { text, at, loose } = replaceOnce(original, { old_string, new_string }, {
652
+ const { text, at, how, count } = replaceOnce(original, { old_string, new_string, replace_all }, {
564
653
  show: target.show, attempted,
565
654
  });
566
655
 
@@ -574,14 +663,18 @@ export async function editFile({ path: p, old_string, new_string }) {
574
663
 
575
664
  const delta = toLines(text).length - toLines(original).length;
576
665
  const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
577
- const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
666
+ const where = count > 1
667
+ ? `${count} occurrences in ${target.show}, the first at line ${at}`
668
+ : `one occurrence in ${target.show} at line ${at}`;
669
+ const matched = how ? `, matched ${how}` : '';
578
670
 
579
671
  const span = toLines(new_string).length;
580
672
  const out = result(
581
- `Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).` +
582
- parseNote(target.show, syntaxProblem(target.abs, text)) +
673
+ `Replaced ${where} (${change}${matched}).` +
674
+ parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
583
675
  nowReads(target.show, text, at, span),
584
- `1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
676
+ `${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
677
+ `${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
585
678
  MAX_FILE_OUTPUT
586
679
  );
587
680
  // The replacement is diffed on its own and offset to where it landed, so
@@ -651,7 +744,7 @@ export async function multiEdit({ path: p, edits }) {
651
744
 
652
745
  const out = result(
653
746
  `Applied ${edits.length} edits to ${target.show} (${change}).` +
654
- parseNote(target.show, syntaxProblem(target.abs, text)) +
747
+ parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
655
748
  nowReads(target.show, text, 1, toLines(text).length),
656
749
  `${edits.length} edits · ${change}`,
657
750
  MAX_FILE_OUTPUT
@@ -741,7 +834,7 @@ export async function editFiles({ files }) {
741
834
  planned.map((p) => {
742
835
  const problem = syntaxProblem(p.target.abs, p.text);
743
836
  return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
744
- parseNote(p.target.show, problem);
837
+ parseNote(p.target.show, problem) + pageNote(p.target, p.text);
745
838
  }).join('\n'),
746
839
  `${planned.length} files · ${edits} edits`
747
840
  );