@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 +38 -38
- package/src/config.js +10 -3
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/tools/edit.js +88 -161
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/read.js +11 -1
- package/src/tools/web-search.js +139 -88
- package/src/tui.js +339 -145
- package/src/web.js +47 -1
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.5.
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
package/src/session-hub.js
CHANGED
|
@@ -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.
|
package/src/settings-schema.js
CHANGED
|
@@ -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: '
|
|
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
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
*
|
package/src/tools/fetch-url.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
},
|