@mjasnikovs/pi-task 0.38.15 → 0.38.17

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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -176,7 +176,8 @@ export function ensureDocsModulesDir(dir) {
176
176
  fs.writeFileSync(pkgPath, '{"name":"pi-worker-docs-modules","private":true}\n', 'utf8');
177
177
  }
178
178
  }
179
- export async function runAutoInstall(spawn, packageName, signal, versionRange) {
179
+ export async function runAutoInstall(spawn, packageName, opts = {}) {
180
+ const { signal, versionRange } = opts;
180
181
  const installDir = getDocsModulesDir();
181
182
  ensureDocsModulesDir(installDir);
182
183
  // `shell: false` in runChild, so a `^`/`~`/space in the range stays a single
@@ -203,31 +204,97 @@ export async function runAutoInstall(spawn, packageName, signal, versionRange) {
203
204
  }, installDir, signal, { mode: 'text', discardStdout: true });
204
205
  return { success: result.exitCode === 0 && !result.aborted, installDir, stderr: result.stderr };
205
206
  }
206
- /** Resolve `name` from cwd; on `not_installed`, auto-install it and resolve from
207
- * the install dir. Returns null on any failure (caller keeps its fallback). */
208
- async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal) {
207
+ /**
208
+ * Get a package onto disk and resolved: resolve from `cwd`, and on
209
+ * `not_installed` install it at the range the PROJECT declares when it declares
210
+ * one — then resolve again from the install dir.
211
+ *
212
+ * One statement of the ladder, for both callers. It was written twice: inline in
213
+ * `docsRaw` for the requested package, and in `tryResolveOrInstall` for each hop
214
+ * of the type-redirect chain — and the copies had drifted on all three things
215
+ * that matter.
216
+ *
217
+ * - The abort signal. The hop copy passed it; the primary copy passed a bare
218
+ * `undefined` placeholder to reach the fourth positional, while
219
+ * `DocsRawInput.signal` was honoured on either side of that call. So a user
220
+ * cancel during the MAIN `npm install` of a model-chosen package was not
221
+ * delivered. `runAutoInstall` takes an options object now, so a hole like that
222
+ * cannot be typed.
223
+ * - The version pin. `findDeclaredRange` had exactly one call site — the primary
224
+ * copy. The hop copy installed `latest` unconditionally, on the hop most likely
225
+ * to be the declared one: `declarationChain` exists precisely because "a
226
+ * project that uses Bun declares `@types/bun`, not `bun`".
227
+ * - The provenance. `autoInstalled`/`autoInstallPin` were locals of `docsRaw`, so
228
+ * a package acquired only through a hop reported neither and got no version
229
+ * banner.
230
+ *
231
+ * CONTEXT.md records the redirect WALK as unified (`resolveTypeSource`) and its
232
+ * `resolveHop` seam as "the one thing its two call sites disagree about". They
233
+ * should disagree only about WHETHER to install, never about HOW.
234
+ */
235
+ export async function acquirePackage(input) {
236
+ const { name, cwd, spawn, resolvePackage, signal } = input;
209
237
  try {
210
- return resolvePackage(name, cwd);
238
+ return { ok: true, pkg: resolvePackage(name, cwd), autoInstalled: false };
211
239
  }
212
- catch (err) {
213
- if (!(err instanceof ResolveError) || err.kind !== 'not_installed')
214
- return null;
215
- const install = await runAutoInstall(spawn, extractParentPackage(name), signal);
216
- if (!install.success)
217
- return null;
240
+ catch (firstErr) {
241
+ if (!(firstErr instanceof ResolveError) || firstErr.kind !== 'not_installed') {
242
+ return { ok: false, stage: 'resolve', err: firstErr };
243
+ }
244
+ // Auto-install — but FIRST honour the version the project intends. If the
245
+ // dep is declared in the project's package.json, install that range so a
246
+ // scaffolding answer is grounded in the project's major, not whatever npm
247
+ // currently tags `latest`.
248
+ const asked = extractParentPackage(name);
249
+ const declaredRange = findDeclaredRange(asked, cwd);
250
+ const pin = declaredRange ?
251
+ { source: 'declared-range', range: declaredRange, asked }
252
+ : { source: 'npm-latest', asked };
253
+ const install = await runAutoInstall(spawn, asked, {
254
+ signal,
255
+ versionRange: declaredRange ?? undefined
256
+ });
257
+ if (!install.success) {
258
+ return { ok: false, stage: 'install', stderr: install.stderr, pin, asked };
259
+ }
218
260
  try {
219
- return resolvePackage(name, install.installDir);
261
+ return {
262
+ ok: true,
263
+ pkg: resolvePackage(name, install.installDir),
264
+ autoInstalled: true,
265
+ pin
266
+ };
220
267
  }
221
- catch {
222
- return null;
268
+ catch (retryErr) {
269
+ return { ok: false, stage: 'reresolve', err: retryErr, pin };
223
270
  }
224
271
  }
225
272
  }
273
+ /** Resolve `name` from cwd; on `not_installed`, auto-install it and resolve from
274
+ * the install dir. Returns null on any failure (caller keeps its fallback). */
275
+ async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal, onAcquired) {
276
+ const got = await acquirePackage({ name, cwd, spawn, resolvePackage, signal });
277
+ if (!got.ok)
278
+ return null;
279
+ if (got.autoInstalled)
280
+ onAcquired?.(got.pin);
281
+ return got.pkg;
282
+ }
226
283
  /** The docs pipeline's adapter over the shared redirect walk (docs-resolve.ts):
227
284
  * hops resolve through the auto-installing lookup, so a declaration package that is
228
285
  * declared but not yet on disk is fetched rather than abandoned. */
229
- function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
230
- return resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal));
286
+ async function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
287
+ // A package acquired ONLY through a hop used to report neither `autoInstalled`
288
+ // nor a pin, so `pi-worker-docs` emitted no version banner for it — while the
289
+ // banner's own text ("only its types are, as `@types/bun` `^1.2`") claims a
290
+ // range as provenance. Report the last hop that actually installed.
291
+ let installed = false;
292
+ let pin;
293
+ const out = await resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal, hopPin => {
294
+ installed = true;
295
+ pin = hopPin;
296
+ }));
297
+ return pin ? { pkg: out, installed, pin } : { pkg: out, installed };
231
298
  }
