pi-web-search 1.0.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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # pi-web-search
2
+
3
+ A pi extension that provides web search and content analysis capabilities powered by Gemini API.
4
+
5
+ ## Features
6
+
7
+ - **šŸ” Web Search** - Google search with citations
8
+ - **šŸ“„ URL Analysis** - Analyze web pages, PDFs, and images (up to 20 URLs)
9
+ - **šŸŽ„ YouTube** - Video summaries and Q&A
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pi install npm:pi-web-search
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ No special configuration required. Configure or login to any of the following model providers in pi, and it will be automatically detected and used:
20
+
21
+ - google-antigravity
22
+ - google-gemini-cli
23
+ - google
24
+ - google-generative-ai
@@ -0,0 +1,8 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const AdvancedSearchSchema: import("@sinclair/typebox").TObject<{
4
+ query: import("@sinclair/typebox").TString;
5
+ urls: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
6
+ }>;
7
+ export type AdvancedSearchInput = Static<typeof AdvancedSearchSchema>;
8
+ export declare function advancedSearch(id: string, params: AdvancedSearchInput, signal: AbortSignal, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<any>>;
@@ -0,0 +1,76 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { callApiStream, getConfig, applyCitations } from "./api.js";
3
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
4
+ export const AdvancedSearchSchema = Type.Object({
5
+ query: Type.String({ description: "The search query or question to answer" }),
6
+ urls: Type.Optional(Type.Array(Type.String(), {
7
+ description: "Additional URLs to include in context (up to 20)",
8
+ maxItems: 20
9
+ })),
10
+ });
11
+ export async function advancedSearch(id, params, signal, onUpdate, ctx) {
12
+ const model = await getModel(ctx);
13
+ if (!model)
14
+ return missingConfigResult(ctx);
15
+ const hasUrls = params.urls && params.urls.length > 0;
16
+ const urlCount = hasUrls ? params.urls.length : 0;
17
+ onUpdate?.({
18
+ content: [{
19
+ type: "text",
20
+ text: hasUrls
21
+ ? `Searching and analyzing ${urlCount} URL(s)...`
22
+ : `Searching for "${params.query}"...`
23
+ }],
24
+ details: {}
25
+ });
26
+ try {
27
+ const config = getConfig(model);
28
+ // Build prompt: include URLs in the query if provided
29
+ const prompt = hasUrls
30
+ ? `${params.query}\n\nAlso analyze these URLs:\n${params.urls.join("\n")}`
31
+ : params.query;
32
+ // Enable both tools: google_search + url_context
33
+ // Gemini will decide when to use each
34
+ const tools = hasUrls
35
+ ? [{ [config.searchTool]: {} }, { [config.urlContextTool]: {} }]
36
+ : [{ [config.searchTool]: {} }];
37
+ const result = await callApiStream(ctx, model, {
38
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
39
+ tools
40
+ }, onUpdate);
41
+ const { text, sources } = applyCitations(result.text, result.groundingMetadata);
42
+ // Handle URL context metadata
43
+ const urlMeta = result.urlContextMetadata?.urlMetadata
44
+ || result.urlContextMetadata?.url_metadata || [];
45
+ const retrieved = urlMeta
46
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) === "URL_RETRIEVAL_STATUS_SUCCESS")
47
+ .map((m) => m.retrievedUrl || m.retrieved_url || m.url);
48
+ const failed = urlMeta
49
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) !== "URL_RETRIEVAL_STATUS_SUCCESS")
50
+ .map((m) => ({
51
+ url: m.retrievedUrl || m.retrieved_url || m.url,
52
+ status: m.urlRetrievalStatus || m.url_retrieval_status
53
+ }));
54
+ let summary = text;
55
+ // Add URL status if there were failures
56
+ if (failed.length > 0) {
57
+ summary += `\n\n## URL Status\nāœ… Retrieved: ${retrieved.length}\nāŒ Failed: ${failed.length}`;
58
+ failed.forEach((f) => { summary += `\n- ${f.url}: ${f.status}`; });
59
+ }
60
+ // Add sources
61
+ if (sources.length > 0) {
62
+ summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;
63
+ }
64
+ return formatResult(summary, {
65
+ sources,
66
+ searchQueries: result.groundingMetadata?.webSearchQueries,
67
+ retrieved: retrieved.length > 0 ? retrieved : undefined,
68
+ failed: failed.length > 0 ? failed : undefined,
69
+ model: model.id,
70
+ grounded: sources.length > 0
71
+ });
72
+ }
73
+ catch (e) {
74
+ return errorResult(e);
75
+ }
76
+ }
package/dist/api.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Model } from "@mariozechner/pi-ai";
3
+ type ProviderConfig = {
4
+ searchTool: string;
5
+ urlContextTool: string;
6
+ buildRequest: (model: Model<any>, body: any, projectId?: string) => {
7
+ url: string;
8
+ headers: Record<string, string>;
9
+ body: any;
10
+ };
11
+ };
12
+ export declare function getConfig(model: Model<any>): ProviderConfig;
13
+ export interface StreamResult {
14
+ text: string;
15
+ groundingMetadata?: any;
16
+ urlContextMetadata?: any;
17
+ }
18
+ export declare function callApiStream(ctx: ExtensionContext, model: Model<any>, body: any, onUpdate?: AgentToolUpdateCallback): Promise<StreamResult>;
19
+ export declare function applyCitations(text: string, groundingMetadata: any): {
20
+ text: string;
21
+ sources: {
22
+ title: string;
23
+ url: string;
24
+ }[];
25
+ };
26
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,182 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ const PROVIDERS = {
3
+ "google-generative-ai": {
4
+ searchTool: "google_search",
5
+ urlContextTool: "url_context",
6
+ buildRequest: (model, body) => ({
7
+ url: `${model.baseUrl}/models/${model.id}:streamGenerateContent?alt=sse`,
8
+ headers: {
9
+ "Content-Type": "application/json",
10
+ "Accept": "text/event-stream",
11
+ },
12
+ body
13
+ })
14
+ },
15
+ "google-gemini-cli": {
16
+ searchTool: "googleSearch",
17
+ urlContextTool: "urlContext",
18
+ buildRequest: (model, body, projectId) => ({
19
+ url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "Accept": "text/event-stream",
23
+ "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
24
+ "X-Goog-Api-Client": "gl-node/22.17.0",
25
+ "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
26
+ },
27
+ body: { project: projectId, model: model.id, request: body }
28
+ })
29
+ },
30
+ "google-antigravity": {
31
+ searchTool: "googleSearch",
32
+ urlContextTool: "urlContext",
33
+ buildRequest: (model, body, projectId) => ({
34
+ url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ "Accept": "text/event-stream",
38
+ "User-Agent": "antigravity/1.15.8 darwin/arm64",
39
+ "X-Goog-Api-Client": "gl-node/22.17.0",
40
+ "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
41
+ },
42
+ body: {
43
+ project: projectId,
44
+ model: model.id,
45
+ request: body,
46
+ requestType: "agent",
47
+ userAgent: "antigravity",
48
+ requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
49
+ }
50
+ })
51
+ }
52
+ };
53
+ export function getConfig(model) {
54
+ return PROVIDERS[model.provider] || PROVIDERS[model.api] || PROVIDERS["google-generative-ai"];
55
+ }
56
+ export async function callApiStream(ctx, model, body, onUpdate) {
57
+ const config = getConfig(model);
58
+ const apiKey = await ctx.modelRegistry.getApiKey(model) || "";
59
+ let projectId;
60
+ if (model.api !== "google-generative-ai") {
61
+ const parsed = JSON.parse(apiKey);
62
+ projectId = parsed.projectId;
63
+ }
64
+ const req = config.buildRequest(model, body, projectId);
65
+ // Handle auth
66
+ if (model.api === "google-generative-ai") {
67
+ req.headers["x-goog-api-key"] = apiKey;
68
+ }
69
+ else {
70
+ const parsed = JSON.parse(apiKey);
71
+ req.headers["Authorization"] = `Bearer ${parsed.token}`;
72
+ }
73
+ const response = await fetch(req.url, {
74
+ method: "POST",
75
+ headers: req.headers,
76
+ body: JSON.stringify(req.body)
77
+ });
78
+ if (!response.ok) {
79
+ throw new Error(`API error (${response.status}): ${await response.text()}`);
80
+ }
81
+ if (!response.body) {
82
+ throw new Error("No response body");
83
+ }
84
+ // Parse SSE stream
85
+ const reader = response.body.getReader();
86
+ const decoder = new TextDecoder();
87
+ let buffer = "";
88
+ let accumulatedText = "";
89
+ let groundingMetadata;
90
+ let urlContextMetadata;
91
+ while (true) {
92
+ const { done, value } = await reader.read();
93
+ if (done)
94
+ break;
95
+ buffer += decoder.decode(value, { stream: true });
96
+ const lines = buffer.split("\n");
97
+ buffer = lines.pop() || "";
98
+ for (const line of lines) {
99
+ if (!line.startsWith("data:"))
100
+ continue;
101
+ const jsonStr = line.slice(5).trim();
102
+ if (!jsonStr)
103
+ continue;
104
+ let chunk;
105
+ try {
106
+ chunk = JSON.parse(jsonStr);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ // Unwrap response for internal APIs
112
+ const data = chunk.response || chunk;
113
+ const candidate = data.candidates?.[0];
114
+ if (candidate?.content?.parts) {
115
+ for (const part of candidate.content.parts) {
116
+ if (part.text) {
117
+ accumulatedText += part.text;
118
+ // Stream update
119
+ onUpdate?.({
120
+ content: [{ type: "text", text: accumulatedText }],
121
+ details: { streaming: true }
122
+ });
123
+ }
124
+ }
125
+ }
126
+ // Capture metadata from final chunk
127
+ if (candidate?.groundingMetadata) {
128
+ groundingMetadata = candidate.groundingMetadata;
129
+ }
130
+ // Handle both camelCase and snake_case
131
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
132
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ text: accumulatedText || "No answer available.",
138
+ groundingMetadata,
139
+ urlContextMetadata
140
+ };
141
+ }
142
+ // --- Citation Processing (byte-safe) ---
143
+ export function applyCitations(text, groundingMetadata) {
144
+ const chunks = groundingMetadata?.groundingChunks || [];
145
+ const supports = groundingMetadata?.groundingSupports || [];
146
+ const sources = chunks
147
+ .filter((c) => c.web)
148
+ .map((c) => ({ title: c.web.title || "Unknown", url: c.web.uri || "" }));
149
+ if (!supports.length || !sources.length)
150
+ return { text, sources };
151
+ // Collect insertions, sort descending
152
+ const insertions = supports
153
+ .filter((s) => s.segment?.endIndex !== undefined && s.groundingChunkIndices?.length)
154
+ .map((s) => ({
155
+ index: s.segment.endIndex,
156
+ marker: s.groundingChunkIndices.map((i) => `[${i + 1}]`).join("")
157
+ }))
158
+ .sort((a, b) => b.index - a.index);
159
+ // Byte-safe insertion
160
+ const encoder = new TextEncoder();
161
+ const decoder = new TextDecoder();
162
+ const bytes = encoder.encode(text);
163
+ const parts = [];
164
+ let lastIndex = bytes.length;
165
+ for (const ins of insertions) {
166
+ const pos = Math.min(ins.index, lastIndex);
167
+ if (pos < lastIndex)
168
+ parts.unshift(bytes.subarray(pos, lastIndex));
169
+ parts.unshift(encoder.encode(ins.marker));
170
+ lastIndex = pos;
171
+ }
172
+ if (lastIndex > 0)
173
+ parts.unshift(bytes.subarray(0, lastIndex));
174
+ const total = parts.reduce((acc, p) => acc + p.length, 0);
175
+ const final = new Uint8Array(total);
176
+ let offset = 0;
177
+ for (const part of parts) {
178
+ final.set(part, offset);
179
+ offset += part.length;
180
+ }
181
+ return { text: decoder.decode(final), sources };
182
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ export default function (pi: ExtensionAPI): void;
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { webSearch, WebSearchSchema } from "./web_search.js";
2
+ import { urlContext, UrlContextSchema } from "./url_context.js";
3
+ import { youtubeVideo, YoutubeVideoSchema } from "./youtube_video.js";
4
+ export default function (pi) {
5
+ pi.registerTool({
6
+ name: "web_search",
7
+ label: "Web Search",
8
+ description: "Search the web using Google Gemini's grounding. Optionally include URLs to analyze alongside search results.",
9
+ parameters: WebSearchSchema,
10
+ execute: webSearch
11
+ });
12
+ pi.registerTool({
13
+ name: "url_context",
14
+ label: "URL Context",
15
+ description: "Analyze web pages and documents. Extract data, compare documents, synthesize content. Supports text/html, PDF, images, JSON, CSV. Up to 20 URLs.",
16
+ parameters: UrlContextSchema,
17
+ execute: urlContext
18
+ });
19
+ pi.registerTool({
20
+ name: "youtube_video",
21
+ label: "YouTube Video",
22
+ description: "Analyze YouTube videos. Summarize, answer questions, find timestamps. Supports video clipping (start/end offsets). Preview feature.",
23
+ parameters: YoutubeVideoSchema,
24
+ execute: youtubeVideo
25
+ });
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const UrlContextSchema: import("@sinclair/typebox").TObject<{
4
+ query: import("@sinclair/typebox").TString;
5
+ urls: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
6
+ }>;
7
+ export type UrlContextInput = Static<typeof UrlContextSchema>;
8
+ export declare function urlContext(id: string, params: UrlContextInput, signal: AbortSignal, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<any>>;
@@ -0,0 +1,55 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { callApiStream, getConfig, applyCitations } from "./api.js";
3
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
4
+ export const UrlContextSchema = Type.Object({
5
+ query: Type.String({ description: "Question or task to perform on the URLs" }),
6
+ urls: Type.Array(Type.String(), {
7
+ description: "URLs to analyze (up to 20). Supports text/html, PDF, images, JSON, CSV, etc.",
8
+ minItems: 1,
9
+ maxItems: 20
10
+ }),
11
+ });
12
+ export async function urlContext(id, params, signal, onUpdate, ctx) {
13
+ const model = await getModel(ctx);
14
+ if (!model)
15
+ return missingConfigResult(ctx);
16
+ const count = params.urls.length;
17
+ onUpdate?.({ content: [{ type: "text", text: `Analyzing ${count} URL${count > 1 ? 's' : ''}...` }], details: {} });
18
+ try {
19
+ const config = getConfig(model);
20
+ const combinedPrompt = `${params.query}\n\nURLs:\n${params.urls.join("\n")}`;
21
+ const result = await callApiStream(ctx, model, {
22
+ contents: [{ role: "user", parts: [{ text: combinedPrompt }] }],
23
+ tools: [{ [config.urlContextTool]: {} }]
24
+ }, onUpdate);
25
+ const { text, sources } = applyCitations(result.text, result.groundingMetadata);
26
+ // Handle both camelCase and snake_case metadata
27
+ const urlMeta = result.urlContextMetadata?.urlMetadata
28
+ || result.urlContextMetadata?.url_metadata || [];
29
+ const retrieved = urlMeta
30
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) === "URL_RETRIEVAL_STATUS_SUCCESS")
31
+ .map((m) => m.retrievedUrl || m.retrieved_url || m.url);
32
+ const failed = urlMeta
33
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) !== "URL_RETRIEVAL_STATUS_SUCCESS")
34
+ .map((m) => ({
35
+ url: m.retrievedUrl || m.retrieved_url || m.url,
36
+ status: m.urlRetrievalStatus || m.url_retrieval_status
37
+ }));
38
+ let summary = text;
39
+ if (failed.length > 0) {
40
+ summary += `\n\n## URL Status\nāœ… Retrieved: ${retrieved.length}\nāŒ Failed: ${failed.length}`;
41
+ failed.forEach((f) => { summary += `\n- ${f.url}: ${f.status}`; });
42
+ }
43
+ if (sources.length > 0 && !summary.includes("## Sources")) {
44
+ summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;
45
+ }
46
+ return formatResult(summary, {
47
+ retrieved,
48
+ failed: failed.length > 0 ? failed : undefined,
49
+ model: model.id
50
+ });
51
+ }
52
+ catch (e) {
53
+ return errorResult(e);
54
+ }
55
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext, AgentToolResult } from "@mariozechner/pi-coding-agent";
2
+ import { type Model } from "@mariozechner/pi-ai";
3
+ export declare function formatResult(text: string, details: any): AgentToolResult<any>;
4
+ export declare function getModel(ctx: ExtensionContext): Promise<Model<any> | undefined>;
5
+ export declare function missingConfigResult(ctx: ExtensionContext): AgentToolResult<any>;
6
+ export declare function errorResult(e: Error): AgentToolResult<any>;
package/dist/utils.js ADDED
@@ -0,0 +1,63 @@
1
+ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
2
+ // --- Formatting ---
3
+ export function formatResult(text, details) {
4
+ const { content, truncated } = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
5
+ return {
6
+ content: [{ type: "text", text: content + (truncated ? "\n\n[Truncated]" : "") }],
7
+ details
8
+ };
9
+ }
10
+ // --- Model Selection ---
11
+ export async function getModel(ctx) {
12
+ // flash first, big first: 3-flash -> 2.5-flash -> 2.0-flash
13
+ // provider priority: google-gemini-cli -> google-antigravity -> google -> google-generative-ai
14
+ const models = ctx.modelRegistry.getAvailable();
15
+ const flashModels = [
16
+ /gemini-3.*flash/i,
17
+ /gemini-2\.5.*flash/i,
18
+ /gemini-2\.0.*flash/i,
19
+ /gemini.*flash/i,
20
+ ];
21
+ const providers = [
22
+ "google-gemini-cli",
23
+ "google-antigravity",
24
+ "google",
25
+ "google-generative-ai",
26
+ ];
27
+ // Filter to only Google-compatible models (those with supported api/provider)
28
+ const googleModels = models.filter(m => providers.includes(m.provider) ||
29
+ m.api === "google-generative-ai" ||
30
+ m.api === "google-gemini-cli");
31
+ // Try each flash pattern in priority order
32
+ for (const pattern of flashModels) {
33
+ const matching = googleModels.filter(m => pattern.test(m.id));
34
+ if (matching.length === 0)
35
+ continue;
36
+ // Among matches, pick by provider priority
37
+ for (const provider of providers) {
38
+ const model = matching.find(m => m.provider === provider);
39
+ if (model)
40
+ return model;
41
+ }
42
+ // Fall back to first match if no priority provider found
43
+ return matching[0];
44
+ }
45
+ // No flash model found, try any Google model by provider priority
46
+ for (const provider of providers) {
47
+ const model = googleModels.find(m => m.provider === provider);
48
+ if (model)
49
+ return model;
50
+ }
51
+ // Return first available Google model if any
52
+ return googleModels[0];
53
+ }
54
+ // --- Error Results ---
55
+ export function missingConfigResult(ctx) {
56
+ const msg = ctx.model && ["google-gemini-cli", "google-antigravity"].includes(ctx.model.provider)
57
+ ? `Provider ${ctx.model.provider} requires valid OAuth credentials.`
58
+ : "No Google Gemini configuration found. Please configure GEMINI_API_KEY.";
59
+ return { content: [{ type: "text", text: `Failed: ${msg}` }], details: { error: "missing_config" } };
60
+ }
61
+ export function errorResult(e) {
62
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], details: { error: true } };
63
+ }
@@ -0,0 +1,8 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const WebSearchSchema: import("@sinclair/typebox").TObject<{
4
+ query: import("@sinclair/typebox").TString;
5
+ urls: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
6
+ }>;
7
+ export type WebSearchInput = Static<typeof WebSearchSchema>;
8
+ export declare function webSearch(id: string, params: WebSearchInput, signal: AbortSignal, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<any>>;
@@ -0,0 +1,75 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { callApiStream, getConfig, applyCitations } from "./api.js";
3
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
4
+ export const WebSearchSchema = Type.Object({
5
+ query: Type.String({ description: "The search query or question to answer" }),
6
+ urls: Type.Optional(Type.Array(Type.String(), {
7
+ description: "Additional URLs to analyze along with search (up to 20)",
8
+ maxItems: 20
9
+ })),
10
+ });
11
+ export async function webSearch(id, params, signal, onUpdate, ctx) {
12
+ const model = await getModel(ctx);
13
+ if (!model)
14
+ return missingConfigResult(ctx);
15
+ const hasUrls = params.urls && params.urls.length > 0;
16
+ const urlCount = hasUrls ? params.urls.length : 0;
17
+ onUpdate?.({
18
+ content: [{
19
+ type: "text",
20
+ text: hasUrls
21
+ ? `Searching and analyzing ${urlCount} URL(s)...`
22
+ : `Searching for "${params.query}"...`
23
+ }],
24
+ details: {}
25
+ });
26
+ try {
27
+ const config = getConfig(model);
28
+ // Build prompt: include URLs if provided
29
+ const prompt = hasUrls
30
+ ? `${params.query}\n\nAlso analyze these URLs:\n${params.urls.join("\n")}`
31
+ : params.query;
32
+ // Enable google_search, add url_context if URLs provided
33
+ const tools = hasUrls
34
+ ? [{ [config.searchTool]: {} }, { [config.urlContextTool]: {} }]
35
+ : [{ [config.searchTool]: {} }];
36
+ const result = await callApiStream(ctx, model, {
37
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
38
+ tools
39
+ }, onUpdate);
40
+ const { text, sources } = applyCitations(result.text, result.groundingMetadata);
41
+ // Handle URL context metadata
42
+ const urlMeta = result.urlContextMetadata?.urlMetadata
43
+ || result.urlContextMetadata?.url_metadata || [];
44
+ const retrieved = urlMeta
45
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) === "URL_RETRIEVAL_STATUS_SUCCESS")
46
+ .map((m) => m.retrievedUrl || m.retrieved_url || m.url);
47
+ const failed = urlMeta
48
+ .filter((m) => (m.urlRetrievalStatus || m.url_retrieval_status) !== "URL_RETRIEVAL_STATUS_SUCCESS")
49
+ .map((m) => ({
50
+ url: m.retrievedUrl || m.retrieved_url || m.url,
51
+ status: m.urlRetrievalStatus || m.url_retrieval_status
52
+ }));
53
+ let summary = text;
54
+ // Add URL status if there were failures
55
+ if (failed.length > 0) {
56
+ summary += `\n\n## URL Status\nāœ… Retrieved: ${retrieved.length}\nāŒ Failed: ${failed.length}`;
57
+ failed.forEach((f) => { summary += `\n- ${f.url}: ${f.status}`; });
58
+ }
59
+ // Add sources
60
+ if (sources.length > 0) {
61
+ summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;
62
+ }
63
+ return formatResult(summary, {
64
+ sources,
65
+ searchQueries: result.groundingMetadata?.webSearchQueries,
66
+ retrieved: retrieved.length > 0 ? retrieved : undefined,
67
+ failed: failed.length > 0 ? failed : undefined,
68
+ model: model.id,
69
+ grounded: sources.length > 0
70
+ });
71
+ }
72
+ catch (e) {
73
+ return errorResult(e);
74
+ }
75
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Static } from "@sinclair/typebox";
3
+ export declare const YoutubeVideoSchema: import("@sinclair/typebox").TObject<{
4
+ video_url: import("@sinclair/typebox").TString;
5
+ query: import("@sinclair/typebox").TString;
6
+ start_offset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
+ end_offset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
8
+ }>;
9
+ export type YoutubeVideoInput = Static<typeof YoutubeVideoSchema>;
10
+ export declare function youtubeVideo(id: string, params: YoutubeVideoInput, signal: AbortSignal, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<any>>;
@@ -0,0 +1,54 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { callApiStream } from "./api.js";
3
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
4
+ import { urlContext } from "./url_context.js";
5
+ export const YoutubeVideoSchema = Type.Object({
6
+ video_url: Type.String({ description: "YouTube video URL" }),
7
+ query: Type.String({ description: "Question or task about the video" }),
8
+ start_offset: Type.Optional(Type.String({ description: "Start time (e.g., '120s' or '2:00')" })),
9
+ end_offset: Type.Optional(Type.String({ description: "End time (e.g., '300s' or '5:00')" })),
10
+ });
11
+ export async function youtubeVideo(id, params, signal, onUpdate, ctx) {
12
+ const model = await getModel(ctx);
13
+ if (!model)
14
+ return missingConfigResult(ctx);
15
+ // Only google-generative-ai supports native video; others use URL context
16
+ if (model.api !== "google-generative-ai") {
17
+ let query = params.query;
18
+ if (params.start_offset || params.end_offset) {
19
+ query += "\n\nFocus on the video section";
20
+ if (params.start_offset)
21
+ query += ` from ${params.start_offset}`;
22
+ if (params.end_offset)
23
+ query += ` to ${params.end_offset}`;
24
+ query += ".";
25
+ }
26
+ return urlContext(id, { query, urls: [params.video_url] }, signal, onUpdate, ctx);
27
+ }
28
+ onUpdate?.({ content: [{ type: "text", text: `Analyzing YouTube video...` }], details: {} });
29
+ try {
30
+ const videoPart = {
31
+ file_data: { file_uri: params.video_url, mime_type: "video/mp4" }
32
+ };
33
+ if (params.start_offset || params.end_offset) {
34
+ videoPart.video_metadata = {};
35
+ if (params.start_offset)
36
+ videoPart.video_metadata.start_offset = params.start_offset;
37
+ if (params.end_offset)
38
+ videoPart.video_metadata.end_offset = params.end_offset;
39
+ }
40
+ const result = await callApiStream(ctx, model, {
41
+ contents: [{ role: "user", parts: [videoPart, { text: params.query }] }]
42
+ }, onUpdate);
43
+ return formatResult(result.text, {
44
+ videoUrl: params.video_url,
45
+ clipping: (params.start_offset || params.end_offset)
46
+ ? { start: params.start_offset, end: params.end_offset }
47
+ : undefined,
48
+ model: model.id
49
+ });
50
+ }
51
+ catch (e) {
52
+ return errorResult(e);
53
+ }
54
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pi-web-search",
3
+ "version": "1.0.0",
4
+ "description": "Web search and content analysis extension for pi, powered by Gemini API",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "keywords": [
9
+ "pi",
10
+ "pi-extension",
11
+ "web-search",
12
+ "gemini",
13
+ "google",
14
+ "url-analysis",
15
+ "youtube"
16
+ ],
17
+ "author": "ttttmr",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/ttttmr/pi-web-search.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/ttttmr/pi-web-search/issues"
25
+ },
26
+ "homepage": "https://github.com/ttttmr/pi-web-search#readme",
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "dependencies": {
32
+ "@mariozechner/pi-coding-agent": "^0.51.5",
33
+ "@sinclair/typebox": "^0.32.15"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.0.0",
37
+ "@types/node": "^20.0.0"
38
+ },
39
+ "pi": {
40
+ "extensions": ["./src/index.ts"]
41
+ }
42
+ }
package/run.sh ADDED
@@ -0,0 +1,9 @@
1
+ pi --no-skills --no-extensions -e ./src/index.ts --models "google-antigravity/*" '搜瓢今天btc价格'
2
+ pi --no-skills --no-extensions -e ./src/index.ts --models "google-gemini-cli/*" '搜瓢今天btc价格'
3
+ pi --no-skills --no-extensions -e ./src/index.ts --models "google/*" '搜瓢今天btc价格'
4
+
5
+ pi --no-skills --no-extensions -e ./src/index.ts --provider google-antigravity --model claude-sonnet-4-5 '搜瓢今天btc价格'
6
+ # ok
7
+ pi --no-skills --no-extensions -e ./src/index.ts --provider google-gemini-cli --model gemini-3-flash-preview '搜瓢今天btc价格'
8
+ # ok
9
+ pi --no-skills --no-extensions -e ./src/index.ts --provider google --model gemini-2.5-flash '搜瓢今天btc价格'
package/src/api.ts ADDED
@@ -0,0 +1,225 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { type Model } from "@mariozechner/pi-ai";
3
+ import { TextEncoder, TextDecoder } from "util";
4
+
5
+ // --- Provider Configuration ---
6
+
7
+ type ProviderConfig = {
8
+ searchTool: string;
9
+ urlContextTool: string;
10
+ buildRequest: (model: Model<any>, body: any, projectId?: string) => { url: string; headers: Record<string, string>; body: any };
11
+ };
12
+
13
+ const PROVIDERS: Record<string, ProviderConfig> = {
14
+ "google-generative-ai": {
15
+ searchTool: "google_search",
16
+ urlContextTool: "url_context",
17
+ buildRequest: (model, body) => ({
18
+ url: `${model.baseUrl}/models/${model.id}:streamGenerateContent?alt=sse`,
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ "Accept": "text/event-stream",
22
+ },
23
+ body
24
+ })
25
+ },
26
+ "google-gemini-cli": {
27
+ searchTool: "googleSearch",
28
+ urlContextTool: "urlContext",
29
+ buildRequest: (model, body, projectId) => ({
30
+ url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ "Accept": "text/event-stream",
34
+ "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
35
+ "X-Goog-Api-Client": "gl-node/22.17.0",
36
+ "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
37
+ },
38
+ body: { project: projectId, model: model.id, request: body }
39
+ })
40
+ },
41
+ "google-antigravity": {
42
+ searchTool: "googleSearch",
43
+ urlContextTool: "urlContext",
44
+ buildRequest: (model, body, projectId) => ({
45
+ url: `${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`,
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ "Accept": "text/event-stream",
49
+ "User-Agent": "antigravity/1.15.8 darwin/arm64",
50
+ "X-Goog-Api-Client": "gl-node/22.17.0",
51
+ "Client-Metadata": JSON.stringify({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
52
+ },
53
+ body: {
54
+ project: projectId,
55
+ model: model.id,
56
+ request: body,
57
+ requestType: "agent",
58
+ userAgent: "antigravity",
59
+ requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
60
+ }
61
+ })
62
+ }
63
+ };
64
+
65
+ export function getConfig(model: Model<any>): ProviderConfig {
66
+ return PROVIDERS[model.provider] || PROVIDERS[model.api] || PROVIDERS["google-generative-ai"];
67
+ }
68
+
69
+ // --- Streaming API Call ---
70
+
71
+ export interface StreamResult {
72
+ text: string;
73
+ groundingMetadata?: any;
74
+ urlContextMetadata?: any;
75
+ }
76
+
77
+ export async function callApiStream(
78
+ ctx: ExtensionContext,
79
+ model: Model<any>,
80
+ body: any,
81
+ onUpdate?: AgentToolUpdateCallback
82
+ ): Promise<StreamResult> {
83
+ const config = getConfig(model);
84
+ const apiKey = await ctx.modelRegistry.getApiKey(model) || "";
85
+
86
+ let projectId: string | undefined;
87
+ if (model.api !== "google-generative-ai") {
88
+ const parsed = JSON.parse(apiKey);
89
+ projectId = parsed.projectId;
90
+ }
91
+
92
+ const req = config.buildRequest(model, body, projectId);
93
+
94
+ // Handle auth
95
+ if (model.api === "google-generative-ai") {
96
+ req.headers["x-goog-api-key"] = apiKey;
97
+ } else {
98
+ const parsed = JSON.parse(apiKey);
99
+ req.headers["Authorization"] = `Bearer ${parsed.token}`;
100
+ }
101
+
102
+ const response = await fetch(req.url, {
103
+ method: "POST",
104
+ headers: req.headers,
105
+ body: JSON.stringify(req.body)
106
+ });
107
+
108
+ if (!response.ok) {
109
+ throw new Error(`API error (${response.status}): ${await response.text()}`);
110
+ }
111
+
112
+ if (!response.body) {
113
+ throw new Error("No response body");
114
+ }
115
+
116
+ // Parse SSE stream
117
+ const reader = response.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buffer = "";
120
+ let accumulatedText = "";
121
+ let groundingMetadata: any;
122
+ let urlContextMetadata: any;
123
+
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done) break;
127
+
128
+ buffer += decoder.decode(value, { stream: true });
129
+ const lines = buffer.split("\n");
130
+ buffer = lines.pop() || "";
131
+
132
+ for (const line of lines) {
133
+ if (!line.startsWith("data:")) continue;
134
+ const jsonStr = line.slice(5).trim();
135
+ if (!jsonStr) continue;
136
+
137
+ let chunk: any;
138
+ try {
139
+ chunk = JSON.parse(jsonStr);
140
+ } catch {
141
+ continue;
142
+ }
143
+
144
+ // Unwrap response for internal APIs
145
+ const data = chunk.response || chunk;
146
+ const candidate = data.candidates?.[0];
147
+
148
+ if (candidate?.content?.parts) {
149
+ for (const part of candidate.content.parts) {
150
+ if (part.text) {
151
+ accumulatedText += part.text;
152
+ // Stream update
153
+ onUpdate?.({
154
+ content: [{ type: "text", text: accumulatedText }],
155
+ details: { streaming: true }
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ // Capture metadata from final chunk
162
+ if (candidate?.groundingMetadata) {
163
+ groundingMetadata = candidate.groundingMetadata;
164
+ }
165
+ // Handle both camelCase and snake_case
166
+ if (candidate?.urlContextMetadata || candidate?.url_context_metadata) {
167
+ urlContextMetadata = candidate.urlContextMetadata || candidate.url_context_metadata;
168
+ }
169
+ }
170
+ }
171
+
172
+ return {
173
+ text: accumulatedText || "No answer available.",
174
+ groundingMetadata,
175
+ urlContextMetadata
176
+ };
177
+ }
178
+
179
+ // --- Citation Processing (byte-safe) ---
180
+
181
+ export function applyCitations(text: string, groundingMetadata: any): { text: string; sources: { title: string; url: string }[] } {
182
+ const chunks = groundingMetadata?.groundingChunks || [];
183
+ const supports = groundingMetadata?.groundingSupports || [];
184
+
185
+ const sources = chunks
186
+ .filter((c: any) => c.web)
187
+ .map((c: any) => ({ title: c.web.title || "Unknown", url: c.web.uri || "" }));
188
+
189
+ if (!supports.length || !sources.length) return { text, sources };
190
+
191
+ // Collect insertions, sort descending
192
+ const insertions = supports
193
+ .filter((s: any) => s.segment?.endIndex !== undefined && s.groundingChunkIndices?.length)
194
+ .map((s: any) => ({
195
+ index: s.segment.endIndex,
196
+ marker: s.groundingChunkIndices.map((i: number) => `[${i + 1}]`).join("")
197
+ }))
198
+ .sort((a: any, b: any) => b.index - a.index);
199
+
200
+ // Byte-safe insertion
201
+ const encoder = new TextEncoder();
202
+ const decoder = new TextDecoder();
203
+ const bytes = encoder.encode(text);
204
+
205
+ const parts: Uint8Array[] = [];
206
+ let lastIndex = bytes.length;
207
+
208
+ for (const ins of insertions) {
209
+ const pos = Math.min(ins.index, lastIndex);
210
+ if (pos < lastIndex) parts.unshift(bytes.subarray(pos, lastIndex));
211
+ parts.unshift(encoder.encode(ins.marker));
212
+ lastIndex = pos;
213
+ }
214
+ if (lastIndex > 0) parts.unshift(bytes.subarray(0, lastIndex));
215
+
216
+ const total = parts.reduce((acc, p) => acc + p.length, 0);
217
+ const final = new Uint8Array(total);
218
+ let offset = 0;
219
+ for (const part of parts) {
220
+ final.set(part, offset);
221
+ offset += part.length;
222
+ }
223
+
224
+ return { text: decoder.decode(final), sources };
225
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { webSearch, WebSearchSchema } from "./web_search.js";
3
+ import { urlContext, UrlContextSchema } from "./url_context.js";
4
+ import { youtubeVideo, YoutubeVideoSchema } from "./youtube_video.js";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ pi.registerTool({
8
+ name: "web_search",
9
+ label: "Web Search",
10
+ description: "Search the web using Google Gemini's grounding. Optionally include URLs to analyze alongside search results.",
11
+ parameters: WebSearchSchema,
12
+ execute: webSearch
13
+ });
14
+
15
+ pi.registerTool({
16
+ name: "url_context",
17
+ label: "URL Context",
18
+ description: "Analyze web pages and documents. Extract data, compare documents, synthesize content. Supports text/html, PDF, images, JSON, CSV. Up to 20 URLs.",
19
+ parameters: UrlContextSchema,
20
+ execute: urlContext
21
+ });
22
+
23
+ pi.registerTool({
24
+ name: "youtube_video",
25
+ label: "YouTube Video",
26
+ description: "Analyze YouTube videos. Summarize, answer questions, find timestamps. Supports video clipping (start/end offsets). Preview feature.",
27
+ parameters: YoutubeVideoSchema,
28
+ execute: youtubeVideo
29
+ });
30
+ }
@@ -0,0 +1,72 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { Type, type Static } from "@sinclair/typebox";
3
+ import { callApiStream, getConfig, applyCitations } from "./api.js";
4
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
5
+
6
+ export const UrlContextSchema = Type.Object({
7
+ query: Type.String({ description: "Question or task to perform on the URLs" }),
8
+ urls: Type.Array(Type.String(), {
9
+ description: "URLs to analyze (up to 20). Supports text/html, PDF, images, JSON, CSV, etc.",
10
+ minItems: 1,
11
+ maxItems: 20
12
+ }),
13
+ });
14
+ export type UrlContextInput = Static<typeof UrlContextSchema>;
15
+
16
+ export async function urlContext(
17
+ id: string,
18
+ params: UrlContextInput,
19
+ signal: AbortSignal,
20
+ onUpdate: AgentToolUpdateCallback | undefined,
21
+ ctx: ExtensionContext
22
+ ) {
23
+ const model = await getModel(ctx);
24
+ if (!model) return missingConfigResult(ctx);
25
+
26
+ const count = params.urls.length;
27
+ onUpdate?.({ content: [{ type: "text", text: `Analyzing ${count} URL${count > 1 ? 's' : ''}...` }], details: {} });
28
+
29
+ try {
30
+ const config = getConfig(model);
31
+ const combinedPrompt = `${params.query}\n\nURLs:\n${params.urls.join("\n")}`;
32
+
33
+ const result = await callApiStream(ctx, model, {
34
+ contents: [{ role: "user", parts: [{ text: combinedPrompt }] }],
35
+ tools: [{ [config.urlContextTool]: {} }]
36
+ }, onUpdate);
37
+
38
+ const { text, sources } = applyCitations(result.text, result.groundingMetadata);
39
+
40
+ // Handle both camelCase and snake_case metadata
41
+ const urlMeta = result.urlContextMetadata?.urlMetadata
42
+ || result.urlContextMetadata?.url_metadata || [];
43
+
44
+ const retrieved = urlMeta
45
+ .filter((m: any) => (m.urlRetrievalStatus || m.url_retrieval_status) === "URL_RETRIEVAL_STATUS_SUCCESS")
46
+ .map((m: any) => m.retrievedUrl || m.retrieved_url || m.url);
47
+
48
+ const failed = urlMeta
49
+ .filter((m: any) => (m.urlRetrievalStatus || m.url_retrieval_status) !== "URL_RETRIEVAL_STATUS_SUCCESS")
50
+ .map((m: any) => ({
51
+ url: m.retrievedUrl || m.retrieved_url || m.url,
52
+ status: m.urlRetrievalStatus || m.url_retrieval_status
53
+ }));
54
+
55
+ let summary = text;
56
+ if (failed.length > 0) {
57
+ summary += `\n\n## URL Status\nāœ… Retrieved: ${retrieved.length}\nāŒ Failed: ${failed.length}`;
58
+ failed.forEach((f: any) => { summary += `\n- ${f.url}: ${f.status}`; });
59
+ }
60
+ if (sources.length > 0 && !summary.includes("## Sources")) {
61
+ summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;
62
+ }
63
+
64
+ return formatResult(summary, {
65
+ retrieved,
66
+ failed: failed.length > 0 ? failed : undefined,
67
+ model: model.id
68
+ });
69
+ } catch (e: any) {
70
+ return errorResult(e);
71
+ }
72
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { ExtensionContext, AgentToolResult } from "@mariozechner/pi-coding-agent";
2
+ import { type Model } from "@mariozechner/pi-ai";
3
+ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
4
+
5
+ // --- Formatting ---
6
+
7
+ export function formatResult(text: string, details: any): AgentToolResult<any> {
8
+ const { content, truncated } = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
9
+ return {
10
+ content: [{ type: "text", text: content + (truncated ? "\n\n[Truncated]" : "") }],
11
+ details
12
+ };
13
+ }
14
+
15
+ // --- Model Selection ---
16
+
17
+ export async function getModel(ctx: ExtensionContext): Promise<Model<any> | undefined> {
18
+ // flash first, big first: 3-flash -> 2.5-flash -> 2.0-flash
19
+ // provider priority: google-gemini-cli -> google-antigravity -> google -> google-generative-ai
20
+ const models = ctx.modelRegistry.getAvailable();
21
+
22
+ const flashModels = [
23
+ /gemini-3.*flash/i,
24
+ /gemini-2\.5.*flash/i,
25
+ /gemini-2\.0.*flash/i,
26
+ /gemini.*flash/i,
27
+ ];
28
+
29
+ const providers = [
30
+ "google-gemini-cli",
31
+ "google-antigravity",
32
+ "google",
33
+ "google-generative-ai",
34
+ ];
35
+
36
+ // Filter to only Google-compatible models (those with supported api/provider)
37
+ const googleModels = models.filter(m =>
38
+ providers.includes(m.provider) ||
39
+ m.api === "google-generative-ai" ||
40
+ m.api === "google-gemini-cli"
41
+ );
42
+
43
+ // Try each flash pattern in priority order
44
+ for (const pattern of flashModels) {
45
+ const matching = googleModels.filter(m => pattern.test(m.id));
46
+ if (matching.length === 0) continue;
47
+
48
+ // Among matches, pick by provider priority
49
+ for (const provider of providers) {
50
+ const model = matching.find(m => m.provider === provider);
51
+ if (model) return model;
52
+ }
53
+
54
+ // Fall back to first match if no priority provider found
55
+ return matching[0];
56
+ }
57
+
58
+ // No flash model found, try any Google model by provider priority
59
+ for (const provider of providers) {
60
+ const model = googleModels.find(m => m.provider === provider);
61
+ if (model) return model;
62
+ }
63
+
64
+ // Return first available Google model if any
65
+ return googleModels[0];
66
+ }
67
+
68
+ // --- Error Results ---
69
+
70
+ export function missingConfigResult(ctx: ExtensionContext): AgentToolResult<any> {
71
+ const msg = ctx.model && ["google-gemini-cli", "google-antigravity"].includes(ctx.model.provider)
72
+ ? `Provider ${ctx.model.provider} requires valid OAuth credentials.`
73
+ : "No Google Gemini configuration found. Please configure GEMINI_API_KEY.";
74
+ return { content: [{ type: "text", text: `Failed: ${msg}` }], details: { error: "missing_config" } };
75
+ }
76
+
77
+ export function errorResult(e: Error): AgentToolResult<any> {
78
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], details: { error: true } };
79
+ }
@@ -0,0 +1,97 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { Type, type Static } from "@sinclair/typebox";
3
+ import { callApiStream, getConfig, applyCitations } from "./api.js";
4
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
5
+
6
+ export const WebSearchSchema = Type.Object({
7
+ query: Type.String({ description: "The search query or question to answer" }),
8
+ urls: Type.Optional(Type.Array(Type.String(), {
9
+ description: "Additional URLs to analyze along with search (up to 20)",
10
+ maxItems: 20
11
+ })),
12
+ });
13
+ export type WebSearchInput = Static<typeof WebSearchSchema>;
14
+
15
+ export async function webSearch(
16
+ id: string,
17
+ params: WebSearchInput,
18
+ signal: AbortSignal,
19
+ onUpdate: AgentToolUpdateCallback | undefined,
20
+ ctx: ExtensionContext
21
+ ) {
22
+ const model = await getModel(ctx);
23
+ if (!model) return missingConfigResult(ctx);
24
+
25
+ const hasUrls = params.urls && params.urls.length > 0;
26
+ const urlCount = hasUrls ? params.urls!.length : 0;
27
+
28
+ onUpdate?.({
29
+ content: [{
30
+ type: "text",
31
+ text: hasUrls
32
+ ? `Searching and analyzing ${urlCount} URL(s)...`
33
+ : `Searching for "${params.query}"...`
34
+ }],
35
+ details: {}
36
+ });
37
+
38
+ try {
39
+ const config = getConfig(model);
40
+
41
+ // Build prompt: include URLs if provided
42
+ const prompt = hasUrls
43
+ ? `${params.query}\n\nAlso analyze these URLs:\n${params.urls!.join("\n")}`
44
+ : params.query;
45
+
46
+ // Enable google_search, add url_context if URLs provided
47
+ const tools = hasUrls
48
+ ? [{ [config.searchTool]: {} }, { [config.urlContextTool]: {} }]
49
+ : [{ [config.searchTool]: {} }];
50
+
51
+ const result = await callApiStream(ctx, model, {
52
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
53
+ tools
54
+ }, onUpdate);
55
+
56
+ const { text, sources } = applyCitations(result.text, result.groundingMetadata);
57
+
58
+ // Handle URL context metadata
59
+ const urlMeta = result.urlContextMetadata?.urlMetadata
60
+ || result.urlContextMetadata?.url_metadata || [];
61
+
62
+ const retrieved = urlMeta
63
+ .filter((m: any) => (m.urlRetrievalStatus || m.url_retrieval_status) === "URL_RETRIEVAL_STATUS_SUCCESS")
64
+ .map((m: any) => m.retrievedUrl || m.retrieved_url || m.url);
65
+
66
+ const failed = urlMeta
67
+ .filter((m: any) => (m.urlRetrievalStatus || m.url_retrieval_status) !== "URL_RETRIEVAL_STATUS_SUCCESS")
68
+ .map((m: any) => ({
69
+ url: m.retrievedUrl || m.retrieved_url || m.url,
70
+ status: m.urlRetrievalStatus || m.url_retrieval_status
71
+ }));
72
+
73
+ let summary = text;
74
+
75
+ // Add URL status if there were failures
76
+ if (failed.length > 0) {
77
+ summary += `\n\n## URL Status\nāœ… Retrieved: ${retrieved.length}\nāŒ Failed: ${failed.length}`;
78
+ failed.forEach((f: any) => { summary += `\n- ${f.url}: ${f.status}`; });
79
+ }
80
+
81
+ // Add sources
82
+ if (sources.length > 0) {
83
+ summary += `\n\n## Sources\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")}`;
84
+ }
85
+
86
+ return formatResult(summary, {
87
+ sources,
88
+ searchQueries: result.groundingMetadata?.webSearchQueries,
89
+ retrieved: retrieved.length > 0 ? retrieved : undefined,
90
+ failed: failed.length > 0 ? failed : undefined,
91
+ model: model.id,
92
+ grounded: sources.length > 0
93
+ });
94
+ } catch (e: any) {
95
+ return errorResult(e);
96
+ }
97
+ }
@@ -0,0 +1,64 @@
1
+ import type { ExtensionContext, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
2
+ import { Type, type Static } from "@sinclair/typebox";
3
+ import { callApiStream } from "./api.js";
4
+ import { getModel, missingConfigResult, errorResult, formatResult } from "./utils.js";
5
+ import { urlContext } from "./url_context.js";
6
+
7
+ export const YoutubeVideoSchema = Type.Object({
8
+ video_url: Type.String({ description: "YouTube video URL" }),
9
+ query: Type.String({ description: "Question or task about the video" }),
10
+ start_offset: Type.Optional(Type.String({ description: "Start time (e.g., '120s' or '2:00')" })),
11
+ end_offset: Type.Optional(Type.String({ description: "End time (e.g., '300s' or '5:00')" })),
12
+ });
13
+ export type YoutubeVideoInput = Static<typeof YoutubeVideoSchema>;
14
+
15
+ export async function youtubeVideo(
16
+ id: string,
17
+ params: YoutubeVideoInput,
18
+ signal: AbortSignal,
19
+ onUpdate: AgentToolUpdateCallback | undefined,
20
+ ctx: ExtensionContext
21
+ ) {
22
+ const model = await getModel(ctx);
23
+ if (!model) return missingConfigResult(ctx);
24
+
25
+ // Only google-generative-ai supports native video; others use URL context
26
+ if (model.api !== "google-generative-ai") {
27
+ let query = params.query;
28
+ if (params.start_offset || params.end_offset) {
29
+ query += "\n\nFocus on the video section";
30
+ if (params.start_offset) query += ` from ${params.start_offset}`;
31
+ if (params.end_offset) query += ` to ${params.end_offset}`;
32
+ query += ".";
33
+ }
34
+ return urlContext(id, { query, urls: [params.video_url] }, signal, onUpdate, ctx);
35
+ }
36
+
37
+ onUpdate?.({ content: [{ type: "text", text: `Analyzing YouTube video...` }], details: {} });
38
+
39
+ try {
40
+ const videoPart: any = {
41
+ file_data: { file_uri: params.video_url, mime_type: "video/mp4" }
42
+ };
43
+
44
+ if (params.start_offset || params.end_offset) {
45
+ videoPart.video_metadata = {};
46
+ if (params.start_offset) videoPart.video_metadata.start_offset = params.start_offset;
47
+ if (params.end_offset) videoPart.video_metadata.end_offset = params.end_offset;
48
+ }
49
+
50
+ const result = await callApiStream(ctx, model, {
51
+ contents: [{ role: "user", parts: [videoPart, { text: params.query }] }]
52
+ }, onUpdate);
53
+
54
+ return formatResult(result.text, {
55
+ videoUrl: params.video_url,
56
+ clipping: (params.start_offset || params.end_offset)
57
+ ? { start: params.start_offset, end: params.end_offset }
58
+ : undefined,
59
+ model: model.id
60
+ });
61
+ } catch (e: any) {
62
+ return errorResult(e);
63
+ }
64
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }