pi-copilot-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/copilot-search.ts +227 -0
- package/index.ts +83 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pi-copilot-web
|
|
2
|
+
|
|
3
|
+
Minimal GitHub Copilot `web_search` for Pi. Reuses the `github-copilot` OAuth token from `pi` (`/login github-copilot`) - no extra API keys.
|
|
4
|
+
|
|
5
|
+
- `copilot_search({query, queries, numResults, recencyFilter, domainFilter})` -> answer + `Sources:` with citations
|
|
6
|
+
- Picks best `github-copilot/openai-responses` model (`gpt-5.6-terra` > `sol` > `luna` > others) that has credentials
|
|
7
|
+
- Base URL derived from token `proxy-ep=proxy.individual.githubcopilot.com` -> `https://api.individual.githubcopilot.com` (enterprise supported via stored `baseUrl`)
|
|
8
|
+
- Request: `POST {baseUrl}/responses` with `tools:[{type:"web_search"}]`, `tool_choice:"required"`, stream SSE, parse `url_citation` + `web_search_call.action.sources` (same parser as `nicobailon/pi-web-access`)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# local
|
|
14
|
+
cp -r pi-copilot-web ~/.pi/agent/extensions/
|
|
15
|
+
# or via pi install
|
|
16
|
+
pi install /home/code/pi-copilot-web
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Login once:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
# inside pi
|
|
23
|
+
/login github-copilot
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Free plan (`free_engaged_oss_quota`) works - tested with 2k completions/50 chats limits, `gpt-5.6-terra` succeeds.
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
copilot_search({"query":"github copilot free plan limits 2025"})
|
|
32
|
+
copilot_search({"queries":["typescript 5.7 release","rust 1.82 release"],"domainFilter":["github.com"]})
|
|
33
|
+
copilot_search({"query":"openai gpt-5 release","recencyFilter":"month","numResults":8})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Also: `/copilot-search-status` to check auth.
|
|
37
|
+
|
|
38
|
+
## How it mirrors pi-web-access/pi-codex-search/ttttmr
|
|
39
|
+
|
|
40
|
+
- Auth: `ctx.modelRegistry.getApiKeyAndHeaders(model)` loop over `github-copilot` models (like `ttttmr/src/api.ts:getAuth` + `Leechael/src/pi-auth.ts:resolveCodexAccountId`)
|
|
41
|
+
- Headers: merges `model.headers` (Copilot's `User-Agent: GitHubCopilotChat/0.35.0` etc) + `Authorization: Bearer <token>`
|
|
42
|
+
- SSE parsing: copied from `pi-web-access/openai-search.ts:parseOpenAIResponse/extractAnswer/extractSearchResults`
|
|
43
|
+
- Tool shape: same as `Leechael/pi-codex-search` but single `copilot_search` instead of `codex_search`
|
|
44
|
+
|
|
45
|
+
## Free tier notes
|
|
46
|
+
|
|
47
|
+
Free `free_engaged_oss_quota` token has `availableModelIds` including `gpt-5.6-terra/luna/sol` etc. Each `copilot_search` counts as one chat request (of 50/month). Avoid batching >5 queries at once.
|
|
48
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal GitHub Copilot web_search - reuses pi's github-copilot OAuth
|
|
3
|
+
* Pattern: copy from ttttmr/pi-web-search + nicobailon/pi-web-access openai-search.ts
|
|
4
|
+
* but auth source is github-copilot provider's openai-responses models
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export interface SearchResult { title: string; url: string; snippet: string; }
|
|
9
|
+
export interface SearchResponse { answer: string; results: SearchResult[]; }
|
|
10
|
+
|
|
11
|
+
export interface SearchOptions {
|
|
12
|
+
numResults?: number;
|
|
13
|
+
recencyFilter?: "day"|"week"|"month"|"year";
|
|
14
|
+
domainFilter?: string[];
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COPILOT_PROVIDER = "github-copilot";
|
|
19
|
+
const TIMEOUT_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
function trimTrailingSlash(s: string){ return s.replace(/\/+$/,""); }
|
|
22
|
+
|
|
23
|
+
function getBaseUrlFromToken(token: string, fallback: string): string {
|
|
24
|
+
const m = token.match(/proxy-ep=([^;]+)/);
|
|
25
|
+
if (!m) return fallback;
|
|
26
|
+
const proxyHost = m[1].toLowerCase();
|
|
27
|
+
// proxy.individual.githubcopilot.com -> api.individual.githubcopilot.com
|
|
28
|
+
if (!proxyHost.startsWith("proxy.")) return fallback;
|
|
29
|
+
return `https://${proxyHost.replace(/^proxy\./,"api.")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveCopilotBaseUrl(token: string|undefined, modelBaseUrl: string, authBaseUrl?: string): string {
|
|
33
|
+
if (authBaseUrl && authBaseUrl.trim()) return trimTrailingSlash(authBaseUrl);
|
|
34
|
+
if (token) {
|
|
35
|
+
const derived = getBaseUrlFromToken(token, "");
|
|
36
|
+
if (derived) return derived;
|
|
37
|
+
}
|
|
38
|
+
return trimTrailingSlash(modelBaseUrl);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveResponsesUrl(baseUrl: string): string {
|
|
42
|
+
return `${trimTrailingSlash(baseUrl)}/responses`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resolveCopilotAuth(ctx: ExtensionContext, signal?: AbortSignal){
|
|
46
|
+
// Try every github-copilot openai-responses model that has credentials
|
|
47
|
+
let models: any[] = [];
|
|
48
|
+
try { models = ctx.modelRegistry.getAll().filter((m:any)=> m.provider===COPILOT_PROVIDER && m.api==="openai-responses"); } catch {}
|
|
49
|
+
// prefer terra > sol > luna > others
|
|
50
|
+
const pref = (id:string)=> id.includes("terra") ? 0 : id.includes("sol") ? 1 : id.includes("luna") ? 2 : 10;
|
|
51
|
+
models.sort((a,b)=> pref(a.id)-pref(b.id) || b.id.localeCompare(a.id, undefined,{numeric:true}));
|
|
52
|
+
|
|
53
|
+
for (const model of models){
|
|
54
|
+
try {
|
|
55
|
+
const r: any = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
56
|
+
if (r.ok && r.apiKey) {
|
|
57
|
+
return { model, apiKey: r.apiKey as string, headers: (r.headers??{}) as Record<string,string>, baseUrl: (r.baseUrl as string|undefined) ?? model.baseUrl };
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
// fallback env
|
|
62
|
+
if (process.env.COPILOT_GITHUB_TOKEN) {
|
|
63
|
+
const m = models[0];
|
|
64
|
+
if (m) return { model:m, apiKey: process.env.COPILOT_GITHUB_TOKEN, headers:{}, baseUrl: m.baseUrl };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function isCopilotAvailable(ctx: ExtensionContext): Promise<boolean>{
|
|
70
|
+
const a = await resolveCopilotAuth(ctx);
|
|
71
|
+
return !!a;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildInstructions(options: SearchOptions): string {
|
|
75
|
+
const lines = ["Search the web and return a concise answer grounded only in the web results.", "Include clickable source citations when possible."];
|
|
76
|
+
if (options.recencyFilter) {
|
|
77
|
+
const labels: Record<string,string> = {day:"past 24 hours",week:"past week",month:"past month",year:"past year"};
|
|
78
|
+
lines.push(`Prefer sources from the ${labels[options.recencyFilter]}.`);
|
|
79
|
+
}
|
|
80
|
+
if (typeof options.numResults==="number" && options.numResults>0) lines.push(`Prefer around ${Math.min(Math.floor(options.numResults),20)} distinct sources.`);
|
|
81
|
+
if (options.domainFilter?.length) {
|
|
82
|
+
const includes = options.domainFilter.filter(d=>!d.startsWith("-"));
|
|
83
|
+
const excludes = options.domainFilter.filter(d=>d.startsWith("-")).map(d=>d.slice(1));
|
|
84
|
+
if (includes.length) lines.push(`Only use sources from: ${includes.join(", ")}.`);
|
|
85
|
+
if (excludes.length) lines.push(`Do not use sources from: ${excludes.join(", ")}.`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join(" ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeDomain(value: string): string|null {
|
|
91
|
+
let input = value.trim().toLowerCase();
|
|
92
|
+
if (!input) return null;
|
|
93
|
+
if (input.startsWith("-")) input=input.slice(1).trim();
|
|
94
|
+
try { const u = input.includes("://") ? new URL(input) : new URL(`https://${input}`); input=u.hostname; } catch { input=input.split("/")[0]?.split(":")[0]??""; }
|
|
95
|
+
input=input.replace(/^\.+|\.+$/g,"");
|
|
96
|
+
return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
|
|
97
|
+
}
|
|
98
|
+
function normalizeDomainFilters(domainFilter: string[]|undefined){
|
|
99
|
+
if (!domainFilter?.length) return null;
|
|
100
|
+
const allowed:string[]=[], blocked:string[]=[];
|
|
101
|
+
for(const raw of domainFilter){
|
|
102
|
+
const d=normalizeDomain(raw); if(!d) continue;
|
|
103
|
+
const target = raw.trim().startsWith("-") ? blocked : allowed;
|
|
104
|
+
if(!target.includes(d)) target.push(d);
|
|
105
|
+
}
|
|
106
|
+
return allowed.length||blocked.length ? { ...(allowed.length?{allowed_domains:allowed.slice(0,100)}:{}), ...(blocked.length?{blocked_domains:block.slice(0,100)}:{} )} : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- OpenAI responses SSE parsing (copied from pi-web-access/openai-search.ts, simplified) ----
|
|
110
|
+
function isWebSearchCall(item:any){ return !!item && typeof item==="object" && item.type==="web_search_call"; }
|
|
111
|
+
async function parseOpenAIResponse(res: Response){
|
|
112
|
+
const text = await res.text();
|
|
113
|
+
const trimmed = text.trim();
|
|
114
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")){
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(trimmed);
|
|
117
|
+
const payload = Array.isArray(parsed) ? {output:parsed} : (parsed && typeof parsed==="object" ? parsed as Record<string,unknown> : {output:[]});
|
|
118
|
+
const output = Array.isArray((payload as any).output) ? (payload as any).output : [];
|
|
119
|
+
return {payload, webSearchCallSeen: output.some(isWebSearchCall)};
|
|
120
|
+
} catch(e){ const m=e instanceof Error?e.message:String(e); throw new Error(`OpenAI API returned invalid JSON: ${m}`); }
|
|
121
|
+
}
|
|
122
|
+
const outputItems: unknown[]=[]; let completed: Record<string,unknown>|null=null; let seen=false;
|
|
123
|
+
for(const line of text.split("\n")){
|
|
124
|
+
if(!line.startsWith("data: ")) continue;
|
|
125
|
+
const data=line.slice(6).trim(); if(!data||data==="[DONE]") continue;
|
|
126
|
+
try{
|
|
127
|
+
const p=JSON.parse(data) as Record<string,unknown>;
|
|
128
|
+
if(typeof p.type==="string" && (p.type as string).startsWith("response.web_search_call")) seen=true;
|
|
129
|
+
if(p.type==="response.output_item.done" && (p as any).item){ outputItems.push((p as any).item); seen ||= isWebSearchCall((p as any).item); }
|
|
130
|
+
if((p.type==="response.done"||p.type==="response.completed") && (p as any).response && typeof (p as any).response==="object") completed=(p as any).response as Record<string,unknown>;
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
if(completed){ const out=Array.isArray((completed as any).output)?(completed as any).output:[]; const payload= out.length? completed: {...completed, output:outputItems}; return {payload, webSearchCallSeen: seen||out.some(isWebSearchCall)}; }
|
|
134
|
+
if(outputItems.length) return {payload:{output:outputItems}, webSearchCallSeen: seen||outputItems.some(isWebSearchCall)};
|
|
135
|
+
throw new Error("OpenAI API returned no parseable response output");
|
|
136
|
+
}
|
|
137
|
+
function cleanUrl(u:string){ try{ const url=new URL(u); if(url.searchParams.get("utm_source")==="openai") url.searchParams.delete("utm_source"); return url.toString(); } catch{ return u.replace(/[?&]utm_source=openai$/,""); } }
|
|
138
|
+
function snippetAround(text:string, start:unknown, end:unknown){
|
|
139
|
+
if(typeof start!=="number"||typeof end!=="number"||!text) return "";
|
|
140
|
+
const s=Math.max(0,start-100), e=Math.min(text.length, end+100);
|
|
141
|
+
const sn=text.slice(s,e).replace(/\[([^\]]*)\]\([^)]*\)/g,"$1").trim();
|
|
142
|
+
return sn.length>300? sn.slice(0,297)+"...":sn;
|
|
143
|
+
}
|
|
144
|
+
function addResult(results:SearchResult[], seen:Set<string>, url:unknown, title:unknown, snippet=""){
|
|
145
|
+
if(typeof url!=="string"||!url.trim()) return;
|
|
146
|
+
const cu=cleanUrl(url); if(seen.has(cu)) return; seen.add(cu);
|
|
147
|
+
results.push({title: typeof title==="string"&&title.trim()?title:cu, url:cu, snippet});
|
|
148
|
+
}
|
|
149
|
+
function extractSearchResults(output: unknown[], numResults: number|undefined): SearchResult[]{
|
|
150
|
+
const results:SearchResult[]=[]; const seen=new Set<string>();
|
|
151
|
+
for(const item of output){
|
|
152
|
+
if(!item||typeof item!=="object"||(item as any).type!=="message") continue;
|
|
153
|
+
const content=(item as any).content; if(!Array.isArray(content)) continue;
|
|
154
|
+
for(const part of content){
|
|
155
|
+
if(!part||typeof part!=="object") continue;
|
|
156
|
+
const text=typeof (part as any).text==="string"?(part as any).text:"";
|
|
157
|
+
const ann=(part as any).annotations; if(!Array.isArray(ann)) continue;
|
|
158
|
+
for(const a of ann){
|
|
159
|
+
if(!a||typeof a!=="object"||(a as any).type!=="url_citation") continue;
|
|
160
|
+
addResult(results, seen, (a as any).url, (a as any).title, snippetAround(text,(a as any).start_index,(a as any).end_index));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for(const item of output){
|
|
165
|
+
if(!item||typeof item!=="object"||(item as any).type!=="web_search_call") continue;
|
|
166
|
+
const v=item as any; const actionSources=v.action&&typeof v.action==="object"?v.action.sources:undefined;
|
|
167
|
+
const groups=[actionSources, v.sources, v.results];
|
|
168
|
+
for(const g of groups){
|
|
169
|
+
if(!Array.isArray(g)) continue;
|
|
170
|
+
for(const s of g){
|
|
171
|
+
if(!s||typeof s!=="object") continue;
|
|
172
|
+
const r=s as Record<string,unknown>;
|
|
173
|
+
addResult(results, seen, r.url??r.source_website_url, r.title??r.caption);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if(typeof numResults==="number"&&numResults>0) return results.slice(0, Math.min(Math.floor(numResults),20));
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
180
|
+
function extractAnswer(output: unknown[]): string{
|
|
181
|
+
const parts:string[]=[];
|
|
182
|
+
for(const item of output){
|
|
183
|
+
if(!item||typeof item!=="object"||(item as any).type!=="message") continue;
|
|
184
|
+
const c=(item as any).content; if(!Array.isArray(c)) continue;
|
|
185
|
+
for(const p of c){
|
|
186
|
+
if(!p||typeof p!=="object") continue;
|
|
187
|
+
const t=(p as any).text; if(typeof t==="string"&&t.trim()) parts.push(t);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return parts.join("\n").trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function searchWithCopilot(query: string, options: SearchOptions={}, ctx: ExtensionContext): Promise<SearchResponse>{
|
|
194
|
+
const auth = await resolveCopilotAuth(ctx, options.signal);
|
|
195
|
+
if(!auth) throw new Error("GitHub Copilot web search unavailable. Run /login github-copilot or set COPILOT_GITHUB_TOKEN");
|
|
196
|
+
const baseUrl = resolveCopilotBaseUrl(auth.apiKey, auth.model.baseUrl, auth.baseUrl);
|
|
197
|
+
const url = resolveResponsesUrl(baseUrl);
|
|
198
|
+
const instructions = buildInstructions(options);
|
|
199
|
+
const domainFilters = normalizeDomainFilters(options.domainFilter);
|
|
200
|
+
const webSearchTool: Record<string,unknown> = {type:"web_search"};
|
|
201
|
+
if(domainFilters) (webSearchTool as any).filters=domainFilters;
|
|
202
|
+
|
|
203
|
+
const headers: Record<string,string> = {...(auth.model.headers??{} as any), ...auth.headers, Authorization:`Bearer ${auth.apiKey}`, "Content-Type":"application/json", "OpenAI-Beta":"responses=experimental"};
|
|
204
|
+
|
|
205
|
+
const body = {
|
|
206
|
+
model: auth.model.id,
|
|
207
|
+
instructions,
|
|
208
|
+
input: [{role:"user", content:[{type:"input_text", text:query}]}],
|
|
209
|
+
tools: [webSearchTool],
|
|
210
|
+
include: ["web_search_call.action.sources"],
|
|
211
|
+
store:false, stream:true, tool_choice:"required" as const, parallel_tool_calls:true,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const ac = options.signal ? AbortSignal.any([AbortSignal.timeout(TIMEOUT_MS), options.signal]) : AbortSignal.timeout(TIMEOUT_MS);
|
|
215
|
+
const res = await fetch(url, {method:"POST", headers, body: JSON.stringify(body), signal: ac});
|
|
216
|
+
if(!res.ok){
|
|
217
|
+
const t = await res.text();
|
|
218
|
+
throw new Error(`Copilot API error ${res.status}: ${t.slice(0,500)}`);
|
|
219
|
+
}
|
|
220
|
+
const parsed = await parseOpenAIResponse(res);
|
|
221
|
+
const output = Array.isArray((parsed.payload as any).output) ? (parsed.payload as any).output : [];
|
|
222
|
+
if(!parsed.webSearchCallSeen) throw new Error("Copilot web_search returned no web_search_call (model may not support web_search)");
|
|
223
|
+
const answer = extractAnswer(output);
|
|
224
|
+
const results = extractSearchResults(output, options.numResults);
|
|
225
|
+
if(!answer && !results.length) throw new Error("Copilot web_search returned no answer or sources");
|
|
226
|
+
return {answer, results};
|
|
227
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionAPI, AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { searchWithCopilot, isCopilotAvailable } from "./copilot-search.ts";
|
|
5
|
+
|
|
6
|
+
export default function(pi: ExtensionAPI){
|
|
7
|
+
|
|
8
|
+
pi.registerTool({
|
|
9
|
+
name: "copilot_search",
|
|
10
|
+
label: "Copilot Web Search",
|
|
11
|
+
description: "Search the web via GitHub Copilot (reuses github-copilot OAuth from /login github-copilot). Uses Copilot's hosted web_search (gpt-5.6 terra/sol). Returns answer + sources. Use for current info, docs, release notes.",
|
|
12
|
+
parameters: Type.Object({
|
|
13
|
+
query: Type.String({description:"Search query"}),
|
|
14
|
+
queries: Type.Optional(Type.Array(Type.String(), {description:"Batch queries (1-5, run sequentially)"})),
|
|
15
|
+
numResults: Type.Optional(Type.Number({description:"Results per query, default 5, max 20"})),
|
|
16
|
+
recencyFilter: Type.Optional(Type.Union([Type.Literal("day"),Type.Literal("week"),Type.Literal("month"),Type.Literal("year")], {description:"Recency"})),
|
|
17
|
+
domainFilter: Type.Optional(Type.Array(Type.String(), {description:"Limit to domains, prefix - to exclude"})),
|
|
18
|
+
}),
|
|
19
|
+
async execute(toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<Record<string,unknown>>>{
|
|
20
|
+
const queries = (params as any).queries?.length ? (params as any).queries as string[] : [(params as any).query as string];
|
|
21
|
+
const clean = queries.map(q=>String(q).trim()).filter(Boolean);
|
|
22
|
+
if (!clean.length) return {content:[{type:"text", text:"Error: query required"}], details:{error:true}};
|
|
23
|
+
if (!(await isCopilotAvailable(ctx))){
|
|
24
|
+
return {content:[{type:"text", text:"GitHub Copilot not logged in. Run /login github-copilot (choose GitHub Copilot) or set COPILOT_GITHUB_TOKEN"}], details:{error:"auth"}};
|
|
25
|
+
}
|
|
26
|
+
onUpdate?.({content:[{type:"text", text:`Searching Copilot for "${clean[0].slice(0,80)}"${clean.length>1?` +${clean.length-1} more`:""}...`}], details:{streaming:true}});
|
|
27
|
+
try {
|
|
28
|
+
const allResults: Array<{query:string, answer:string, results:any[]}> = [];
|
|
29
|
+
const errors: string[]=[];
|
|
30
|
+
for (const q of clean.slice(0,5)){
|
|
31
|
+
try {
|
|
32
|
+
const r = await searchWithCopilot(q, {numResults:(params as any).numResults, recencyFilter:(params as any).recencyFilter, domainFilter:(params as any).domainFilter, signal}, ctx);
|
|
33
|
+
allResults.push({query:q, answer:r.answer, results:r.results});
|
|
34
|
+
onUpdate?.({content:[{type:"text", text: r.answer.slice(0,800)}], details:{streaming:true, query:q}});
|
|
35
|
+
} catch(e){
|
|
36
|
+
errors.push(`${q}: ${e instanceof Error?e.message:String(e)}`);
|
|
37
|
+
allResults.push({query:q, answer:`Error: ${e instanceof Error?e.message:String(e)}`, results:[]});
|
|
38
|
+
}
|
|
39
|
+
if(signal.aborted) break;
|
|
40
|
+
}
|
|
41
|
+
let out="";
|
|
42
|
+
for(const {query, answer, results} of allResults){
|
|
43
|
+
if(clean.length>1) out+=`## Query: "${query}"\n\n`;
|
|
44
|
+
out+= answer + "\n\n";
|
|
45
|
+
if(results.length) out+= `Sources:\n` + results.map((r,i)=> `${i+1}. ${r.title}\n ${r.url}`).join("\n\n") + "\n\n";
|
|
46
|
+
}
|
|
47
|
+
if(errors.length) out+= `\n---\nErrors: ${errors.join("; ")}`;
|
|
48
|
+
return {content:[{type:"text", text: out.trim() || "No results"}], details:{tool:"copilot_search", queries: clean, allResults, errors}};
|
|
49
|
+
} catch(e){
|
|
50
|
+
const msg = e instanceof Error? e.message: String(e);
|
|
51
|
+
return {content:[{type:"text", text:`Copilot search failed: ${msg}`}], details:{error:msg}};
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
renderCall(args, theme){
|
|
55
|
+
const q = (args as any).query || (args as any).queries?.[0] || "…";
|
|
56
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("copilot_search"))} ${theme.fg("accent", String(q).slice(0,80))}`,0,0);
|
|
57
|
+
},
|
|
58
|
+
renderResult(result, opts, theme){
|
|
59
|
+
const text = result.content.filter(p=>p.type==="text").map(p=>p.text).join("\n");
|
|
60
|
+
if(!opts.expanded) {
|
|
61
|
+
const firstLine = text.split("\n")[0]||"";
|
|
62
|
+
return new Text(theme.fg("toolOutput", firstLine.slice(0,200)),0,0);
|
|
63
|
+
}
|
|
64
|
+
const isErr = Boolean((result.details as any)?.error);
|
|
65
|
+
return new Text(theme.fg(isErr?"error":"toolOutput", text),0,0);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Also register as web_search alias if no other web_search exists (pi-web-search style fallback)
|
|
70
|
+
// We keep copilot_search primary to avoid colliding with pi-web-access/pi-web-search.
|
|
71
|
+
|
|
72
|
+
pi.registerCommand("copilot-search-status", {
|
|
73
|
+
description: "Check GitHub Copilot web_search status",
|
|
74
|
+
async handler(args, ctx){
|
|
75
|
+
const avail = await isCopilotAvailable(ctx);
|
|
76
|
+
if(!avail){ ctx.ui.notify("GitHub Copilot not logged in. Run /login github-copilot","warning"); return; }
|
|
77
|
+
// try to resolve model
|
|
78
|
+
const all = (ctx.modelRegistry as any).getAll?.() ?? [];
|
|
79
|
+
const copilotModels = all.filter((m:any)=> m.provider==="github-copilot");
|
|
80
|
+
ctx.ui.notify(`Copilot OK: ${copilotModels.length} models (e.g. ${copilotModels.slice(0,3).map((m:any)=>m.id).join(", ")}) - try copilot_search`, "info");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-copilot-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitHub Copilot web_search for Pi - reuses github-copilot OAuth, no extra API keys. Free plan works.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package","pi","pi-extension","web-search","github-copilot","copilot"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "wassname",
|
|
9
|
+
"repository": {"type":"git","url":"git+https://github.com/wassname/pi-copilot-web.git"},
|
|
10
|
+
"homepage": "https://github.com/wassname/pi-copilot-web#readme",
|
|
11
|
+
"bugs": {"url":"https://github.com/wassname/pi-copilot-web/issues"},
|
|
12
|
+
"files": ["index.ts","copilot-search.ts","README.md"],
|
|
13
|
+
"pi": {"extensions":["./index.ts"]},
|
|
14
|
+
"publishConfig": {"access":"public"},
|
|
15
|
+
"peerDependencies": {"@earendil-works/pi-coding-agent":"*","@earendil-works/pi-ai":"*","@earendil-works/pi-tui":"*"},
|
|
16
|
+
"devDependencies": {"typescript":"^5.9.0"},
|
|
17
|
+
"engines": {"node": ">=22"}
|
|
18
|
+
}
|