@hncode/hncode 0.5.4 → 0.5.6

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,38 +1,38 @@
1
- {
2
- "name": "@hncode/hncode",
3
- "version": "0.5.4",
4
- "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "bin": {
8
- "hncode": "bin/hncode"
9
- },
10
- "files": [
11
- "bin/",
12
- "src/",
13
- "LICENSE",
14
- "README.md",
15
- "README_CN.md",
16
- "package.json"
17
- ],
18
- "scripts": {
19
- "preinstall": "node bin/preinstall.cjs",
20
- "install": "node bin/install.cjs",
21
- "i18n": "node tools/sync-i18n.mjs",
22
- "i18n:check": "node tools/sync-i18n.mjs --check",
23
- "test": "node --test \"test/**/*.test.mjs\""
24
- },
25
- "engines": {
26
- "node": ">=20"
27
- },
28
- "license": "PolyForm-Noncommercial-1.0.0",
29
- "author": "NiceHello666",
30
- "keywords": [
31
- "cli",
32
- "agent",
33
- "coding",
34
- "ai",
35
- "tui"
36
- ],
37
- "dependencies": {}
38
- }
1
+ {
2
+ "name": "@hncode/hncode",
3
+ "version": "0.5.6",
4
+ "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "hncode": "bin/hncode"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "LICENSE",
14
+ "README.md",
15
+ "README_CN.md",
16
+ "package.json"
17
+ ],
18
+ "scripts": {
19
+ "preinstall": "node bin/preinstall.cjs",
20
+ "install": "node bin/install.cjs",
21
+ "i18n": "node tools/sync-i18n.mjs",
22
+ "i18n:check": "node tools/sync-i18n.mjs --check",
23
+ "test": "node --test \"test/**/*.test.mjs\""
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "license": "PolyForm-Noncommercial-1.0.0",
29
+ "author": "NiceHello666",
30
+ "keywords": [
31
+ "cli",
32
+ "agent",
33
+ "coding",
34
+ "ai",
35
+ "tui"
36
+ ],
37
+ "dependencies": {}
38
+ }
package/src/config.js CHANGED
@@ -339,9 +339,16 @@ const b = baseUrl.replace(/\/+$/, '');
339
339
  // An empty base URL produces a relative endpoint like "/chat/completions",
340
340
  // which fetch() rejects with a URL-scheme error the model can't decode. Leave
341
341
  // the endpoint empty instead so the caller can show "not set".
