@gavin-hu/my-pi-agent 0.12.0 → 0.13.1
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/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +162 -70
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +38 -6
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +46 -4
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +8 -5
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +52 -14
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +129 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/format.ts +4 -7
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +19 -8
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/index.ts +19 -0
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +81 -16
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +55 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +14 -2
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +25 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +14 -10
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +361 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/tui/tool.ts +6 -2
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -22,6 +22,8 @@ import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types
|
|
|
22
22
|
const MAX_CHUNK = 1024 * 1024;
|
|
23
23
|
/** A line without a newline is flushed once it grows past this. */
|
|
24
24
|
const MAX_LINE_BYTES = 1024 * 1024;
|
|
25
|
+
/** Grace period before a stopped command is escalated to SIGKILL. */
|
|
26
|
+
const KILL_GRACE_MS = 2000;
|
|
25
27
|
|
|
26
28
|
/** Internal watcher state: the public record plus non-serializable fields. */
|
|
27
29
|
interface Internal {
|
|
@@ -50,6 +52,7 @@ interface Internal {
|
|
|
50
52
|
rawLines: string[];
|
|
51
53
|
matches: WatchMatch[];
|
|
52
54
|
child?: SpawnedProcess;
|
|
55
|
+
killTimer?: ReturnType<typeof setTimeout>;
|
|
53
56
|
notified: boolean;
|
|
54
57
|
seq: number;
|
|
55
58
|
}
|
|
@@ -95,6 +98,8 @@ export interface WatchRuntimeOptions {
|
|
|
95
98
|
config?: WatchConfig;
|
|
96
99
|
/** When false, never create a real interval; tests call `tick()` directly. */
|
|
97
100
|
autoClock?: boolean;
|
|
101
|
+
/** Grace before SIGTERM escalates to SIGKILL; default {@link KILL_GRACE_MS}. */
|
|
102
|
+
killGraceMs?: number;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRuntime {
|
|
@@ -103,6 +108,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
103
108
|
const now = options.now ?? (() => Date.now());
|
|
104
109
|
const fixedConfig = options.config;
|
|
105
110
|
const autoClock = options.autoClock ?? true;
|
|
111
|
+
const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
|
|
106
112
|
|
|
107
113
|
let config: WatchConfig = fixedConfig ?? DEFAULT_CONFIG;
|
|
108
114
|
const watches = new Map<string, Internal>();
|
|
@@ -243,15 +249,60 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
243
249
|
emit(w, rest, time);
|
|
244
250
|
};
|
|
245
251
|
|
|
246
|
-
const
|
|
252
|
+
const clearKillTimer = (w: Internal): void => {
|
|
253
|
+
if (w.killTimer) {
|
|
254
|
+
clearTimeout(w.killTimer);
|
|
255
|
+
w.killTimer = undefined;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const killChild = (w: Internal, signal: "SIGTERM" | "SIGKILL"): void => {
|
|
247
260
|
const pid = w.pid;
|
|
248
261
|
try {
|
|
249
|
-
if (pid !== undefined) killTree(pid, "
|
|
250
|
-
else w.child?.kill(
|
|
262
|
+
if (pid !== undefined) killTree(pid, signal, signal === "SIGKILL");
|
|
263
|
+
else w.child?.kill(signal);
|
|
251
264
|
} catch {
|
|
252
265
|
// Best-effort: the child may already be gone.
|
|
253
266
|
}
|
|
254
|
-
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** SIGTERM a command, escalating to SIGKILL when it has not closed in time. */
|
|
270
|
+
const terminateChild = (w: Internal): void => {
|
|
271
|
+
clearKillTimer(w);
|
|
272
|
+
killChild(w, "SIGTERM");
|
|
273
|
+
if (w.pid === undefined && !w.child) return;
|
|
274
|
+
w.killTimer = setTimeout(() => {
|
|
275
|
+
w.killTimer = undefined;
|
|
276
|
+
if (w.child) killChild(w, "SIGKILL");
|
|
277
|
+
}, killGraceMs);
|
|
278
|
+
w.killTimer.unref?.();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Resolve when every command has closed, or after `ms`, whichever comes first. */
|
|
282
|
+
const waitForChildren = (watches: Internal[], ms: number): Promise<void> => {
|
|
283
|
+
const alive = watches.filter((w) => w.child !== undefined);
|
|
284
|
+
if (alive.length === 0) return Promise.resolve();
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
let remaining = alive.length;
|
|
287
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
288
|
+
const settle = (): void => {
|
|
289
|
+
if (--remaining > 0) return;
|
|
290
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
291
|
+
resolve();
|
|
292
|
+
};
|
|
293
|
+
for (const w of alive) {
|
|
294
|
+
let done = false;
|
|
295
|
+
const once = (): void => {
|
|
296
|
+
if (done) return;
|
|
297
|
+
done = true;
|
|
298
|
+
settle();
|
|
299
|
+
};
|
|
300
|
+
w.child?.on("close", once);
|
|
301
|
+
w.child?.on("error", once);
|
|
302
|
+
}
|
|
303
|
+
timer = setTimeout(resolve, ms);
|
|
304
|
+
timer.unref?.();
|
|
305
|
+
});
|
|
255
306
|
};
|
|
256
307
|
|
|
257
308
|
const pollFile = (w: Internal, time: number): void => {
|
|
@@ -278,6 +329,9 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
278
329
|
const read = readSync(fd, buffer, 0, length, w.offset);
|
|
279
330
|
w.offset += read;
|
|
280
331
|
consume(w, buffer.subarray(0, read), time);
|
|
332
|
+
} catch {
|
|
333
|
+
// The path can vanish or change type between stat and read (rotation, a
|
|
334
|
+
// replacement); skip this tick and pick it up next time.
|
|
281
335
|
} finally {
|
|
282
336
|
if (fd !== undefined) {
|
|
283
337
|
try {
|
|
@@ -305,22 +359,24 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
305
359
|
child.stdout?.on("data", onData);
|
|
306
360
|
child.stderr?.on("data", onData);
|
|
307
361
|
child.on("close", (code, signal) => {
|
|
362
|
+
clearKillTimer(w);
|
|
363
|
+
w.child = undefined;
|
|
308
364
|
if (w.status !== "running") return;
|
|
309
365
|
flushPending(w, now());
|
|
310
366
|
w.status = "exited";
|
|
311
367
|
w.exitCode = code;
|
|
312
368
|
w.signal = signal;
|
|
313
369
|
w.endedAt = now();
|
|
314
|
-
w.child = undefined;
|
|
315
370
|
ui.paint();
|
|
316
371
|
if (runningCount() === 0) stopClock();
|
|
317
372
|
});
|
|
318
373
|
child.on("error", (error) => {
|
|
374
|
+
clearKillTimer(w);
|
|
375
|
+
w.child = undefined;
|
|
319
376
|
if (w.status !== "running") return;
|
|
320
377
|
w.status = "error";
|
|
321
378
|
w.error = error.message;
|
|
322
379
|
w.endedAt = now();
|
|
323
|
-
w.child = undefined;
|
|
324
380
|
ui.paint();
|
|
325
381
|
if (runningCount() === 0) stopClock();
|
|
326
382
|
});
|
|
@@ -337,6 +393,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
337
393
|
};
|
|
338
394
|
|
|
339
395
|
const add = (opts: AddWatchOptions, ctx: ExtensionContext): WatchRecord => {
|
|
396
|
+
if (!config.enabled) throw new Error("watch is disabled.");
|
|
340
397
|
if (watches.size >= config.maxWatchers) {
|
|
341
398
|
throw new Error(`at most ${config.maxWatchers} watchers; stop one first.`);
|
|
342
399
|
}
|
|
@@ -349,11 +406,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
349
406
|
cwd: opts.cwd ? (isAbsolute(opts.cwd) ? opts.cwd : resolve(base, opts.cwd)) : base,
|
|
350
407
|
};
|
|
351
408
|
if (source.kind === "file") {
|
|
409
|
+
let info: ReturnType<typeof statSync>;
|
|
352
410
|
try {
|
|
353
|
-
statSync(source.path ?? "");
|
|
411
|
+
info = statSync(source.path ?? "");
|
|
354
412
|
} catch {
|
|
355
413
|
throw new Error(`file not found: ${source.path}`);
|
|
356
414
|
}
|
|
415
|
+
if (!info.isFile()) throw new Error(`not a regular file: ${source.path}`);
|
|
357
416
|
}
|
|
358
417
|
const caseSensitive = opts.caseSensitive ?? false;
|
|
359
418
|
const regex = compileRegex(opts.pattern, caseSensitive);
|
|
@@ -423,7 +482,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
423
482
|
const w = watches.get(id);
|
|
424
483
|
if (!w || w.status !== "running") return false;
|
|
425
484
|
flushPending(w, now());
|
|
426
|
-
|
|
485
|
+
terminateChild(w);
|
|
427
486
|
w.status = "stopped";
|
|
428
487
|
w.endedAt = now();
|
|
429
488
|
ui.paint();
|
|
@@ -473,7 +532,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
473
532
|
const load = (ctx: ExtensionContext): void => {
|
|
474
533
|
disposed = false;
|
|
475
534
|
stopClock();
|
|
476
|
-
for (const w of watches.values())
|
|
535
|
+
for (const w of watches.values()) {
|
|
536
|
+
if (w.status !== "running") continue;
|
|
537
|
+
w.status = "stopped";
|
|
538
|
+
clearKillTimer(w);
|
|
539
|
+
killChild(w, "SIGKILL");
|
|
540
|
+
w.child = undefined;
|
|
541
|
+
}
|
|
477
542
|
watches.clear();
|
|
478
543
|
counter = 0;
|
|
479
544
|
config = fixedConfig ?? loadConfig(ctx.cwd);
|
|
@@ -484,12 +549,18 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
|
|
|
484
549
|
const shutdown = async (): Promise<void> => {
|
|
485
550
|
disposed = true;
|
|
486
551
|
stopClock();
|
|
487
|
-
|
|
488
|
-
|
|
552
|
+
const running = [...watches.values()].filter((w) => w.status === "running");
|
|
553
|
+
for (const w of running) {
|
|
489
554
|
flushPending(w, now());
|
|
490
|
-
killChild(w);
|
|
491
555
|
w.status = "stopped";
|
|
492
556
|
w.endedAt = now();
|
|
557
|
+
clearKillTimer(w);
|
|
558
|
+
killChild(w, "SIGTERM");
|
|
559
|
+
}
|
|
560
|
+
await waitForChildren(running, killGraceMs);
|
|
561
|
+
for (const w of running) {
|
|
562
|
+
clearKillTimer(w);
|
|
563
|
+
if (w.child) killChild(w, "SIGKILL");
|
|
493
564
|
}
|
|
494
565
|
watches.clear();
|
|
495
566
|
ui.detach();
|
|
@@ -28,6 +28,9 @@ How the `web_search` and `web_fetch` calls and their results look in the transcr
|
|
|
28
28
|
always with no configuration required.
|
|
29
29
|
- Registers `web_fetch`, which fetches a URL (GET or POST) and
|
|
30
30
|
returns readable text with links kept as `[text](url)`.
|
|
31
|
+
- Decodes by the byte-order mark, the declared `Content-Type` charset, or the
|
|
32
|
+
page's own `<meta charset>`, so a GBK/GB18030 page reads as text instead of
|
|
33
|
+
replacement characters.
|
|
31
34
|
- Pages long output and reports the next `startIndex` (`nextIndex` in
|
|
32
35
|
`structuredContent`); `find` returns matching passages instead of the whole
|
|
33
36
|
page, and says when the match cap hid further hits.
|
|
@@ -148,6 +151,8 @@ as `Authorization: Bearer <key>`; for Brave it is sent as
|
|
|
148
151
|
cannot redirect into a private address.
|
|
149
152
|
- Transport headers (`Host`, `Content-Length`, `Connection`, `Transfer-Encoding`,
|
|
150
153
|
and similar) are refused, and request bodies are capped.
|
|
154
|
+
- `maxBytes` is enforced while the body streams in: a chunked or compressed
|
|
155
|
+
response is abandoned at the limit instead of being buffered whole.
|
|
151
156
|
|
|
152
157
|
## `web_search`
|
|
153
158
|
|
|
@@ -173,6 +178,13 @@ The SearXNG instance must have the **JSON API enabled** (`format=json` in its
|
|
|
173
178
|
`settings.yml`); most public instances disable it, so a self-hosted instance is
|
|
174
179
|
recommended. If the response is not JSON, the error says so.
|
|
175
180
|
|
|
181
|
+
`run_doctor` reports what this session actually resolved to — the provider id and
|
|
182
|
+
whether it came from an explicit choice, the `auto` rule, or the keyless default;
|
|
183
|
+
the API-key variable when the provider takes one and no literal `apiKey` is set,
|
|
184
|
+
and whether that variable is present; and, only on an explicit `refresh`, whether
|
|
185
|
+
the configured `endpoint` answers. The key is never reported, only its variable
|
|
186
|
+
name. See [`bytefly`](../bytefly/README.md#what-it-does) for the report.
|
|
187
|
+
|
|
176
188
|
| Parameter | Type | Notes |
|
|
177
189
|
|---|---|---|
|
|
178
190
|
| `query` | string | 1–400 chars, required, any language |
|
|
@@ -200,7 +212,7 @@ address. Loopback, private, link-local, and cloud-metadata addresses are refused
|
|
|
200
212
|
| `headers` | object | Extra request headers (transport headers are refused) |
|
|
201
213
|
| `body` | string | POST body; implies `method: "POST"` unless set explicitly |
|
|
202
214
|
| `startIndex` | integer | Code-point offset to start from (default 0) |
|
|
203
|
-
| `maxChars` | integer | ≥200; capped by `maxOutputChars` |
|
|
215
|
+
| `maxChars` | integer | ≥200; capped by `maxOutputChars`; also caps `find` output |
|
|
204
216
|
| `find` | string[] | 1–10 strings; return matching passages instead of the page |
|
|
205
217
|
| `mode` | enum | `insensitive` (default) \| `exact` \| `fuzzy` |
|
|
206
218
|
| `contextChars` | integer | 0–2000 chars of context per match (default 200) |
|
|
@@ -209,10 +221,14 @@ address. Loopback, private, link-local, and cloud-metadata addresses are refused
|
|
|
209
221
|
|
|
210
222
|
Long pages are paged: a truncated page names the `startIndex` to use next, inside
|
|
211
223
|
its own character budget, and `structuredContent` repeats it as `nextIndex`.
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
A `find` result spends the same `maxChars` budget: passages are added while they
|
|
225
|
+
fit, and the note names `maxMatches` when the match cap hid the rest or
|
|
226
|
+
`maxChars`/`contextChars` when the budget did. Fetched pages are cached for the
|
|
227
|
+
registration, so paging and `find` do not refetch. POST responses and requests
|
|
228
|
+
with custom headers are never cached.
|
|
214
229
|
`structuredContent` is the page object, with an `error` field when the fetch
|
|
215
|
-
failed and `matchesTruncated` when
|
|
230
|
+
failed and `matchesTruncated` when the match cap or the `maxChars` budget hid
|
|
231
|
+
matches.
|
|
216
232
|
|
|
217
233
|
```
|
|
218
234
|
web_fetch({ url: "https://pi.dev/" })
|
|
@@ -243,6 +259,9 @@ the content type and size.
|
|
|
243
259
|
- **Heuristic extraction**: `web_fetch` strips scripts/styles/nav/footer and
|
|
244
260
|
prefers `<main>`/`<article>`, but is not a full readability engine. PDF
|
|
245
261
|
extraction is optional (`unpdf`) and off unless installed.
|
|
262
|
+
- **`find` output is budgeted.** With `maxMatches: 50` and `contextChars: 2000`
|
|
263
|
+
the passages alone would exceed `maxOutputChars`; the result shows the passages
|
|
264
|
+
that fit and names the knob to change.
|
|
246
265
|
- **The page cache is in-memory and per registration**; a page can be stale
|
|
247
266
|
within a session. `refresh: true` refetches, and it is cleared on
|
|
248
267
|
`session_start` and `session_shutdown`.
|
|
@@ -279,7 +298,12 @@ the content type and size.
|
|
|
279
298
|
live at the top level (`text.ts`, `html.ts`, `transcript.ts`, `http.ts`).
|
|
280
299
|
- **Shared transport.** [`http.ts`](./http.ts) holds the fetch runner
|
|
281
300
|
(`HttpRunner`, `createFetchRunner`, HTTP error types), used by both halves and
|
|
282
|
-
injectable for tests.
|
|
301
|
+
injectable for tests. It reads the body through a byte-counting stream, so
|
|
302
|
+
`maxBytes` stops a chunked or compressed response before it is buffered whole.
|
|
303
|
+
- **Charset-aware decoding.** [`charset.ts`](./charset.ts) is the one place a
|
|
304
|
+
byte body becomes text: BOM first, then the declared `Content-Type` charset,
|
|
305
|
+
then the page's own `<meta charset>`, then UTF-8. An unknown label falls back
|
|
306
|
+
to UTF-8 instead of failing the fetch.
|
|
283
307
|
- **One config file, isolated sections.** `config.ts` reads the global then
|
|
284
308
|
project file and validates each section with its half's normalizer; unknown
|
|
285
309
|
keys and malformed files are ignored.
|
|
@@ -302,7 +326,11 @@ the content type and size.
|
|
|
302
326
|
mutable is held at module scope.
|
|
303
327
|
- **The paging budget is explicit.** `formatPage` counts the header and the
|
|
304
328
|
truncation note against `maxChars`, so a paged page always carries its
|
|
305
|
-
`startIndex` hint inside the limit.
|
|
329
|
+
`startIndex` hint inside the limit. `formatMatches` applies the same rule to
|
|
330
|
+
`find`: it includes the passages that fit and names the knob that hid the rest.
|
|
331
|
+
- **Code points without a copy.** [`text.ts`](./text.ts) counts and slices by
|
|
332
|
+
walking the string in place, so a multi-megabyte page is never expanded into an
|
|
333
|
+
array of one-character strings on every call.
|
|
306
334
|
- **One JSON boundary per concern.** `search/json.ts` owns `requestJson` (status
|
|
307
335
|
check and parse error) and `collectResults` (title + http(s) URL + de-dupe), so
|
|
308
336
|
the three providers cannot drift on either.
|
|
@@ -321,7 +349,8 @@ the content type and size.
|
|
|
321
349
|
|---|---|
|
|
322
350
|
| `index.ts` | Register both tools |
|
|
323
351
|
| `config.ts` | The `web-access` section of `bytefly.json` loader (via `lib/config.ts`) |
|
|
324
|
-
| `http.ts` | Shared fetch runner and HTTP error types |
|
|
352
|
+
| `http.ts` | Shared fetch runner and HTTP error types; caps the body while streaming |
|
|
353
|
+
| `charset.ts` | Charset-aware body decoding (BOM, header, `<meta>`, UTF-8 fallback) |
|
|
325
354
|
| `text.ts` | Shared code-point length, slice, and truncation helpers |
|
|
326
355
|
| `html.ts` | Shared HTML entity decoding |
|
|
327
356
|
| `transcript.ts` | Shared `oneLine` sanitizer for both transcript renderers |
|
|
@@ -353,11 +382,12 @@ the content type and size.
|
|
|
353
382
|
`config.test.ts` and `index.test.ts`. It covers the shared runner (via injected
|
|
354
383
|
`fetch`), the provider registry and resolution, each provider's URL builder and
|
|
355
384
|
parser (DuckDuckGo, SearXNG, Brave), the orchestration pipeline and throttle,
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
registration
|
|
385
|
+
charset decoding (declared, `<meta>`, and BOM precedence), a streamed body over
|
|
386
|
+
the byte cap, redirect re-validation (per-hop SSRF with literal IPs),
|
|
387
|
+
method/header/body validation, the nested config merge and section isolation,
|
|
388
|
+
extraction, formatting/paging through the tool (including a regression test that
|
|
389
|
+
a truncated page keeps its `startIndex` hint), find and its cap/budget notices,
|
|
390
|
+
the per-registration cache, the SSRF guard, the injected PDF dependency, and both
|
|
391
|
+
tools' registration/execution. Tests inject the runner, throttle, and PDF extractor
|
|
362
392
|
through the tools' dependencies, so no network or PDF library is needed; the
|
|
363
393
|
runtime smoke test only asserts registration.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charset-aware body decoding shared by the web-access halves.
|
|
3
|
+
*
|
|
4
|
+
* HTTP lets a response name its encoding in `Content-Type`, and an HTML page can
|
|
5
|
+
* name one in its markup, so decoding every body as UTF-8 turns a GBK/GB18030
|
|
6
|
+
* page into replacement characters. `http.ts` honours the BOM and the declared
|
|
7
|
+
* charset; `page.ts` adds the `<meta charset>` fallback for HTML. An unknown
|
|
8
|
+
* label falls back to UTF-8 instead of failing the fetch.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FALLBACK_CHARSET = "utf-8";
|
|
12
|
+
|
|
13
|
+
/** How much of an HTML document is scanned for a `<meta charset>` declaration. */
|
|
14
|
+
const SNIFF_BYTES = 4096;
|
|
15
|
+
|
|
16
|
+
/** The `charset` parameter of a content type, lowercased. */
|
|
17
|
+
export function charsetOf(contentType: string): string | undefined {
|
|
18
|
+
const match = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
|
|
19
|
+
return match?.[1]?.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The charset a leading byte-order mark declares. */
|
|
23
|
+
export function bomCharset(bytes: Uint8Array): string | undefined {
|
|
24
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
|
|
25
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
|
|
26
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The charset a document declares in its first few kilobytes. Both
|
|
32
|
+
* `<meta charset="gbk">` and `<meta http-equiv="content-type" content="text/html; charset=gbk">`
|
|
33
|
+
* are matched by the same `charset=` scan.
|
|
34
|
+
*/
|
|
35
|
+
export function sniffHtmlCharset(bytes: Uint8Array): string | undefined {
|
|
36
|
+
// Latin-1 keeps every byte addressable and ASCII tags intact.
|
|
37
|
+
const head = new TextDecoder("latin1").decode(bytes.subarray(0, SNIFF_BYTES));
|
|
38
|
+
const match = /<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_.:-]+)/i.exec(head);
|
|
39
|
+
return match?.[1]?.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The label type `TextDecoder` accepts; a runtime label is validated by the constructor. */
|
|
43
|
+
type DecoderLabel = ConstructorParameters<typeof TextDecoder>[0];
|
|
44
|
+
|
|
45
|
+
/** Decode bytes with `charset`, falling back to UTF-8 for an absent or unknown label. */
|
|
46
|
+
export function decodeBytes(bytes: Uint8Array, charset?: string): string {
|
|
47
|
+
if (!charset) return new TextDecoder(FALLBACK_CHARSET).decode(bytes);
|
|
48
|
+
try {
|
|
49
|
+
return new TextDecoder(charset as DecoderLabel).decode(bytes);
|
|
50
|
+
} catch {
|
|
51
|
+
return new TextDecoder(FALLBACK_CHARSET).decode(bytes);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decode an HTML body: BOM first, then the declared charset, then its own `<meta>`, then UTF-8. */
|
|
56
|
+
export function decodeHtml(bytes: Uint8Array, contentType: string): string {
|
|
57
|
+
return decodeBytes(bytes, bomCharset(bytes) ?? charsetOf(contentType) ?? sniffHtmlCharset(bytes));
|
|
58
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* model already receives, not the raw HTML.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { charLength, sliceChars } from "../text.ts";
|
|
17
17
|
|
|
18
18
|
export type FindMode = "insensitive" | "exact" | "fuzzy";
|
|
19
19
|
|
|
@@ -46,12 +46,12 @@ export function toCodePointOffset(text: string, utf16Index: number): number {
|
|
|
46
46
|
return offset;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function passageAround(
|
|
49
|
+
function passageAround(text: string, start: number, end: number, contextChars: number, total: number): string {
|
|
50
50
|
const from = Math.max(0, start - contextChars);
|
|
51
|
-
const to = Math.min(
|
|
51
|
+
const to = Math.min(total, end + contextChars);
|
|
52
52
|
const prefix = from > 0 ? "…" : "";
|
|
53
|
-
const suffix = to <
|
|
54
|
-
return `${prefix}${
|
|
53
|
+
const suffix = to < total ? "…" : "";
|
|
54
|
+
return `${prefix}${sliceChars(text, from, to).trim()}${suffix}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function substringMatches(text: string, query: string, insensitive: boolean): Array<[number, number]> {
|
|
@@ -131,7 +131,7 @@ function fuzzyMatches(text: string, query: string): Array<[number, number]> {
|
|
|
131
131
|
*/
|
|
132
132
|
export function findPassages(text: string, queries: string[], options: FindOptions): Passage[] {
|
|
133
133
|
if (text.length === 0) return [];
|
|
134
|
-
const
|
|
134
|
+
const total = charLength(text);
|
|
135
135
|
const results: Passage[] = [];
|
|
136
136
|
const seen = new Set<string>();
|
|
137
137
|
|
|
@@ -150,7 +150,11 @@ export function findPassages(text: string, queries: string[], options: FindOptio
|
|
|
150
150
|
if (seen.has(key)) continue;
|
|
151
151
|
seen.add(key);
|
|
152
152
|
const endOffset = toCodePointOffset(text, end);
|
|
153
|
-
results.push({
|
|
153
|
+
results.push({
|
|
154
|
+
query: trimmed,
|
|
155
|
+
offset,
|
|
156
|
+
passage: passageAround(text, offset, endOffset, options.contextChars, total),
|
|
157
|
+
});
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* and the truncation note — within `maxChars`. The header and the note are
|
|
6
6
|
* counted against the budget up front, so the "call web_fetch again with
|
|
7
7
|
* startIndex=" instruction always survives inside the limit.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* `formatMatches` renders find-in-page passages with their offsets inside the
|
|
10
|
+
* same budget: it includes as many passages as fit and says which knob to turn
|
|
11
|
+
* when it had to drop the rest, rather than returning every match the caller
|
|
12
|
+
* allowed.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import { charLength,
|
|
15
|
+
import { charLength, sliceChars, truncateChars } from "../text.ts";
|
|
12
16
|
import type { Passage } from "./find.ts";
|
|
13
17
|
|
|
14
18
|
interface PageFormatInput {
|
|
@@ -46,8 +50,7 @@ function truncationNote(nextIndex: number, total: number): string {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function formatPage(input: PageFormatInput): FormattedPage {
|
|
49
|
-
const
|
|
50
|
-
const total = points.length;
|
|
53
|
+
const total = charLength(input.text);
|
|
51
54
|
const header = pageHeader(input.title, input.finalUrl, input.status, input.contentType);
|
|
52
55
|
|
|
53
56
|
if (total === 0) {
|
|
@@ -68,15 +71,14 @@ export function formatPage(input: PageFormatInput): FormattedPage {
|
|
|
68
71
|
// `total` for both offsets gives the widest digit count) so the hint survives.
|
|
69
72
|
const plainCapacity = Math.max(1, input.maxChars - charLength(header) - 2);
|
|
70
73
|
if (input.startIndex + plainCapacity >= total) {
|
|
71
|
-
const body =
|
|
74
|
+
const body = sliceChars(input.text, input.startIndex);
|
|
72
75
|
return { body, text: `${header}\n\n${body}`, truncated: false, nextIndex: input.startIndex };
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
const reserve = charLength(header) + 2 + charLength(truncationNote(total, total));
|
|
76
79
|
const capacity = Math.max(1, input.maxChars - reserve);
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const nextIndex = input.startIndex + slicePoints.length;
|
|
80
|
+
const body = sliceChars(input.text, input.startIndex, input.startIndex + capacity);
|
|
81
|
+
const nextIndex = input.startIndex + charLength(body);
|
|
80
82
|
const text = `${header}\n\n${body}${truncationNote(nextIndex, total)}`;
|
|
81
83
|
|
|
82
84
|
return { body, text, truncated: true, nextIndex };
|
|
@@ -89,15 +91,21 @@ interface MatchFormatInput {
|
|
|
89
91
|
contentType: string;
|
|
90
92
|
matches: Passage[];
|
|
91
93
|
find: string[];
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
+
/** Budget for the whole formatted result: header, passages, and note. */
|
|
95
|
+
maxChars: number;
|
|
96
|
+
/** True when the provider capped the list at `maxMatches`. */
|
|
97
|
+
cappedByMaxMatches: boolean;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
interface FormattedMatches {
|
|
101
|
+
/** The passages that fit, rendered one per block. */
|
|
97
102
|
body: string;
|
|
103
|
+
/** Header, passages, and any truncation note. */
|
|
98
104
|
text: string;
|
|
99
|
-
/** True when the match
|
|
105
|
+
/** True when anything was hidden: the match cap, the budget, or an oversized passage. */
|
|
100
106
|
truncated: boolean;
|
|
107
|
+
/** The passages `body` and `text` actually contain. */
|
|
108
|
+
included: Passage[];
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
export function formatMatches(input: MatchFormatInput): FormattedMatches {
|
|
@@ -105,13 +113,51 @@ export function formatMatches(input: MatchFormatInput): FormattedMatches {
|
|
|
105
113
|
const header = `${pageHeader(input.title, input.finalUrl, input.status, input.contentType)}\nMatches for ${quoted}: ${input.matches.length}`;
|
|
106
114
|
|
|
107
115
|
if (input.matches.length === 0) {
|
|
108
|
-
return { body: "", text: `${header}\n\nNo matches.`, truncated: false };
|
|
116
|
+
return { body: "", text: `${header}\n\nNo matches.`, truncated: false, included: [] };
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
const blocks = input.matches.map((match, index) => `${index + 1}. [offset ${match.offset}] ${match.passage}`);
|
|
120
|
+
|
|
121
|
+
// Add whole blocks while they fit; the first one is kept even when oversized.
|
|
122
|
+
const shown: string[] = [];
|
|
123
|
+
let size = charLength(header) + 2;
|
|
124
|
+
for (const block of blocks) {
|
|
125
|
+
const length = charLength(block);
|
|
126
|
+
if (shown.length > 0 && size + length + 2 > input.maxChars) break;
|
|
127
|
+
shown.push(block);
|
|
128
|
+
size += length + 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const noteFor = (count: number): string => {
|
|
132
|
+
if (count < blocks.length) {
|
|
133
|
+
return `(showing ${count} of ${blocks.length} matches within ${input.maxChars} characters; narrow find or lower contextChars)`;
|
|
134
|
+
}
|
|
135
|
+
if (input.cappedByMaxMatches) return `(first ${count} matches; raise maxMatches for more)`;
|
|
136
|
+
return "";
|
|
137
|
+
};
|
|
138
|
+
const render = (count: number, note: string): string => {
|
|
139
|
+
const base = `${header}\n\n${shown.slice(0, count).join("\n\n")}`;
|
|
140
|
+
return note ? `${base}\n\n${note}` : base;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The note must survive the budget too, so drop trailing passages until it fits.
|
|
144
|
+
let count = shown.length;
|
|
145
|
+
let note = noteFor(count);
|
|
146
|
+
while (count > 1 && charLength(render(count, note)) > input.maxChars) {
|
|
147
|
+
count--;
|
|
148
|
+
note = noteFor(count);
|
|
149
|
+
}
|
|
150
|
+
if (note && charLength(render(count, note)) > input.maxChars) note = "";
|
|
151
|
+
|
|
152
|
+
let text = render(count, note);
|
|
153
|
+
const overflow = charLength(text) > input.maxChars;
|
|
154
|
+
if (overflow) text = truncateChars(text, input.maxChars);
|
|
155
|
+
|
|
156
|
+
const body = shown.slice(0, count).join("\n\n");
|
|
157
|
+
return {
|
|
158
|
+
body,
|
|
159
|
+
text,
|
|
160
|
+
truncated: count < blocks.length || input.cappedByMaxMatches || overflow,
|
|
161
|
+
included: input.matches.slice(0, count),
|
|
162
|
+
};
|
|
117
163
|
}
|
|
@@ -13,6 +13,7 @@ import type { WebFetchConfig } from "./config.ts";
|
|
|
13
13
|
import { extractReadable } from "./extract.ts";
|
|
14
14
|
import { extractPdfText, type PdfExtractor } from "./pdf.ts";
|
|
15
15
|
import { createFetchRunner, type HttpRunner, type HttpResponse } from "../http.ts";
|
|
16
|
+
import { decodeHtml } from "../charset.ts";
|
|
16
17
|
import { assertAllowedUrl } from "./ssrf.ts";
|
|
17
18
|
import type { FetchRequest } from "./types.ts";
|
|
18
19
|
|
|
@@ -149,7 +150,10 @@ export async function runFetch(
|
|
|
149
150
|
text = `(binary content: ${response.contentType || "application/pdf"}, ${response.sizeBytes} bytes; PDF text extraction failed: ${reason})`;
|
|
150
151
|
}
|
|
151
152
|
} else if (isHtmlType(response.contentType)) {
|
|
152
|
-
|
|
153
|
+
// `bytes` carries the raw body, so a page that declares GBK in a `<meta>` tag
|
|
154
|
+
// but not in the header is still decoded correctly.
|
|
155
|
+
const html = response.bytes ? decodeHtml(response.bytes, response.contentType) : response.body;
|
|
156
|
+
const page = extractReadable(html, response.finalUrl);
|
|
153
157
|
title = page.title;
|
|
154
158
|
text = page.text;
|
|
155
159
|
} else if (isTextualType(response.contentType)) {
|
|
@@ -27,7 +27,7 @@ import { charLength } from "../text.ts";
|
|
|
27
27
|
import { oneLine } from "../transcript.ts";
|
|
28
28
|
import { createPageCache, type PageCache } from "./cache.ts";
|
|
29
29
|
import type { WebFetchConfig } from "./config.ts";
|
|
30
|
-
import { findPassages } from "./find.ts";
|
|
30
|
+
import { findPassages, type Passage } from "./find.ts";
|
|
31
31
|
import { formatMatches, formatPage } from "./format.ts";
|
|
32
32
|
import { runFetch, type FetchDeps, type PageResult } from "./page.ts";
|
|
33
33
|
import { resolveRequest, WebFetchOutput, WebFetchParams, type FetchResponse, type WebFetchArgs } from "./schema.ts";
|
|
@@ -85,29 +85,33 @@ async function loadPage(
|
|
|
85
85
|
function pageResult(loaded: LoadedPage, request: FetchRequest): { response: FetchResponse; text: string } {
|
|
86
86
|
const { page, cached, fetchedAt } = loaded;
|
|
87
87
|
const finding = request.find.length > 0;
|
|
88
|
-
const matches = finding
|
|
89
|
-
? findPassages(page.text, request.find, {
|
|
90
|
-
mode: request.mode,
|
|
91
|
-
contextChars: request.contextChars,
|
|
92
|
-
maxMatches: request.maxMatches + 1,
|
|
93
|
-
})
|
|
94
|
-
: [];
|
|
95
|
-
// Request one extra so a full list proves more hits exist; only the cap is shown.
|
|
96
|
-
const matchesTruncated = finding && matches.length > request.maxMatches;
|
|
97
|
-
const visibleMatches = matchesTruncated ? matches.slice(0, request.maxMatches) : matches;
|
|
98
88
|
|
|
99
89
|
let formatted: { body: string; text: string; truncated: boolean };
|
|
100
90
|
let nextIndex: number;
|
|
91
|
+
let matches: Passage[] = [];
|
|
92
|
+
let matchesTruncated = false;
|
|
93
|
+
|
|
101
94
|
if (finding) {
|
|
102
|
-
|
|
95
|
+
// Request one extra so a full list proves more hits exist; only the cap is shown.
|
|
96
|
+
const found = findPassages(page.text, request.find, {
|
|
97
|
+
mode: request.mode,
|
|
98
|
+
contextChars: request.contextChars,
|
|
99
|
+
maxMatches: request.maxMatches + 1,
|
|
100
|
+
});
|
|
101
|
+
const cappedByMaxMatches = found.length > request.maxMatches;
|
|
102
|
+
const rendered = formatMatches({
|
|
103
103
|
finalUrl: page.finalUrl,
|
|
104
104
|
title: page.title,
|
|
105
105
|
status: page.status,
|
|
106
106
|
contentType: page.contentType,
|
|
107
|
-
matches:
|
|
107
|
+
matches: cappedByMaxMatches ? found.slice(0, request.maxMatches) : found,
|
|
108
108
|
find: request.find,
|
|
109
|
-
|
|
109
|
+
maxChars: request.maxChars,
|
|
110
|
+
cappedByMaxMatches,
|
|
110
111
|
});
|
|
112
|
+
formatted = rendered;
|
|
113
|
+
matches = rendered.included;
|
|
114
|
+
matchesTruncated = rendered.truncated;
|
|
111
115
|
// Find results are not paged, so the caller keeps reading from the same offset.
|
|
112
116
|
nextIndex = request.startIndex;
|
|
113
117
|
} else {
|
|
@@ -136,7 +140,7 @@ function pageResult(loaded: LoadedPage, request: FetchRequest): { response: Fetc
|
|
|
136
140
|
nextIndex,
|
|
137
141
|
truncated: formatted.truncated,
|
|
138
142
|
cached,
|
|
139
|
-
matches
|
|
143
|
+
matches,
|
|
140
144
|
matchesTruncated,
|
|
141
145
|
fetchedAt,
|
|
142
146
|
error: "",
|
|
@@ -192,9 +196,6 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
|
|
|
192
196
|
parameters: WebFetchParams,
|
|
193
197
|
outputSchema: WebFetchOutput,
|
|
194
198
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
195
|
-
// The page cache is shared in-memory state created per registration, so
|
|
196
|
-
// sibling calls must not run concurrently (matches web_search's throttle).
|
|
197
|
-
executionMode: "sequential",
|
|
198
199
|
|
|
199
200
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
200
201
|
const config = loadFetchConfig(ctx.cwd);
|