gsd-pi 0.1.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 (128) hide show
  1. package/README.md +341 -0
  2. package/dist/app-paths.d.ts +4 -0
  3. package/dist/app-paths.js +6 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +35 -0
  6. package/dist/loader.d.ts +2 -0
  7. package/dist/loader.js +69 -0
  8. package/dist/modes/interactive/theme/dark.json +85 -0
  9. package/dist/modes/interactive/theme/light.json +84 -0
  10. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  11. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  12. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  13. package/dist/modes/interactive/theme/theme.js +949 -0
  14. package/dist/modes/interactive/theme/theme.js.map +1 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +48 -0
  17. package/dist/wizard.d.ts +20 -0
  18. package/dist/wizard.js +132 -0
  19. package/package.json +39 -0
  20. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  21. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  22. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  23. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  24. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  25. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  26. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  27. package/pkg/package.json +8 -0
  28. package/scripts/postinstall.js +10 -0
  29. package/src/resources/AGENTS.md +204 -0
  30. package/src/resources/GSD-WORKFLOW.md +661 -0
  31. package/src/resources/agents/researcher.md +29 -0
  32. package/src/resources/agents/scout.md +56 -0
  33. package/src/resources/agents/worker.md +31 -0
  34. package/src/resources/extensions/ask-user-questions.ts +200 -0
  35. package/src/resources/extensions/bg-shell/index.ts +2554 -0
  36. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  37. package/src/resources/extensions/browser-tools/core.js +1057 -0
  38. package/src/resources/extensions/browser-tools/index.ts +4916 -0
  39. package/src/resources/extensions/browser-tools/package.json +20 -0
  40. package/src/resources/extensions/context7/index.ts +428 -0
  41. package/src/resources/extensions/context7/package.json +11 -0
  42. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  43. package/src/resources/extensions/gsd/activity-log.ts +48 -0
  44. package/src/resources/extensions/gsd/auto.ts +2032 -0
  45. package/src/resources/extensions/gsd/commands.ts +292 -0
  46. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  47. package/src/resources/extensions/gsd/dashboard-overlay.ts +516 -0
  48. package/src/resources/extensions/gsd/docs/preferences-reference.md +103 -0
  49. package/src/resources/extensions/gsd/doctor.ts +683 -0
  50. package/src/resources/extensions/gsd/files.ts +730 -0
  51. package/src/resources/extensions/gsd/gitignore.ts +104 -0
  52. package/src/resources/extensions/gsd/guided-flow.ts +800 -0
  53. package/src/resources/extensions/gsd/index.ts +418 -0
  54. package/src/resources/extensions/gsd/metrics.ts +372 -0
  55. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  56. package/src/resources/extensions/gsd/package.json +11 -0
  57. package/src/resources/extensions/gsd/paths.ts +308 -0
  58. package/src/resources/extensions/gsd/preferences.ts +600 -0
  59. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  60. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  61. package/src/resources/extensions/gsd/prompts/complete-slice.md +27 -0
  62. package/src/resources/extensions/gsd/prompts/discuss.md +151 -0
  63. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  64. package/src/resources/extensions/gsd/prompts/execute-task.md +64 -0
  65. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  66. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  67. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  68. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  69. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  70. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  71. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  72. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  73. package/src/resources/extensions/gsd/prompts/plan-milestone.md +47 -0
  74. package/src/resources/extensions/gsd/prompts/plan-slice.md +63 -0
  75. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  76. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  77. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  78. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  79. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  80. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  81. package/src/resources/extensions/gsd/prompts/system.md +220 -0
  82. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  83. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  84. package/src/resources/extensions/gsd/state.ts +439 -0
  85. package/src/resources/extensions/gsd/templates/context.md +76 -0
  86. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  87. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  88. package/src/resources/extensions/gsd/templates/plan.md +133 -0
  89. package/src/resources/extensions/gsd/templates/preferences.md +15 -0
  90. package/src/resources/extensions/gsd/templates/project.md +31 -0
  91. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  92. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  93. package/src/resources/extensions/gsd/templates/research.md +46 -0
  94. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  95. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  96. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  97. package/src/resources/extensions/gsd/templates/state.md +19 -0
  98. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  99. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  100. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  101. package/src/resources/extensions/gsd/types.ts +159 -0
  102. package/src/resources/extensions/gsd/unit-runtime.ts +162 -0
  103. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  104. package/src/resources/extensions/gsd/worktree.ts +182 -0
  105. package/src/resources/extensions/plan-mode/README.md +65 -0
  106. package/src/resources/extensions/plan-mode/index.ts +521 -0
  107. package/src/resources/extensions/plan-mode/utils.ts +168 -0
  108. package/src/resources/extensions/search-the-web/cache.ts +70 -0
  109. package/src/resources/extensions/search-the-web/format.ts +134 -0
  110. package/src/resources/extensions/search-the-web/http.ts +147 -0
  111. package/src/resources/extensions/search-the-web/index.ts +46 -0
  112. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +374 -0
  113. package/src/resources/extensions/search-the-web/tool-search.ts +424 -0
  114. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  115. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  116. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  117. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  118. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  119. package/src/resources/extensions/shared/ui.ts +400 -0
  120. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  121. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  122. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  123. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  124. package/src/resources/extensions/slash-commands/gsd-run.ts +34 -0
  125. package/src/resources/extensions/slash-commands/index.ts +12 -0
  126. package/src/resources/extensions/subagent/agents.ts +126 -0
  127. package/src/resources/extensions/subagent/index.ts +1021 -0
  128. package/src/resources/extensions/worktree/index.ts +420 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * search-the-web tool — Rich web search with full Brave API support.