232
299
  export async function docsRaw(input) {
233
300
  const resolvePackage = input.resolvePackage ?? defaultResolvePackage;
@@ -247,83 +314,76 @@ export async function docsRaw(input) {
247
314
  const npmVersionPromise = npmVersionLookup(extractParentPackage(requested), {
248
315
  signal: input.signal
249
316
  }).catch(() => null);
250
- // Step 1: resolve package
251
- let pkg;
252
- let autoInstalled = false;
253
- let autoInstallPin;
254
- try {
255
- pkg = resolvePackage(requested, input.cwd);
256
- }
257
- catch (firstErr) {
258
- if (firstErr instanceof ResolveError && firstErr.kind === 'not_installed') {
259
- // auto-install — but FIRST honour the version the project intends.
260
- // If the dep is declared in the project's package.json, install that
261
- // range so a scaffolding answer is grounded in the project's major,
262
- // not whatever npm currently tags `latest`.
263
- const parentPkg = extractParentPackage(requested);
264
- const declaredRange = findDeclaredRange(parentPkg, input.cwd);
265
- autoInstallPin =
266
- declaredRange ?
267
- { source: 'declared-range', range: declaredRange, asked: parentPkg }
268
- : { source: 'npm-latest', asked: parentPkg };
269
- const installResult = await runAutoInstall(spawn, parentPkg, undefined, declaredRange ?? undefined);
270
- if (!installResult.success) {
271
- return {
272
- kind: 'error',
273
- message: `Package "${parentPkg}" is not installed and auto-install failed.\n${installResult.stderr}`,
274
- resolveError: 'not_installed',
275
- installError: installResult.stderr,
276
- autoInstallPin,
277
- npmVersion: await npmVersionPromise
278
- };
279
- }
280
- autoInstalled = true;
281
- try {
282
- pkg = resolvePackage(requested, installResult.installDir);
283
- }
284
- catch (retryErr) {
285
- if (retryErr instanceof ResolveError) {
286
- return {
287
- kind: 'error',
288
- message: retryErr.message,
289
- resolveError: retryErr.kind,
290
- autoInstalled,
291
- autoInstallPin,
292
- npmVersion: await npmVersionPromise
293
- };
294
- }
317
+ // Step 1: acquire the package — resolve, or install-at-the-declared-range and
318
+ // resolve again. The ladder is `acquirePackage`; this maps its stages onto the
319
+ // rich error results the docs tool reports. `input.signal` now reaches the
320
+ // install, which it never did while the range was a fourth positional.
321
+ const got = await acquirePackage({
322
+ name: requested,
323
+ cwd: input.cwd,
324
+ spawn,
325
+ resolvePackage,
326
+ signal: input.signal
327
+ });
328
+ if (!got.ok) {
329
+ if (got.stage === 'install') {
330
+ return {
331
+ kind: 'error',
332
+ message: `Package "${got.asked}" is not installed and auto-install failed.\n${got.stderr}`,
333
+ resolveError: 'not_installed',
334
+ installError: got.stderr,
335
+ autoInstallPin: got.pin,
336
+ npmVersion: await npmVersionPromise
337
+ };
338
+ }
339
+ if (got.stage === 'reresolve') {
340
+ if (got.err instanceof ResolveError) {
295
341
  return {
296
342
  kind: 'error',
297
- message: `Could not resolve "${input.pkg}" after install: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
298
- autoInstalled,
299
- autoInstallPin,
343
+ message: got.err.message,
344
+ resolveError: got.err.kind,
345
+ autoInstalled: true,
346
+ autoInstallPin: got.pin,
300
347
  npmVersion: await npmVersionPromise
301
348
  };
302
349
  }
303
- }
304
- else if (firstErr instanceof ResolveError) {
305
350
  return {
306
351
  kind: 'error',
307
- message: firstErr.message,
308
- resolveError: firstErr.kind,
352
+ message: `Could not resolve "${input.pkg}" after install: ${got.err instanceof Error ? got.err.message : String(got.err)}`,
353
+ autoInstalled: true,
354
+ autoInstallPin: got.pin,
309
355
  npmVersion: await npmVersionPromise
310
356
  };
311
357
  }
312
- else {
358
+ if (got.err instanceof ResolveError) {
313
359
  return {
314
360
  kind: 'error',
315
- message: `Could not resolve "${input.pkg}": ${firstErr instanceof Error ? firstErr.message : String(firstErr)}`,
361
+ message: got.err.message,
362
+ resolveError: got.err.kind,
316
363
  npmVersion: await npmVersionPromise
317
364
  };
318
365
  }
366
+ return {
367
+ kind: 'error',
368
+ message: `Could not resolve "${input.pkg}": ${got.err instanceof Error ? got.err.message : String(got.err)}`,
369
+ npmVersion: await npmVersionPromise
370
+ };
319
371
  }
372
+ let pkg = got.pkg;
373
+ let autoInstalled = got.autoInstalled;
374
+ let autoInstallPin = got.pin;
320
375
  // Step 1b: if the resolved package ships no usable type declarations (e.g.
321
376
  // the `bun` runtime launcher, which is just a binary + install README), or is
322
377
  // a pure `@types/<name>` redirect stub, follow the conventional
323
378
  // @types/<name> + triple-slash `<reference types>` chain to the package that
324
379
  // actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
325
380
  // Best-effort: any failure leaves the original resolution untouched.
326
- pkg = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
381
+ const viaTypes = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
382
+ pkg = viaTypes.pkg;
383
+ if (viaTypes.installed) {
384
+ autoInstalled = true;
385
+ autoInstallPin ??= viaTypes.pin;
386
+ }
327
387
  // Step 2: open cache
328
388
  let cache = null;
329
389
  let cacheError;
@@ -7,9 +7,9 @@
7
7
  * and the result payload is one text blob of `Title:`/`URL:`/`Text:` blocks
8
8
  * separated by `---`, which we parse back into structured results.
9
9
  */
10
+ import { type FetchLike } from './http-request.js';
10
11
  import type { SearchResult } from './search-types.js';
11
- /** Injectable fetch the narrow signature keeps test fakes free of Bun's extras. */
12
- export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
12
+ export type { FetchLike };
13
13
  export interface ExaSearchOpts {
14
14
  count?: number;
15
15
  timeoutMs?: number;
@@ -7,6 +7,7 @@
7
7
  * and the result payload is one text blob of `Title:`/`URL:`/`Text:` blocks
8
8
  * separated by `---`, which we parse back into structured results.
9
9
  */
10
+ import { httpRequest, HttpRequestError } from './http-request.js';
10
11
  const EXA_MCP_ENDPOINT = 'https://mcp.exa.ai/mcp';
11
12
  const DEFAULT_COUNT = 10;
12
13
  const MAX_COUNT = 20;
@@ -24,72 +25,61 @@ export class ExaSearchError extends Error {
24
25
  }
25
26
  export async function exaSearch(query, opts = {}) {
26
27
  const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
27
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
- const fetchImpl = opts.fetchImpl ?? fetch;
29
- const internalController = new AbortController();
30
- let userAborted = false;
31
- const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
32
- const onUserAbort = () => {
33
- userAborted = true;
34
- internalController.abort();
35
- };
36
- if (opts.signal) {
37
- if (opts.signal.aborted)
38
- onUserAbort();
39
- else
40
- opts.signal.addEventListener('abort', onUserAbort, { once: true });
41
- }
28
+ return await request({
29
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
30
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
31
+ ...(opts.fetchImpl === undefined ? {} : { fetchImpl: opts.fetchImpl })
32
+ }, count, query);
33
+ }
34
+ /** The Exa-specific half: its argv, its status policy, its error type. */
35
+ async function request(bounds, count, query) {
42
36
  try {
43
- let response;
44
- try {
45
- response = await fetchImpl(EXA_MCP_ENDPOINT, {
46
- method: 'POST',
47
- headers: {
48
- 'content-type': 'application/json',
49
- accept: 'application/json, text/event-stream'
50
- },
51
- body: JSON.stringify({
52
- jsonrpc: '2.0',
53
- id: 1,
54
- method: 'tools/call',
55
- params: {
56
- name: 'web_search_exa',
57
- arguments: {
58
- query,
59
- numResults: count,
60
- type: 'auto',
61
- livecrawl: 'fallback',
62
- contextMaxCharacters: 3000
63
- }
37
+ return await httpRequest(EXA_MCP_ENDPOINT, {
38
+ ...bounds,
39
+ method: 'POST',
40
+ headers: {
41
+ 'content-type': 'application/json',
42
+ accept: 'application/json, text/event-stream'
43
+ },
44
+ body: JSON.stringify({
45
+ jsonrpc: '2.0',
46
+ id: 1,
47
+ method: 'tools/call',
48
+ params: {
49
+ name: 'web_search_exa',
50
+ arguments: {
51
+ query,
52
+ numResults: count,
53
+ type: 'auto',
54
+ livecrawl: 'fallback',
55
+ contextMaxCharacters: 3000
64
56
  }
65
- }),
66
- signal: internalController.signal
67
- });
68
- }
69
- catch (err) {
70
- if (userAborted)
71
- throw new ExaSearchError('Search aborted.', 'aborted');
72
- throw new ExaSearchError(`Exa search request failed: ${describeError(err)}`, 'network');
73
- }
74
- if (!response.ok) {
75
- throw new ExaSearchError(`Exa search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
76
- }
77
- const rpc = parseRpcBody(await response.text());
78
- if (rpc.error) {
79
- throw new ExaSearchError(`Exa MCP error${rpc.error.code !== undefined ? ` ${rpc.error.code}` : ''}: ${rpc.error.message ?? 'unknown error'}`, 'protocol');
80
- }
81
- const text = rpc.result?.content?.find(c => c.type === 'text' && typeof c.text === 'string' && c.text.trim().length > 0)?.text;
82
- if (rpc.result?.isError) {
83
- throw new ExaSearchError(text?.trim() || 'Exa MCP returned an error result.', 'protocol');
84
- }
85
- if (!text)
86
- throw new ExaSearchError('Exa MCP returned no text content.', 'protocol');
87
- return parseResultBlocks(text).slice(0, count);
57
+ }
58
+ })
59
+ }, async (response) => {
60
+ if (!response.ok) {
61
+ throw new ExaSearchError(`Exa search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
62
+ }
63
+ const rpc = parseRpcBody(await response.text());
64
+ if (rpc.error) {
65
+ throw new ExaSearchError(`Exa MCP error${rpc.error.code !== undefined ? ` ${rpc.error.code}` : ''}: ${rpc.error.message ?? 'unknown error'}`, 'protocol');
66
+ }
67
+ const text = rpc.result?.content?.find(c => c.type === 'text' && typeof c.text === 'string' && c.text.trim().length > 0)?.text;
68
+ if (rpc.result?.isError) {
69
+ throw new ExaSearchError(text?.trim() || 'Exa MCP returned an error result.', 'protocol');
70
+ }
71
+ if (!text)
72
+ throw new ExaSearchError('Exa MCP returned no text content.', 'protocol');
73
+ return parseResultBlocks(text).slice(0, count);
74
+ });
88
75
  }
89
- finally {
90
- clearTimeout(timeoutHandle);
91
- if (opts.signal)
92
- opts.signal.removeEventListener('abort', onUserAbort);
76
+ catch (err) {
77
+ if (err instanceof HttpRequestError) {
78
+ throw err.kind === 'aborted' ?
79
+ new ExaSearchError('Search aborted.', 'aborted')
80
+ : new ExaSearchError(`Exa search request failed: ${err.detail}`, 'network');
81
+ }
82
+ throw err;
93
83
  }
94
84
  }
95
85
  /**
@@ -157,8 +147,3 @@ function parseResultBlocks(text) {
157
147
  }
158
148
  return results;
159
149
  }
160
- function describeError(err) {
161
- if (err instanceof Error)
162
- return err.message;
163
- return String(err);
164
- }
@@ -2,6 +2,7 @@ import { parseHTML } from 'linkedom';
2
2
  import { Readability } from '@mozilla/readability';
3
3
  import TurndownService from 'turndown';
4
4
  import { readPkgVersion } from '../shared/pkg-version.js';
5
+ import { httpRequest, HttpRequestError, describeError } from './http-request.js';
5
6
  const turndown = new TurndownService({
6
7
  codeBlockStyle: 'fenced',
7
8
  headingStyle: 'atx',
@@ -86,100 +87,83 @@ export class FetchAndCleanError extends Error {
86
87
  }
87
88
  }
88
89
  export async function fetchAndClean(url, opts = {}) {
89
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
90
90
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
91
- const internalController = new AbortController();
92
91
  let sizeExceeded = false;
93
- let userAborted = false;
94
- const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
95
- const onUserAbort = () => {
96
- userAborted = true;
97
- internalController.abort();
98
- };
99
- if (opts.signal) {
100
- if (opts.signal.aborted)
101
- onUserAbort();
102
- else
103
- opts.signal.addEventListener('abort', onUserAbort, { once: true });
104
- }
105
92
  try {
106
- let response;
107
- try {
108
- response = await fetch(url, {
109
- headers: { 'user-agent': USER_AGENT },
110
- redirect: 'follow',
111
- signal: internalController.signal
112
- });
113
- }
114
- catch (err) {
115
- if (userAborted) {
116
- throw new FetchAndCleanError('Fetch aborted.', 'aborted', err);
93
+ return await httpRequest(url, {
94
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
95
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
96
+ headers: { 'user-agent': USER_AGENT },
97
+ redirect: 'follow'
98
+ }, async (response, ctl) => {
99
+ if (!response.ok) {
100
+ throw new FetchAndCleanError(`Fetch failed: HTTP ${response.status} ${response.statusText} for ${url}`, 'http-error');
117
101
  }
118
- throw new FetchAndCleanError(`Could not fetch ${url}: ${describeError(err)}`, 'network', err);
119
- }
120
- if (!response.ok) {
121
- throw new FetchAndCleanError(`Fetch failed: HTTP ${response.status} ${response.statusText} for ${url}`, 'http-error');
122
- }
123
- const contentType = response.headers.get('content-type') ?? '';
124
- const kind = classifyContentType(contentType);
125
- if (kind === 'reject') {
126
- throw new FetchAndCleanError(`${url} is ${contentType || 'unknown content type'}, not a text or HTML page that pi-worker-fetch can read.`, 'not-html');
127
- }
128
- const reader = response.body?.getReader();
129
- if (!reader) {
130
- throw new FetchAndCleanError(`Could not fetch ${url}: empty response body`, 'network');
131
- }
132
- const decoder = decoderFor(contentType);
133
- let text = '';
134
- let bytesRead = 0;
135
- try {
136
- while (true) {
137
- // response.body's stream type doesn't resolve here, so the chunk
138
- // surfaces as `any`; pin it to the Uint8Array the reader yields.
139
- const { value, done } = (await reader.read());
140
- if (done)
141
- break;
142
- if (value) {
143
- bytesRead += value.byteLength;
144
- if (bytesRead > maxBytes) {
145
- sizeExceeded = true;
146
- internalController.abort();
102
+ const contentType = response.headers.get('content-type') ?? '';
103
+ const kind = classifyContentType(contentType);
104
+ if (kind === 'reject') {
105
+ throw new FetchAndCleanError(`${url} is ${contentType || 'unknown content type'}, not a text or HTML page that pi-worker-fetch can read.`, 'not-html');
106
+ }
107
+ const reader = response.body?.getReader();
108
+ if (!reader) {
109
+ throw new FetchAndCleanError(`Could not fetch ${url}: empty response body`, 'network');
110
+ }
111
+ const decoder = decoderFor(contentType);
112
+ let text = '';
113
+ let bytesRead = 0;
114
+ try {
115
+ while (true) {
116
+ // response.body's stream type doesn't resolve here, so the chunk
117
+ // surfaces as `any`; pin it to the Uint8Array the reader yields.
118
+ const { value, done } = (await reader.read());
119
+ if (done)
147
120
  break;
121
+ if (value) {
122
+ bytesRead += value.byteLength;
123
+ if (bytesRead > maxBytes) {
124
+ // OUR abort, told apart from the user's and the clock's
125
+ // by the seam — all three abort the same signal.
126
+ sizeExceeded = true;
127
+ ctl.abort();
128
+ break;
129
+ }
130
+ text += decoder.decode(value, { stream: true });
148
131
  }
149
- text += decoder.decode(value, { stream: true });
150
132
  }
133
+ text += decoder.decode();
151
134
  }
152
- text += decoder.decode();
153
- }
154
- catch (err) {
155
- if (sizeExceeded) {
156
- // fall through to throw outside the catch
157
- }
158
- else if (userAborted) {
159
- throw new FetchAndCleanError('Fetch aborted.', 'aborted', err);
135
+ catch (err) {
136
+ if (sizeExceeded) {
137
+ // fall through to throw outside the catch
138
+ }
139
+ else if (ctl.userAborted()) {
140
+ throw new FetchAndCleanError('Fetch aborted.', 'aborted', err);
141
+ }
142
+ else {
143
+ throw new FetchAndCleanError(`Could not fetch ${url}: ${describeError(err)}`, 'network', err);
144
+ }
160
145
  }
161
- else {
162
- throw new FetchAndCleanError(`Could not fetch ${url}: ${describeError(err)}`, 'network', err);
146
+ if (sizeExceeded) {
147
+ throw new FetchAndCleanError(`${url} exceeds ${formatBytes(maxBytes)} size cap. Try a more specific URL.`, 'too-large');
163
148
  }
164
- }
165
- if (sizeExceeded) {
166
- throw new FetchAndCleanError(`${url} exceeds ${formatBytes(maxBytes)} size cap. Try a more specific URL.`, 'too-large');
167
- }
168
- const finalUrl = response.url || url;
169
- if (kind === 'html') {
170
- return cleanHtml(text, finalUrl);
171
- }
172
- // text-ish formats are already clean — return them verbatim.
173
- return {
174
- title: hostnameOf(finalUrl),
175
- markdown: text.trim(),
176
- finalUrl
177
- };
149
+ const finalUrl = response.url || url;
150
+ if (kind === 'html')
151
+ return cleanHtml(text, finalUrl);
152
+ // text-ish formats are already clean — return them verbatim.
153
+ return {
154
+ title: hostnameOf(finalUrl),
155
+ markdown: text.trim(),
156
+ finalUrl
157
+ };
158
+ });
178
159
  }
179
- finally {
180
- clearTimeout(timeoutHandle);
181
- if (opts.signal)
182
- opts.signal.removeEventListener('abort', onUserAbort);
160
+ catch (err) {
161
+ if (err instanceof HttpRequestError) {
162
+ throw err.kind === 'aborted' ?
163
+ new FetchAndCleanError('Fetch aborted.', 'aborted', err.cause)
164
+ : new FetchAndCleanError(`Could not fetch ${url}: ${err.detail}`, 'network', err.cause);
165
+ }
166
+ throw err;
183
167
  }
184
168
  }
185
169
  function hostnameOf(url) {
@@ -190,11 +174,6 @@ function hostnameOf(url) {
190
174
  return url;
191
175
  }
192
176
  }
193
- function describeError(err) {
194
- if (err instanceof Error)
195
- return err.message;
196
- return String(err);
197
- }
198
177
  function formatBytes(n) {
199
178
  if (n >= 1024 * 1024)
200
179
  return `${(n / 1024 / 1024).toFixed(1)} MB`;