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 +24 -0
- package/dist/advanced_search.d.ts +8 -0
- package/dist/advanced_search.js +76 -0
- package/dist/api.d.ts +26 -0
- package/dist/api.js +182 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +26 -0
- package/dist/url_context.d.ts +8 -0
- package/dist/url_context.js +55 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +63 -0
- package/dist/web_search.d.ts +8 -0
- package/dist/web_search.js +75 -0
- package/dist/youtube_video.d.ts +10 -0
- package/dist/youtube_video.js +54 -0
- package/package.json +42 -0
- package/run.sh +9 -0
- package/src/api.ts +225 -0
- package/src/index.ts +30 -0
- package/src/url_context.ts +72 -0
- package/src/utils.ts +79 -0
- package/src/web_search.ts +97 -0
- package/src/youtube_video.ts +64 -0
- package/tsconfig.json +14 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|