342
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
343
- : protocol === 'responses' ? `${b}/responses`
344
- : `${b}/chat/completions`);
342
+ // ANTHROPIC ALWAYS SPEAKS /v1/messages. A base of `https://api.anthropic.com` (which is what the
343
+ // provider table stores, and what people type) produced `…/messages` and 404'd; adding /v1 only when
344
+ // the base does not already carry a version segment makes both spellings work.
345
+ //
346
+ // The trailing slashes are stripped above, which is what kept `https://api.anthropic.com/v1/` from
347
+ // becoming `…/v1//messages`.
348
+ const versioned = protocol === 'anthropic' && !/\/v\d+$/.test(b) ? `${b}/v1` : b;
349
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${versioned}/messages`
350
+ : protocol === 'responses' ? `${b}/responses`
351
+ : `${b}/chat/completions`);
345
352
 
346
353
  // Context window: an explicit env/root override wins, then the model's own
347
354
  // `contextLength` (discovered from the provider's /v1/models), then the
@@ -128,6 +128,32 @@ function snapshotRow(msg) {
128
128
  if (Object.keys(a).length) out.toolArgs = a;
129
129
  }
130
130
  if (Array.isArray(msg.diff)) out.diff = msg.diff.slice(0, 400);
131
+ // The same pieces `formatToolLine` composes, as data rather than as ANSI.
132
+ if (out.role === 'tool') {
133
+ const verb = msg.pending ? 'Using' : 'Used';
134
+ const name = out.toolName || 'tool';
135
+ // The identifying argument, by the same key order the line uses. `toolArgs` above already holds
136
+ // only the short, displayable ones.
137
+ const arg = out.toolArgs
138
+ ? (out.toolArgs.command || out.toolArgs.cmd || out.toolArgs.path || out.toolArgs.file_path
139
+ || out.toolArgs.pattern || out.toolArgs.query || out.toolArgs.url || out.toolArgs.description || '')
140
+ : '';
141
+ const summary = arg ? `${verb} ${name} (${arg})` : `${verb} ${name}`;
142
+ let added = 0;
143
+ let removed = 0;
144
+ if (Array.isArray(msg.diff)) for (const d of msg.diff) {
145
+ if (d && (d.type === 'add' || d.type === '+')) added++;
146
+ else if (d && (d.type === 'del' || d.type === '-')) removed++;
147
+ }
148
+ out.display = {
149
+ verb,
150
+ tool: name,
151
+ arg: arg ? String(arg).slice(0, 400) : '',
152
+ summary,
153
+ status: msg.pending ? 'running' : (msg.failed ? 'failed' : 'done'),
154
+ diff: (added || removed) ? { added, removed } : null,
155
+ };
156
+ }
131
157
  if (msg.card && typeof msg.card === 'object') {
132
158
  out.card = { phase: msg.card.phase, headline: msg.card.headline, detail: msg.card.detail };
133
159
  }
@@ -235,6 +261,15 @@ export class SessionHub {
235
261
  * Returns nothing; failures here must never affect rendering.
236
262
  */
237
263
  syncChat(chat) {
264
+ // NOBODY IS WATCHING, SO NOTHING IS BUILT.
265
+ //
266
+ // `/web` starts a server, but a browser may never connect — and this ran on every frame regardless,
267
+ // walking the whole transcript to build rows no one would read. Measured on a 300-message transcript:
268
+ // 8.8ms per frame with zero subscribers, which is most of a 60fps budget spent on nothing.
269
+ //
270
+ // The rows are rebuilt from scratch on the next sync after a client connects (it starts with no rows
271
+ // and `_rowFp` empty), so skipping here costs one frame of latency at connect time and nothing else.
272
+ if (this.subscribers.size === 0) return;
238
273
  const list = Array.isArray(chat) ? chat : [];
239
274
  const seen = new Set();
240
275
  for (let i = 0; i < list.length; i++) {
@@ -256,6 +291,7 @@ export class SessionHub {
256
291
  this.rows.push(row);
257
292
  this._rowById.set(id, row);
258
293
  this.emit({ type: 'row', row });
294
+ this._rowFp.set(id, fp);
259
295
  } else if (known !== fp) {
260
296
  const target = this._rowById.get(id);
261
297
  // The WHOLE row's renderable fields, not a text delta.
@@ -275,6 +311,7 @@ export class SessionHub {
275
311
  const patch = snapshotRow(msg);
276
312
  if (target) Object.assign(target, patch);
277
313
  this.emit({ type: 'row_patch', id, patch });
314
+ this._rowFp.set(id, fp);
278
315
  }
279
316
  }
280
317
  // The TUI rebuilds `state.chat` wholesale on /compact and on session resume.
@@ -79,6 +79,8 @@ export const SETTINGS_SCHEMA = [
79
79
  ui: { tab: 'appearance', group: 'Display', label: 'Calm mode', hint: 'Fewer decorative flourishes; keeps the information, drops the animation' } },
80
80
  { key: 'reduced_motion', ...bool(false),
81
81
  ui: { tab: 'appearance', group: 'Motion', label: 'Reduced motion', hint: 'No sweeps or pulses — for a terminal over a slow link, or a preference' } },
82
+ { key: 'render_fps', kind: 'int', default: '30',
83
+ ui: { tab: 'appearance', group: 'Performance', label: 'Render FPS', hint: 'How often the screen is repainted. 0 means 1 — a terminal cannot render at zero. Higher is smoother and costs more CPU' } },
82
84
  { key: 'shimmer_edge', ...bool(true),
83
85
  ui: { tab: 'appearance', group: 'Motion', label: 'Shimmer edge', hint: 'The travelling highlight on a running tool name' } },
84
86
  { key: 'show_tool_details', ...bool(true),
package/src/tools/edit.js CHANGED
@@ -37,7 +37,7 @@ export const spec = {
37
37
  new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
38
38
 
39
39
  replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
40
- allow_dup: { type: 'boolean', default: false, description: 'Permit an edit that leaves two adjacent identical lines. OFF by default, so an edit that restates the line just past end_line is refused — that line is outside the replaced range and was never deleted, so the file would end up holding it twice. Set true only when the duplicate IS the intent.' },
40
+ allow_dup: { type: 'boolean', default: false, description: 'Default OFF, and when off duplicate lines are removed automatically: if new_string repeats a line that already exists next to it, that copy is not inserted. The file\'s own lines are never touched. Set true to KEEP the repetition — needed only when two identical adjacent lines are the intended result.' },
41
41
  start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). With old_string too, it is the first line to SEARCH for it in.' },
42
42
  end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line). With old_string too, it is the last line to search in.' },
43
43
  },
@@ -206,55 +206,34 @@ export const spec = {
206
206
  // reply says what was dropped and how to keep it.
207
207
  //
208
208
  // `allow_dup: true` keeps the duplicate, for the real case where two identical lines are the point.
209
- // DEDUPE REPEATEDLY, not once.
210
- //
211
- // One pass was not enough: `new_string` can carry the SAME line twice in a row on its own — a
212
- // pasted block whose first line got duplicated — and removing one copy leaves the other against
213
- // the file's own line. Each removal shortens `inserted`, so the scan has to run again on what is
214
- // left. Bounded by the block's own length, so it always terminates.
215
- let insertedFinal = inserted;
216
- const drops = [];
217
- if (args.allow_dup !== true) {
218
- for (let guard = 0; guard <= inserted.length; guard++) {
219
- const hit = findRestatedLine(lines, insertedFinal, start, end);
220
- if (!hit || hit.index < 0 || hit.index >= insertedFinal.length) break;
221
- drops.push(hit);
222
- // REMOVE THE DUPLICATE AND ANY BLANK LINE THAT WAS PART OF THE SAME FRAGMENT.
223
- //
224
- // `new_string` often carries a trailing blank line — a copy that ended with one, or a block
225
- // pasted with its closing newline. When the line above that blank is the duplicate, removing only
226
- // the line leaves the blank stranded: the file keeps a whitespace-only row the caller never
227
- // meant to add, and it sits exactly where the duplicate used to be.
228
- //
229
- // Blanks DIRECTLY adjacent to the removed line go with it, on both sides. A blank line elsewhere
230
- // in the block is untouched — it is not this fragment's.
231
- let lo = hit.index;
232
- let hi = hit.index + 1;
233
- while (lo > 0 && String(insertedFinal[lo - 1]).trim() === '') lo--;
234
- while (hi < insertedFinal.length && String(insertedFinal[hi]).trim() === '') hi++;
235
- insertedFinal = [...insertedFinal.slice(0, lo), ...insertedFinal.slice(hi)];
236
- }
237
- }
238
- const seamDrop = drops.length ? drops[0] : null;
239
-
240
- // LAST CHANCE: if a duplicate SURVIVED the removal, do not write the file.
241
- //
242
- // The dedupe above tries repeatedly, but every rule it applies is a heuristic about which line is
243
- // the caller's — and a wrong guess leaves the duplicate in place with a confident "dropped" note in
244
- // the reply. That is worse than refusing: the message says the problem was handled.
245
- //
246
- // So the result is checked ONCE MORE with the same test, and if it still finds a pair the write is
247
- // abandoned. `allow_dup` skips this too — a deliberate duplicate must not be refused at the door.
248
- const out = [...lines.slice(0, start - 1), ...insertedFinal, ...lines.slice(end)];
249
- if (args.allow_dup !== true) {
250
- const leftover = findRestatedLine(lines, insertedFinal, start, end);
251
- if (leftover) {
252
- return `Error: the edit would leave "${leftover.text}" twice in a row (result lines `
253
- + `${leftover.a} and ${leftover.b}), and automatic removal could not settle it.\n`
254
- + 'Nothing was written. Narrow the range so it covers only the lines you mean to replace, '
255
- + 'or pass allow_dup: true if the duplicate is intentional.';
256
- }
257
- }
209
+ // REMOVE ADJACENT DUPLICATES FROM THE RESULT — the file the caller will open.
210
+ //
211
+ // Nothing here tries to work out WHICH line the caller meant; that guessing is what made every
212
+ // previous version leak. The rule is the one a reader would apply: if the edit leaves two identical
213
+ // lines next to each other, one of them is redundant, so it goes.
214
+ //
215
+ // `new_string` is what gets adjusted, because the file's own lines were already there — editing
216
+ // them would delete content the caller never asked to touch.
217
+ //
218
+ // A blank or whitespace-only line between two identical lines counts as adjacent: the blank is
219
+ // almost always the tail of a pasted block, and leaving it strands a gap where the duplicate was.
220
+ //
221
+ // `allow_dup: true` skips all of this and writes exactly what was asked for.
222
+ let insertedFinal = inserted;
223
+ const drops = [];
224
+ if (args.allow_dup !== true) {
225
+ // One pass per candidate line: each removal can expose another pair, so the scan repeats until
226
+ // it finds nothing. Bounded by the block length, so it always terminates.
227
+ for (let guard = 0; guard <= insertedFinal.length; guard++) {
228
+ const hit = findAdjacentDuplicate(insertedFinal) || findSeamDuplicate(lines, insertedFinal, start, end);
229
+ if (!hit) break;
230
+ drops.push(hit);
231
+ insertedFinal = [...insertedFinal.slice(0, hit.index), ...insertedFinal.slice(hit.index + 1)];
232
+ }
233
+ }
234
+ const seamDrop = drops.length ? drops[0] : null;
235
+
236
+ const out = [...lines.slice(0, start - 1), ...insertedFinal, ...lines.slice(end)];
258
237
  const normUpdated = out.join('\n');
259
238
  const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
260
239
 
@@ -282,10 +261,17 @@ export const spec = {
282
261
  // with its ACTUAL pair (the two result lines that would have been identical) — it is not
283
262
  // always "the last line of new_string against end_line + 1": a block can carry the repeat
284
263
  // inside itself, with blank lines between the copies.
285
- const seamNote = seamDrop
286
- ? ` Dropped a duplicated line: "${seamDrop.text}" would have appeared twice in a row`
287
- + ` (result lines ${seamDrop.a} and ${seamDrop.b}). Pass allow_dup: true to keep it.`
288
- : (args.allow_dup === true ? ' Duplicate kept (allow_dup: true).' : '');
264
+ // THE NOTE HAS TO SAY WHICH COPY WENT, or it reads as "the tool is eating my code".
265
+ //
266
+ // It used to read `Dropped a duplicated line: "}" ... Pass allow_dup: true to keep it`, which a
267
+ // caller interpreted as the tool silently deleting a line it had written on purpose — so it stopped
268
+ // trusting the tool and never reached for the flag. What was actually removed is the copy inside
269
+ // new_string; every line already in the file is untouched.
270
+ const seamNote = seamDrop
271
+ ? ` One line from new_string was NOT inserted: "${seamDrop.text}" already sits at result line`
272
+ + ` ${seamDrop.a} (it would have been adjacent to the copy at line ${seamDrop.b}). The file's own`
273
+ + ' lines were not touched. If that repetition was intended, resend with allow_dup: true.'
274
+ : (args.allow_dup === true ? ' allow_dup: true — the repeated line was kept as asked.' : '');
289
275
  return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${insertedFinal.length} line${insertedFinal.length === 1 ? '' : 's'}.${seamNote}`;
290
276
  }
291
277
 
@@ -481,113 +467,54 @@ function leadingWs(s) {
481
467
  return m ? m[0] : '';
482
468
  }
483
469
 
484
- /**
485
- * Did THIS edit restate a line it did not replace?
486
- *
487
- * The exact shape of the recurring mistake: `new_string` ends with the same text as the line at
488
- * `end_line + 1`. That line is outside the replaced range, so it survives — and the copy inside
489
- * `new_string` lands directly above it, leaving the file holding it twice.
490
- *
491
- * Detected DIRECTLY rather than by scanning the result for any adjacent pair. A scan cannot tell "the
492
- * caller fumbled the range" from "the file always had two of these", so it either misses the case or cries
493
- * wolf on legitimate code. Comparing the last inserted line against the first untouched line is exact: it
494
- * fires only when the edit itself created the repetition, and never on one that was already there.
495
- *
496
- * `lines` is the content BEFORE the edit; `inserted` is the new lines; `start`/`end` are the range.
497
- * Returns the 1-based line numbers of the pair, or null.
498
- */
499
- function findRestatedLine(lines, inserted, start, end) {
500
- // ASK THE RESULT, NOT THE SHAPE.
501
- //
502
- // Every earlier version tried to predict the duplicate from the arguments — "the last line of
503
- // new_string equals the line at end_line + 1", gated on the replacement growing, or on that line
504
- // differing from the one it replaced. Each rule missed a real case:
505
- //
506
- // replacing line N with the same text, when line N+1 already held it — nothing in the arguments
507
- // distinguishes that from an ordinary rewrite, and the file ended up with the text twice.
508
- //
509
- // Building the result and looking at it cannot miss a case, because it is the thing being asked about.
510
- const result = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
511
- // Scan from the start of the replaced region to a few lines PAST the inserted block.
512
- //
513
- // The old bound was `start - 1 + inserted.length`, which stops at the first untouched line. That is one
514
- // line short whenever blank lines sit between the inserted block and the file's own copy — and exactly
515
- // one line short in the shape that shipped a duplicate: `new_string` ending in `*/` while the file's own
516
- // `*/` followed a blank. The pair straddled the bound and neither half was compared.
517
- //
518
- // A few extra lines is enough and still narrow: a duplicate further away is not this edit's doing.
519
- const from = Math.max(0, start - 1);
520
- const to = Math.min(result.length - 1, start - 1 + inserted.length + 4);
521
- // TWO COMPARISON CHAINS, not one.
522
- //
523
- // `prevText` follows the last line with content; `prevPunct` follows the last line that is only
524
- // punctuation. A closer is compared with the previous closer, a sentence with the previous sentence.
525
- //
526
- // One chain was wrong: a block ending `/** * note */` followed by `* note` `*/` repeats the `*/`, but the
527
- // `* note` line overwrote the comparison base, so the second `*/` was compared against text and the
528
- // duplicate went through. Measured on the reported file.
529
- let prevText = '';
530
- let prevTextAt = 0;
531
- let prevPunct = '';
532
- let prevPunctAt = 0;
533
- // HOW MANY BLANK LINES SEPARATE the previous content line from this one. A blank line between two
534
- // identical lines used to reset the comparison, so `A / <blank> / A` went through as though it were not
535
- // a duplicate — and it is one: the caller's `new_string` carried an extra blank line, and the file ended
536
- // up with the text twice with a gap in it.
537
- let blanksSince = 0;
538
- for (let i = from; i <= to; i++) {
539
- const t = String(result[i] == null ? '' : result[i]).trim();
540
- // A blank line is not content, but it does not erase the line above it either. A line made
541
- // ENTIRELY of punctuation (`}`, `)`, `});` — closers) is compared too, but ONLY when the two
542
- // copies carry the SAME leading whitespace, checked below. Trim-equal closers at DIFFERENT
543
- // indentation are the nested closers of legitimate code (` )` above `)` in Python, an
544
- // inner ` }` above the outer `}`) — treating those as a restated duplicate deleted working
545
- // syntax. Same indentation, though, is the signature of a RESTATED closer: a model rewriting
546
- // a block's ending copies the closers that already exist just past the range, and two
547
- // same-indent identical closers in a row close the same scope twice.
548
- // PUNCTUATION IS COMPARED AGAINST THE PREVIOUS PUNCTUATION, not against whatever line came last.
549
- //
550
- // `* xxx` sits between the two `*/` that repeat, and treating the text line as the comparison base
551
- // meant the second `*/` was compared with the text and never with the first `*/`. Measured on the
552
- // reported file: `/** * 注释 */` … `* 注释` `*/` went through with the closer duplicated.
553
- //
554
- // So two chains are kept: the last CONTENT line, and the last PUNCTUATION line. A closer is compared
555
- // with the previous closer; a text line with the previous text line.
556
- if (!t) { blanksSince++; continue; }
557
- const isPunct = !/[a-zA-Z0-9_\u0080-\uFFFF]/.test(t);
558
- // A LONE CLOSER IS NOT A DUPLICATE. `}`, `)`, `});`, `]` each close one scope and appear at every
559
- // nesting level, so two in a row is ordinary code rather than a restatement. `*/` is deliberately
560
- // NOT in this list: a doc block has exactly one closer, so two means the block was written twice —
561
- // which is the case this whole check exists for.
562
- if (t === '{' || t === '}' || t === ')' || t === '});' || t === '};' || t === ']' || t === '];') {
563
- prevPunct = ''; prevPunctAt = 0; blanksSince = 0; continue;
564
- }
565
- const base = isPunct ? prevPunct : prevText;
566
- const baseAt = isPunct ? prevPunctAt : prevTextAt;
567
- if (base && t === base) {
568
- const sameIndent = leadingWs(result[i]) === leadingWs(result[baseAt]);
569
- if (sameIndent) {
570
- // WHICH LINE IS OURS — the one INSIDE `new_string`, since the file's own copy must survive.
571
- //
572
- // `i` is the later of the pair and `baseAt` the earlier. Preferring `i` is what handles
573
- // `A / <blank> / A`: both copies came from `new_string`, and deleting the later one leaves the
574
- // earlier in place with no gap after it.
575
- //
576
- // Falls back to `baseAt` when `i` lies past the inserted block: that is the ordinary seam case,
577
- // where the duplicate's second half is the file's own line and must not be touched.
578
- let idx = i - (start - 1);
579
- if (idx < 0 || idx >= inserted.length) idx = baseAt - (start - 1);
580
- idx = Math.max(0, Math.min(inserted.length - 1, idx));
581
- return { a: baseAt + 1, b: i + 1, text: t, index: idx, blanksBetween: blanksSince };
582
- }
583
- }
584
- if (isPunct) { prevPunct = t; prevPunctAt = i; }
585
- else { prevText = t; prevTextAt = i; }
586
- blanksSince = 0;
587
- }
588
- return null;
589
- }
590
-
470
+ /**
471
+ * The first repeated line in `inserted`, and where to remove one copy.
472
+ *
473
+ * ONE RULE: a line that repeats the one above it is one too many, so it goes. Nothing here looks at what
474
+ * the line contains — a brace, a comment closer, a string of spaces are all just text, and asking which
475
+ * of them "may legitimately repeat" is a question this tool has no business answering.
476
+ *
477
+ * A blank line between the two does not break the pair: a pasted block usually ends with one, and leaving
478
+ * it behind strands a gap where the duplicate was.
479
+ *
480
+ * Only lines FROM `new_string` are ever removed. The file's own lines were already there, and deleting one
481
+ * would remove content the caller never asked to touch — that is the whole reason to look at `inserted`
482
+ * rather than at the result.
483
+ */
484
+ function findAdjacentDuplicate(inserted) {
485
+ let prev = null;
486
+ let prevAt = -1;
487
+ for (let i = 0; i < inserted.length; i++) {
488
+ const t = String(inserted[i] == null ? '' : inserted[i]).trim();
489
+ if (!t) continue;
490
+ if (prev !== null && t === prev) return { a: prevAt + 1, b: i + 1, text: t, index: i };
491
+ prev = t;
492
+ prevAt = i;
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * The pair at the SEAM: the last line of `inserted` against the line at `end_line + 1`, which the edit
499
+ * never replaced.
500
+ *
501
+ * Same rule, same answer — the inserted copy goes, the file's own line stays.
502
+ */
503
+ function findSeamDuplicate(lines, inserted, start, end) {
504
+ if (!inserted.length) return null;
505
+ const norm = (v) => String(v == null ? '' : v).trim();
506
+ // THE END: new_string's last line against the first line the edit did not replace.
507
+ const last = norm(inserted[inserted.length - 1]);
508
+ if (last && norm(lines[end]) === last) {
509
+ return { a: inserted.length, b: end + 1, text: last, index: inserted.length - 1 };
510
+ }
511
+ // THE START: new_string's first line against the line just above the range.
512
+ const first = norm(inserted[0]);
513
+ if (first && norm(lines[start - 2]) === first) {
514
+ return { a: start - 1, b: start, text: first, index: 0 };
515
+ }
516
+ return null;
517
+ }
591
518
  /**
592
519
  * Build the diff payload, including the unchanged lines around the change.
593
520
  *
@@ -1,6 +1,8 @@
1
1
  // FetchURL tool — fetch a web page's main text content (Node built-in fetch, no deps).
2
2
 
3
3
  import os from 'node:os';
4
+ import http from 'node:http';
5
+ import https from 'node:https';
4
6
 
5
7
  const MAX_BYTES = 200 * 1024; // generous cap for large pages
6
8
 
@@ -42,6 +44,100 @@ function rejectUrl(raw) {
42
44
  return null; // ok
43
45
  }
44
46
 
47
+ /**
48
+ * A browser-ish header set.
49
+ *
50
+ * `Accept` is part of it: a request with a UA but an empty Accept still looks like a script to some
51
+ * servers, and they answer 406 or 429.
52
+ */
53
+ const HEADERS = {
54
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
55
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5',
56
+ 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8',
57
+ };
58
+
59
+ /** How long to wait before retrying a rate-limited request. */
60
+ const RETRY_AFTER_MS = 1500;
61
+
62
+ /**
63
+ * Fetch with the browser headers, retrying a 429 once.
64
+ *
65
+ * One retry after a short pause turns a transient rate limit into a success; a second immediate attempt
66
+ * would only count as another request against the limit.
67
+ */
68
+ /**
69
+ * Fetch with the browser headers, retrying a 429 once.
70
+ *
71
+ * `insecure` SKIPS CERTIFICATE VERIFICATION, for a host whose chain is broken — an internal server with a
72
+ * self-signed certificate, an expired intermediate — where the alternative is not fetching it at all.
73
+ *
74
+ * `fetch` has no per-request switch for this: the option lives on the dispatcher, and Node does not expose
75
+ * one without the `undici` package this project does not depend on. So that path goes through
76
+ * `node:https` instead. It is the SAME request — same headers, same redirects, same body cap — just built
77
+ * by hand so `rejectUnauthorized: false` can be passed.
78
+ */
79
+ async function fetchWithRetry(url, signal, insecure = false) {
80
+ if (insecure) return httpsRequest(url, signal, true);
81
+ const first = await fetch(url, { signal, redirect: 'follow', headers: HEADERS });
82
+ if (first.status !== 429) return first;
83
+ await new Promise((r) => setTimeout(r, RETRY_AFTER_MS));
84
+ // The first response body was never read; release it so the connection can be reused.
85
+ try { await first.body?.cancel(); } catch { /* already gone */ }
86
+ return fetch(url, { signal, redirect: 'follow', headers: HEADERS });
87
+ }
88
+
89
+ /**
90
+ * One GET over `node:https`, returning the shape the caller already handles.
91
+ *
92
+ * Only used for the `insecure` path, where `fetch` cannot express what is needed. The response is
93
+ * buffered up to MAX_BYTES and handed back as an object with `.ok`, `.status`, `.headers.get()` and
94
+ * `.arrayBuffer()`, so the rest of `execute` does not know which transport ran.
95
+ */
96
+ function httpsRequest(url, signal, insecure) {
97
+ return new Promise((resolve, reject) => {
98
+ let u;
99
+ try { u = new URL(url); } catch (e) { reject(e); return; }
100
+ const mod = u.protocol === 'http:' ? http : https;
101
+ const req = mod.request({
102
+ protocol: u.protocol,
103
+ hostname: u.hostname,
104
+ port: u.port || undefined,
105
+ path: u.pathname + u.search,
106
+ method: 'GET',
107
+ headers: HEADERS,
108
+ // The whole point of this path. Every other request in the tool keeps verification on.
109
+ rejectUnauthorized: !insecure,
110
+ }, (res) => {
111
+ // A redirect is followed rather than reported: `fetch` defaults to `follow`, and a fetch that
112
+ // stopped at a 301 would silently return an empty body for most of the web.
113
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
114
+ res.resume();
115
+ const next = new URL(res.headers.location, u).href;
116
+ resolve(httpsRequest(next, signal, insecure));
117
+ return;
118
+ }
119
+ const chunks = [];
120
+ let got = 0;
121
+ res.on('data', (c) => { if (got < MAX_BYTES) { chunks.push(c); got += c.length; } });
122
+ res.on('end', () => {
123
+ const body = Buffer.concat(chunks);
124
+ resolve({
125
+ ok: res.statusCode >= 200 && res.statusCode < 300,
126
+ status: res.statusCode,
127
+ statusText: res.statusMessage || '',
128
+ headers: { get: (n) => res.headers[String(n).toLowerCase()] || null },
129
+ arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
130
+ body: null,
131
+ });
132
+ });
133
+ res.on('error', reject);
134
+ });
135
+ req.on('error', reject);
136
+ if (signal) signal.addEventListener('abort', () => req.destroy(new Error('interrupted')), { once: true });
137
+ req.end();
138
+ });
139
+ }
140
+
45
141
  export const spec = {
46
142
  name: 'FetchURL',
47
143
  description: 'Fetch a URL and return its text (main page text, or the full body; a leading note says which). Any http/https URL, including localhost.',
@@ -49,6 +145,7 @@ export const spec = {
49
145
  type: 'object',
50
146
  properties: {
51
147
  url: { type: 'string', description: 'URL to fetch.' },
148
+ insecure: { type: 'boolean', default: false, description: 'Skip TLS certificate verification. For a host with a self-signed or expired certificate whose content is still wanted — the connection is no longer authenticated, so only use it for a source you already trust.' },
52
149
  },
53
150
  required: ['url'],
54
151
  },
@@ -57,10 +154,21 @@ export const spec = {
57
154
  if (rejected) return rejected;
58
155
  const signal = (ctx && ctx.signal) || undefined;
59
156
  try {
60
- const res = await fetch(args.url.trim(), { signal, redirect: 'follow' });
61
- if (!res.ok) {
62
- return `Error: ${res.status} ${res.statusText} from ${args.url}`;
63
- }
157
+ // A BROWSER USER-AGENT, and it is not cosmetic.
158
+ //
159
+ // Without one Node sends `user-agent: node`, and a great many sites answer that with 403 or 429
160
+ // before serving anything. Measured on a wukongsch.com article: 429 with the default headers,
161
+ // served normally with a browser UA.
162
+ const res = await fetchWithRetry(args.url.trim(), signal, args.insecure === true);
163
+ if (!res.ok) {
164
+ // SAY WHAT THE STATUS MEANS. A bare 429 left the caller re-fetching the same page to see the
165
+ // same number.
166
+ const hint = res.status === 429 ? ' — the site is rate-limiting requests; wait and retry, or find another source.'
167
+ : res.status === 403 ? ' — the site refused the request; it may require a login or block automated fetches.'
168
+ : res.status === 404 ? ' — no such page.'
169
+ : '';
170
+ return `Error: ${res.status} ${res.statusText} from ${args.url}${hint}`;
171
+ }
64
172
  const contentType = (res.headers.get('content-type') || '').toLowerCase();
65
173
  const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml');
66
174
  let isTruncated = false;
@@ -101,7 +209,12 @@ export const spec = {
101
209
  if (isTruncated) out += `\n\n[... truncated; page larger than ${Math.round(MAX_BYTES / 1024)} KB]`;
102
210
  return out;
103
211
  } catch (e) {
104
- const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
212
+ // 'fetch failed' IS NOT A REASON. Node puts the real cause — ENOTFOUND, ECONNREFUSED,
213
+ // CERT_HAS_EXPIRED, a TLS handshake failure — on `e.cause`, and only `e.message` was read, so
214
+ // every distinct failure looked identical and told the caller nothing they could act on.
215
+ const cause = e && e.cause ? (e.cause.code || e.cause.message) : '';
216
+ const base = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
217
+ const msg = cause && !String(base).includes(cause) ? `${base} (${cause})` : base;
105
218
  return `Error fetching ${args.url}: ${msg}`;
106
219
  }
107
220
  },