3
+ *
4
+ * Improvements over v1:
5
+ * - Extra snippets (up to 5 per result) for 3-5x more useful context
6
+ * - Freshness filtering (day/week/month/year/custom)
7
+ * - Domain filtering
8
+ * - AI summarizer integration (Brave Summarizer API)
9
+ * - Result age/date surfaced
10
+ * - Auto-freshness detection for recency-sensitive queries
11
+ * - Token-efficient compact output format
12
+ * - Richer parameter set for precise searching
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { truncateHead, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
17
+ import { Text } from "@mariozechner/pi-tui";
18
+ import { Type } from "@sinclair/typebox";
19
+ import { StringEnum } from "@mariozechner/pi-ai";
20
+
21
+ import { LRUTTLCache } from "./cache";
22
+ import { fetchWithRetry, HttpError } from "./http";
23
+ import { normalizeQuery, toDedupeKey, detectFreshness } from "./url-utils";
24
+ import { formatSearchResults, type SearchResultFormatted } from "./format";
25
+
26
+ // =============================================================================
27
+ // Types
28
+ // =============================================================================
29
+
30
+ interface BraveWebResult {
31
+ title: string;
32
+ url: string;
33
+ description: string;
34
+ age?: string;
35
+ page_age?: string;
36
+ language?: string;
37
+ extra_snippets?: string[];
38
+ meta_url?: { scheme?: string; netloc?: string; hostname?: string; path?: string };
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ interface BraveSummarizerResponse {
43
+ type?: string;
44
+ status?: number;
45
+ title?: string;
46
+ summary?: Array<{ type: string; data: string }>;
47
+ enrichments?: unknown;
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ interface CachedSearchResult {
52
+ results: SearchResultFormatted[];
53
+ summarizerKey?: string;
54
+ }
55
+
56
+ // =============================================================================
57
+ // Caches
58
+ // =============================================================================
59
+
60
+ // Search results: max 100 entries, 10-minute TTL
61
+ const searchCache = new LRUTTLCache<CachedSearchResult>({ max: 100, ttlMs: 600_000 });
62
+ searchCache.startPurgeInterval(60_000);
63
+
64
+ // Summarizer responses: max 50 entries, 15-minute TTL
65
+ const summarizerCache = new LRUTTLCache<string>({ max: 50, ttlMs: 900_000 });
66
+
67
+ // =============================================================================
68
+ // Brave API helpers
69
+ // =============================================================================
70
+
71
+ function getBraveApiKey(): string {
72
+ return process.env.BRAVE_API_KEY || "";
73
+ }
74
+
75
+ function braveHeaders(): Record<string, string> {
76
+ return {
77
+ "Accept": "application/json",
78
+ "Accept-Encoding": "gzip",
79
+ "X-Subscription-Token": getBraveApiKey(),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Normalize a Brave result into our formatted result type.
85
+ * Extracts the useful fields and discards the rest.
86
+ */
87
+ function normalizeBraveResult(r: BraveWebResult): SearchResultFormatted {
88
+ return {
89
+ title: r.title || "(untitled)",
90
+ url: r.url,
91
+ description: r.description || "",
92
+ age: r.age || r.page_age || undefined,
93
+ extra_snippets: r.extra_snippets || undefined,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Deduplicate results by URL (first occurrence wins).
99
+ */
100
+ function deduplicateResults(results: SearchResultFormatted[]): SearchResultFormatted[] {
101
+ const seen = new Map<string, SearchResultFormatted>();
102
+ for (const result of results) {
103
+ const key = toDedupeKey(result.url);
104
+ if (key !== null && !seen.has(key)) {
105
+ seen.set(key, result);
106
+ }
107
+ }
108
+ return Array.from(seen.values());
109
+ }
110
+
111
+ /**
112
+ * Fetch AI summary from Brave Summarizer API.
113
+ * This endpoint is free (not billed separately).
114
+ */
115
+ async function fetchSummary(
116
+ summarizerKey: string,
117
+ signal?: AbortSignal
118
+ ): Promise<string | null> {
119
+ const cached = summarizerCache.get(summarizerKey);
120
+ if (cached !== undefined) return cached;
121
+
122
+ try {
123
+ const url = `https://api.search.brave.com/res/v1/summarizer/search?key=${encodeURIComponent(summarizerKey)}&entity_info=false`;
124
+ const response = await fetchWithRetry(url, {
125
+ method: "GET",
126
+ headers: braveHeaders(),
127
+ signal,
128
+ }, 1);
129
+
130
+ const data: BraveSummarizerResponse = await response.json();
131
+
132
+ // Extract summary text from the structured response
133
+ let summaryText = "";
134
+ if (data.summary && Array.isArray(data.summary)) {
135
+ summaryText = data.summary
136
+ .filter((s) => s.type === "token" || s.type === "text")
137
+ .map((s) => s.data)
138
+ .join("");
139
+ }
140
+
141
+ if (summaryText) {
142
+ summarizerCache.set(summarizerKey, summaryText);
143
+ return summaryText;
144
+ }
145
+ return null;
146
+ } catch {
147
+ // Summarizer is best-effort — don't fail the search
148
+ return null;
149
+ }
150
+ }
151
+
152
+ // =============================================================================
153
+ // Tool Registration
154
+ // =============================================================================
155
+
156
+ export function registerSearchTool(pi: ExtensionAPI) {
157
+ pi.registerTool({
158
+ name: "search-the-web",
159
+ label: "Web Search",
160
+ description:
161
+ "Search the web using Brave Search API. Returns top results with titles, URLs, descriptions, " +
162
+ "extra contextual snippets, result ages, and optional AI summary. " +
163
+ "Supports freshness filtering, domain filtering, and auto-detects recency-sensitive queries.",
164
+ promptSnippet: "Search the web for information",
165
+ promptGuidelines: [
166
+ "Use this tool when the user asks about current events, facts, or external knowledge not in the codebase.",
167
+ "Always provide the search query to the user in your response.",
168
+ "Limit to 3-5 results unless more context is needed.",
169
+ "Use freshness='week' or 'month' for queries about recent events, releases, or updates.",
170
+ "Use the fetch_page tool to read the full content of promising URLs from search results.",
171
+ ],
172
+ parameters: Type.Object({
173
+ query: Type.String({ description: "Search query (e.g., 'latest AI news')" }),
174
+ count: Type.Optional(
175
+ Type.Number({ minimum: 1, maximum: 10, default: 5, description: "Number of results to return (default: 5)" })
176
+ ),
177
+ freshness: Type.Optional(
178
+ StringEnum(["auto", "day", "week", "month", "year"] as const, {
179
+ description:
180
+ "Filter by recency. 'auto' (default) detects from query. 'day'=past 24h, 'week'=past 7d, 'month'=past 30d, 'year'=past 365d.",
181
+ })
182
+ ),
183
+ domain: Type.Optional(
184
+ Type.String({
185
+ description: "Limit results to a specific domain (e.g., 'stackoverflow.com', 'github.com')",
186
+ })
187
+ ),
188
+ summary: Type.Optional(
189
+ Type.Boolean({
190
+ description: "Request an AI-generated summary of the search results (default: false). Adds latency but provides a concise answer.",
191
+ default: false,
192
+ })
193
+ ),
194
+ }),
195
+
196
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
197
+ if (signal?.aborted) {
198
+ return { content: [{ type: "text", text: "Search cancelled." }] };
199
+ }
200
+
201
+ const apiKey = getBraveApiKey();
202
+ if (!apiKey) {
203
+ return {
204
+ content: [{ type: "text", text: "Web search unavailable: BRAVE_API_KEY is not set. Set the BRAVE_API_KEY environment variable." }],
205
+ isError: true,
206
+ };
207
+ }
208
+
209
+ const count = params.count ?? 5;
210
+ const wantSummary = params.summary ?? false;
211
+
212
+ // ------------------------------------------------------------------
213
+ // Resolve freshness — auto-detect if not explicit
214
+ // ------------------------------------------------------------------
215
+ let freshness: string | null = null;
216
+ if (params.freshness && params.freshness !== "auto") {
217
+ const freshnessMap: Record<string, string> = {
218
+ day: "pd",
219
+ week: "pw",
220
+ month: "pm",
221
+ year: "py",
222
+ };
223
+ freshness = freshnessMap[params.freshness] || null;
224
+ } else {
225
+ // Auto-detect recency from query
226
+ freshness = detectFreshness(params.query);
227
+ }
228
+
229
+ // ------------------------------------------------------------------
230
+ // Handle domain filter — prepend site: to query if specified
231
+ // ------------------------------------------------------------------
232
+ let effectiveQuery = params.query;
233
+ if (params.domain) {
234
+ // Only add site: filter if not already in query
235
+ if (!effectiveQuery.toLowerCase().includes("site:")) {
236
+ effectiveQuery = `site:${params.domain} ${effectiveQuery}`;
237
+ }
238
+ }
239
+
240
+ // ------------------------------------------------------------------
241
+ // Cache lookup
242
+ // ------------------------------------------------------------------
243
+ const cacheKey = normalizeQuery(effectiveQuery) + `|f:${freshness || ""}|s:${wantSummary}`;
244
+ const cached = searchCache.get(cacheKey);
245
+
246
+ if (cached) {
247
+ const limited = cached.results.slice(0, count);
248
+
249
+ // Try to get summary from cache too
250
+ let summaryText: string | undefined;
251
+ if (wantSummary && cached.summarizerKey) {
252
+ summaryText = (await fetchSummary(cached.summarizerKey, signal)) ?? undefined;
253
+ }
254
+
255
+ const output = formatSearchResults(params.query, limited, {
256
+ cached: true,
257
+ summary: summaryText,
258
+ });
259
+
260
+ const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
261
+ let content = truncation.content;
262
+ if (truncation.truncated) {
263
+ const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" });
264
+ content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
265
+ }
266
+
267
+ return {
268
+ content: [{ type: "text", text: content }],
269
+ details: {
270
+ query: params.query,
271
+ effectiveQuery,
272
+ results: limited,
273
+ count: limited.length,
274
+ cached: true,
275
+ freshness: freshness || "none",
276
+ hasSummary: !!summaryText,
277
+ },
278
+ };
279
+ }
280
+
281
+ onUpdate?.({ content: [{ type: "text", text: `Searching for "${params.query}"...` }] });
282
+
283
+ try {
284
+ // ------------------------------------------------------------------
285
+ // Build Brave API request
286
+ // ------------------------------------------------------------------
287
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
288
+ url.searchParams.append("q", effectiveQuery);
289
+ url.searchParams.append("count", "10"); // Fetch extra for dedup headroom
290
+ url.searchParams.append("extra_snippets", "true");
291
+ url.searchParams.append("text_decorations", "false"); // No HTML bold tags in snippets
292
+
293
+ if (freshness) {
294
+ url.searchParams.append("freshness", freshness);
295
+ }
296
+
297
+ if (wantSummary) {
298
+ url.searchParams.append("summary", "1");
299
+ }
300
+
301
+ const fetchOptions: RequestInit = {
302
+ method: "GET",
303
+ headers: braveHeaders(),
304
+ signal,
305
+ };
306
+
307
+ let response: Response;
308
+ try {
309
+ response = await fetchWithRetry(url.toString(), fetchOptions, 2);
310
+ } catch (fetchErr) {
311
+ const message = fetchErr instanceof HttpError
312
+ ? `HTTP ${fetchErr.statusCode}: ${fetchErr.message}`
313
+ : (fetchErr as Error).message ?? String(fetchErr);
314
+ return {
315
+ content: [{ type: "text", text: `Search failed: ${message}` }],
316
+ details: { error: message, query: params.query },
317
+ isError: true,
318
+ };
319
+ }
320
+
321
+ const data = await response.json();
322
+ const rawResults: BraveWebResult[] = data.web?.results ?? [];
323
+ const summarizerKey: string | undefined = data.summarizer?.key;
324
+
325
+ // ------------------------------------------------------------------
326
+ // Normalize, deduplicate, cache
327
+ // ------------------------------------------------------------------
328
+ const normalized = rawResults.map(normalizeBraveResult);
329
+ const deduplicated = deduplicateResults(normalized);
330
+
331
+ searchCache.set(cacheKey, { results: deduplicated, summarizerKey });
332
+
333
+ const results = deduplicated.slice(0, count);
334
+
335
+ // ------------------------------------------------------------------
336
+ // Optionally fetch AI summary (non-blocking — best-effort)
337
+ // ------------------------------------------------------------------
338
+ let summaryText: string | undefined;
339
+ if (wantSummary && summarizerKey) {
340
+ summaryText = (await fetchSummary(summarizerKey, signal)) ?? undefined;
341
+ }
342
+
343
+ // ------------------------------------------------------------------
344
+ // Format output
345
+ // ------------------------------------------------------------------
346
+ const output = formatSearchResults(params.query, results, {
347
+ summary: summaryText,
348
+ });
349
+
350
+ const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
351
+ let content = truncation.content;
352
+
353
+ if (truncation.truncated) {
354
+ const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" });
355
+ content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
356
+ }
357
+
358
+ return {
359
+ content: [{ type: "text", text: content }],
360
+ details: {
361
+ query: params.query,
362
+ effectiveQuery,
363
+ results,
364
+ count: results.length,
365
+ cached: false,
366
+ freshness: freshness || "none",
367
+ hasSummary: !!summaryText,
368
+ },
369
+ };
370
+ } catch (error) {
371
+ return {
372
+ content: [{ type: "text", text: `Search failed: ${(error as Error).message}` }],
373
+ details: { error: (error as Error).message, query: params.query },
374
+ isError: true,
375
+ };
376
+ }
377
+ },
378
+
379
+ renderCall(args, theme) {
380
+ let text = theme.fg("toolTitle", theme.bold("search-the-web "));
381
+ text += theme.fg("muted", `"${args.query}"`);
382
+
383
+ const meta: string[] = [];
384
+ if (args.count && args.count !== 5) meta.push(`${args.count} results`);
385
+ if (args.freshness && args.freshness !== "auto") meta.push(`freshness:${args.freshness}`);
386
+ if (args.domain) meta.push(`site:${args.domain}`);
387
+ if (args.summary) meta.push("+ summary");
388
+ if (meta.length > 0) {
389
+ text += " " + theme.fg("dim", `(${meta.join(", ")})`);
390
+ }
391
+
392
+ return new Text(text, 0, 0);
393
+ },
394
+
395
+ renderResult(result, { expanded }, theme) {
396
+ const details = result.details as any;
397
+ if (details?.error) {
398
+ return new Text(theme.fg("error", `✗ Search failed: ${details.error}`), 0, 0);
399
+ }
400
+
401
+ const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : "";
402
+ const freshTag = details?.freshness && details.freshness !== "none"
403
+ ? theme.fg("dim", ` [${details.freshness}]`)
404
+ : "";
405
+ const summaryTag = details?.hasSummary ? theme.fg("dim", " [+summary]") : "";
406
+
407
+ let text = theme.fg("success", `✓ ${details?.count ?? 0} results for "${details?.query}"`) +
408
+ cacheTag + freshTag + summaryTag;
409
+
410
+ if (expanded && details?.results) {
411
+ text += "\n\n";
412
+ for (const r of details.results.slice(0, 3)) {
413
+ const age = r.age ? theme.fg("dim", ` (${r.age})`) : "";
414
+ text += `${theme.bold(r.title)}${age}\n${r.url}\n${r.description}\n\n`;
415
+ }
416
+ if (details.results.length > 3) {
417
+ text += theme.fg("dim", `... and ${details.results.length - 3} more`);
418
+ }
419
+ }
420
+
421
+ return new Text(text, 0, 0);
422
+ },
423
+ });
424
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * URL normalization and query utilities.
3
+ */
4
+
5
+ /** Normalize a search query into a stable cache key. */
6
+ export function normalizeQuery(query: string): string {
7
+ return query.trim().toLowerCase().replace(/\s+/g, " ").normalize("NFC");
8
+ }
9
+
10
+ /**
11
+ * Canonical URL for deduplication.
12
+ * Strips fragment, tracking params, lowercases hostname, sorts query params,
13
+ * strips trailing "/" on root paths.
14
+ */
15
+ export function toDedupeKey(url: string): string | null {
16
+ try {
17
+ const parsed = new URL(url);
18
+ parsed.hostname = parsed.hostname.toLowerCase();
19
+ parsed.hash = "";
20
+
21
+ const TRACKING_PARAMS = new Set(["fbclid", "gclid"]);
22
+ const toDelete: string[] = [];
23
+ for (const key of parsed.searchParams.keys()) {
24
+ if (key.startsWith("utm_") || TRACKING_PARAMS.has(key)) {
25
+ toDelete.push(key);
26
+ }
27
+ }
28
+ for (const key of toDelete) parsed.searchParams.delete(key);
29
+ parsed.searchParams.sort();
30
+
31
+ let canonical = parsed.toString();
32
+ if (parsed.pathname === "/" && !parsed.search) {
33
+ canonical = canonical.replace(/\/$/, "");
34
+ }
35
+ return canonical;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Extract a clean domain from a URL for display.
43
+ * "https://docs.python.org/3/library/asyncio.html" → "docs.python.org"
44
+ */
45
+ export function extractDomain(url: string): string {
46
+ try {
47
+ return new URL(url).hostname.replace(/^www\./, "");
48
+ } catch {
49
+ return url;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Detect if a query likely wants fresh/recent results.
55
+ * Returns a suggested Brave freshness parameter or null.
56
+ */
57
+ export function detectFreshness(query: string): string | null {
58
+ const q = query.toLowerCase();
59
+
60
+ // Explicit year references for current/recent years
61
+ const currentYear = new Date().getFullYear();
62
+ for (let y = currentYear; y >= currentYear - 1; y--) {
63
+ if (q.includes(String(y))) return "py"; // past year
64
+ }
65
+
66
+ // Recency keywords
67
+ const recentPatterns = [
68
+ /\b(latest|newest|recent|new|just released|just launched)\b/,
69
+ /\b(today|yesterday|this week|this month)\b/,
70
+ /\b(breaking|update|announcement|release notes?)\b/,
71
+ /\b(what('?s| is) new)\b/,
72
+ ];
73
+ for (const pattern of recentPatterns) {
74
+ if (pattern.test(q)) return "pm"; // past month
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Detect if a query targets specific domains.
82
+ * Returns extracted domains or null.
83
+ */
84
+ export function detectDomainHints(query: string): string[] | null {
85
+ // Match "site:example.com" patterns
86
+ const siteMatches = query.match(/site:(\S+)/gi);
87
+ if (siteMatches) {
88
+ return siteMatches.map((m) => m.replace(/^site:/i, ""));
89
+ }
90
+ return null;
91
+ }