@tuan_son.dinh/gsd 2.6.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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +269 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +70 -0
- package/dist/logo.d.ts +16 -0
- package/dist/logo.js +25 -0
- package/dist/onboarding.d.ts +43 -0
- package/dist/onboarding.js +418 -0
- package/dist/pi-migration.d.ts +14 -0
- package/dist/pi-migration.js +57 -0
- package/dist/resource-loader.d.ts +22 -0
- package/dist/resource-loader.js +60 -0
- package/dist/tool-bootstrap.d.ts +4 -0
- package/dist/tool-bootstrap.js +74 -0
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.js +25 -0
- package/package.json +60 -0
- package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
- package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +127 -0
- package/src/resources/GSD-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +249 -0
- package/src/resources/extensions/bg-shell/index.ts +2808 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4989 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/google-search/index.ts +323 -0
- package/src/resources/extensions/google-search/package.json +9 -0
- package/src/resources/extensions/gsd/activity-log.ts +69 -0
- package/src/resources/extensions/gsd/auto.ts +2744 -0
- package/src/resources/extensions/gsd/commands.ts +313 -0
- package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
- package/src/resources/extensions/gsd/doctor.ts +690 -0
- package/src/resources/extensions/gsd/files.ts +732 -0
- package/src/resources/extensions/gsd/git-service.ts +597 -0
- package/src/resources/extensions/gsd/gitignore.ts +168 -0
- package/src/resources/extensions/gsd/guided-flow.ts +817 -0
- package/src/resources/extensions/gsd/index.ts +558 -0
- package/src/resources/extensions/gsd/metrics.ts +374 -0
- package/src/resources/extensions/gsd/migrate/command.ts +218 -0
- package/src/resources/extensions/gsd/migrate/index.ts +42 -0
- package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
- package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
- package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
- package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
- package/src/resources/extensions/gsd/migrate/types.ts +370 -0
- package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
- package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
- package/src/resources/extensions/gsd/observability-validator.ts +408 -0
- package/src/resources/extensions/gsd/package.json +11 -0
- package/src/resources/extensions/gsd/paths.ts +308 -0
- package/src/resources/extensions/gsd/preferences.ts +757 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
- package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
- package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
- package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
- package/src/resources/extensions/gsd/prompts/queue.md +85 -0
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
- package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
- package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
- package/src/resources/extensions/gsd/prompts/system.md +187 -0
- package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
- package/src/resources/extensions/gsd/session-forensics.ts +487 -0
- package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
- package/src/resources/extensions/gsd/state.ts +460 -0
- package/src/resources/extensions/gsd/templates/context.md +76 -0
- package/src/resources/extensions/gsd/templates/decisions.md +8 -0
- package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/gsd/templates/plan.md +131 -0
- package/src/resources/extensions/gsd/templates/preferences.md +24 -0
- package/src/resources/extensions/gsd/templates/project.md +31 -0
- package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
- package/src/resources/extensions/gsd/templates/requirements.md +81 -0
- package/src/resources/extensions/gsd/templates/research.md +46 -0
- package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
- package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
- package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
- package/src/resources/extensions/gsd/templates/state.md +19 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
- package/src/resources/extensions/gsd/templates/uat.md +54 -0
- package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
- package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
- package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
- package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
- package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
- package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
- package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
- package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
- package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
- package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
- package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
- package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
- package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
- package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
- package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
- package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
- package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
- package/src/resources/extensions/gsd/types.ts +159 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
- package/src/resources/extensions/gsd/workspace-index.ts +203 -0
- package/src/resources/extensions/gsd/worktree-command.ts +845 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
- package/src/resources/extensions/gsd/worktree.ts +183 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/mcporter/index.ts +429 -0
- package/src/resources/extensions/remote-questions/config.ts +81 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
- package/src/resources/extensions/remote-questions/format.ts +163 -0
- package/src/resources/extensions/remote-questions/manager.ts +192 -0
- package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
- package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
- package/src/resources/extensions/remote-questions/status.ts +31 -0
- package/src/resources/extensions/remote-questions/store.ts +77 -0
- package/src/resources/extensions/remote-questions/types.ts +75 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +65 -0
- package/src/resources/extensions/search-the-web/native-search.ts +157 -0
- package/src/resources/extensions/search-the-web/provider.ts +118 -0
- package/src/resources/extensions/search-the-web/tavily.ts +116 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +613 -0
- package/src/resources/extensions/shared/next-action-ui.ts +197 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/terminal.ts +23 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +88 -0
- package/src/resources/extensions/slash-commands/clear.ts +10 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1020 -0
- package/src/resources/extensions/voice/index.ts +195 -0
- package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-the-web tool — Rich web search with full Brave API support.
|
|
3
|
+
*
|
|
4
|
+
* v3 improvements:
|
|
5
|
+
* - Structured error taxonomy (auth_error, rate_limited, network_error, etc.)
|
|
6
|
+
* - Spellcheck/query correction surfacing
|
|
7
|
+
* - Latency tracking in details
|
|
8
|
+
* - more_results_available from Brave response
|
|
9
|
+
* - Adaptive snippet budget (fewer results = more snippets each)
|
|
10
|
+
* - Rate limit info in details
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { truncateHead, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
18
|
+
|
|
19
|
+
import { LRUTTLCache } from "./cache";
|
|
20
|
+
import { fetchWithRetryTimed, fetchWithRetry, classifyError, type RateLimitInfo } from "./http";
|
|
21
|
+
import { normalizeQuery, toDedupeKey, detectFreshness } from "./url-utils";
|
|
22
|
+
import { formatSearchResults, type SearchResultFormatted, type FormatSearchOptions } from "./format";
|
|
23
|
+
import { getTavilyApiKey, resolveSearchProvider } from "./provider";
|
|
24
|
+
import { normalizeTavilyResult, mapFreshnessToTavily, type TavilySearchResponse } from "./tavily";
|
|
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 BraveSearchResponse {
|
|
52
|
+
query?: {
|
|
53
|
+
original?: string;
|
|
54
|
+
altered?: string;
|
|
55
|
+
show_strict_warning?: boolean;
|
|
56
|
+
more_results_available?: boolean;
|
|
57
|
+
spellcheck_off?: boolean;
|
|
58
|
+
};
|
|
59
|
+
web?: {
|
|
60
|
+
results?: BraveWebResult[];
|
|
61
|
+
};
|
|
62
|
+
summarizer?: {
|
|
63
|
+
key?: string;
|
|
64
|
+
};
|
|
65
|
+
[key: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface CachedSearchResult {
|
|
69
|
+
results: SearchResultFormatted[];
|
|
70
|
+
summarizerKey?: string;
|
|
71
|
+
summaryText?: string;
|
|
72
|
+
queryCorrected?: boolean;
|
|
73
|
+
originalQuery?: string;
|
|
74
|
+
correctedQuery?: string;
|
|
75
|
+
moreResultsAvailable?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Structured details returned from the search tool. */
|
|
79
|
+
interface SearchDetails {
|
|
80
|
+
query: string;
|
|
81
|
+
effectiveQuery: string;
|
|
82
|
+
results: SearchResultFormatted[];
|
|
83
|
+
count: number;
|
|
84
|
+
cached: boolean;
|
|
85
|
+
freshness: string;
|
|
86
|
+
hasSummary: boolean;
|
|
87
|
+
latencyMs?: number;
|
|
88
|
+
rateLimit?: RateLimitInfo;
|
|
89
|
+
queryCorrected?: boolean;
|
|
90
|
+
originalQuery?: string;
|
|
91
|
+
correctedQuery?: string;
|
|
92
|
+
moreResultsAvailable?: boolean;
|
|
93
|
+
errorKind?: string;
|
|
94
|
+
error?: string;
|
|
95
|
+
retryAfterMs?: number;
|
|
96
|
+
provider?: 'tavily' | 'brave';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// Caches
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
// Search results: max 100 entries, 10-minute TTL
|
|
104
|
+
const searchCache = new LRUTTLCache<CachedSearchResult>({ max: 100, ttlMs: 600_000 });
|
|
105
|
+
searchCache.startPurgeInterval(60_000);
|
|
106
|
+
|
|
107
|
+
// Summarizer responses: max 50 entries, 15-minute TTL
|
|
108
|
+
const summarizerCache = new LRUTTLCache<string>({ max: 50, ttlMs: 900_000 });
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Brave API helpers
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
function getBraveApiKey(): string {
|
|
115
|
+
return process.env.BRAVE_API_KEY || "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function braveHeaders(): Record<string, string> {
|
|
119
|
+
return {
|
|
120
|
+
"Accept": "application/json",
|
|
121
|
+
"Accept-Encoding": "gzip",
|
|
122
|
+
"X-Subscription-Token": getBraveApiKey(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize a Brave result into our formatted result type.
|
|
128
|
+
*/
|
|
129
|
+
function normalizeBraveResult(r: BraveWebResult): SearchResultFormatted {
|
|
130
|
+
return {
|
|
131
|
+
title: r.title || "(untitled)",
|
|
132
|
+
url: r.url,
|
|
133
|
+
description: r.description || "",
|
|
134
|
+
age: r.age || r.page_age || undefined,
|
|
135
|
+
extra_snippets: r.extra_snippets || undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Deduplicate results by URL (first occurrence wins).
|
|
141
|
+
*/
|
|
142
|
+
function deduplicateResults(results: SearchResultFormatted[]): SearchResultFormatted[] {
|
|
143
|
+
const seen = new Map<string, SearchResultFormatted>();
|
|
144
|
+
for (const result of results) {
|
|
145
|
+
const key = toDedupeKey(result.url);
|
|
146
|
+
if (key !== null && !seen.has(key)) {
|
|
147
|
+
seen.set(key, result);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return Array.from(seen.values());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fetch AI summary from Brave Summarizer API (best-effort, free).
|
|
155
|
+
*/
|
|
156
|
+
async function fetchSummary(
|
|
157
|
+
summarizerKey: string,
|
|
158
|
+
signal?: AbortSignal
|
|
159
|
+
): Promise<string | null> {
|
|
160
|
+
const cached = summarizerCache.get(summarizerKey);
|
|
161
|
+
if (cached !== undefined) return cached;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const url = `https://api.search.brave.com/res/v1/summarizer/search?key=${encodeURIComponent(summarizerKey)}&entity_info=false`;
|
|
165
|
+
const response = await fetchWithRetry(url, {
|
|
166
|
+
method: "GET",
|
|
167
|
+
headers: braveHeaders(),
|
|
168
|
+
signal,
|
|
169
|
+
}, 1);
|
|
170
|
+
|
|
171
|
+
const data: BraveSummarizerResponse = await response.json();
|
|
172
|
+
|
|
173
|
+
let summaryText = "";
|
|
174
|
+
if (data.summary && Array.isArray(data.summary)) {
|
|
175
|
+
summaryText = data.summary
|
|
176
|
+
.filter((s) => s.type === "token" || s.type === "text")
|
|
177
|
+
.map((s) => s.data)
|
|
178
|
+
.join("");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (summaryText) {
|
|
182
|
+
summarizerCache.set(summarizerKey, summaryText);
|
|
183
|
+
return summaryText;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// Tavily API execution
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Execute a search against the Tavily API.
|
|
197
|
+
* Returns a CachedSearchResult with normalized, deduplicated results.
|
|
198
|
+
*/
|
|
199
|
+
async function executeTavilySearch(
|
|
200
|
+
params: { query: string; freshness: string | null; domain?: string; wantSummary: boolean },
|
|
201
|
+
signal?: AbortSignal
|
|
202
|
+
): Promise<{ results: CachedSearchResult; latencyMs: number; rateLimit?: RateLimitInfo }> {
|
|
203
|
+
const requestBody: Record<string, unknown> = {
|
|
204
|
+
query: params.query,
|
|
205
|
+
max_results: 10,
|
|
206
|
+
search_depth: "basic",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const tavilyTimeRange = mapFreshnessToTavily(params.freshness);
|
|
210
|
+
if (tavilyTimeRange) {
|
|
211
|
+
requestBody.time_range = tavilyTimeRange;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (params.domain) {
|
|
215
|
+
requestBody.include_domains = [params.domain];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (params.wantSummary) {
|
|
219
|
+
requestBody.include_answer = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const timed = await fetchWithRetryTimed("https://api.tavily.com/search", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"Authorization": `Bearer ${getTavilyApiKey()}`,
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify(requestBody),
|
|
229
|
+
signal,
|
|
230
|
+
}, 2);
|
|
231
|
+
|
|
232
|
+
const data: TavilySearchResponse = await timed.response.json();
|
|
233
|
+
const normalized = data.results.map(normalizeTavilyResult);
|
|
234
|
+
const deduplicated = deduplicateResults(normalized);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
results: {
|
|
238
|
+
results: deduplicated,
|
|
239
|
+
summaryText: data.answer || undefined,
|
|
240
|
+
queryCorrected: false,
|
|
241
|
+
moreResultsAvailable: false,
|
|
242
|
+
},
|
|
243
|
+
latencyMs: timed.latencyMs,
|
|
244
|
+
rateLimit: timed.rateLimit,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// =============================================================================
|
|
249
|
+
// Tool Registration
|
|
250
|
+
// =============================================================================
|
|
251
|
+
|
|
252
|
+
export function registerSearchTool(pi: ExtensionAPI) {
|
|
253
|
+
pi.registerTool({
|
|
254
|
+
name: "search-the-web",
|
|
255
|
+
label: "Web Search",
|
|
256
|
+
description:
|
|
257
|
+
"Search the web using Brave Search API. Returns top results with titles, URLs, descriptions, " +
|
|
258
|
+
"extra contextual snippets, result ages, and optional AI summary. " +
|
|
259
|
+
"Supports freshness filtering, domain filtering, and auto-detects recency-sensitive queries.",
|
|
260
|
+
promptSnippet: "Search the web for information",
|
|
261
|
+
promptGuidelines: [
|
|
262
|
+
"Use this tool when the user asks about current events, facts, or external knowledge not in the codebase.",
|
|
263
|
+
"Always provide the search query to the user in your response.",
|
|
264
|
+
"Limit to 3-5 results unless more context is needed.",
|
|
265
|
+
"Use freshness='week' or 'month' for queries about recent events, releases, or updates.",
|
|
266
|
+
"Use the fetch_page tool to read the full content of promising URLs from search results.",
|
|
267
|
+
],
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
query: Type.String({ description: "Search query (e.g., 'latest AI news')" }),
|
|
270
|
+
count: Type.Optional(
|
|
271
|
+
Type.Number({ minimum: 1, maximum: 10, default: 5, description: "Number of results to return (default: 5)" })
|
|
272
|
+
),
|
|
273
|
+
freshness: Type.Optional(
|
|
274
|
+
StringEnum(["auto", "day", "week", "month", "year"] as const, {
|
|
275
|
+
description:
|
|
276
|
+
"Filter by recency. 'auto' (default) detects from query. 'day'=past 24h, 'week'=past 7d, 'month'=past 30d, 'year'=past 365d.",
|
|
277
|
+
})
|
|
278
|
+
),
|
|
279
|
+
domain: Type.Optional(
|
|
280
|
+
Type.String({
|
|
281
|
+
description: "Limit results to a specific domain (e.g., 'stackoverflow.com', 'github.com')",
|
|
282
|
+
})
|
|
283
|
+
),
|
|
284
|
+
summary: Type.Optional(
|
|
285
|
+
Type.Boolean({
|
|
286
|
+
description: "Request an AI-generated summary of the search results (default: false). Adds latency but provides a concise answer.",
|
|
287
|
+
default: false,
|
|
288
|
+
})
|
|
289
|
+
),
|
|
290
|
+
}),
|
|
291
|
+
|
|
292
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
293
|
+
if (signal?.aborted) {
|
|
294
|
+
return { content: [{ type: "text", text: "Search cancelled." }] };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ------------------------------------------------------------------
|
|
298
|
+
// Resolve search provider
|
|
299
|
+
// ------------------------------------------------------------------
|
|
300
|
+
const provider = resolveSearchProvider();
|
|
301
|
+
if (!provider) {
|
|
302
|
+
return {
|
|
303
|
+
content: [{ type: "text", text: "Web search unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY or BRAVE_API_KEY." }],
|
|
304
|
+
isError: true,
|
|
305
|
+
details: { errorKind: "auth_error", error: "No search API key set" } satisfies Partial<SearchDetails>,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const count = params.count ?? 5;
|
|
310
|
+
const wantSummary = params.summary ?? false;
|
|
311
|
+
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
// Resolve freshness (shared — Brave format, converted for Tavily later)
|
|
314
|
+
// ------------------------------------------------------------------
|
|
315
|
+
let freshness: string | null = null;
|
|
316
|
+
if (params.freshness && params.freshness !== "auto") {
|
|
317
|
+
const freshnessMap: Record<string, string> = {
|
|
318
|
+
day: "pd", week: "pw", month: "pm", year: "py",
|
|
319
|
+
};
|
|
320
|
+
freshness = freshnessMap[params.freshness] || null;
|
|
321
|
+
} else {
|
|
322
|
+
freshness = detectFreshness(params.query);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ------------------------------------------------------------------
|
|
326
|
+
// Handle domain filter (provider-specific)
|
|
327
|
+
// ------------------------------------------------------------------
|
|
328
|
+
let effectiveQuery = params.query;
|
|
329
|
+
if (provider === "brave" && params.domain) {
|
|
330
|
+
if (!effectiveQuery.toLowerCase().includes("site:")) {
|
|
331
|
+
effectiveQuery = `site:${params.domain} ${effectiveQuery}`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Tavily uses include_domains in request body — no query modification
|
|
335
|
+
|
|
336
|
+
// ------------------------------------------------------------------
|
|
337
|
+
// Cache lookup (provider-prefixed key)
|
|
338
|
+
// ------------------------------------------------------------------
|
|
339
|
+
const cacheKey = normalizeQuery(effectiveQuery) + `|f:${freshness || ""}|s:${wantSummary}|p:${provider}`;
|
|
340
|
+
const cached = searchCache.get(cacheKey);
|
|
341
|
+
|
|
342
|
+
if (cached) {
|
|
343
|
+
const limited = cached.results.slice(0, count);
|
|
344
|
+
|
|
345
|
+
let summaryText: string | undefined;
|
|
346
|
+
if (wantSummary) {
|
|
347
|
+
if (cached.summaryText) {
|
|
348
|
+
summaryText = cached.summaryText;
|
|
349
|
+
} else if (cached.summarizerKey) {
|
|
350
|
+
summaryText = (await fetchSummary(cached.summarizerKey, signal)) ?? undefined;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const formatOpts: FormatSearchOptions = {
|
|
355
|
+
cached: true,
|
|
356
|
+
summary: summaryText,
|
|
357
|
+
queryCorrected: cached.queryCorrected,
|
|
358
|
+
originalQuery: cached.originalQuery,
|
|
359
|
+
correctedQuery: cached.correctedQuery,
|
|
360
|
+
moreResultsAvailable: cached.moreResultsAvailable,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const output = formatSearchResults(params.query, limited, formatOpts);
|
|
364
|
+
|
|
365
|
+
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
366
|
+
let content = truncation.content;
|
|
367
|
+
if (truncation.truncated) {
|
|
368
|
+
const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" });
|
|
369
|
+
content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const details: SearchDetails = {
|
|
373
|
+
query: params.query,
|
|
374
|
+
effectiveQuery,
|
|
375
|
+
results: limited,
|
|
376
|
+
count: limited.length,
|
|
377
|
+
cached: true,
|
|
378
|
+
freshness: freshness || "none",
|
|
379
|
+
hasSummary: !!summaryText,
|
|
380
|
+
queryCorrected: cached.queryCorrected,
|
|
381
|
+
originalQuery: cached.originalQuery,
|
|
382
|
+
correctedQuery: cached.correctedQuery,
|
|
383
|
+
moreResultsAvailable: cached.moreResultsAvailable,
|
|
384
|
+
provider,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return { content: [{ type: "text", text: content }], details };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
onUpdate?.({ content: [{ type: "text", text: `Searching for "${params.query}"...` }] });
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
// ------------------------------------------------------------------
|
|
394
|
+
// Provider-specific fetch
|
|
395
|
+
// ------------------------------------------------------------------
|
|
396
|
+
let searchResult: CachedSearchResult;
|
|
397
|
+
let latencyMs: number | undefined;
|
|
398
|
+
let rateLimit: RateLimitInfo | undefined;
|
|
399
|
+
|
|
400
|
+
if (provider === "tavily") {
|
|
401
|
+
const tavilyResult = await executeTavilySearch(
|
|
402
|
+
{ query: params.query, freshness, domain: params.domain, wantSummary },
|
|
403
|
+
signal
|
|
404
|
+
);
|
|
405
|
+
searchResult = tavilyResult.results;
|
|
406
|
+
latencyMs = tavilyResult.latencyMs;
|
|
407
|
+
rateLimit = tavilyResult.rateLimit;
|
|
408
|
+
} else {
|
|
409
|
+
// ================================================================
|
|
410
|
+
// BRAVE PATH (unchanged API logic)
|
|
411
|
+
// ================================================================
|
|
412
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
413
|
+
url.searchParams.append("q", effectiveQuery);
|
|
414
|
+
url.searchParams.append("count", "10"); // Extra for dedup headroom
|
|
415
|
+
url.searchParams.append("extra_snippets", "true");
|
|
416
|
+
url.searchParams.append("text_decorations", "false");
|
|
417
|
+
|
|
418
|
+
if (freshness) {
|
|
419
|
+
url.searchParams.append("freshness", freshness);
|
|
420
|
+
}
|
|
421
|
+
if (wantSummary) {
|
|
422
|
+
url.searchParams.append("summary", "1");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const timed = await fetchWithRetryTimed(url.toString(), {
|
|
426
|
+
method: "GET",
|
|
427
|
+
headers: braveHeaders(),
|
|
428
|
+
signal,
|
|
429
|
+
}, 2);
|
|
430
|
+
|
|
431
|
+
const data: BraveSearchResponse = await timed.response.json();
|
|
432
|
+
const rawResults: BraveWebResult[] = data.web?.results ?? [];
|
|
433
|
+
const summarizerKey: string | undefined = data.summarizer?.key;
|
|
434
|
+
|
|
435
|
+
// Extract spellcheck/correction info
|
|
436
|
+
const queryInfo = data.query;
|
|
437
|
+
const queryCorrected = !!(queryInfo?.altered && queryInfo.altered !== queryInfo.original);
|
|
438
|
+
const originalQuery = queryCorrected ? (queryInfo?.original ?? params.query) : undefined;
|
|
439
|
+
const correctedQuery = queryCorrected ? queryInfo?.altered : undefined;
|
|
440
|
+
const moreResultsAvailable = queryInfo?.more_results_available ?? false;
|
|
441
|
+
|
|
442
|
+
// Normalize, deduplicate
|
|
443
|
+
const normalized = rawResults.map(normalizeBraveResult);
|
|
444
|
+
const deduplicated = deduplicateResults(normalized);
|
|
445
|
+
|
|
446
|
+
searchResult = {
|
|
447
|
+
results: deduplicated,
|
|
448
|
+
summarizerKey,
|
|
449
|
+
queryCorrected,
|
|
450
|
+
originalQuery,
|
|
451
|
+
correctedQuery,
|
|
452
|
+
moreResultsAvailable,
|
|
453
|
+
};
|
|
454
|
+
latencyMs = timed.latencyMs;
|
|
455
|
+
rateLimit = timed.rateLimit;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ------------------------------------------------------------------
|
|
459
|
+
// Shared post-fetch: cache, summary, format, return
|
|
460
|
+
// ------------------------------------------------------------------
|
|
461
|
+
searchCache.set(cacheKey, searchResult);
|
|
462
|
+
const results = searchResult.results.slice(0, count);
|
|
463
|
+
|
|
464
|
+
let summaryText: string | undefined;
|
|
465
|
+
if (wantSummary) {
|
|
466
|
+
if (searchResult.summaryText) {
|
|
467
|
+
summaryText = searchResult.summaryText;
|
|
468
|
+
} else if (searchResult.summarizerKey) {
|
|
469
|
+
summaryText = (await fetchSummary(searchResult.summarizerKey, signal)) ?? undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const formatOpts: FormatSearchOptions = {
|
|
474
|
+
summary: summaryText,
|
|
475
|
+
queryCorrected: searchResult.queryCorrected,
|
|
476
|
+
originalQuery: searchResult.originalQuery,
|
|
477
|
+
correctedQuery: searchResult.correctedQuery,
|
|
478
|
+
moreResultsAvailable: searchResult.moreResultsAvailable,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const output = formatSearchResults(params.query, results, formatOpts);
|
|
482
|
+
|
|
483
|
+
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
484
|
+
let content = truncation.content;
|
|
485
|
+
|
|
486
|
+
if (truncation.truncated) {
|
|
487
|
+
const tempFile = await pi.writeTempFile(output, { prefix: "web-search-" });
|
|
488
|
+
content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const details: SearchDetails = {
|
|
492
|
+
query: params.query,
|
|
493
|
+
effectiveQuery,
|
|
494
|
+
results,
|
|
495
|
+
count: results.length,
|
|
496
|
+
cached: false,
|
|
497
|
+
freshness: freshness || "none",
|
|
498
|
+
hasSummary: !!summaryText,
|
|
499
|
+
latencyMs,
|
|
500
|
+
rateLimit,
|
|
501
|
+
queryCorrected: searchResult.queryCorrected,
|
|
502
|
+
originalQuery: searchResult.originalQuery,
|
|
503
|
+
correctedQuery: searchResult.correctedQuery,
|
|
504
|
+
moreResultsAvailable: searchResult.moreResultsAvailable,
|
|
505
|
+
provider,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
return { content: [{ type: "text", text: content }], details };
|
|
509
|
+
} catch (error) {
|
|
510
|
+
const classified = classifyError(error);
|
|
511
|
+
return {
|
|
512
|
+
content: [{ type: "text", text: `Search failed: ${classified.message}` }],
|
|
513
|
+
details: {
|
|
514
|
+
errorKind: classified.kind,
|
|
515
|
+
error: classified.message,
|
|
516
|
+
retryAfterMs: classified.retryAfterMs,
|
|
517
|
+
query: params.query,
|
|
518
|
+
provider,
|
|
519
|
+
} satisfies Partial<SearchDetails>,
|
|
520
|
+
isError: true,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
renderCall(args, theme) {
|
|
526
|
+
let text = theme.fg("toolTitle", theme.bold("search-the-web "));
|
|
527
|
+
text += theme.fg("muted", `"${args.query}"`);
|
|
528
|
+
|
|
529
|
+
const meta: string[] = [];
|
|
530
|
+
if (args.count && args.count !== 5) meta.push(`${args.count} results`);
|
|
531
|
+
if (args.freshness && args.freshness !== "auto") meta.push(`freshness:${args.freshness}`);
|
|
532
|
+
if (args.domain) meta.push(`site:${args.domain}`);
|
|
533
|
+
if (args.summary) meta.push("+ summary");
|
|
534
|
+
if (meta.length > 0) {
|
|
535
|
+
text += " " + theme.fg("dim", `(${meta.join(", ")})`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return new Text(text, 0, 0);
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
renderResult(result, { expanded }, theme) {
|
|
542
|
+
const details = result.details as SearchDetails | undefined;
|
|
543
|
+
if (details?.errorKind || details?.error) {
|
|
544
|
+
const kindTag = details.errorKind ? theme.fg("dim", ` [${details.errorKind}]`) : "";
|
|
545
|
+
return new Text(theme.fg("error", `✗ ${details.error ?? "Search failed"}`) + kindTag, 0, 0);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const providerTag = details?.provider ? theme.fg("dim", ` [${details.provider}]`) : "";
|
|
549
|
+
const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : "";
|
|
550
|
+
const freshTag = details?.freshness && details.freshness !== "none"
|
|
551
|
+
? theme.fg("dim", ` [${details.freshness}]`)
|
|
552
|
+
: "";
|
|
553
|
+
const summaryTag = details?.hasSummary ? theme.fg("dim", " [+summary]") : "";
|
|
554
|
+
const latencyTag = details?.latencyMs ? theme.fg("dim", ` ${details.latencyMs}ms`) : "";
|
|
555
|
+
const correctedTag = details?.queryCorrected
|
|
556
|
+
? theme.fg("warning", ` [corrected→"${details.correctedQuery}"]`)
|
|
557
|
+
: "";
|
|
558
|
+
|
|
559
|
+
let text = theme.fg("success", `✓ ${details?.count ?? 0} results for "${details?.query}"`) +
|
|
560
|
+
providerTag + cacheTag + freshTag + summaryTag + latencyTag + correctedTag;
|
|
561
|
+
|
|
562
|
+
if (expanded && details?.results) {
|
|
563
|
+
text += "\n\n";
|
|
564
|
+
for (const r of details.results.slice(0, 3)) {
|
|
565
|
+
const age = r.age ? theme.fg("dim", ` (${r.age})`) : "";
|
|
566
|
+
text += `${theme.bold(r.title)}${age}\n${r.url}\n${r.description}\n\n`;
|
|
567
|
+
}
|
|
568
|
+
if (details.results.length > 3) {
|
|
569
|
+
text += theme.fg("dim", `... and ${details.results.length - 3} more`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return new Text(text, 0, 0);
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
}
|
|
@@ -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
|
+
}
|