@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,681 @@
1
+ /**
2
+ * Web search for the agent. Provider chain, first match wins:
3
+ *
4
+ * 0. `DAWG_WEB_SEARCH` names a backend (`duckduckgo`, `openrouter`, `gateway`,
5
+ * `brave`) whose credentials exist → that backend.
6
+ * 1. `BRAVE_SEARCH_API_KEY` set → Brave Web Search API (explicit override).
7
+ * 2. An AI Gateway key → one non-streaming Chat Completions call on a small
8
+ * model with a gateway server-side search tool (`vercel:exa_search` by
9
+ * default; `DAWG_WEB_SEARCH=exa|perplexity|parallel|browserbase`). The
10
+ * gateway runs the search and bills it; the model relays the results as a
11
+ * JSON array that is parsed from `unknown`.
12
+ * 3. An OpenRouter key → the same one-shot with OpenRouter's `web` plugin;
13
+ * `annotations[].url_citation` entries become results.
14
+ * 4. DuckDuckGo's HTML endpoint parsed with a bounded regex extractor.
15
+ *
16
+ * A gateway or OpenRouter failure falls through to DuckDuckGo and says so.
17
+ * Every request is capped in time and bytes; results are plain
18
+ * `{title, url, snippet}` records and keys are never echoed.
19
+ */
20
+ import {
21
+ decodeEntities,
22
+ describeFetchError,
23
+ readBounded,
24
+ stripTags,
25
+ timeoutSignal,
26
+ WebError,
27
+ type FetchLike,
28
+ } from "./http.ts";
29
+
30
+ export const SEARCH_LIMITS = Object.freeze({
31
+ maxQueryChars: 400,
32
+ defaultCount: 8,
33
+ maxCount: 10,
34
+ timeoutMs: 10_000,
35
+ /** Model-backed providers get a little longer: the search runs server-side first. */
36
+ modelTimeoutMs: 20_000,
37
+ maxResponseBytes: 64 * 1024,
38
+ maxTitleChars: 160,
39
+ maxSnippetChars: 300,
40
+ maxUrlChars: 500,
41
+ relayMaxTokens: 800,
42
+ });
43
+
44
+ export const BRAVE_SEARCH_URL =
45
+ "https://api.search.brave.com/res/v1/web/search";
46
+ export const DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/";
47
+ export const GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1";
48
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
49
+ /** Cheap relay model; the same ID exists on the gateway and on OpenRouter. */
50
+ export const SEARCH_RELAY_MODEL = "anthropic/claude-haiku-4.5";
51
+
52
+ export const GATEWAY_SEARCH_TOOLS = Object.freeze([
53
+ "exa",
54
+ "perplexity",
55
+ "parallel",
56
+ "browserbase",
57
+ ] as const);
58
+ export type GatewaySearchTool = (typeof GATEWAY_SEARCH_TOOLS)[number];
59
+
60
+ /** Estimated USD per search request (vercel.com/docs/ai-gateway/models-and-providers/web-search, 2026-10-06). */
61
+ export const SEARCH_TOOL_USD: Readonly<Record<GatewaySearchTool, number>> =
62
+ Object.freeze({
63
+ exa: 0.007,
64
+ perplexity: 0.005,
65
+ parallel: 0.005,
66
+ browserbase: 0.007,
67
+ });
68
+ /** OpenRouter's web plugin bills per result ($4 per 1,000 results). */
69
+ export const OPENROUTER_RESULT_USD = 0.004;
70
+
71
+ export type SearchResult = Readonly<{
72
+ title: string;
73
+ url: string;
74
+ snippet: string;
75
+ }>;
76
+
77
+ export type SearchProvider = "brave" | "gateway" | "openrouter" | "duckduckgo";
78
+
79
+ export type SearchSpend = Readonly<{
80
+ provider: "gateway" | "openrouter";
81
+ tool: string;
82
+ /** Estimated USD for the search call (tool price plus any reported model cost). */
83
+ usd: number;
84
+ }>;
85
+
86
+ export type SearchOptions = Readonly<{
87
+ count?: number;
88
+ /** Brave Web Search API key; when set it overrides the whole chain. */
89
+ braveApiKey?: string;
90
+ /** AI Gateway key; enables the gateway search tools. */
91
+ gatewayApiKey?: string;
92
+ gatewayBaseUrl?: string;
93
+ /** `DAWG_WEB_SEARCH`: a gateway tool (`exa`, default) or a pinned backend (`duckduckgo`, `openrouter`, `gateway`, `brave`). */
94
+ searchTool?: string;
95
+ /** OpenRouter key; enables the `web` plugin. */
96
+ openRouterApiKey?: string;
97
+ openRouterBaseUrl?: string;
98
+ relayModel?: string;
99
+ fetch?: FetchLike;
100
+ signal?: AbortSignal;
101
+ timeoutMs?: number;
102
+ /** Called once per billed search so the host can record it in its spend ledger. */
103
+ onSpend?: (spend: SearchSpend) => void;
104
+ }>;
105
+
106
+ export type SearchOutcome = Readonly<{
107
+ provider: SearchProvider;
108
+ /** The gateway tool (`exa`) or OpenRouter plugin (`web`) that answered. */
109
+ tool?: string;
110
+ results: readonly SearchResult[];
111
+ /** Set when a paid provider failed and DuckDuckGo answered instead. */
112
+ fallbackFrom?: string;
113
+ }>;
114
+
115
+ export function parseSearchTool(value: unknown): GatewaySearchTool {
116
+ const name = typeof value === "string" ? value.trim().toLowerCase() : "";
117
+ return (GATEWAY_SEARCH_TOOLS as readonly string[]).includes(name)
118
+ ? (name as GatewaySearchTool)
119
+ : "exa";
120
+ }
121
+
122
+ const PINNABLE: readonly SearchProvider[] = [
123
+ "brave",
124
+ "gateway",
125
+ "openrouter",
126
+ "duckduckgo",
127
+ ];
128
+
129
+ /** A backend named by `DAWG_WEB_SEARCH` (`duckduckgo`, `openrouter`, ...), if any. */
130
+ export function pinnedSearchProvider(
131
+ value: unknown,
132
+ ): SearchProvider | undefined {
133
+ const name = typeof value === "string" ? value.trim().toLowerCase() : "";
134
+ return PINNABLE.find((provider) => provider === name);
135
+ }
136
+
137
+ /**
138
+ * Which provider a search tries first: a backend pinned by `searchTool`
139
+ * when its credentials exist, else Brave (override), gateway, OpenRouter,
140
+ * DuckDuckGo.
141
+ */
142
+ export function searchProvider(
143
+ options: Pick<
144
+ SearchOptions,
145
+ "braveApiKey" | "gatewayApiKey" | "openRouterApiKey" | "searchTool"
146
+ >,
147
+ ): SearchProvider {
148
+ const pinned = pinnedSearchProvider(options.searchTool);
149
+ if (pinned && hasCredentials(pinned, options)) return pinned;
150
+ if (present(options.braveApiKey)) return "brave";
151
+ if (present(options.gatewayApiKey)) return "gateway";
152
+ if (present(options.openRouterApiKey)) return "openrouter";
153
+ return "duckduckgo";
154
+ }
155
+
156
+ function hasCredentials(
157
+ provider: SearchProvider,
158
+ options: Pick<
159
+ SearchOptions,
160
+ "braveApiKey" | "gatewayApiKey" | "openRouterApiKey"
161
+ >,
162
+ ): boolean {
163
+ switch (provider) {
164
+ case "brave":
165
+ return present(options.braveApiKey);
166
+ case "gateway":
167
+ return present(options.gatewayApiKey);
168
+ case "openrouter":
169
+ return present(options.openRouterApiKey);
170
+ default:
171
+ return true;
172
+ }
173
+ }
174
+
175
+ function present(value: string | undefined): value is string {
176
+ return typeof value === "string" && value.trim().length > 0;
177
+ }
178
+
179
+ export async function webSearch(
180
+ query: string,
181
+ options: SearchOptions = {},
182
+ ): Promise<SearchOutcome> {
183
+ const trimmed = query.replace(/\s+/g, " ").trim();
184
+ if (trimmed.length === 0) throw new WebError("query is empty");
185
+ if (trimmed.length > SEARCH_LIMITS.maxQueryChars)
186
+ throw new WebError(
187
+ `query is longer than ${SEARCH_LIMITS.maxQueryChars} characters`,
188
+ );
189
+ const count = Math.max(
190
+ 1,
191
+ Math.min(
192
+ SEARCH_LIMITS.maxCount,
193
+ options.count ?? SEARCH_LIMITS.defaultCount,
194
+ ),
195
+ );
196
+ const provider = searchProvider(options);
197
+ if (provider === "brave") return braveSearch(trimmed, count, options);
198
+ if (provider === "gateway" || provider === "openrouter") {
199
+ try {
200
+ return provider === "gateway"
201
+ ? await gatewaySearch(trimmed, count, options)
202
+ : await openRouterSearch(trimmed, count, options);
203
+ } catch (error) {
204
+ if (options.signal?.aborted) throw error;
205
+ const reason = error instanceof WebError ? error.message : "failed";
206
+ const fallback = await duckDuckGoSearch(trimmed, count, options);
207
+ return { ...fallback, fallbackFrom: reason };
208
+ }
209
+ }
210
+ return duckDuckGoSearch(trimmed, count, options);
211
+ }
212
+
213
+ type BoundedRequest = Readonly<{
214
+ provider: SearchProvider;
215
+ url: string;
216
+ init: RequestInit;
217
+ timeoutMs: number;
218
+ }>;
219
+
220
+ /** One bounded request: timeout, status check, 64 KiB body. */
221
+ async function boundedRequest(
222
+ request: BoundedRequest,
223
+ options: SearchOptions,
224
+ ): Promise<string> {
225
+ const fetcher = options.fetch ?? globalThis.fetch;
226
+ const timer = timeoutSignal(request.timeoutMs, options.signal);
227
+ try {
228
+ let response: Response;
229
+ try {
230
+ response = await fetcher(request.url, {
231
+ ...request.init,
232
+ signal: timer.signal,
233
+ });
234
+ } catch (error) {
235
+ throw new WebError(
236
+ `${request.provider} search failed: ${timer.timedOut() ? `timed out after ${request.timeoutMs / 1000}s` : describeFetchError(error)}`,
237
+ );
238
+ }
239
+ if (!response.ok) {
240
+ await response.body?.cancel().catch(() => undefined);
241
+ throw new WebError(
242
+ response.status === 401 || response.status === 403
243
+ ? `${request.provider} search rejected the API key (HTTP ${response.status})`
244
+ : response.status === 402
245
+ ? `${request.provider} search needs credits (HTTP 402)`
246
+ : `${request.provider} search returned HTTP ${response.status}`,
247
+ );
248
+ }
249
+ const { bytes } = await readBounded(
250
+ response,
251
+ SEARCH_LIMITS.maxResponseBytes,
252
+ timer.signal,
253
+ );
254
+ return new TextDecoder("utf-8").decode(bytes);
255
+ } finally {
256
+ timer.dispose();
257
+ }
258
+ }
259
+
260
+ function parseJson(text: string, provider: SearchProvider): unknown {
261
+ try {
262
+ return JSON.parse(text);
263
+ } catch {
264
+ throw new WebError(`${provider} search returned invalid JSON`);
265
+ }
266
+ }
267
+
268
+ async function braveSearch(
269
+ query: string,
270
+ count: number,
271
+ options: SearchOptions,
272
+ ): Promise<SearchOutcome> {
273
+ const text = await boundedRequest(
274
+ {
275
+ provider: "brave",
276
+ url: `${BRAVE_SEARCH_URL}?q=${encodeURIComponent(query)}&count=${count}`,
277
+ init: {
278
+ method: "GET",
279
+ headers: {
280
+ accept: "application/json",
281
+ "accept-encoding": "gzip",
282
+ "x-subscription-token": options.braveApiKey!.trim(),
283
+ },
284
+ redirect: "follow",
285
+ },
286
+ timeoutMs: options.timeoutMs ?? SEARCH_LIMITS.timeoutMs,
287
+ },
288
+ options,
289
+ );
290
+ return {
291
+ provider: "brave",
292
+ results: parseBraveResults(parseJson(text, "brave"), count),
293
+ };
294
+ }
295
+
296
+ async function duckDuckGoSearch(
297
+ query: string,
298
+ count: number,
299
+ options: SearchOptions,
300
+ ): Promise<SearchOutcome> {
301
+ const text = await boundedRequest(
302
+ {
303
+ provider: "duckduckgo",
304
+ url: `${DUCKDUCKGO_HTML_URL}?q=${encodeURIComponent(query)}`,
305
+ init: {
306
+ method: "GET",
307
+ headers: {
308
+ accept: "text/html",
309
+ "user-agent": "Mozilla/5.0 (compatible; dawg-agent)",
310
+ },
311
+ redirect: "follow",
312
+ },
313
+ timeoutMs: options.timeoutMs ?? SEARCH_LIMITS.timeoutMs,
314
+ },
315
+ options,
316
+ );
317
+ return { provider: "duckduckgo", results: parseDuckDuckGoHtml(text, count) };
318
+ }
319
+
320
+ const RELAY_SYSTEM_PROMPT =
321
+ 'You are a search relay. Using only the web search results provided to you, reply with exactly one JSON array and nothing else (no prose, no code fence) of at most {count} objects shaped {"title":string,"url":string,"snippet":string}, where snippet is one sentence taken from that result. Reply [] if there are no results.';
322
+
323
+ /** The Chat Completions body for a gateway server-side search tool. */
324
+ export function gatewaySearchBody(
325
+ query: string,
326
+ count: number,
327
+ tool: GatewaySearchTool,
328
+ model: string,
329
+ ): Record<string, unknown> {
330
+ const config: Record<string, unknown> =
331
+ tool === "parallel"
332
+ ? { objective: query, max_results: count }
333
+ : tool === "perplexity"
334
+ ? { query, max_results: count }
335
+ : tool === "browserbase"
336
+ ? { query: query.slice(0, 200), num_results: count }
337
+ : { query, num_results: count, type: "instant" };
338
+ return {
339
+ model,
340
+ messages: relayMessages(query, count),
341
+ tools: [{ type: `vercel:${tool}_search`, config }],
342
+ tool_choice: "required",
343
+ max_tokens: SEARCH_LIMITS.relayMaxTokens,
344
+ stream: false,
345
+ };
346
+ }
347
+
348
+ /** The Chat Completions body for OpenRouter's `web` plugin. */
349
+ export function openRouterSearchBody(
350
+ query: string,
351
+ count: number,
352
+ model: string,
353
+ ): Record<string, unknown> {
354
+ return {
355
+ model,
356
+ messages: relayMessages(query, count),
357
+ plugins: [{ id: "web", max_results: count }],
358
+ max_tokens: SEARCH_LIMITS.relayMaxTokens,
359
+ stream: false,
360
+ };
361
+ }
362
+
363
+ function relayMessages(query: string, count: number) {
364
+ return [
365
+ {
366
+ role: "system",
367
+ content: RELAY_SYSTEM_PROMPT.replace("{count}", String(count)),
368
+ },
369
+ { role: "user", content: `Search: ${query}` },
370
+ ];
371
+ }
372
+
373
+ async function gatewaySearch(
374
+ query: string,
375
+ count: number,
376
+ options: SearchOptions,
377
+ ): Promise<SearchOutcome> {
378
+ const tool = parseSearchTool(options.searchTool);
379
+ const model = options.relayModel ?? SEARCH_RELAY_MODEL;
380
+ const base = (options.gatewayBaseUrl ?? GATEWAY_BASE_URL).replace(/\/$/, "");
381
+ const text = await boundedRequest(
382
+ {
383
+ provider: "gateway",
384
+ url: `${base}/chat/completions`,
385
+ init: {
386
+ method: "POST",
387
+ headers: {
388
+ authorization: `Bearer ${options.gatewayApiKey!.trim()}`,
389
+ "content-type": "application/json",
390
+ accept: "application/json",
391
+ },
392
+ body: JSON.stringify(gatewaySearchBody(query, count, tool, model)),
393
+ },
394
+ timeoutMs: options.timeoutMs ?? SEARCH_LIMITS.modelTimeoutMs,
395
+ },
396
+ options,
397
+ );
398
+ const json = parseJson(text, "gateway");
399
+ const results = parseCompletionResults(json, count);
400
+ options.onSpend?.({
401
+ provider: "gateway",
402
+ tool,
403
+ usd: round(SEARCH_TOOL_USD[tool] + reportedCost(json)),
404
+ });
405
+ return { provider: "gateway", tool, results };
406
+ }
407
+
408
+ async function openRouterSearch(
409
+ query: string,
410
+ count: number,
411
+ options: SearchOptions,
412
+ ): Promise<SearchOutcome> {
413
+ const model = options.relayModel ?? SEARCH_RELAY_MODEL;
414
+ const base = (options.openRouterBaseUrl ?? OPENROUTER_BASE_URL).replace(
415
+ /\/$/,
416
+ "",
417
+ );
418
+ const text = await boundedRequest(
419
+ {
420
+ provider: "openrouter",
421
+ url: `${base}/chat/completions`,
422
+ init: {
423
+ method: "POST",
424
+ headers: {
425
+ authorization: `Bearer ${options.openRouterApiKey!.trim()}`,
426
+ "content-type": "application/json",
427
+ accept: "application/json",
428
+ "http-referer": "https://dawg.sh",
429
+ "x-title": "dawg",
430
+ },
431
+ body: JSON.stringify(openRouterSearchBody(query, count, model)),
432
+ },
433
+ timeoutMs: options.timeoutMs ?? SEARCH_LIMITS.modelTimeoutMs,
434
+ },
435
+ options,
436
+ );
437
+ const json = parseJson(text, "openrouter");
438
+ const results = parseCompletionResults(json, count);
439
+ options.onSpend?.({
440
+ provider: "openrouter",
441
+ tool: "web",
442
+ usd: round(
443
+ OPENROUTER_RESULT_USD * Math.max(results.length, 1) + reportedCost(json),
444
+ ),
445
+ });
446
+ return { provider: "openrouter", tool: "web", results };
447
+ }
448
+
449
+ function round(usd: number): number {
450
+ return Math.round(usd * 1e6) / 1e6;
451
+ }
452
+
453
+ /** `usage.cost` (OpenRouter) or `usage.cost_usd`/`usage.cost` (gateway) when reported. */
454
+ function reportedCost(json: unknown): number {
455
+ const usage = record(record(json)?.usage);
456
+ const cost = usage?.cost ?? usage?.cost_usd;
457
+ return typeof cost === "number" && Number.isFinite(cost) && cost >= 0
458
+ ? Math.min(cost, 1)
459
+ : 0;
460
+ }
461
+
462
+ /**
463
+ * Results from a relay completion: `url_citation` annotations first (they
464
+ * carry the provider's own titles and excerpts), then the JSON array in the
465
+ * message text, de-duplicated by URL.
466
+ */
467
+ export function parseCompletionResults(
468
+ json: unknown,
469
+ count: number,
470
+ ): SearchResult[] {
471
+ const message = record(
472
+ (Array.isArray(record(json)?.choices) &&
473
+ record((record(json)!.choices as unknown[])[0])?.message) ||
474
+ undefined,
475
+ );
476
+ if (!message) throw new WebError("search relay returned no message");
477
+ const seen = new Set<string>();
478
+ const results: SearchResult[] = [];
479
+ const push = (title: unknown, url: unknown, snippet: unknown) => {
480
+ if (results.length >= count) return;
481
+ const result = makeResult(title, url, snippet);
482
+ if (!result || seen.has(result.url)) return;
483
+ seen.add(result.url);
484
+ results.push(result);
485
+ };
486
+ const annotations = Array.isArray(message.annotations)
487
+ ? message.annotations.slice(0, 64)
488
+ : [];
489
+ for (const entry of annotations) {
490
+ const citation = record(record(entry)?.url_citation);
491
+ if (citation) push(citation.title, citation.url, citation.content);
492
+ }
493
+ const content = messageText(message.content);
494
+ for (const item of jsonArrayIn(content).slice(0, 64)) {
495
+ const value = record(item);
496
+ if (value) push(value.title, value.url, value.snippet ?? value.description);
497
+ }
498
+ return results;
499
+ }
500
+
501
+ function messageText(content: unknown): string {
502
+ if (typeof content === "string") return content;
503
+ if (Array.isArray(content))
504
+ return content
505
+ .map((part) => {
506
+ const text = record(part)?.text;
507
+ return typeof text === "string" ? text : "";
508
+ })
509
+ .join("");
510
+ return "";
511
+ }
512
+
513
+ /** The first JSON array in `text` (direct, fenced, or embedded), else `[]`. */
514
+ export function jsonArrayIn(text: string): unknown[] {
515
+ const source = text.slice(0, SEARCH_LIMITS.maxResponseBytes);
516
+ const start = source.indexOf("[");
517
+ if (start === -1) return [];
518
+ let depth = 0;
519
+ let inString = false;
520
+ let escaped = false;
521
+ for (let index = start; index < source.length; index += 1) {
522
+ const char = source[index]!;
523
+ if (inString) {
524
+ if (escaped) escaped = false;
525
+ else if (char === "\\") escaped = true;
526
+ else if (char === '"') inString = false;
527
+ continue;
528
+ }
529
+ if (char === '"') inString = true;
530
+ else if (char === "[" || char === "{") depth += 1;
531
+ else if (char === "]" || char === "}") {
532
+ depth -= 1;
533
+ if (depth === 0) {
534
+ try {
535
+ const parsed: unknown = JSON.parse(source.slice(start, index + 1));
536
+ return Array.isArray(parsed) ? parsed : [];
537
+ } catch {
538
+ return [];
539
+ }
540
+ }
541
+ }
542
+ }
543
+ return [];
544
+ }
545
+
546
+ /** Parse Brave's `web.results[]` from an untrusted JSON value. */
547
+ export function parseBraveResults(
548
+ json: unknown,
549
+ count: number,
550
+ ): SearchResult[] {
551
+ const results: SearchResult[] = [];
552
+ const items = record(record(json)?.web)?.results;
553
+ if (!Array.isArray(items)) return results;
554
+ for (const item of items) {
555
+ if (results.length >= count) break;
556
+ const value = record(item);
557
+ const result = makeResult(value?.title, value?.url, value?.description);
558
+ if (result) results.push(result);
559
+ }
560
+ return results;
561
+ }
562
+
563
+ const DDG_RESULT =
564
+ /<a[^>]{0,300}class="[^"]*result__a[^"]*"[^>]{0,300}href="([^"]{1,1500})"[^>]{0,300}>([\s\S]{0,1000}?)<\/a>/g;
565
+ const DDG_SNIPPET =
566
+ /class="[^"]*(?:result__snippet|result-snippet)[^"]*"[^>]{0,300}>([\s\S]{0,2000}?)<\/(?:a|td|div|span)>/g;
567
+
568
+ /** Parse DuckDuckGo's HTML result page with bounded regexes. */
569
+ export function parseDuckDuckGoHtml(
570
+ html: string,
571
+ count: number,
572
+ ): SearchResult[] {
573
+ const results: SearchResult[] = [];
574
+ const source = html.slice(0, SEARCH_LIMITS.maxResponseBytes);
575
+ const anchors: { title: string; url: string; end: number }[] = [];
576
+ DDG_RESULT.lastIndex = 0;
577
+ for (
578
+ let match = DDG_RESULT.exec(source);
579
+ match && anchors.length < count;
580
+ match = DDG_RESULT.exec(source)
581
+ ) {
582
+ anchors.push({
583
+ url: decodeEntities(match[1]!),
584
+ title: stripTags(match[2]!),
585
+ end: DDG_RESULT.lastIndex,
586
+ });
587
+ }
588
+ for (const [index, anchor] of anchors.entries()) {
589
+ const limit = anchors[index + 1]?.end ?? source.length;
590
+ DDG_SNIPPET.lastIndex = anchor.end;
591
+ const snippet = DDG_SNIPPET.exec(source);
592
+ const text = snippet && snippet.index < limit ? stripTags(snippet[1]!) : "";
593
+ const result = makeResult(anchor.title, anchor.url, text);
594
+ if (result) results.push(result);
595
+ }
596
+ return results;
597
+ }
598
+
599
+ const TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|msclkid$|mc_eid$|igshid$)/i;
600
+
601
+ /**
602
+ * Unwrap DuckDuckGo's `/l/?uddg=` redirect and drop common tracking
603
+ * parameters. Anything that is not an http(s) URL yields `undefined`.
604
+ */
605
+ export function cleanResultUrl(raw: string): string | undefined {
606
+ let text = raw.trim();
607
+ if (text.startsWith("//")) text = `https:${text}`;
608
+ let url: URL;
609
+ try {
610
+ url = new URL(text);
611
+ } catch {
612
+ return undefined;
613
+ }
614
+ if (isDuckDuckGoHost(url.hostname) && url.pathname.startsWith("/l/")) {
615
+ const target = url.searchParams.get("uddg");
616
+ if (!target) return undefined;
617
+ return cleanResultUrl(target);
618
+ }
619
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
620
+ for (const key of [...url.searchParams.keys()])
621
+ if (TRACKING_PARAMS.test(key)) url.searchParams.delete(key);
622
+ url.hash = "";
623
+ const cleaned = url.toString();
624
+ return cleaned.length > SEARCH_LIMITS.maxUrlChars ? undefined : cleaned;
625
+ }
626
+
627
+ /** Exactly `duckduckgo.com` or one of its subdomains; never a lookalike host. */
628
+ function isDuckDuckGoHost(hostname: string): boolean {
629
+ const host = hostname.toLowerCase();
630
+ return host === "duckduckgo.com" || host.endsWith(".duckduckgo.com");
631
+ }
632
+
633
+ function makeResult(
634
+ title: unknown,
635
+ url: unknown,
636
+ snippet: unknown,
637
+ ): SearchResult | undefined {
638
+ if (typeof url !== "string") return undefined;
639
+ const cleaned = cleanResultUrl(url);
640
+ if (!cleaned) return undefined;
641
+ const titleText =
642
+ typeof title === "string"
643
+ ? stripTags(title).slice(0, SEARCH_LIMITS.maxTitleChars)
644
+ : "";
645
+ const snippetText =
646
+ typeof snippet === "string"
647
+ ? stripTags(snippet).slice(0, SEARCH_LIMITS.maxSnippetChars)
648
+ : "";
649
+ return { title: titleText || cleaned, url: cleaned, snippet: snippetText };
650
+ }
651
+
652
+ function record(value: unknown): Record<string, unknown> | undefined {
653
+ return typeof value === "object" && value !== null && !Array.isArray(value)
654
+ ? (value as Record<string, unknown>)
655
+ : undefined;
656
+ }
657
+
658
+ /** `gateway · exa`, `openrouter · web`, `duckduckgo`. */
659
+ export function describeSearchProvider(outcome: SearchOutcome): string {
660
+ return outcome.tool
661
+ ? `${outcome.provider} · ${outcome.tool}`
662
+ : outcome.provider;
663
+ }
664
+
665
+ /** Render results as the compact text the model reads. */
666
+ export function formatSearchResults(
667
+ outcome: SearchOutcome,
668
+ query: string,
669
+ ): string {
670
+ const via = describeSearchProvider(outcome);
671
+ const fallback = outcome.fallbackFrom
672
+ ? `\n(${outcome.fallbackFrom}; answered by DuckDuckGo instead)`
673
+ : "";
674
+ if (outcome.results.length === 0)
675
+ return `no results for "${query.slice(0, 80)}" (${via})${fallback}`;
676
+ const lines = outcome.results.map(
677
+ (result, index) =>
678
+ `${index + 1}. ${result.title}\n ${result.url}${result.snippet ? `\n ${result.snippet}` : ""}`,
679
+ );
680
+ return `${outcome.results.length} result${outcome.results.length === 1 ? "" : "s"} via ${via}${fallback}\n${lines.join("\n")}`;
681
+ }