@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.
Files changed (167) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +162 -70
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +38 -6
  7. package/extensions/bytefly/footer/budget.ts +64 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +16 -0
  12. package/extensions/bytefly/footer/price/types.ts +5 -0
  13. package/extensions/bytefly/footer/register.ts +46 -4
  14. package/extensions/bytefly/footer/render/lines.ts +2 -0
  15. package/extensions/bytefly/footer/render/segments.ts +27 -0
  16. package/extensions/bytefly/footer/types.ts +11 -0
  17. package/extensions/bytefly/index.ts +8 -5
  18. package/extensions/bytefly/notify/config.ts +2 -0
  19. package/extensions/bytefly/notify/format.ts +19 -1
  20. package/extensions/bytefly/notify/register.ts +52 -14
  21. package/extensions/bytefly/notify/types.ts +2 -0
  22. package/extensions/bytefly/panel/commands.ts +34 -13
  23. package/extensions/bytefly/panel/settings.ts +129 -16
  24. package/extensions/bytefly/update/cache.ts +73 -0
  25. package/extensions/bytefly/update/format.ts +65 -10
  26. package/extensions/bytefly/update/register.ts +99 -13
  27. package/extensions/bytefly/update/state.ts +13 -1
  28. package/extensions/document/README.md +22 -12
  29. package/extensions/document/config.ts +4 -0
  30. package/extensions/document/deadline.ts +61 -0
  31. package/extensions/document/tool.ts +13 -2
  32. package/extensions/file-browse/README.md +12 -8
  33. package/extensions/file-browse/client.ts +6 -7
  34. package/extensions/file-browse/files.ts +0 -4
  35. package/extensions/file-browse/git.ts +9 -10
  36. package/extensions/file-browse/html.ts +17 -7
  37. package/extensions/file-browse/router.ts +24 -15
  38. package/extensions/goal/README.md +7 -1
  39. package/extensions/goal/schema.ts +6 -2
  40. package/extensions/goal/types.ts +6 -2
  41. package/extensions/job/README.md +16 -12
  42. package/extensions/job/format.ts +4 -7
  43. package/extensions/job/runtime.ts +23 -6
  44. package/extensions/job/status.ts +4 -2
  45. package/extensions/memory/README.md +55 -13
  46. package/extensions/memory/commands.ts +20 -2
  47. package/extensions/memory/doctor.ts +137 -0
  48. package/extensions/memory/format.ts +24 -7
  49. package/extensions/memory/runtime.ts +49 -0
  50. package/extensions/memory/store.ts +31 -0
  51. package/extensions/memory/tui.ts +136 -12
  52. package/extensions/plan/README.md +60 -29
  53. package/extensions/plan/commands.ts +67 -8
  54. package/extensions/plan/index.ts +35 -11
  55. package/extensions/plan/plan-index.ts +234 -0
  56. package/extensions/plan/plan-text.ts +95 -0
  57. package/extensions/plan/plans.ts +90 -1
  58. package/extensions/plan/review.ts +12 -9
  59. package/extensions/plan/runtime.ts +72 -27
  60. package/extensions/plan/steps.ts +35 -25
  61. package/extensions/plan/todos.ts +24 -2
  62. package/extensions/plan/tools.ts +124 -24
  63. package/extensions/plan/tui.ts +7 -28
  64. package/extensions/plan/types.ts +9 -0
  65. package/extensions/plan/web-page.ts +2 -2
  66. package/extensions/plan/web.ts +12 -11
  67. package/extensions/rewind/README.md +28 -8
  68. package/extensions/rewind/commands.ts +1 -1
  69. package/extensions/rewind/format.ts +4 -4
  70. package/extensions/rewind/git.ts +56 -18
  71. package/extensions/rewind/index.ts +3 -5
  72. package/extensions/rewind/path-picker.ts +198 -0
  73. package/extensions/rewind/paths.ts +128 -0
  74. package/extensions/rewind/restore.ts +69 -28
  75. package/extensions/rewind/rewind.ts +80 -3
  76. package/extensions/rewind/runtime.ts +13 -5
  77. package/extensions/rewind/snapshot-run.ts +5 -2
  78. package/extensions/rewind/types.ts +2 -0
  79. package/extensions/subagent/README.md +7 -1
  80. package/extensions/subagent/accounting.ts +34 -0
  81. package/extensions/subagent/index.ts +23 -1
  82. package/extensions/subagent/stream.ts +2 -0
  83. package/extensions/subagent/types.ts +2 -0
  84. package/extensions/todo/nudge.ts +5 -1
  85. package/extensions/watch/README.md +19 -8
  86. package/extensions/watch/commands.ts +4 -3
  87. package/extensions/watch/config.ts +1 -1
  88. package/extensions/watch/format.ts +4 -7
  89. package/extensions/watch/index.ts +19 -0
  90. package/extensions/watch/runtime.ts +83 -12
  91. package/extensions/web-access/README.md +43 -13
  92. package/extensions/web-access/charset.ts +58 -0
  93. package/extensions/web-access/fetch/find.ts +11 -7
  94. package/extensions/web-access/fetch/format.ts +64 -18
  95. package/extensions/web-access/fetch/page.ts +5 -1
  96. package/extensions/web-access/fetch/tool.ts +19 -18
  97. package/extensions/web-access/http.ts +49 -8
  98. package/extensions/web-access/search/facts.ts +38 -0
  99. package/extensions/web-access/search/tool.ts +13 -0
  100. package/extensions/web-access/text.ts +50 -11
  101. package/extensions/worktree/README.md +81 -16
  102. package/extensions/worktree/config.ts +11 -0
  103. package/extensions/worktree/enter.ts +10 -3
  104. package/extensions/worktree/exit.ts +55 -3
  105. package/extensions/worktree/gaps.ts +74 -0
  106. package/extensions/worktree/git.ts +28 -0
  107. package/extensions/worktree/guard.ts +50 -4
  108. package/extensions/worktree/handoff.ts +55 -0
  109. package/extensions/worktree/index.ts +14 -2
  110. package/extensions/worktree/root-tools.ts +1 -1
  111. package/extensions/worktree/runtime.ts +25 -0
  112. package/extensions/worktree/status.ts +15 -2
  113. package/extensions/worktree/tools.ts +3 -2
  114. package/lib/README.md +14 -10
  115. package/lib/events.ts +86 -1
  116. package/lib/goal-state.ts +95 -0
  117. package/lib/requirements.ts +361 -62
  118. package/lib/tui/README.md +91 -0
  119. package/lib/tui/columns.ts +17 -0
  120. package/lib/tui/glyphs.ts +1 -0
  121. package/lib/tui/tool.ts +6 -2
  122. package/lib/web/layout.ts +12 -2
  123. package/package.json +5 -3
  124. package/themes/bytefly-dark.json +1 -1
  125. package/assets/chrome.png +0 -0
  126. package/assets/file-browse.png +0 -0
  127. package/assets/job.png +0 -0
  128. package/assets/plan-review.png +0 -0
  129. package/assets/question.png +0 -0
  130. package/assets/rewind.png +0 -0
  131. package/assets/todo.png +0 -0
  132. package/assets/tool-renderings.png +0 -0
  133. package/assets/tools/ask_user_question-cancelled.png +0 -0
  134. package/assets/tools/ask_user_question.png +0 -0
  135. package/assets/tools/bash.png +0 -0
  136. package/assets/tools/edit.png +0 -0
  137. package/assets/tools/enter_plan_mode.png +0 -0
  138. package/assets/tools/enter_worktree.png +0 -0
  139. package/assets/tools/exit_plan_mode.png +0 -0
  140. package/assets/tools/exit_worktree.png +0 -0
  141. package/assets/tools/find.png +0 -0
  142. package/assets/tools/goal.png +0 -0
  143. package/assets/tools/grep.png +0 -0
  144. package/assets/tools/job.png +0 -0
  145. package/assets/tools/list_worktrees.png +0 -0
  146. package/assets/tools/ls.png +0 -0
  147. package/assets/tools/memory-long-subject.png +0 -0
  148. package/assets/tools/memory.png +0 -0
  149. package/assets/tools/notice.png +0 -0
  150. package/assets/tools/open_path.png +0 -0
  151. package/assets/tools/prune_worktrees.png +0 -0
  152. package/assets/tools/read.png +0 -0
  153. package/assets/tools/read_doc-running.png +0 -0
  154. package/assets/tools/read_doc.png +0 -0
  155. package/assets/tools/run_doctor.png +0 -0
  156. package/assets/tools/send_wechat.png +0 -0
  157. package/assets/tools/set_plan_steps.png +0 -0
  158. package/assets/tools/subagent.png +0 -0
  159. package/assets/tools/todo.png +0 -0
  160. package/assets/tools/turn-rule.png +0 -0
  161. package/assets/tools/user-message.png +0 -0
  162. package/assets/tools/watch.png +0 -0
  163. package/assets/tools/web_fetch-rejected.png +0 -0
  164. package/assets/tools/web_fetch.png +0 -0
  165. package/assets/tools/web_search.png +0 -0
  166. package/assets/tools/write.png +0 -0
  167. 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 killChild = (w: Internal): void => {
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, "SIGTERM");
250
- else w.child?.kill("SIGTERM");
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
- w.child = undefined;
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
- killChild(w);
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()) if (w.status === "running") killChild(w);
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
- for (const w of watches.values()) {
488
- if (w.status !== "running") continue;
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
- Fetched pages are cached for the registration, so paging and `find` do not
213
- refetch. POST responses and requests with custom headers are never cached.
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 `maxMatches` capped the match list.
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
- redirect re-validation (per-hop SSRF with literal IPs), method/header/body
357
- validation, the nested config merge and section isolation, extraction,
358
- formatting/paging through the tool (including a regression test that a truncated
359
- page keeps its `startIndex` hint), find and its cap notice, the per-registration
360
- cache, the SSRF guard, the injected PDF dependency, and both tools'
361
- registration/execution. Tests inject the runner, throttle, and PDF extractor
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 { toChars } from "../text.ts";
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(points: string[], start: number, end: number, contextChars: number): string {
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(points.length, end + contextChars);
51
+ const to = Math.min(total, end + contextChars);
52
52
  const prefix = from > 0 ? "…" : "";
53
- const suffix = to < points.length ? "…" : "";
54
- return `${prefix}${points.slice(from, to).join("").trim()}${suffix}`;
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 points = toChars(text);
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({ query: trimmed, offset, passage: passageAround(points, offset, endOffset, options.contextChars) });
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
- * `formatMatches` renders find-in-page passages with their offsets.
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, toChars } from "../text.ts";
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 points = toChars(input.text);
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 = points.slice(input.startIndex).join("");
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 slicePoints = points.slice(input.startIndex, input.startIndex + capacity);
78
- const body = slicePoints.join("");
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
- /** True when the match list was capped by `maxMatches`. */
93
- truncated: boolean;
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 list was capped by `maxMatches`. */
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 body = input.matches
112
- .map((match, index) => `${index + 1}. [offset ${match.offset}] ${match.passage}`)
113
- .join("\n\n");
114
- let text = `${header}\n\n${body}`;
115
- if (input.truncated) text += `\n\n(first ${input.matches.length} matches; raise maxMatches for more)`;
116
- return { body, text, truncated: input.truncated };
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
- const page = extractReadable(response.body, response.finalUrl);
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
- formatted = formatMatches({
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: visibleMatches,
107
+ matches: cappedByMaxMatches ? found.slice(0, request.maxMatches) : found,
108
108
  find: request.find,
109
- truncated: matchesTruncated,
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: visibleMatches,
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);