@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,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_and_read tool — web search + content extraction for AI agents.
|
|
3
|
+
*
|
|
4
|
+
* Single-call web search + page content extraction optimized for AI agents.
|
|
5
|
+
* Unlike search-the-web → fetch_page (two steps), this returns pre-extracted,
|
|
6
|
+
* relevance-scored page content in one API call.
|
|
7
|
+
*
|
|
8
|
+
* Supports two backends:
|
|
9
|
+
* - Tavily: POST-based, client-side token budgeting via budgetContent()
|
|
10
|
+
* - Brave: GET-based LLM Context API with server-side budgeting
|
|
11
|
+
*
|
|
12
|
+
* Provider is selected by resolveSearchProvider() — same as tool-search.ts.
|
|
13
|
+
*
|
|
14
|
+
* Best for: "I need to know about X" — when you want content, not just links.
|
|
15
|
+
* Use search-the-web when you want links/URLs to browse selectively.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
21
|
+
import { Type } from "@sinclair/typebox";
|
|
22
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
23
|
+
|
|
24
|
+
import { LRUTTLCache } from "./cache";
|
|
25
|
+
import { fetchWithRetryTimed, HttpError, classifyError, type RateLimitInfo } from "./http";
|
|
26
|
+
import { normalizeQuery, extractDomain } from "./url-utils";
|
|
27
|
+
import { formatLLMContext, type LLMContextSnippet, type LLMContextSource } from "./format";
|
|
28
|
+
import type { TavilyResult, TavilySearchResponse } from "./tavily";
|
|
29
|
+
import { publishedDateToAge } from "./tavily";
|
|
30
|
+
import { getTavilyApiKey, resolveSearchProvider } from "./provider";
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Types
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
interface BraveLLMContextResponse {
|
|
37
|
+
grounding?: {
|
|
38
|
+
generic?: Array<{
|
|
39
|
+
url: string;
|
|
40
|
+
title: string;
|
|
41
|
+
snippets: string[];
|
|
42
|
+
}>;
|
|
43
|
+
poi?: {
|
|
44
|
+
name: string;
|
|
45
|
+
url: string;
|
|
46
|
+
title: string;
|
|
47
|
+
snippets: string[];
|
|
48
|
+
} | null;
|
|
49
|
+
map?: Array<{
|
|
50
|
+
name: string;
|
|
51
|
+
url: string;
|
|
52
|
+
title: string;
|
|
53
|
+
snippets: string[];
|
|
54
|
+
}>;
|
|
55
|
+
};
|
|
56
|
+
sources?: Record<string, {
|
|
57
|
+
title: string;
|
|
58
|
+
hostname: string;
|
|
59
|
+
age: string[] | null;
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CachedLLMContext {
|
|
64
|
+
grounding: LLMContextSnippet[];
|
|
65
|
+
sources: Record<string, LLMContextSource>;
|
|
66
|
+
estimatedTokens: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface LLMContextDetails {
|
|
70
|
+
query: string;
|
|
71
|
+
sourceCount: number;
|
|
72
|
+
snippetCount: number;
|
|
73
|
+
estimatedTokens: number;
|
|
74
|
+
cached: boolean;
|
|
75
|
+
latencyMs?: number;
|
|
76
|
+
rateLimit?: RateLimitInfo;
|
|
77
|
+
threshold?: string;
|
|
78
|
+
maxTokens?: number;
|
|
79
|
+
errorKind?: string;
|
|
80
|
+
error?: string;
|
|
81
|
+
retryAfterMs?: number;
|
|
82
|
+
provider?: 'tavily' | 'brave';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Cache
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
// LLM Context cache: max 50 entries, 10-minute TTL
|
|
90
|
+
const contextCache = new LRUTTLCache<CachedLLMContext>({ max: 50, ttlMs: 600_000 });
|
|
91
|
+
contextCache.startPurgeInterval(60_000);
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// Helpers
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
function getBraveApiKey(): string {
|
|
98
|
+
return process.env.BRAVE_API_KEY || "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function braveHeaders(): Record<string, string> {
|
|
102
|
+
return {
|
|
103
|
+
"Accept": "application/json",
|
|
104
|
+
"Accept-Encoding": "gzip",
|
|
105
|
+
"X-Subscription-Token": getBraveApiKey(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Rough token estimate: ~4 chars per token for English text. */
|
|
110
|
+
function estimateTokens(text: string): number {
|
|
111
|
+
return Math.ceil(text.length / 4);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Distribute a token budget across Tavily results to build LLM context.
|
|
116
|
+
*
|
|
117
|
+
* Client-side equivalent of Brave's server-side LLM Context API budgeting.
|
|
118
|
+
* Filters by score threshold, sorts by relevance, and truncates content to fit
|
|
119
|
+
* within the token budget. Uses `raw_content` when available (richer text from
|
|
120
|
+
* Tavily's "advanced" search depth), falling back to `content`.
|
|
121
|
+
*
|
|
122
|
+
* @param results — Raw Tavily search results
|
|
123
|
+
* @param maxTokens — Caller-requested token limit
|
|
124
|
+
* @param threshold — Minimum score (0–1) for inclusion
|
|
125
|
+
* @returns Grounding snippets, source metadata, and estimated token usage
|
|
126
|
+
*/
|
|
127
|
+
export function budgetContent(
|
|
128
|
+
results: TavilyResult[],
|
|
129
|
+
maxTokens: number,
|
|
130
|
+
threshold: number,
|
|
131
|
+
): { grounding: LLMContextSnippet[]; sources: Record<string, LLMContextSource>; estimatedTokens: number } {
|
|
132
|
+
// Filter by score threshold and sort by score descending (highest relevance first)
|
|
133
|
+
const filtered = results
|
|
134
|
+
.filter(r => r.score >= threshold)
|
|
135
|
+
.sort((a, b) => b.score - a.score);
|
|
136
|
+
|
|
137
|
+
if (filtered.length === 0) {
|
|
138
|
+
return { grounding: [], sources: {}, estimatedTokens: 0 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Use 80% of maxTokens as effective budget (conservative to avoid overshoot)
|
|
142
|
+
const effectiveBudget = Math.floor(maxTokens * 0.8);
|
|
143
|
+
const perResultBudget = Math.max(1, Math.floor(effectiveBudget / filtered.length));
|
|
144
|
+
|
|
145
|
+
const grounding: LLMContextSnippet[] = [];
|
|
146
|
+
const sources: Record<string, LLMContextSource> = {};
|
|
147
|
+
let totalTokens = 0;
|
|
148
|
+
|
|
149
|
+
for (const result of filtered) {
|
|
150
|
+
if (totalTokens >= effectiveBudget) break;
|
|
151
|
+
|
|
152
|
+
const remainingBudget = effectiveBudget - totalTokens;
|
|
153
|
+
const budget = Math.min(perResultBudget, remainingBudget);
|
|
154
|
+
|
|
155
|
+
// Use raw_content if available, fall back to content
|
|
156
|
+
let text = result.raw_content ?? result.content;
|
|
157
|
+
|
|
158
|
+
// Truncate to per-result budget (tokens → chars at ~4 chars/token)
|
|
159
|
+
const maxChars = budget * 4;
|
|
160
|
+
if (text.length > maxChars) {
|
|
161
|
+
text = text.slice(0, maxChars);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const tokens = estimateTokens(text);
|
|
165
|
+
totalTokens += tokens;
|
|
166
|
+
|
|
167
|
+
grounding.push({
|
|
168
|
+
url: result.url,
|
|
169
|
+
title: result.title || "(untitled)",
|
|
170
|
+
snippets: [text],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Build source with age in [null, null, ageString] format for formatLLMContext compatibility.
|
|
174
|
+
// formatLLMContext reads source.age?.[2] for the human-readable age display.
|
|
175
|
+
const ageString = result.published_date ? publishedDateToAge(result.published_date) : undefined;
|
|
176
|
+
sources[result.url] = {
|
|
177
|
+
title: result.title || "(untitled)",
|
|
178
|
+
hostname: extractDomain(result.url),
|
|
179
|
+
age: ageString ? [null as unknown as string, null as unknown as string, ageString] : null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { grounding, sources, estimatedTokens: totalTokens };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =============================================================================
|
|
187
|
+
// Tavily LLM Context Execution
|
|
188
|
+
// =============================================================================
|
|
189
|
+
|
|
190
|
+
/** Map threshold names to Tavily score cutoffs. */
|
|
191
|
+
const THRESHOLD_TO_SCORE: Record<string, number> = {
|
|
192
|
+
strict: 0.7,
|
|
193
|
+
balanced: 0.5,
|
|
194
|
+
lenient: 0.3,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Execute a search_and_read query against the Tavily API.
|
|
199
|
+
*
|
|
200
|
+
* Uses POST with advanced search depth + raw_content to get full page text,
|
|
201
|
+
* then feeds results through budgetContent() for client-side token budgeting.
|
|
202
|
+
*/
|
|
203
|
+
async function executeTavilyLLMContext(
|
|
204
|
+
params: { query: string; maxTokens: number; maxUrls: number; threshold: string; count: number },
|
|
205
|
+
signal?: AbortSignal,
|
|
206
|
+
): Promise<{ cached: CachedLLMContext; latencyMs: number; rateLimit?: RateLimitInfo }> {
|
|
207
|
+
const scoreThreshold = THRESHOLD_TO_SCORE[params.threshold] ?? 0.5;
|
|
208
|
+
|
|
209
|
+
const requestBody: Record<string, unknown> = {
|
|
210
|
+
query: params.query,
|
|
211
|
+
max_results: params.count,
|
|
212
|
+
search_depth: "advanced",
|
|
213
|
+
include_raw_content: true,
|
|
214
|
+
include_answer: true,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const timed = await fetchWithRetryTimed("https://api.tavily.com/search", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
"Authorization": `Bearer ${getTavilyApiKey()}`,
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify(requestBody),
|
|
224
|
+
signal,
|
|
225
|
+
}, 2);
|
|
226
|
+
|
|
227
|
+
const data: TavilySearchResponse = await timed.response.json();
|
|
228
|
+
const cached = budgetContent(data.results, params.maxTokens, scoreThreshold);
|
|
229
|
+
|
|
230
|
+
return { cached, latencyMs: timed.latencyMs, rateLimit: timed.rateLimit };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// =============================================================================
|
|
234
|
+
// Tool Registration
|
|
235
|
+
// =============================================================================
|
|
236
|
+
|
|
237
|
+
export function registerLLMContextTool(pi: ExtensionAPI) {
|
|
238
|
+
pi.registerTool({
|
|
239
|
+
name: "search_and_read",
|
|
240
|
+
label: "Search & Read",
|
|
241
|
+
description:
|
|
242
|
+
"Search the web AND read page content in a single call. Returns pre-extracted, " +
|
|
243
|
+
"relevance-scored text from multiple pages — no separate fetch_page needed. " +
|
|
244
|
+
"Best when you need content, not just links. " +
|
|
245
|
+
"For selective URL browsing, use search-the-web + fetch_page instead.",
|
|
246
|
+
promptSnippet: "Search and read web page content in one step",
|
|
247
|
+
promptGuidelines: [
|
|
248
|
+
"Use search_and_read when you need actual page content about a topic — it searches and extracts in one call.",
|
|
249
|
+
"Prefer search_and_read over search-the-web + fetch_page when you just need to learn about something.",
|
|
250
|
+
"Use search-the-web when you need to browse specific URLs, control which pages to read, or want just links.",
|
|
251
|
+
"Start with the default maxTokens (8192). Use smaller values (2048-4096) for simple factual queries.",
|
|
252
|
+
"Use threshold='strict' for focused, high-relevance results. Use 'lenient' for broad coverage.",
|
|
253
|
+
],
|
|
254
|
+
parameters: Type.Object({
|
|
255
|
+
query: Type.String({ description: "Search query — what you want to learn about" }),
|
|
256
|
+
maxTokens: Type.Optional(
|
|
257
|
+
Type.Number({
|
|
258
|
+
minimum: 1024,
|
|
259
|
+
maximum: 32768,
|
|
260
|
+
default: 8192,
|
|
261
|
+
description: "Approximate maximum tokens of content to return (default: 8192). Lower = faster + cheaper inference.",
|
|
262
|
+
})
|
|
263
|
+
),
|
|
264
|
+
maxUrls: Type.Optional(
|
|
265
|
+
Type.Number({
|
|
266
|
+
minimum: 1,
|
|
267
|
+
maximum: 20,
|
|
268
|
+
default: 10,
|
|
269
|
+
description: "Maximum number of source URLs to include (default: 10).",
|
|
270
|
+
})
|
|
271
|
+
),
|
|
272
|
+
threshold: Type.Optional(
|
|
273
|
+
StringEnum(["strict", "balanced", "lenient"] as const, {
|
|
274
|
+
description: "Relevance threshold. 'strict' = fewer but more relevant. 'balanced' (default). 'lenient' = broader coverage.",
|
|
275
|
+
})
|
|
276
|
+
),
|
|
277
|
+
count: Type.Optional(
|
|
278
|
+
Type.Number({
|
|
279
|
+
minimum: 1,
|
|
280
|
+
maximum: 50,
|
|
281
|
+
default: 20,
|
|
282
|
+
description: "Maximum search results to consider (default: 20). More = broader but slower.",
|
|
283
|
+
})
|
|
284
|
+
),
|
|
285
|
+
}),
|
|
286
|
+
|
|
287
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
288
|
+
if (signal?.aborted) {
|
|
289
|
+
return { content: [{ type: "text", text: "Search cancelled." }] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ------------------------------------------------------------------
|
|
293
|
+
// Resolve search provider
|
|
294
|
+
// ------------------------------------------------------------------
|
|
295
|
+
const provider = resolveSearchProvider();
|
|
296
|
+
if (!provider) {
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: "text", text: "search_and_read unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY or BRAVE_API_KEY." }],
|
|
299
|
+
isError: true,
|
|
300
|
+
details: { errorKind: "auth_error", error: "No search API key set" } satisfies Partial<LLMContextDetails>,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const maxTokens = params.maxTokens ?? 8192;
|
|
305
|
+
const maxUrls = params.maxUrls ?? 10;
|
|
306
|
+
const threshold = params.threshold ?? "balanced";
|
|
307
|
+
const count = params.count ?? 20;
|
|
308
|
+
|
|
309
|
+
// ------------------------------------------------------------------
|
|
310
|
+
// Cache lookup (provider-prefixed key)
|
|
311
|
+
// ------------------------------------------------------------------
|
|
312
|
+
const cacheKey = normalizeQuery(params.query) + `|t:${maxTokens}|u:${maxUrls}|th:${threshold}|c:${count}|p:${provider}`;
|
|
313
|
+
const cached = contextCache.get(cacheKey);
|
|
314
|
+
|
|
315
|
+
if (cached) {
|
|
316
|
+
const output = formatLLMContext(params.query, cached.grounding, cached.sources, {
|
|
317
|
+
cached: true,
|
|
318
|
+
tokenCount: cached.estimatedTokens,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
322
|
+
let content = truncation.content;
|
|
323
|
+
if (truncation.truncated) {
|
|
324
|
+
const tempFile = await pi.writeTempFile(output, { prefix: "llm-context-" });
|
|
325
|
+
content += `\n\n[Truncated. Full content: ${tempFile}]`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const totalSnippets = cached.grounding.reduce((sum, g) => sum + g.snippets.length, 0);
|
|
329
|
+
const details: LLMContextDetails = {
|
|
330
|
+
query: params.query,
|
|
331
|
+
sourceCount: cached.grounding.length,
|
|
332
|
+
snippetCount: totalSnippets,
|
|
333
|
+
estimatedTokens: cached.estimatedTokens,
|
|
334
|
+
cached: true,
|
|
335
|
+
threshold,
|
|
336
|
+
maxTokens,
|
|
337
|
+
provider,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return { content: [{ type: "text", text: content }], details };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
onUpdate?.({ content: [{ type: "text", text: `Searching & reading about "${params.query}"...` }] });
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
// ------------------------------------------------------------------
|
|
347
|
+
// Provider-specific fetch
|
|
348
|
+
// ------------------------------------------------------------------
|
|
349
|
+
let result: CachedLLMContext;
|
|
350
|
+
let latencyMs: number | undefined;
|
|
351
|
+
let rateLimit: RateLimitInfo | undefined;
|
|
352
|
+
|
|
353
|
+
if (provider === "tavily") {
|
|
354
|
+
const tavilyResult = await executeTavilyLLMContext(
|
|
355
|
+
{ query: params.query, maxTokens, maxUrls, threshold, count },
|
|
356
|
+
signal,
|
|
357
|
+
);
|
|
358
|
+
result = tavilyResult.cached;
|
|
359
|
+
latencyMs = tavilyResult.latencyMs;
|
|
360
|
+
rateLimit = tavilyResult.rateLimit;
|
|
361
|
+
} else {
|
|
362
|
+
// ================================================================
|
|
363
|
+
// BRAVE PATH (unchanged API logic)
|
|
364
|
+
// ================================================================
|
|
365
|
+
const url = new URL("https://api.search.brave.com/res/v1/llm/context");
|
|
366
|
+
url.searchParams.append("q", params.query);
|
|
367
|
+
url.searchParams.append("count", String(count));
|
|
368
|
+
url.searchParams.append("maximum_number_of_tokens", String(maxTokens));
|
|
369
|
+
url.searchParams.append("maximum_number_of_urls", String(maxUrls));
|
|
370
|
+
url.searchParams.append("context_threshold_mode", threshold);
|
|
371
|
+
|
|
372
|
+
// Use a custom fetch flow to read error bodies from the Brave API
|
|
373
|
+
let timed;
|
|
374
|
+
try {
|
|
375
|
+
timed = await fetchWithRetryTimed(url.toString(), {
|
|
376
|
+
method: "GET",
|
|
377
|
+
headers: braveHeaders(),
|
|
378
|
+
signal,
|
|
379
|
+
}, 2);
|
|
380
|
+
} catch (fetchErr) {
|
|
381
|
+
// Try to extract Brave's structured error detail from the response body.
|
|
382
|
+
// This is especially useful for plan/subscription errors (OPTION_NOT_IN_PLAN).
|
|
383
|
+
let errorMessage: string | undefined;
|
|
384
|
+
let errorKindOverride: string | undefined;
|
|
385
|
+
if (fetchErr instanceof HttpError && fetchErr.response) {
|
|
386
|
+
try {
|
|
387
|
+
const body = await fetchErr.response.clone().json().catch(() => null);
|
|
388
|
+
if (body?.error?.detail) {
|
|
389
|
+
errorMessage = body.error.detail;
|
|
390
|
+
if (body.error.code === "OPTION_NOT_IN_PLAN") {
|
|
391
|
+
errorKindOverride = "plan_error";
|
|
392
|
+
errorMessage = `LLM Context API not available on your current Brave plan. ${body.error.detail} Upgrade at https://api-dashboard.search.brave.com/app/subscriptions — or use search-the-web + fetch_page as an alternative.`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch { /* body already consumed or parse error — use generic message */ }
|
|
396
|
+
}
|
|
397
|
+
const classified = classifyError(fetchErr);
|
|
398
|
+
const message = errorMessage || classified.message;
|
|
399
|
+
return {
|
|
400
|
+
content: [{ type: "text", text: `search_and_read unavailable: ${message}` }],
|
|
401
|
+
details: {
|
|
402
|
+
errorKind: errorKindOverride || classified.kind,
|
|
403
|
+
error: message,
|
|
404
|
+
retryAfterMs: classified.retryAfterMs,
|
|
405
|
+
query: params.query,
|
|
406
|
+
provider,
|
|
407
|
+
} satisfies Partial<LLMContextDetails>,
|
|
408
|
+
isError: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const data: BraveLLMContextResponse = await timed.response.json();
|
|
413
|
+
|
|
414
|
+
// ------------------------------------------------------------------
|
|
415
|
+
// Normalize Brave response
|
|
416
|
+
// ------------------------------------------------------------------
|
|
417
|
+
const grounding: LLMContextSnippet[] = [];
|
|
418
|
+
|
|
419
|
+
if (data.grounding?.generic) {
|
|
420
|
+
for (const item of data.grounding.generic) {
|
|
421
|
+
if (item.snippets && item.snippets.length > 0) {
|
|
422
|
+
grounding.push({
|
|
423
|
+
url: item.url,
|
|
424
|
+
title: item.title,
|
|
425
|
+
snippets: item.snippets,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Include POI data if present
|
|
432
|
+
if (data.grounding?.poi && data.grounding.poi.snippets?.length) {
|
|
433
|
+
grounding.push({
|
|
434
|
+
url: data.grounding.poi.url,
|
|
435
|
+
title: data.grounding.poi.title || data.grounding.poi.name,
|
|
436
|
+
snippets: data.grounding.poi.snippets,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Include map data if present
|
|
441
|
+
if (data.grounding?.map) {
|
|
442
|
+
for (const item of data.grounding.map) {
|
|
443
|
+
if (item.snippets?.length) {
|
|
444
|
+
grounding.push({
|
|
445
|
+
url: item.url,
|
|
446
|
+
title: item.title || item.name,
|
|
447
|
+
snippets: item.snippets,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const sources: Record<string, LLMContextSource> = {};
|
|
454
|
+
if (data.sources) {
|
|
455
|
+
for (const [sourceUrl, sourceInfo] of Object.entries(data.sources)) {
|
|
456
|
+
sources[sourceUrl] = {
|
|
457
|
+
title: sourceInfo.title,
|
|
458
|
+
hostname: sourceInfo.hostname,
|
|
459
|
+
age: sourceInfo.age,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Estimate total token count from all snippets
|
|
465
|
+
const allText = grounding.map(g => g.snippets.join(" ")).join(" ");
|
|
466
|
+
const estimatedTokens = estimateTokens(allText);
|
|
467
|
+
|
|
468
|
+
result = { grounding, sources, estimatedTokens };
|
|
469
|
+
latencyMs = timed.latencyMs;
|
|
470
|
+
rateLimit = timed.rateLimit;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ------------------------------------------------------------------
|
|
474
|
+
// Shared post-fetch: cache, format, truncate, return
|
|
475
|
+
// ------------------------------------------------------------------
|
|
476
|
+
contextCache.set(cacheKey, result);
|
|
477
|
+
|
|
478
|
+
const output = formatLLMContext(params.query, result.grounding, result.sources, {
|
|
479
|
+
tokenCount: result.estimatedTokens,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
483
|
+
let content = truncation.content;
|
|
484
|
+
|
|
485
|
+
if (truncation.truncated) {
|
|
486
|
+
const tempFile = await pi.writeTempFile(output, { prefix: "llm-context-" });
|
|
487
|
+
content += `\n\n[Truncated. Full content: ${tempFile}]`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const totalSnippets = result.grounding.reduce((sum, g) => sum + g.snippets.length, 0);
|
|
491
|
+
const details: LLMContextDetails = {
|
|
492
|
+
query: params.query,
|
|
493
|
+
sourceCount: result.grounding.length,
|
|
494
|
+
snippetCount: totalSnippets,
|
|
495
|
+
estimatedTokens: result.estimatedTokens,
|
|
496
|
+
cached: false,
|
|
497
|
+
latencyMs,
|
|
498
|
+
rateLimit,
|
|
499
|
+
threshold,
|
|
500
|
+
maxTokens,
|
|
501
|
+
provider,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return { content: [{ type: "text", text: content }], details };
|
|
505
|
+
} catch (error) {
|
|
506
|
+
const classified = classifyError(error);
|
|
507
|
+
return {
|
|
508
|
+
content: [{ type: "text", text: `Search failed: ${classified.message}` }],
|
|
509
|
+
details: {
|
|
510
|
+
errorKind: classified.kind,
|
|
511
|
+
error: classified.message,
|
|
512
|
+
query: params.query,
|
|
513
|
+
provider,
|
|
514
|
+
} satisfies Partial<LLMContextDetails>,
|
|
515
|
+
isError: true,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
renderCall(args, theme) {
|
|
521
|
+
let text = theme.fg("toolTitle", theme.bold("search_and_read "));
|
|
522
|
+
text += theme.fg("muted", `"${args.query}"`);
|
|
523
|
+
|
|
524
|
+
const meta: string[] = [];
|
|
525
|
+
if (args.maxTokens && args.maxTokens !== 8192) meta.push(`${(args.maxTokens / 1000).toFixed(0)}k tokens`);
|
|
526
|
+
if (args.threshold && args.threshold !== "balanced") meta.push(`threshold:${args.threshold}`);
|
|
527
|
+
if (args.maxUrls && args.maxUrls !== 10) meta.push(`${args.maxUrls} urls`);
|
|
528
|
+
if (meta.length > 0) {
|
|
529
|
+
text += " " + theme.fg("dim", `(${meta.join(", ")})`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return new Text(text, 0, 0);
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
renderResult(result, { expanded }, theme) {
|
|
536
|
+
const details = result.details as LLMContextDetails | undefined;
|
|
537
|
+
if (details?.errorKind || details?.error) {
|
|
538
|
+
const kindTag = details.errorKind ? theme.fg("dim", ` [${details.errorKind}]`) : "";
|
|
539
|
+
return new Text(theme.fg("error", `✗ ${details.error ?? "Search failed"}`) + kindTag, 0, 0);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const providerTag = details?.provider ? theme.fg("dim", ` [${details.provider}]`) : "";
|
|
543
|
+
const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : "";
|
|
544
|
+
const latencyTag = details?.latencyMs ? theme.fg("dim", ` ${details.latencyMs}ms`) : "";
|
|
545
|
+
const tokenTag = details?.estimatedTokens
|
|
546
|
+
? theme.fg("dim", ` ~${(details.estimatedTokens / 1000).toFixed(1)}k tokens`)
|
|
547
|
+
: "";
|
|
548
|
+
|
|
549
|
+
let text = theme.fg("success",
|
|
550
|
+
`✓ ${details?.sourceCount ?? 0} sources, ${details?.snippetCount ?? 0} snippets for "${details?.query}"`) +
|
|
551
|
+
providerTag + tokenTag + cacheTag + latencyTag;
|
|
552
|
+
|
|
553
|
+
if (expanded && result.content[0]?.type === "text") {
|
|
554
|
+
const preview = result.content[0].text.split("\n").slice(0, 10).join("\n");
|
|
555
|
+
text += "\n\n" + theme.fg("dim", preview);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return new Text(text, 0, 0);
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
}
|