@hraness/dawg 0.2.0 → 0.4.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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line response sketches for the focused menu row: a filter curve for
|
|
3
|
+
* a cutoff, the envelope shape for attack/decay/sustain/release, and the
|
|
4
|
+
* position inside a wavetable. Sixteen cells, so they sit beside the row's
|
|
5
|
+
* note at 80 columns. Pure and cheap: they run once per menu view.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CELLS = 16;
|
|
9
|
+
const LEVELS = "▁▂▃▄▅▆▇█";
|
|
10
|
+
|
|
11
|
+
/** The parameter a menu command sets: `fx filter cutoff 800` → cutoff. */
|
|
12
|
+
export function commandParam(command: string): string | undefined {
|
|
13
|
+
const words = command.trim().split(/\s+/);
|
|
14
|
+
if (words.length < 2) return undefined;
|
|
15
|
+
const key = words.at(-2)!.toLowerCase();
|
|
16
|
+
return /^[a-z][a-z0-9]*$/.test(key) ? key : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Low- or high-pass response over 20 Hz..20 kHz on a log axis. */
|
|
20
|
+
export function filterSketch(hz: number, highpass = false): string {
|
|
21
|
+
const at = Math.round(
|
|
22
|
+
((CELLS - 1) * Math.log(Math.max(20, Math.min(20_000, hz)) / 20)) /
|
|
23
|
+
Math.log(1000),
|
|
24
|
+
);
|
|
25
|
+
let out = "";
|
|
26
|
+
for (let cell = 0; cell < CELLS; cell += 1) {
|
|
27
|
+
const past = highpass ? at - cell : cell - at;
|
|
28
|
+
out += past < 0 ? "▇" : past === 0 ? "▅" : past === 1 ? "▂" : "▁";
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** ADSR amplitude over time; sustain is held for a quarter of the rest. */
|
|
34
|
+
export function envelopeSketch(
|
|
35
|
+
attack: number,
|
|
36
|
+
decay: number,
|
|
37
|
+
sustain: number,
|
|
38
|
+
release: number,
|
|
39
|
+
): string {
|
|
40
|
+
const a = Math.max(0, attack);
|
|
41
|
+
const d = Math.max(0, decay);
|
|
42
|
+
const r = Math.max(0, release);
|
|
43
|
+
const s = Math.max(0, Math.min(1, sustain));
|
|
44
|
+
const hold = Math.max(0.05, 0.25 * (a + d + r));
|
|
45
|
+
const total = a + d + hold + r;
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let cell = 0; cell < CELLS; cell += 1) {
|
|
48
|
+
const t = ((cell + 0.5) / CELLS) * total;
|
|
49
|
+
const amp =
|
|
50
|
+
t < a
|
|
51
|
+
? t / a
|
|
52
|
+
: t < a + d
|
|
53
|
+
? 1 - ((t - a) / d) * (1 - s)
|
|
54
|
+
: t < a + d + hold
|
|
55
|
+
? s
|
|
56
|
+
: r > 0
|
|
57
|
+
? s * (1 - (t - a - d - hold) / r)
|
|
58
|
+
: 0;
|
|
59
|
+
out += LEVELS[Math.round(Math.max(0, Math.min(1, amp)) * 7)]!;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A marker at `value` in min..max. */
|
|
65
|
+
export function positionSketch(value: number, min = 0, max = 1): string {
|
|
66
|
+
const span = max - min || 1;
|
|
67
|
+
const at = Math.round(
|
|
68
|
+
(CELLS - 1) * Math.max(0, Math.min(1, (value - min) / span)),
|
|
69
|
+
);
|
|
70
|
+
return "─".repeat(at) + "●" + "─".repeat(CELLS - 1 - at);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ENVELOPE = new Set(["attack", "decay", "sustain", "release"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The sketch for a focused number row, or undefined when it has none.
|
|
77
|
+
* `valueOf` reads sibling rows by parameter (the other envelope stages);
|
|
78
|
+
* `filterType` is the filter's type row (a band-pass draws no sketch).
|
|
79
|
+
*/
|
|
80
|
+
export function sketchFor(
|
|
81
|
+
command: string,
|
|
82
|
+
value: number,
|
|
83
|
+
valueOf: (param: string) => number | undefined,
|
|
84
|
+
filterType = "lpf",
|
|
85
|
+
): string | undefined {
|
|
86
|
+
const param = commandParam(command);
|
|
87
|
+
if (!param) return undefined;
|
|
88
|
+
if (param === "cutoff")
|
|
89
|
+
return filterType === "hpf"
|
|
90
|
+
? filterSketch(value, true)
|
|
91
|
+
: filterType === "lpf"
|
|
92
|
+
? filterSketch(value)
|
|
93
|
+
: undefined;
|
|
94
|
+
if (param === "lpf") return filterSketch(value);
|
|
95
|
+
if (param === "hpf") return filterSketch(value, true);
|
|
96
|
+
if (param === "wt") return positionSketch(value);
|
|
97
|
+
if (ENVELOPE.has(param)) {
|
|
98
|
+
const stage = (name: string, fallback: number) =>
|
|
99
|
+
name === param ? value : (valueOf(name) ?? fallback);
|
|
100
|
+
return envelopeSketch(
|
|
101
|
+
stage("attack", 0.01),
|
|
102
|
+
stage("decay", 0.1),
|
|
103
|
+
stage("sustain", 0.7),
|
|
104
|
+
stage("release", 0.2),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
package/src/web/fetch.ts
ADDED
|
@@ -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
|
+
}
|
package/src/web/http.ts
ADDED
|
@@ -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
|
+
}
|