@hraness/dawg 0.2.0 → 0.3.0

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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Fetch a public http(s) URL for the agent and reduce it to readable text.
3
+ * Hostnames are resolved first and every address must be public (no
4
+ * loopback, private, link-local, CGNAT or multicast ranges); redirects are
5
+ * followed manually so each hop gets the same check. Bodies are capped at
6
+ * 2 MiB and the text handed to the model at 32 KiB.
7
+ */
8
+ import { lookup as dnsLookup } from "node:dns/promises";
9
+ import { isIP } from "node:net";
10
+ import {
11
+ decodeEntities,
12
+ describeFetchError,
13
+ readBounded,
14
+ timeoutSignal,
15
+ WebError,
16
+ type FetchLike,
17
+ } from "./http.ts";
18
+
19
+ export const FETCH_LIMITS = Object.freeze({
20
+ maxUrlChars: 2048,
21
+ timeoutMs: 20_000,
22
+ maxBodyBytes: 2 * 1024 * 1024,
23
+ maxOutputChars: 32 * 1024,
24
+ maxRedirects: 3,
25
+ maxLinkChars: 120,
26
+ });
27
+
28
+ /** Resolve a hostname to its addresses; injected in tests. */
29
+ export type Lookup = (hostname: string) => Promise<readonly string[]>;
30
+
31
+ export type FetchUrlOptions = Readonly<{
32
+ fetch?: FetchLike;
33
+ lookup?: Lookup;
34
+ signal?: AbortSignal;
35
+ timeoutMs?: number;
36
+ maxOutputChars?: number;
37
+ }>;
38
+
39
+ export type FetchedPage = Readonly<{
40
+ url: string;
41
+ status: number;
42
+ contentType: string;
43
+ title?: string;
44
+ /** Readable text, at most `maxOutputChars`. */
45
+ text: string;
46
+ bytes: number;
47
+ /** The body hit the byte cap or the text hit the output cap. */
48
+ truncated: boolean;
49
+ }>;
50
+
51
+ const defaultLookup: Lookup = async (hostname) =>
52
+ (await dnsLookup(hostname, { all: true, verbatim: true })).map(
53
+ (entry) => entry.address,
54
+ );
55
+
56
+ /** True for loopback, unspecified, private, link-local, CGNAT, multicast and reserved addresses. */
57
+ export function isPrivateAddress(address: string): boolean {
58
+ const kind = isIP(address);
59
+ if (kind === 4) return isPrivateV4(address);
60
+ if (kind === 6) return isPrivateV6(address);
61
+ return true;
62
+ }
63
+
64
+ function isPrivateV4(address: string): boolean {
65
+ const parts = address.split(".").map((part) => Number.parseInt(part, 10));
66
+ const [a, b] = parts as [number, number, number, number];
67
+ return (
68
+ a === 0 ||
69
+ a === 10 ||
70
+ a === 127 ||
71
+ (a === 100 && b >= 64 && b <= 127) ||
72
+ (a === 169 && b === 254) ||
73
+ (a === 172 && b >= 16 && b <= 31) ||
74
+ (a === 192 && b === 168) ||
75
+ (a === 192 && b === 0 && parts[2] === 0) ||
76
+ (a === 192 && b === 0 && parts[2] === 2) ||
77
+ (a === 198 && (b === 18 || b === 19)) ||
78
+ (a === 198 && b === 51 && parts[2] === 100) ||
79
+ (a === 203 && b === 0 && parts[2] === 113) ||
80
+ a >= 224
81
+ );
82
+ }
83
+
84
+ function isPrivateV6(address: string): boolean {
85
+ const lower = address.toLowerCase().split("%")[0]!;
86
+ if (lower === "::" || lower === "::1") return true;
87
+ const mapped = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
88
+ if (mapped) return isPrivateV4(mapped[1]!);
89
+ const hexMapped = /^(?:0*:)*:?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(
90
+ lower,
91
+ );
92
+ if (hexMapped) {
93
+ const high = Number.parseInt(hexMapped[1]!, 16);
94
+ const low = Number.parseInt(hexMapped[2]!, 16);
95
+ return isPrivateV4(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
96
+ }
97
+ const first = Number.parseInt(lower.split(":")[0] || "0", 16);
98
+ return (
99
+ (first & 0xfe00) === 0xfc00 || // fc00::/7 unique local
100
+ (first & 0xffc0) === 0xfe80 || // fe80::/10 link-local
101
+ (first & 0xff00) === 0xff00 || // ff00::/8 multicast
102
+ lower.startsWith("64:ff9b:") || // NAT64 well-known prefix
103
+ lower.startsWith("2001:db8:") // documentation
104
+ );
105
+ }
106
+
107
+ /** Parse and admit a URL: http(s) only, bounded, host not an obvious local name. */
108
+ export function admitUrl(raw: unknown): URL {
109
+ if (typeof raw !== "string" || raw.trim().length === 0)
110
+ throw new WebError("url must be a non-empty string");
111
+ const text = raw.trim();
112
+ if (text.length > FETCH_LIMITS.maxUrlChars)
113
+ throw new WebError(
114
+ `url is longer than ${FETCH_LIMITS.maxUrlChars} characters`,
115
+ );
116
+ let url: URL;
117
+ try {
118
+ url = new URL(text);
119
+ } catch {
120
+ throw new WebError("url is not a valid absolute URL");
121
+ }
122
+ if (url.protocol !== "http:" && url.protocol !== "https:")
123
+ throw new WebError(
124
+ `only http and https URLs are fetched (got ${url.protocol})`,
125
+ );
126
+ if (url.username || url.password)
127
+ throw new WebError("urls with embedded credentials are not fetched");
128
+ const host = url.hostname.replace(/^\[|\]$/g, "");
129
+ if (
130
+ !host ||
131
+ host === "localhost" ||
132
+ host.endsWith(".localhost") ||
133
+ host.endsWith(".local")
134
+ )
135
+ throw new WebError(`${host || "empty host"} is not a public host`);
136
+ return url;
137
+ }
138
+
139
+ async function assertPublicHost(url: URL, lookup: Lookup): Promise<void> {
140
+ const host = url.hostname.replace(/^\[|\]$/g, "");
141
+ if (isIP(host)) {
142
+ if (isPrivateAddress(host))
143
+ throw new WebError(`${host} is a private or local address`);
144
+ return;
145
+ }
146
+ let addresses: readonly string[];
147
+ try {
148
+ addresses = await lookup(host);
149
+ } catch {
150
+ throw new WebError(`${host} did not resolve`);
151
+ }
152
+ if (addresses.length === 0) throw new WebError(`${host} did not resolve`);
153
+ for (const address of addresses)
154
+ if (isPrivateAddress(address))
155
+ throw new WebError(`${host} resolves to a private or local address`);
156
+ }
157
+
158
+ export async function fetchUrl(
159
+ raw: unknown,
160
+ options: FetchUrlOptions = {},
161
+ ): Promise<FetchedPage> {
162
+ const fetcher = options.fetch ?? globalThis.fetch;
163
+ const lookup = options.lookup ?? defaultLookup;
164
+ const timeoutMs = options.timeoutMs ?? FETCH_LIMITS.timeoutMs;
165
+ const maxOutputChars = options.maxOutputChars ?? FETCH_LIMITS.maxOutputChars;
166
+ const timer = timeoutSignal(timeoutMs, options.signal);
167
+ try {
168
+ let url = admitUrl(raw);
169
+ let response: Response | undefined;
170
+ for (let hop = 0; hop <= FETCH_LIMITS.maxRedirects; hop += 1) {
171
+ await assertPublicHost(url, lookup);
172
+ try {
173
+ response = await fetcher(url, {
174
+ method: "GET",
175
+ redirect: "manual",
176
+ headers: {
177
+ accept:
178
+ "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8,*/*;q=0.5",
179
+ "user-agent": "Mozilla/5.0 (compatible; dawg-agent)",
180
+ },
181
+ signal: timer.signal,
182
+ });
183
+ } catch (error) {
184
+ throw new WebError(
185
+ `fetch failed: ${timer.timedOut() ? `timed out after ${timeoutMs / 1000}s` : describeFetchError(error)}`,
186
+ );
187
+ }
188
+ if (response.status >= 300 && response.status < 400) {
189
+ const location = response.headers.get("location");
190
+ await response.body?.cancel().catch(() => undefined);
191
+ if (!location)
192
+ throw new WebError(`HTTP ${response.status} without a location`);
193
+ if (hop === FETCH_LIMITS.maxRedirects)
194
+ throw new WebError(
195
+ `more than ${FETCH_LIMITS.maxRedirects} redirects`,
196
+ );
197
+ let next: URL;
198
+ try {
199
+ next = new URL(location, url);
200
+ } catch {
201
+ throw new WebError("redirect to an invalid URL");
202
+ }
203
+ url = admitUrl(next.toString());
204
+ response = undefined;
205
+ continue;
206
+ }
207
+ break;
208
+ }
209
+ if (!response) throw new WebError("no response");
210
+ if (!response.ok) {
211
+ await response.body?.cancel().catch(() => undefined);
212
+ throw new WebError(`HTTP ${response.status} from ${url.hostname}`);
213
+ }
214
+ const contentType = (response.headers.get("content-type") ?? "")
215
+ .split(";")[0]!
216
+ .trim()
217
+ .toLowerCase();
218
+ const { bytes, truncated: bodyTruncated } = await readBounded(
219
+ response,
220
+ FETCH_LIMITS.maxBodyBytes,
221
+ timer.signal,
222
+ );
223
+ const base = {
224
+ url: url.toString(),
225
+ status: response.status,
226
+ contentType: contentType || "application/octet-stream",
227
+ bytes: bytes.byteLength,
228
+ };
229
+ if (!isTextual(contentType, bytes)) {
230
+ return {
231
+ ...base,
232
+ text: `${base.contentType}, ${bytes.byteLength}${bodyTruncated ? "+" : ""} bytes; not text`,
233
+ truncated: bodyTruncated,
234
+ };
235
+ }
236
+ const decoded = new TextDecoder("utf-8").decode(bytes);
237
+ const readable =
238
+ contentType.includes("html") || looksLikeHtml(decoded)
239
+ ? htmlToText(decoded)
240
+ : { text: decoded.replace(/\r\n?/g, "\n").trim() };
241
+ const clipped = readable.text.length > maxOutputChars;
242
+ const text = clipped
243
+ ? `${readable.text.slice(0, maxOutputChars)}\n\n[truncated: showing ${maxOutputChars} of ${readable.text.length} characters${bodyTruncated ? "; the body itself was cut at 2 MiB" : ""}]`
244
+ : bodyTruncated
245
+ ? `${readable.text}\n\n[truncated: the body was cut at 2 MiB]`
246
+ : readable.text;
247
+ return {
248
+ ...base,
249
+ ...(readable.title ? { title: readable.title } : {}),
250
+ text,
251
+ truncated: clipped || bodyTruncated,
252
+ };
253
+ } finally {
254
+ timer.dispose();
255
+ }
256
+ }
257
+
258
+ function isTextual(contentType: string, bytes: Uint8Array): boolean {
259
+ if (
260
+ contentType.startsWith("text/") ||
261
+ contentType.includes("json") ||
262
+ contentType.includes("xml") ||
263
+ contentType.includes("javascript")
264
+ )
265
+ return true;
266
+ if (contentType && contentType !== "application/octet-stream") return false;
267
+ return !bytes.subarray(0, 8192).includes(0);
268
+ }
269
+
270
+ function looksLikeHtml(text: string): boolean {
271
+ return /^\s*(?:<!doctype html|<html|<head|<body)/i.test(text.slice(0, 512));
272
+ }
273
+
274
+ const INLINE_TAGS =
275
+ "a|em|strong|b|i|u|s|span|code|kbd|var|samp|small|sup|sub|mark|abbr|cite|q|time|label|del|ins|wbr";
276
+ const BLOCK_TAGS =
277
+ "p|div|section|article|main|aside|header|footer|ul|ol|table|tr|blockquote|pre|figure|figcaption|form|fieldset|hr|dl|dt|dd|address|details|summary";
278
+
279
+ /**
280
+ * HTML → readable text: drops script, style, nav and similar; keeps heading
281
+ * levels as `#` prefixes, list items as `- `, and absolute links as
282
+ * `text (url)`; collapses whitespace.
283
+ */
284
+ export function htmlToText(html: string): { title?: string; text: string } {
285
+ const titleMatch = /<title[^>]{0,200}>([\s\S]{0,500}?)<\/title>/i.exec(html);
286
+ const title = titleMatch
287
+ ? decodeEntities(titleMatch[1]!).replace(/\s+/g, " ").trim()
288
+ : undefined;
289
+ let text = html
290
+ .replace(/<!--[\s\S]*?-->/g, " ")
291
+ .replace(
292
+ /<(script|style|noscript|template|svg|canvas|iframe|nav|head|title)\b[^>]*>[\s\S]*?<\/\1\s*>/gi,
293
+ " ",
294
+ )
295
+ .replace(
296
+ /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi,
297
+ (_m, level: string, body: string) =>
298
+ `\n\n${"#".repeat(Number(level))} ${inline(body)}\n\n`,
299
+ )
300
+ .replace(
301
+ /<a\b[^>]*?href="(https?:\/\/[^"]{1,2000})"[^>]*>([\s\S]{0,2000}?)<\/a\s*>/gi,
302
+ (_m, href: string, body: string) => {
303
+ const label = inline(body);
304
+ if (!label) return " ";
305
+ const target = decodeEntities(href);
306
+ return target.length > FETCH_LIMITS.maxLinkChars
307
+ ? label
308
+ : `${label} (${target})`;
309
+ },
310
+ )
311
+ .replace(/\s*<li\b[^>]*>/gi, "\n- ")
312
+ .replace(/<\/li\s*>/gi, "")
313
+ .replace(/<br\s*\/?>/gi, "\n")
314
+ .replace(new RegExp(`</?(?:${INLINE_TAGS})\\b[^>]*>`, "gi"), "")
315
+ .replace(/<\/(?:td|th)\s*>/gi, " \t")
316
+ .replace(new RegExp(`</?(?:${BLOCK_TAGS})\\b[^>]*>`, "gi"), "\n\n")
317
+ .replace(/<[^>]{0,1000}>/g, " ");
318
+ text = decodeEntities(text)
319
+ .replace(/\r\n?/g, "\n")
320
+ .replace(/[ \t\f\v ]+/g, " ")
321
+ .replace(/ *\n */g, "\n")
322
+ .replace(/\n{3,}/g, "\n\n")
323
+ .trim();
324
+ return title ? { title, text } : { text };
325
+ }
326
+
327
+ function inline(fragment: string): string {
328
+ return decodeEntities(fragment.replace(/<[^>]{0,1000}>/g, " "))
329
+ .replace(/\s+/g, " ")
330
+ .trim();
331
+ }
332
+
333
+ /** Render a fetched page as the text the model reads. */
334
+ export function formatFetchedPage(page: FetchedPage): string {
335
+ const head = [
336
+ `${page.url} · HTTP ${page.status} · ${page.contentType} · ${page.bytes} bytes`,
337
+ ...(page.title ? [`title: ${page.title}`] : []),
338
+ ];
339
+ return `${head.join("\n")}\n\n${page.text}`;
340
+ }
@@ -0,0 +1,137 @@
1
+ /** Bounded HTTP helpers shared by web search and URL fetching. */
2
+
3
+ export type FetchLike = (
4
+ input: string | URL,
5
+ init?: RequestInit,
6
+ ) => Promise<Response>;
7
+
8
+ /** A refused or failed web request; safe to show to the model. */
9
+ export class WebError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "WebError";
13
+ }
14
+ }
15
+
16
+ /** An abort signal that fires after `timeoutMs` or when `parent` aborts. */
17
+ export function timeoutSignal(
18
+ timeoutMs: number,
19
+ parent?: AbortSignal,
20
+ ): { signal: AbortSignal; dispose: () => void; timedOut: () => boolean } {
21
+ const controller = new AbortController();
22
+ let fired = false;
23
+ const timer = setTimeout(() => {
24
+ fired = true;
25
+ controller.abort(new WebError(`timed out after ${timeoutMs / 1000}s`));
26
+ }, timeoutMs);
27
+ const signal = parent
28
+ ? AbortSignal.any([parent, controller.signal])
29
+ : controller.signal;
30
+ return {
31
+ signal,
32
+ dispose: () => clearTimeout(timer),
33
+ timedOut: () => fired,
34
+ };
35
+ }
36
+
37
+ /** Read at most `maxBytes` of a response body, cancelling the rest. */
38
+ export async function readBounded(
39
+ response: Response,
40
+ maxBytes: number,
41
+ signal?: AbortSignal,
42
+ ): Promise<{ bytes: Uint8Array; truncated: boolean }> {
43
+ const body = response.body;
44
+ if (!body) return { bytes: new Uint8Array(0), truncated: false };
45
+ const reader = body.getReader();
46
+ const chunks: Uint8Array[] = [];
47
+ let total = 0;
48
+ let truncated = false;
49
+ try {
50
+ for (;;) {
51
+ if (signal?.aborted) throw signal.reason;
52
+ const { done, value } = await reader.read();
53
+ if (done) break;
54
+ if (!value) continue;
55
+ if (total + value.byteLength > maxBytes) {
56
+ chunks.push(value.subarray(0, maxBytes - total));
57
+ total = maxBytes;
58
+ truncated = true;
59
+ break;
60
+ }
61
+ chunks.push(value);
62
+ total += value.byteLength;
63
+ }
64
+ } finally {
65
+ await reader.cancel().catch(() => undefined);
66
+ }
67
+ const bytes = new Uint8Array(total);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ bytes.set(chunk, offset);
71
+ offset += chunk.byteLength;
72
+ }
73
+ return { bytes, truncated };
74
+ }
75
+
76
+ const NAMED_ENTITIES: Record<string, string> = {
77
+ amp: "&",
78
+ lt: "<",
79
+ gt: ">",
80
+ quot: '"',
81
+ apos: "'",
82
+ nbsp: " ",
83
+ ndash: "–",
84
+ mdash: "—",
85
+ hellip: "…",
86
+ copy: "©",
87
+ reg: "®",
88
+ trade: "™",
89
+ laquo: "«",
90
+ raquo: "»",
91
+ lsquo: "‘",
92
+ rsquo: "’",
93
+ ldquo: "“",
94
+ rdquo: "”",
95
+ bull: "•",
96
+ middot: "·",
97
+ };
98
+
99
+ export function decodeEntities(text: string): string {
100
+ return text.replace(
101
+ /&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,10});/gi,
102
+ (whole, body: string) => {
103
+ if (body[0] === "#") {
104
+ const code =
105
+ body[1] === "x" || body[1] === "X"
106
+ ? Number.parseInt(body.slice(2), 16)
107
+ : Number.parseInt(body.slice(1), 10);
108
+ if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff)
109
+ return whole;
110
+ try {
111
+ return String.fromCodePoint(code);
112
+ } catch {
113
+ return whole;
114
+ }
115
+ }
116
+ return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
117
+ },
118
+ );
119
+ }
120
+
121
+ /** Strip tags from a fragment and collapse whitespace. */
122
+ export function stripTags(fragment: string): string {
123
+ return decodeEntities(fragment.replace(/<[^>]{0,500}>/g, " "))
124
+ .replace(/\s+/g, " ")
125
+ .trim();
126
+ }
127
+
128
+ /** Describe a fetch failure without echoing request details. */
129
+ export function describeFetchError(error: unknown): string {
130
+ if (error instanceof WebError) return error.message;
131
+ if (error instanceof Error) {
132
+ if (error.name === "AbortError" || error.name === "TimeoutError")
133
+ return "request aborted";
134
+ return error.message.slice(0, 160) || "request failed";
135
+ }
136
+ return "request failed";
137
+ }