remove stale web access override
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,325 +0,0 @@
|
|||||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
|
||||||
import { generateCuratorPage } from "./curator-page.js";
|
|
||||||
|
|
||||||
const STALE_THRESHOLD_MS = 30000;
|
|
||||||
const WATCHDOG_INTERVAL_MS = 5000;
|
|
||||||
const MAX_BODY_SIZE = 64 * 1024;
|
|
||||||
|
|
||||||
type ServerState = "SEARCHING" | "RESULT_SELECTION" | "COMPLETED";
|
|
||||||
|
|
||||||
export interface CuratorServerOptions {
|
|
||||||
queries: string[];
|
|
||||||
sessionToken: string;
|
|
||||||
timeout: number;
|
|
||||||
availableProviders: { perplexity: boolean; exa: boolean; gemini: boolean };
|
|
||||||
defaultProvider: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CuratorServerCallbacks {
|
|
||||||
onSubmit: (selectedQueryIndices: number[]) => void;
|
|
||||||
onCancel: (reason: "user" | "timeout" | "stale") => void;
|
|
||||||
onProviderChange: (provider: string) => void;
|
|
||||||
onAddSearch: (query: string, queryIndex: number) => Promise<{ answer: string; results: Array<{ title: string; url: string; domain: string }> }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CuratorServerHandle {
|
|
||||||
server: http.Server;
|
|
||||||
url: string;
|
|
||||||
close: () => void;
|
|
||||||
pushResult: (queryIndex: number, data: { answer: string; results: Array<{ title: string; url: string; domain: string }> }) => void;
|
|
||||||
pushError: (queryIndex: number, error: string) => void;
|
|
||||||
searchesDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
||||||
res.writeHead(status, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJSONBody(req: IncomingMessage): Promise<unknown> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = "";
|
|
||||||
let size = 0;
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
|
||||||
size += chunk.length;
|
|
||||||
if (size > MAX_BODY_SIZE) {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error("Request body too large"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
try { resolve(JSON.parse(body)); }
|
|
||||||
catch { reject(new Error("Invalid JSON")); }
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startCuratorServer(
|
|
||||||
options: CuratorServerOptions,
|
|
||||||
callbacks: CuratorServerCallbacks,
|
|
||||||
): Promise<CuratorServerHandle> {
|
|
||||||
const { queries, sessionToken, timeout, availableProviders, defaultProvider } = options;
|
|
||||||
let browserConnected = false;
|
|
||||||
let lastHeartbeatAt = Date.now();
|
|
||||||
let completed = false;
|
|
||||||
let watchdog: NodeJS.Timeout | null = null;
|
|
||||||
let state: ServerState = "SEARCHING";
|
|
||||||
let sseResponse: ServerResponse | null = null;
|
|
||||||
const sseBuffer: string[] = [];
|
|
||||||
let nextQueryIndex = queries.length;
|
|
||||||
|
|
||||||
let sseKeepalive: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
const markCompleted = (): boolean => {
|
|
||||||
if (completed) return false;
|
|
||||||
completed = true;
|
|
||||||
state = "COMPLETED";
|
|
||||||
if (watchdog) { clearInterval(watchdog); watchdog = null; }
|
|
||||||
if (sseKeepalive) { clearInterval(sseKeepalive); sseKeepalive = null; }
|
|
||||||
if (sseResponse) {
|
|
||||||
try { sseResponse.end(); } catch {}
|
|
||||||
sseResponse = null;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const touchHeartbeat = (): void => {
|
|
||||||
lastHeartbeatAt = Date.now();
|
|
||||||
browserConnected = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
function validateToken(body: unknown, res: ServerResponse): boolean {
|
|
||||||
if (!body || typeof body !== "object") {
|
|
||||||
sendJson(res, 400, { ok: false, error: "Invalid body" });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ((body as { token?: string }).token !== sessionToken) {
|
|
||||||
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendSSE(event: string, data: unknown): void {
|
|
||||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
||||||
const res = sseResponse;
|
|
||||||
if (res && !res.writableEnded && res.socket && !res.socket.destroyed) {
|
|
||||||
try {
|
|
||||||
const ok = res.write(payload);
|
|
||||||
if (!ok) res.once("drain", () => {});
|
|
||||||
} catch {
|
|
||||||
sseBuffer.push(payload);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sseBuffer.push(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageHtml = generateCuratorPage(queries, sessionToken, timeout, availableProviders, defaultProvider);
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const method = req.method || "GET";
|
|
||||||
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
||||||
|
|
||||||
if (method === "GET" && url.pathname === "/") {
|
|
||||||
const token = url.searchParams.get("session");
|
|
||||||
if (token !== sessionToken) {
|
|
||||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
||||||
res.end("Invalid session");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
touchHeartbeat();
|
|
||||||
res.writeHead(200, {
|
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
});
|
|
||||||
res.end(pageHtml);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "GET" && url.pathname === "/events") {
|
|
||||||
const token = url.searchParams.get("session");
|
|
||||||
if (token !== sessionToken) {
|
|
||||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
||||||
res.end("Invalid session");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state === "COMPLETED") {
|
|
||||||
sendJson(res, 409, { ok: false, error: "No events available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sseResponse) {
|
|
||||||
try { sseResponse.end(); } catch {}
|
|
||||||
}
|
|
||||||
res.writeHead(200, {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
});
|
|
||||||
res.flushHeaders();
|
|
||||||
if (res.socket) res.socket.setNoDelay(true);
|
|
||||||
sseResponse = res;
|
|
||||||
for (const msg of sseBuffer) {
|
|
||||||
try { res.write(msg); } catch {}
|
|
||||||
}
|
|
||||||
sseBuffer.length = 0;
|
|
||||||
if (sseKeepalive) clearInterval(sseKeepalive);
|
|
||||||
sseKeepalive = setInterval(() => {
|
|
||||||
if (sseResponse) {
|
|
||||||
try { sseResponse.write(":keepalive\n\n"); } catch {}
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
req.on("close", () => {
|
|
||||||
if (sseResponse === res) sseResponse = null;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && url.pathname === "/heartbeat") {
|
|
||||||
const body = await parseJSONBody(req).catch(() => null);
|
|
||||||
if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
|
|
||||||
if (!validateToken(body, res)) return;
|
|
||||||
touchHeartbeat();
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && url.pathname === "/provider") {
|
|
||||||
const body = await parseJSONBody(req).catch(() => null);
|
|
||||||
if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
|
|
||||||
if (!validateToken(body, res)) return;
|
|
||||||
const { provider } = body as { provider?: string };
|
|
||||||
if (typeof provider === "string" && provider.length > 0) {
|
|
||||||
setImmediate(() => callbacks.onProviderChange(provider));
|
|
||||||
}
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && url.pathname === "/search") {
|
|
||||||
const body = await parseJSONBody(req).catch(() => null);
|
|
||||||
if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
|
|
||||||
if (!validateToken(body, res)) return;
|
|
||||||
if (state === "COMPLETED") {
|
|
||||||
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { query } = body as { query?: string };
|
|
||||||
if (typeof query !== "string" || query.trim().length === 0) {
|
|
||||||
sendJson(res, 400, { ok: false, error: "Invalid query" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const qi = nextQueryIndex++;
|
|
||||||
touchHeartbeat();
|
|
||||||
try {
|
|
||||||
const result = await callbacks.onAddSearch(query.trim(), qi);
|
|
||||||
sendJson(res, 200, { ok: true, queryIndex: qi, answer: result.answer, results: result.results });
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Search failed";
|
|
||||||
sendJson(res, 200, { ok: true, queryIndex: qi, error: message });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && url.pathname === "/submit") {
|
|
||||||
const body = await parseJSONBody(req).catch(() => null);
|
|
||||||
if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
|
|
||||||
if (!validateToken(body, res)) return;
|
|
||||||
const { selected } = body as { selected?: number[] };
|
|
||||||
if (!Array.isArray(selected) || !selected.every(n => typeof n === "number")) {
|
|
||||||
sendJson(res, 400, { ok: false, error: "Invalid selection" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state !== "SEARCHING" && state !== "RESULT_SELECTION") {
|
|
||||||
sendJson(res, 409, { ok: false, error: "Cannot submit in current state" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!markCompleted()) {
|
|
||||||
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
setImmediate(() => callbacks.onSubmit(selected));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && url.pathname === "/cancel") {
|
|
||||||
const body = await parseJSONBody(req).catch(() => null);
|
|
||||||
if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
|
|
||||||
if (!validateToken(body, res)) return;
|
|
||||||
if (!markCompleted()) {
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { reason } = body as { reason?: string };
|
|
||||||
sendJson(res, 200, { ok: true });
|
|
||||||
const cancelReason = reason === "timeout" ? "timeout" : "user";
|
|
||||||
setImmediate(() => callbacks.onCancel(cancelReason));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
||||||
res.end("Not found");
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Server error";
|
|
||||||
sendJson(res, 500, { ok: false, error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onError = (err: Error) => {
|
|
||||||
reject(new Error(`Curator server failed to start: ${err.message}`));
|
|
||||||
};
|
|
||||||
|
|
||||||
server.once("error", onError);
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
server.off("error", onError);
|
|
||||||
const addr = server.address();
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
reject(new Error("Curator server: invalid address"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = `http://localhost:${addr.port}/?session=${sessionToken}`;
|
|
||||||
|
|
||||||
watchdog = setInterval(() => {
|
|
||||||
if (completed || !browserConnected) return;
|
|
||||||
if (Date.now() - lastHeartbeatAt <= STALE_THRESHOLD_MS) return;
|
|
||||||
if (!markCompleted()) return;
|
|
||||||
setImmediate(() => callbacks.onCancel("stale"));
|
|
||||||
}, WATCHDOG_INTERVAL_MS);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
server,
|
|
||||||
url,
|
|
||||||
close: () => {
|
|
||||||
const wasOpen = markCompleted();
|
|
||||||
try { server.close(); } catch {}
|
|
||||||
if (wasOpen) {
|
|
||||||
setImmediate(() => callbacks.onCancel("stale"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pushResult: (queryIndex, data) => {
|
|
||||||
if (completed) return;
|
|
||||||
sendSSE("result", { queryIndex, query: queries[queryIndex] ?? "", ...data });
|
|
||||||
},
|
|
||||||
pushError: (queryIndex, error) => {
|
|
||||||
if (completed) return;
|
|
||||||
sendSSE("search-error", { queryIndex, query: queries[queryIndex] ?? "", error });
|
|
||||||
},
|
|
||||||
searchesDone: () => {
|
|
||||||
if (completed) return;
|
|
||||||
sendSSE("done", {});
|
|
||||||
state = "RESULT_SELECTION";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { activityMonitor } from "./activity.js";
|
|
||||||
|
|
||||||
import type { SearchOptions, SearchResponse, SearchResult } from "./perplexity.js";
|
|
||||||
|
|
||||||
const EXA_API_URL = "https://api.exa.ai/search";
|
|
||||||
const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
|
|
||||||
|
|
||||||
interface WebSearchConfig {
|
|
||||||
exaApiKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExaSearchResult {
|
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
text?: string;
|
|
||||||
highlights?: string[];
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedConfig: WebSearchConfig | null = null;
|
|
||||||
|
|
||||||
function loadConfig(): WebSearchConfig {
|
|
||||||
if (cachedConfig) return cachedConfig;
|
|
||||||
|
|
||||||
if (existsSync(CONFIG_PATH)) {
|
|
||||||
try {
|
|
||||||
cachedConfig = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebSearchConfig;
|
|
||||||
return cachedConfig;
|
|
||||||
} catch {
|
|
||||||
cachedConfig = {};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cachedConfig = {};
|
|
||||||
}
|
|
||||||
return cachedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiKey(): string {
|
|
||||||
const config = loadConfig();
|
|
||||||
const key = process.env.EXA_API_KEY || config.exaApiKey;
|
|
||||||
if (!key) {
|
|
||||||
throw new Error(
|
|
||||||
"Exa API key not found. Either:\n" +
|
|
||||||
` 1. Create ${CONFIG_PATH} with { "exaApiKey": "your-key" }\n` +
|
|
||||||
" 2. Set EXA_API_KEY environment variable\n" +
|
|
||||||
"Get a key from the Exa dashboard."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSnippet(result: ExaSearchResult): string {
|
|
||||||
if (Array.isArray(result.highlights) && result.highlights.length > 0) {
|
|
||||||
return result.highlights.join(" ");
|
|
||||||
}
|
|
||||||
if (typeof result.summary === "string" && result.summary.trim()) {
|
|
||||||
return result.summary.trim();
|
|
||||||
}
|
|
||||||
if (typeof result.text === "string" && result.text.trim()) {
|
|
||||||
return result.text.trim().slice(0, 400);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAnswer(results: SearchResult[]): string {
|
|
||||||
return results
|
|
||||||
.map((result, index) => {
|
|
||||||
const snippet = result.snippet ? `\n${result.snippet}` : "";
|
|
||||||
return `${index + 1}. ${result.title}\n${result.url}${snippet}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isExaAvailable(): boolean {
|
|
||||||
const config = loadConfig();
|
|
||||||
return Boolean(process.env.EXA_API_KEY || config.exaApiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchWithExa(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
|
|
||||||
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
||||||
const apiKey = getApiKey();
|
|
||||||
const numResults = Math.min(options.numResults ?? 5, 20);
|
|
||||||
const includeDomains = options.domainFilter?.filter((entry) => !entry.startsWith("-")) ?? [];
|
|
||||||
const excludeDomains = options.domainFilter?.filter((entry) => entry.startsWith("-")).map((entry) => entry.slice(1)) ?? [];
|
|
||||||
|
|
||||||
const requestBody: Record<string, unknown> = {
|
|
||||||
query,
|
|
||||||
type: "auto",
|
|
||||||
numResults,
|
|
||||||
contents: {
|
|
||||||
highlights: {
|
|
||||||
numSentences: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeDomains.length > 0) {
|
|
||||||
requestBody.includeDomains = includeDomains;
|
|
||||||
}
|
|
||||||
if (excludeDomains.length > 0) {
|
|
||||||
requestBody.excludeDomains = excludeDomains;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(EXA_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal: options.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
activityMonitor.logComplete(activityId, response.status);
|
|
||||||
throw new Error(`Exa API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as { results?: ExaSearchResult[] };
|
|
||||||
const results = (Array.isArray(data.results) ? data.results : [])
|
|
||||||
.slice(0, numResults)
|
|
||||||
.map((result, index) => ({
|
|
||||||
title: result.title?.trim() || `Source ${index + 1}`,
|
|
||||||
url: result.url?.trim() || "",
|
|
||||||
snippet: toSnippet(result),
|
|
||||||
}))
|
|
||||||
.filter((result) => result.url.length > 0);
|
|
||||||
|
|
||||||
activityMonitor.logComplete(activityId, response.status);
|
|
||||||
return {
|
|
||||||
answer: formatAnswer(results),
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (message.toLowerCase().includes("abort")) {
|
|
||||||
activityMonitor.logComplete(activityId, 0);
|
|
||||||
} else {
|
|
||||||
activityMonitor.logError(activityId, message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { activityMonitor } from "./activity.js";
|
|
||||||
import { isExaAvailable, searchWithExa } from "./exa.js";
|
|
||||||
import { getApiKey, API_BASE, DEFAULT_MODEL } from "./gemini-api.js";
|
|
||||||
import { isGeminiWebAvailable, queryWithCookies } from "./gemini-web.js";
|
|
||||||
import { isPerplexityAvailable, searchWithPerplexity, type SearchResult, type SearchResponse, type SearchOptions } from "./perplexity.js";
|
|
||||||
|
|
||||||
export type SearchProvider = "auto" | "perplexity" | "exa" | "gemini";
|
|
||||||
|
|
||||||
const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
|
|
||||||
|
|
||||||
let cachedSearchConfig: { searchProvider: SearchProvider; searchModel?: string } | null = null;
|
|
||||||
|
|
||||||
function getSearchConfig(): { searchProvider: SearchProvider; searchModel?: string } {
|
|
||||||
if (cachedSearchConfig) return cachedSearchConfig;
|
|
||||||
try {
|
|
||||||
if (existsSync(CONFIG_PATH)) {
|
|
||||||
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as {
|
|
||||||
searchProvider?: SearchProvider;
|
|
||||||
searchModel?: unknown;
|
|
||||||
};
|
|
||||||
cachedSearchConfig = {
|
|
||||||
searchProvider: raw.searchProvider ?? "auto",
|
|
||||||
searchModel: normalizeSearchModel(raw.searchModel),
|
|
||||||
};
|
|
||||||
return cachedSearchConfig;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
cachedSearchConfig = { searchProvider: "auto", searchModel: undefined };
|
|
||||||
return cachedSearchConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSearchModel(value: unknown): string | undefined {
|
|
||||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FullSearchOptions extends SearchOptions {
|
|
||||||
provider?: SearchProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function search(query: string, options: FullSearchOptions = {}): Promise<SearchResponse> {
|
|
||||||
const config = getSearchConfig();
|
|
||||||
const provider = options.provider ?? config.searchProvider;
|
|
||||||
|
|
||||||
if (provider === "perplexity") {
|
|
||||||
return searchWithPerplexity(query, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "exa") {
|
|
||||||
return searchWithExa(query, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "gemini") {
|
|
||||||
const result = await searchWithGeminiApi(query, options)
|
|
||||||
?? await searchWithGeminiWeb(query, options);
|
|
||||||
if (result) return result;
|
|
||||||
throw new Error(
|
|
||||||
"Gemini search unavailable. Either:\n" +
|
|
||||||
" 1. Set GEMINI_API_KEY in ~/.pi/web-search.json\n" +
|
|
||||||
" 2. Sign into gemini.google.com in a supported Chromium-based browser"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPerplexityAvailable()) {
|
|
||||||
return searchWithPerplexity(query, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExaAvailable()) {
|
|
||||||
return searchWithExa(query, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const geminiResult = await searchWithGeminiApi(query, options)
|
|
||||||
?? await searchWithGeminiWeb(query, options);
|
|
||||||
if (geminiResult) return geminiResult;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"No search provider available. Either:\n" +
|
|
||||||
" 1. Set perplexityApiKey in ~/.pi/web-search.json\n" +
|
|
||||||
" 2. Set exaApiKey or EXA_API_KEY\n" +
|
|
||||||
" 3. Set GEMINI_API_KEY in ~/.pi/web-search.json\n" +
|
|
||||||
" 4. Sign into gemini.google.com in a supported Chromium-based browser"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchWithGeminiApi(query: string, options: SearchOptions = {}): Promise<SearchResponse | null> {
|
|
||||||
const apiKey = getApiKey();
|
|
||||||
if (!apiKey) return null;
|
|
||||||
|
|
||||||
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const model = getSearchConfig().searchModel ?? DEFAULT_MODEL;
|
|
||||||
const body = {
|
|
||||||
contents: [{ parts: [{ text: query }] }],
|
|
||||||
tools: [{ google_search: {} }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/models/${model}:generateContent?key=${apiKey}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.any([
|
|
||||||
AbortSignal.timeout(60000),
|
|
||||||
...(options.signal ? [options.signal] : []),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text();
|
|
||||||
throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as GeminiSearchResponse;
|
|
||||||
activityMonitor.logComplete(activityId, res.status);
|
|
||||||
|
|
||||||
const answer = data.candidates?.[0]?.content?.parts
|
|
||||||
?.map(p => p.text).filter(Boolean).join("\n") ?? "";
|
|
||||||
|
|
||||||
const metadata = data.candidates?.[0]?.groundingMetadata;
|
|
||||||
const results = await resolveGroundingChunks(metadata?.groundingChunks, options.signal);
|
|
||||||
|
|
||||||
if (!answer && results.length === 0) return null;
|
|
||||||
return { answer, results };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.toLowerCase().includes("abort")) {
|
|
||||||
activityMonitor.logComplete(activityId, 0);
|
|
||||||
} else {
|
|
||||||
activityMonitor.logError(activityId, message);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchWithGeminiWeb(query: string, options: SearchOptions = {}): Promise<SearchResponse | null> {
|
|
||||||
const cookies = await isGeminiWebAvailable();
|
|
||||||
if (!cookies) return null;
|
|
||||||
|
|
||||||
const prompt = buildSearchPrompt(query, options);
|
|
||||||
const activityId = activityMonitor.logStart({ type: "api", query });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await queryWithCookies(prompt, cookies, {
|
|
||||||
model: "gemini-3-flash-preview",
|
|
||||||
signal: options.signal,
|
|
||||||
timeoutMs: 60000,
|
|
||||||
});
|
|
||||||
|
|
||||||
activityMonitor.logComplete(activityId, 200);
|
|
||||||
|
|
||||||
const results = extractSourceUrls(text);
|
|
||||||
return { answer: text, results };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.toLowerCase().includes("abort")) {
|
|
||||||
activityMonitor.logComplete(activityId, 0);
|
|
||||||
} else {
|
|
||||||
activityMonitor.logError(activityId, message);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchPrompt(query: string, options: SearchOptions): string {
|
|
||||||
let prompt = `Search the web and answer the following question. Include source URLs for your claims.\nFormat your response as:\n1. A direct answer to the question\n2. Cited sources as markdown links\n\nQuestion: ${query}`;
|
|
||||||
|
|
||||||
if (options.recencyFilter) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
day: "past 24 hours",
|
|
||||||
week: "past week",
|
|
||||||
month: "past month",
|
|
||||||
year: "past year",
|
|
||||||
};
|
|
||||||
prompt += `\n\nOnly include results from the ${labels[options.recencyFilter]}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.domainFilter?.length) {
|
|
||||||
const includes = options.domainFilter.filter(d => !d.startsWith("-"));
|
|
||||||
const excludes = options.domainFilter.filter(d => d.startsWith("-")).map(d => d.slice(1));
|
|
||||||
if (includes.length) prompt += `\n\nOnly cite sources from: ${includes.join(", ")}`;
|
|
||||||
if (excludes.length) prompt += `\n\nDo not cite sources from: ${excludes.join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSourceUrls(markdown: string): SearchResult[] {
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
|
|
||||||
for (const match of markdown.matchAll(linkRegex)) {
|
|
||||||
const url = match[2];
|
|
||||||
if (seen.has(url)) continue;
|
|
||||||
seen.add(url);
|
|
||||||
results.push({ title: match[1], url, snippet: "" });
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveGroundingChunks(
|
|
||||||
chunks: GroundingChunk[] | undefined,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<SearchResult[]> {
|
|
||||||
if (!chunks?.length) return [];
|
|
||||||
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (!chunk.web) continue;
|
|
||||||
const title = chunk.web.title || "";
|
|
||||||
let url = chunk.web.uri || "";
|
|
||||||
|
|
||||||
if (url.includes("vertexaisearch.cloud.google.com/grounding-api-redirect")) {
|
|
||||||
const resolved = await resolveRedirect(url, signal);
|
|
||||||
if (resolved) url = resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url) results.push({ title, url, snippet: "" });
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveRedirect(proxyUrl: string, signal?: AbortSignal): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(proxyUrl, {
|
|
||||||
method: "HEAD",
|
|
||||||
redirect: "manual",
|
|
||||||
signal: AbortSignal.any([
|
|
||||||
AbortSignal.timeout(5000),
|
|
||||||
...(signal ? [signal] : []),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
return res.headers.get("location") || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeminiSearchResponse {
|
|
||||||
candidates?: Array<{
|
|
||||||
content?: { parts?: Array<{ text?: string }> };
|
|
||||||
groundingMetadata?: {
|
|
||||||
webSearchQueries?: string[];
|
|
||||||
groundingChunks?: GroundingChunk[];
|
|
||||||
groundingSupports?: Array<{
|
|
||||||
segment?: { startIndex?: number; endIndex?: number; text?: string };
|
|
||||||
groundingChunkIndices?: number[];
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroundingChunk {
|
|
||||||
web?: { uri?: string; title?: string };
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -149,3 +149,12 @@ Use this file to track chronology, not release notes. Keep entries short, factua
|
|||||||
- Failed / learned: The MiniMax provider catalog in Pi already uses canonical IDs like `MiniMax-M2.7`, so the only failure during validation was a test assertion using the wrong casing rather than a runtime bug.
|
- Failed / learned: The MiniMax provider catalog in Pi already uses canonical IDs like `MiniMax-M2.7`, so the only failure during validation was a test assertion using the wrong casing rather than a runtime bug.
|
||||||
- Blockers: The Cloud Code Assist fix is validated by targeted patch tests and code-path review rather than an end-to-end Google account repro in this environment.
|
- Blockers: The Cloud Code Assist fix is validated by targeted patch tests and code-path review rather than an end-to-end Google account repro in this environment.
|
||||||
- Next: Push the tracker-triage commit, close the docs/MiniMax PRs as superseded by main, close the support-style model issues against the new docs, and decide whether the remaining feature requests should be left open or closed as not planned/upstream-dependent.
|
- Next: Push the tracker-triage commit, close the docs/MiniMax PRs as superseded by main, close the support-style model issues against the new docs, and decide whether the remaining feature requests should be left open or closed as not planned/upstream-dependent.
|
||||||
|
|
||||||
|
### 2026-04-10 10:22 PDT — web-access-stale-override-fix
|
||||||
|
|
||||||
|
- Objective: Fix the new `ctx.modelRegistry.getApiKeyAndHeaders is not a function` / stale `search-filter.js` report without reintroducing broad vendor drift.
|
||||||
|
- Changed: Removed the stale `.feynman/vendor-overrides/pi-web-access/*` files and removed `syncVendorOverride` from `scripts/patch-embedded-pi.mjs`; kept the targeted `pi-web-access` runtime config-path patch; added `feynman search set <provider> [api-key]` and `feynman search clear` commands with a shared save path in `src/pi/web-access.ts`.
|
||||||
|
- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`; ran `node scripts/patch-embedded-pi.mjs`, confirmed the installed `pi-web-access/index.ts` has no `search-filter` / condense helper references, and smoke-imported `./.feynman/npm/node_modules/pi-web-access/index.ts`; ran `npm pack --dry-run` and confirmed stale `vendor-overrides` files are no longer in the package tarball.
|
||||||
|
- Failed / learned: The public Linux installer Docker test was attempted but Docker Desktop became unresponsive even for simple `docker run node:22-bookworm node -v` commands; the earlier Linux npm-artifact container smoke remains valid, but this specific public-installer run is blocked by the local Docker daemon.
|
||||||
|
- Blockers: Issue `#54` is too underspecified to fix directly without logs; public Linux installer behavior still needs a stable Docker daemon or a real Linux shell to reproduce the user's exact npm errors.
|
||||||
|
- Next: Push the stale-override fix, close PR `#52` and PR `#53` as superseded/merged-by-main once pushed, and ask for logs on issue `#54` instead of guessing.
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export const cliCommandSections = [
|
|||||||
{ usage: "feynman packages list", description: "Show core and optional Pi package presets." },
|
{ usage: "feynman packages list", description: "Show core and optional Pi package presets." },
|
||||||
{ usage: "feynman packages install <preset>", description: "Install optional package presets on demand." },
|
{ usage: "feynman packages install <preset>", description: "Install optional package presets on demand." },
|
||||||
{ usage: "feynman search status", description: "Show Pi web-access status and config path." },
|
{ usage: "feynman search status", description: "Show Pi web-access status and config path." },
|
||||||
|
{ usage: "feynman search set <provider> [api-key]", description: "Set the web search provider and optionally save its API key." },
|
||||||
|
{ usage: "feynman search clear", description: "Reset web search provider to auto while preserving API keys." },
|
||||||
{ usage: "feynman update [package]", description: "Update installed packages, or a specific package." },
|
{ usage: "feynman update [package]", description: "Update installed packages, or a specific package." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
".feynman/settings.json",
|
".feynman/settings.json",
|
||||||
".feynman/SYSTEM.md",
|
".feynman/SYSTEM.md",
|
||||||
".feynman/themes/",
|
".feynman/themes/",
|
||||||
".feynman/vendor-overrides/",
|
|
||||||
"extensions/",
|
"extensions/",
|
||||||
"prompts/",
|
"prompts/",
|
||||||
"logo.mjs",
|
"logo.mjs",
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const workspaceExtensionLoaderPath = resolve(
|
|||||||
"extensions",
|
"extensions",
|
||||||
"loader.js",
|
"loader.js",
|
||||||
);
|
);
|
||||||
const vendorOverrideRoot = resolve(appRoot, ".feynman", "vendor-overrides");
|
|
||||||
const piSubagentsRoot = resolve(workspaceRoot, "pi-subagents");
|
const piSubagentsRoot = resolve(workspaceRoot, "pi-subagents");
|
||||||
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
|
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
|
||||||
const sessionSearchIndexerPath = resolve(
|
const sessionSearchIndexerPath = resolve(
|
||||||
@@ -211,18 +210,6 @@ function resolveExecutable(name, fallbackPaths = []) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncVendorOverride(relativePath) {
|
|
||||||
const sourcePath = resolve(vendorOverrideRoot, relativePath);
|
|
||||||
const targetPath = resolve(workspaceRoot, relativePath);
|
|
||||||
if (!existsSync(sourcePath) || !existsSync(targetPath)) return;
|
|
||||||
|
|
||||||
const source = readFileSync(sourcePath, "utf8");
|
|
||||||
const current = readFileSync(targetPath, "utf8");
|
|
||||||
if (source !== current) {
|
|
||||||
writeFileSync(targetPath, source, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensurePackageWorkspace() {
|
function ensurePackageWorkspace() {
|
||||||
if (!existsSync(settingsPath)) return;
|
if (!existsSync(settingsPath)) return;
|
||||||
|
|
||||||
@@ -571,16 +558,6 @@ if (editorPath && existsSync(editorPath)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(webAccessPath)) {
|
if (existsSync(webAccessPath)) {
|
||||||
for (const relativePath of [
|
|
||||||
"pi-web-access/index.ts",
|
|
||||||
"pi-web-access/gemini-search.ts",
|
|
||||||
"pi-web-access/curator-page.ts",
|
|
||||||
"pi-web-access/curator-server.ts",
|
|
||||||
"pi-web-access/exa.ts",
|
|
||||||
]) {
|
|
||||||
syncVendorOverride(relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = readFileSync(webAccessPath, "utf8");
|
const source = readFileSync(webAccessPath, "utf8");
|
||||||
if (source.includes('pi.registerCommand("search",')) {
|
if (source.includes('pi.registerCommand("search",')) {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
22
src/cli.ts
22
src/cli.ts
@@ -28,7 +28,8 @@ import {
|
|||||||
printModelList,
|
printModelList,
|
||||||
setDefaultModelSpec,
|
setDefaultModelSpec,
|
||||||
} from "./model/commands.js";
|
} from "./model/commands.js";
|
||||||
import { printSearchStatus } from "./search/commands.js";
|
import { clearSearchConfig, printSearchStatus, setSearchProvider } from "./search/commands.js";
|
||||||
|
import type { PiWebSearchProvider } from "./pi/web-access.js";
|
||||||
import { runDoctor, runStatus } from "./setup/doctor.js";
|
import { runDoctor, runStatus } from "./setup/doctor.js";
|
||||||
import { setupPreviewDependencies } from "./setup/preview.js";
|
import { setupPreviewDependencies } from "./setup/preview.js";
|
||||||
import { runSetup } from "./setup/setup.js";
|
import { runSetup } from "./setup/setup.js";
|
||||||
@@ -269,12 +270,27 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin
|
|||||||
console.log("Optional packages installed.");
|
console.log("Optional packages installed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchCommand(subcommand: string | undefined): void {
|
function handleSearchCommand(subcommand: string | undefined, args: string[]): void {
|
||||||
if (!subcommand || subcommand === "status") {
|
if (!subcommand || subcommand === "status") {
|
||||||
printSearchStatus();
|
printSearchStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subcommand === "set") {
|
||||||
|
const provider = args[0] as PiWebSearchProvider | undefined;
|
||||||
|
const validProviders: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"];
|
||||||
|
if (!provider || !validProviders.includes(provider)) {
|
||||||
|
throw new Error("Usage: feynman search set <auto|perplexity|exa|gemini> [api-key]");
|
||||||
|
}
|
||||||
|
setSearchProvider(provider, args[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "clear") {
|
||||||
|
clearSearchConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown search command: ${subcommand}`);
|
throw new Error(`Unknown search command: ${subcommand}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +458,7 @@ export async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "search") {
|
if (command === "search") {
|
||||||
handleSearchCommand(rest[0]);
|
handleSearchCommand(rest[0], rest.slice(1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { dirname, resolve } from "node:path";
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { getFeynmanHome } from "../config/paths.js";
|
import { getFeynmanHome } from "../config/paths.js";
|
||||||
|
|
||||||
export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini";
|
export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini";
|
||||||
@@ -53,6 +52,23 @@ export function loadPiWebAccessConfig(configPath = getPiWebSearchConfigPath()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function savePiWebAccessConfig(
|
||||||
|
updates: Partial<Record<keyof PiWebAccessConfig, unknown>>,
|
||||||
|
configPath = getPiWebSearchConfigPath(),
|
||||||
|
): void {
|
||||||
|
const merged: Record<string, unknown> = { ...loadPiWebAccessConfig(configPath) };
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete merged[key];
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(configPath), { recursive: true });
|
||||||
|
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
function formatRouteLabel(provider: PiWebSearchProvider): string {
|
function formatRouteLabel(provider: PiWebSearchProvider): string {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "perplexity":
|
case "perplexity":
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { getPiWebAccessStatus } from "../pi/web-access.js";
|
import {
|
||||||
|
getPiWebAccessStatus,
|
||||||
|
savePiWebAccessConfig,
|
||||||
|
type PiWebAccessConfig,
|
||||||
|
type PiWebSearchProvider,
|
||||||
|
} from "../pi/web-access.js";
|
||||||
import { printInfo } from "../ui/terminal.js";
|
import { printInfo } from "../ui/terminal.js";
|
||||||
|
|
||||||
|
const SEARCH_PROVIDERS: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"];
|
||||||
|
const PROVIDER_API_KEY_FIELDS: Partial<Record<PiWebSearchProvider, keyof PiWebAccessConfig>> = {
|
||||||
|
perplexity: "perplexityApiKey",
|
||||||
|
exa: "exaApiKey",
|
||||||
|
gemini: "geminiApiKey",
|
||||||
|
};
|
||||||
|
|
||||||
export function printSearchStatus(): void {
|
export function printSearchStatus(): void {
|
||||||
const status = getPiWebAccessStatus();
|
const status = getPiWebAccessStatus();
|
||||||
printInfo("Managed by: pi-web-access");
|
printInfo("Managed by: pi-web-access");
|
||||||
@@ -12,3 +24,35 @@ export function printSearchStatus(): void {
|
|||||||
printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`);
|
printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`);
|
||||||
printInfo(`Config path: ${status.configPath}`);
|
printInfo(`Config path: ${status.configPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSearchProvider(provider: PiWebSearchProvider, apiKey?: string): void {
|
||||||
|
if (!SEARCH_PROVIDERS.includes(provider)) {
|
||||||
|
throw new Error(`Usage: feynman search set <${SEARCH_PROVIDERS.join("|")}> [api-key]`);
|
||||||
|
}
|
||||||
|
if (apiKey !== undefined && provider === "auto") {
|
||||||
|
throw new Error("The auto provider does not use an API key. Usage: feynman search set auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<Record<keyof PiWebAccessConfig, unknown>> = {
|
||||||
|
provider,
|
||||||
|
searchProvider: provider,
|
||||||
|
route: undefined,
|
||||||
|
};
|
||||||
|
const apiKeyField = PROVIDER_API_KEY_FIELDS[provider];
|
||||||
|
if (apiKeyField && apiKey !== undefined) {
|
||||||
|
updates[apiKeyField] = apiKey;
|
||||||
|
}
|
||||||
|
savePiWebAccessConfig(updates);
|
||||||
|
|
||||||
|
const status = getPiWebAccessStatus();
|
||||||
|
console.log(`Web search provider set to ${status.routeLabel}.`);
|
||||||
|
console.log(`Config path: ${status.configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSearchConfig(): void {
|
||||||
|
savePiWebAccessConfig({ provider: undefined, searchProvider: undefined, route: undefined });
|
||||||
|
|
||||||
|
const status = getPiWebAccessStatus();
|
||||||
|
console.log(`Web search provider reset to ${status.routeLabel}.`);
|
||||||
|
console.log(`Config path: ${status.configPath}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getPiWebAccessStatus,
|
getPiWebAccessStatus,
|
||||||
getPiWebSearchConfigPath,
|
getPiWebSearchConfigPath,
|
||||||
loadPiWebAccessConfig,
|
loadPiWebAccessConfig,
|
||||||
|
savePiWebAccessConfig,
|
||||||
} from "../src/pi/web-access.js";
|
} from "../src/pi/web-access.js";
|
||||||
|
|
||||||
test("loadPiWebAccessConfig returns empty config when Pi web config is missing", () => {
|
test("loadPiWebAccessConfig returns empty config when Pi web config is missing", () => {
|
||||||
@@ -22,6 +23,26 @@ test("getPiWebSearchConfigPath respects FEYNMAN_HOME semantics", () => {
|
|||||||
assert.equal(getPiWebSearchConfigPath("/tmp/custom-home"), "/tmp/custom-home/.feynman/web-search.json");
|
assert.equal(getPiWebSearchConfigPath("/tmp/custom-home"), "/tmp/custom-home/.feynman/web-search.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("savePiWebAccessConfig merges updates and deletes undefined values", () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
|
||||||
|
const configPath = getPiWebSearchConfigPath(root);
|
||||||
|
|
||||||
|
savePiWebAccessConfig({
|
||||||
|
provider: "perplexity",
|
||||||
|
searchProvider: "perplexity",
|
||||||
|
perplexityApiKey: "pplx_...",
|
||||||
|
}, configPath);
|
||||||
|
savePiWebAccessConfig({
|
||||||
|
provider: undefined,
|
||||||
|
searchProvider: undefined,
|
||||||
|
route: undefined,
|
||||||
|
}, configPath);
|
||||||
|
|
||||||
|
assert.deepEqual(loadPiWebAccessConfig(configPath), {
|
||||||
|
perplexityApiKey: "pplx_...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("getPiWebAccessStatus reads Pi web-access config directly", () => {
|
test("getPiWebAccessStatus reads Pi web-access config directly", () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
|
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
|
||||||
const configPath = getPiWebSearchConfigPath(root);
|
const configPath = getPiWebSearchConfigPath(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user