Finish backlog cleanup for Pi integration
This commit is contained in:
1210
.feynman/vendor-overrides/pi-web-access/curator-page.ts
Normal file
1210
.feynman/vendor-overrides/pi-web-access/curator-page.ts
Normal file
File diff suppressed because it is too large
Load Diff
325
.feynman/vendor-overrides/pi-web-access/curator-server.ts
Normal file
325
.feynman/vendor-overrides/pi-web-access/curator-server.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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";
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
147
.feynman/vendor-overrides/pi-web-access/exa.ts
Normal file
147
.feynman/vendor-overrides/pi-web-access/exa.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
256
.feynman/vendor-overrides/pi-web-access/gemini-search.ts
Normal file
256
.feynman/vendor-overrides/pi-web-access/gemini-search.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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 };
|
||||
}
|
||||
1658
.feynman/vendor-overrides/pi-web-access/index.ts
Normal file
1658
.feynman/vendor-overrides/pi-web-access/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -104,3 +104,12 @@ Use this file to track chronology, not release notes. Keep entries short, factua
|
||||
- Failed / learned: PR #14 is a stale branch with no clean merge path against current `main`; the only user-facing delta is the ValiChord prompt/skill addition, and the branch also carries unrelated release churn plus demo-style material, so it was not merged in this pass.
|
||||
- Blockers: None in the local repo state; remote merge/push still depends on repository credentials and branch policy.
|
||||
- Next: If remote write access is available, commit and push the validated maintenance changes, then close issue #22 and resolve PR #15 as merged while leaving PR #14 unmerged pending a cleaned-up, non-promotional resubmission.
|
||||
|
||||
### 2026-03-31 12:05 PDT — pi-backlog-cleanup-round-2
|
||||
|
||||
- Objective: Finish the remaining high-confidence open tracker items after the Pi 0.64.0 upgrade instead of leaving the issue list half-reconciled.
|
||||
- Changed: Added a Windows extension-loader patch helper so Feynman rewrites Pi extension imports to `file://` URLs on Windows before interactive startup; added `/commands`, `/tools`, and `/capabilities` discovery commands and surfaced `/hotkeys` plus `/service-tier` in help metadata; added explicit service-tier support via `feynman model tier`, `--service-tier`, status/doctor output, and a provider-payload hook that passes `service_tier` only to supported OpenAI/OpenAI Codex/Anthropic models; added Exa provider recognition to Feynman's web-search status layer and vendored `pi-web-access`.
|
||||
- Verified: Ran `npm test`, `npm run typecheck`, and `npm run build`; smoke-imported the modified vendored `pi-web-access` modules with `node --import tsx`.
|
||||
- Failed / learned: The remaining ValiChord PR is still stale and mixes a real prompt/skill update with unrelated branch churn; it is a review/triage item, not a clean merge candidate.
|
||||
- Blockers: No local build blockers remain; issue/PR closure still depends on the final push landing on `main`.
|
||||
- Next: Push the verified cleanup commit, then close issues fixed by the dependency bump plus the new discoverability/service-tier/Windows patches, and close the stale ValiChord PR explicitly instead of leaving it open indefinitely.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { registerAlphaTools } from "./research-tools/alpha.js";
|
||||
import { registerDiscoveryCommands } from "./research-tools/discovery.js";
|
||||
import { registerFeynmanModelCommand } from "./research-tools/feynman-model.js";
|
||||
import { installFeynmanHeader } from "./research-tools/header.js";
|
||||
import { registerHelpCommand } from "./research-tools/help.js";
|
||||
import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js";
|
||||
import { registerServiceTierControls } from "./research-tools/service-tier.js";
|
||||
|
||||
export default function researchTools(pi: ExtensionAPI): void {
|
||||
const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {};
|
||||
@@ -18,8 +20,10 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
registerAlphaTools(pi);
|
||||
registerDiscoveryCommands(pi);
|
||||
registerFeynmanModelCommand(pi);
|
||||
registerHelpCommand(pi);
|
||||
registerInitCommand(pi);
|
||||
registerOutputsCommand(pi);
|
||||
registerServiceTierControls(pi);
|
||||
}
|
||||
|
||||
130
extensions/research-tools/discovery.ts
Normal file
130
extensions/research-tools/discovery.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { ExtensionAPI, SlashCommandInfo, ToolInfo } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
function resolveFeynmanSettingsPath(): string {
|
||||
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
const agentDir = configured
|
||||
? configured.startsWith("~/")
|
||||
? resolve(homedir(), configured.slice(2))
|
||||
: resolve(configured)
|
||||
: resolve(homedir(), ".feynman", "agent");
|
||||
return resolve(agentDir, "settings.json");
|
||||
}
|
||||
|
||||
function readConfiguredPackages(): string[] {
|
||||
const settingsPath = resolveFeynmanSettingsPath();
|
||||
if (!existsSync(settingsPath)) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown[] };
|
||||
return Array.isArray(parsed.packages)
|
||||
? parsed.packages
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
const record = entry as { source?: unknown };
|
||||
return typeof record.source === "string" ? record.source : undefined;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatSourceLabel(sourceInfo: { source: string; path: string }): string {
|
||||
if (sourceInfo.source === "local") {
|
||||
if (sourceInfo.path.includes("/prompts/")) return "workflow";
|
||||
if (sourceInfo.path.includes("/extensions/")) return "extension";
|
||||
return "local";
|
||||
}
|
||||
return sourceInfo.source.replace(/^npm:/, "").replace(/^git:/, "");
|
||||
}
|
||||
|
||||
function formatCommandLine(command: SlashCommandInfo): string {
|
||||
const source = formatSourceLabel(command.sourceInfo);
|
||||
return `/${command.name} — ${command.description ?? ""} [${source}]`;
|
||||
}
|
||||
|
||||
function summarizeToolParameters(tool: ToolInfo): string {
|
||||
const properties =
|
||||
tool.parameters &&
|
||||
typeof tool.parameters === "object" &&
|
||||
"properties" in tool.parameters &&
|
||||
tool.parameters.properties &&
|
||||
typeof tool.parameters.properties === "object"
|
||||
? Object.keys(tool.parameters.properties as Record<string, unknown>)
|
||||
: [];
|
||||
return properties.length > 0 ? properties.join(", ") : "no parameters";
|
||||
}
|
||||
|
||||
function formatToolLine(tool: ToolInfo): string {
|
||||
const source = formatSourceLabel(tool.sourceInfo);
|
||||
return `${tool.name} — ${tool.description ?? ""} [${source}]`;
|
||||
}
|
||||
|
||||
export function registerDiscoveryCommands(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("commands", {
|
||||
description: "Browse all available slash commands, including package and built-in commands.",
|
||||
handler: async (_args, ctx) => {
|
||||
const commands = pi
|
||||
.getCommands()
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const items = commands.map((command) => formatCommandLine(command));
|
||||
const selected = await ctx.ui.select("Slash Commands", items);
|
||||
if (!selected) return;
|
||||
ctx.ui.setEditorText(selected.split(" — ")[0] ?? "");
|
||||
ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("tools", {
|
||||
description: "Browse all callable tools with their source and parameter summary.",
|
||||
handler: async (_args, ctx) => {
|
||||
const tools = pi
|
||||
.getAllTools()
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const selected = await ctx.ui.select("Tools", tools.map((tool) => formatToolLine(tool)));
|
||||
if (!selected) return;
|
||||
|
||||
const toolName = selected.split(" — ")[0] ?? selected;
|
||||
const tool = tools.find((entry) => entry.name === toolName);
|
||||
if (!tool) return;
|
||||
ctx.ui.notify(`${tool.name}: ${summarizeToolParameters(tool)}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("capabilities", {
|
||||
description: "Show installed packages, discovery entrypoints, and high-level runtime capability counts.",
|
||||
handler: async (_args, ctx) => {
|
||||
const commands = pi.getCommands();
|
||||
const tools = pi.getAllTools();
|
||||
const workflows = commands.filter((command) => formatSourceLabel(command.sourceInfo) === "workflow");
|
||||
const packages = readConfiguredPackages();
|
||||
const items = [
|
||||
`Commands: ${commands.length}`,
|
||||
`Workflows: ${workflows.length}`,
|
||||
`Tools: ${tools.length}`,
|
||||
`Packages: ${packages.length}`,
|
||||
"--- Discovery ---",
|
||||
"/commands — browse slash commands",
|
||||
"/tools — inspect callable tools",
|
||||
"/hotkeys — view keyboard shortcuts",
|
||||
"/service-tier — set request tier for supported providers",
|
||||
"--- Installed Packages ---",
|
||||
...packages.map((pkg) => pkg),
|
||||
];
|
||||
const selected = await ctx.ui.select("Capabilities", items);
|
||||
if (!selected || selected.startsWith("---")) return;
|
||||
if (selected.startsWith("/")) {
|
||||
ctx.ui.setEditorText(selected.split(" — ")[0] ?? selected);
|
||||
ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
174
extensions/research-tools/service-tier.ts
Normal file
174
extensions/research-tools/service-tier.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { homedir } from "node:os";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const FEYNMAN_SERVICE_TIERS = [
|
||||
"auto",
|
||||
"default",
|
||||
"flex",
|
||||
"priority",
|
||||
"standard_only",
|
||||
] as const;
|
||||
|
||||
type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number];
|
||||
|
||||
const SERVICE_TIER_SET = new Set<string>(FEYNMAN_SERVICE_TIERS);
|
||||
const OPENAI_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "default", "flex", "priority"]);
|
||||
const ANTHROPIC_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "standard_only"]);
|
||||
|
||||
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
||||
|
||||
type SelectOption<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
};
|
||||
|
||||
function resolveFeynmanSettingsPath(): string {
|
||||
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
const agentDir = configured
|
||||
? configured.startsWith("~/")
|
||||
? resolve(homedir(), configured.slice(2))
|
||||
: resolve(configured)
|
||||
: resolve(homedir(), ".feynman", "agent");
|
||||
return resolve(agentDir, "settings.json");
|
||||
}
|
||||
|
||||
function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined;
|
||||
}
|
||||
|
||||
function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string };
|
||||
return normalizeServiceTier(parsed.serviceTier);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void {
|
||||
let settings: Record<string, unknown> = {};
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {}
|
||||
|
||||
if (tier) {
|
||||
settings.serviceTier = tier;
|
||||
} else {
|
||||
delete settings.serviceTier;
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
|
||||
return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath);
|
||||
}
|
||||
|
||||
function resolveProviderServiceTier(
|
||||
provider: string | undefined,
|
||||
tier: FeynmanServiceTier | undefined,
|
||||
): FeynmanServiceTier | undefined {
|
||||
if (!provider || !tier) return undefined;
|
||||
if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) {
|
||||
return tier;
|
||||
}
|
||||
if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) {
|
||||
return tier;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function selectOption<T>(
|
||||
ctx: CommandContext,
|
||||
title: string,
|
||||
options: SelectOption<T>[],
|
||||
): Promise<T | undefined> {
|
||||
const selected = await ctx.ui.select(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
);
|
||||
if (!selected) return undefined;
|
||||
return options.find((option) => option.label === selected)?.value;
|
||||
}
|
||||
|
||||
function parseRequestedTier(rawArgs: string): FeynmanServiceTier | null | undefined {
|
||||
const trimmed = rawArgs.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed === "unset" || trimmed === "clear" || trimmed === "off") return null;
|
||||
return normalizeServiceTier(trimmed);
|
||||
}
|
||||
|
||||
export function registerServiceTierControls(pi: ExtensionAPI): void {
|
||||
pi.on("before_provider_request", (event, ctx) => {
|
||||
if (!ctx.model || !event.payload || typeof event.payload !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTier = resolveActiveServiceTier(resolveFeynmanSettingsPath());
|
||||
const providerTier = resolveProviderServiceTier(ctx.model.provider, activeTier);
|
||||
if (!providerTier) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...(event.payload as Record<string, unknown>),
|
||||
service_tier: providerTier,
|
||||
};
|
||||
});
|
||||
|
||||
pi.registerCommand("service-tier", {
|
||||
description: "View or set the provider service tier override used for supported models.",
|
||||
handler: async (args, ctx) => {
|
||||
const settingsPath = resolveFeynmanSettingsPath();
|
||||
const requested = parseRequestedTier(args);
|
||||
|
||||
if (requested === undefined && !args.trim()) {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify(getConfiguredServiceTier(settingsPath) ?? "not set", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getConfiguredServiceTier(settingsPath);
|
||||
const selected = await selectOption(
|
||||
ctx,
|
||||
"Select service tier",
|
||||
[
|
||||
{ label: current ? `unset (current: ${current})` : "unset (current)", value: null },
|
||||
...FEYNMAN_SERVICE_TIERS.map((tier) => ({
|
||||
label: tier === current ? `${tier} (current)` : tier,
|
||||
value: tier,
|
||||
})),
|
||||
],
|
||||
);
|
||||
if (selected === undefined) return;
|
||||
if (selected === null) {
|
||||
setConfiguredServiceTier(settingsPath, undefined);
|
||||
ctx.ui.notify("Cleared service tier override.", "info");
|
||||
return;
|
||||
}
|
||||
setConfiguredServiceTier(settingsPath, selected);
|
||||
ctx.ui.notify(`Service tier set to ${selected}.`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requested === null) {
|
||||
setConfiguredServiceTier(settingsPath, undefined);
|
||||
ctx.ui.notify("Cleared service tier override.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requested) {
|
||||
ctx.ui.notify("Use auto, default, flex, priority, standard_only, or unset.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setConfiguredServiceTier(settingsPath, requested);
|
||||
ctx.ui.notify(`Service tier set to ${requested}.`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -35,10 +35,14 @@ export function readPromptSpecs(appRoot) {
|
||||
}
|
||||
|
||||
export const extensionCommandSpecs = [
|
||||
{ name: "capabilities", args: "", section: "Project & Session", description: "Show installed packages, discovery entrypoints, and runtime capability counts.", publicDocs: true },
|
||||
{ name: "commands", args: "", section: "Project & Session", description: "Browse all available slash commands, including built-in and package commands.", publicDocs: true },
|
||||
{ name: "help", args: "", section: "Project & Session", description: "Show grouped Feynman commands and prefill the editor with a selected command.", publicDocs: true },
|
||||
{ name: "feynman-model", args: "", section: "Project & Session", description: "Open Feynman model menu (main + per-subagent overrides).", publicDocs: true },
|
||||
{ name: "init", args: "", section: "Project & Session", description: "Bootstrap AGENTS.md and session-log folders for a research project.", publicDocs: true },
|
||||
{ name: "outputs", args: "", section: "Project & Session", description: "Browse all research artifacts (papers, outputs, experiments, notes).", publicDocs: true },
|
||||
{ name: "service-tier", args: "", section: "Project & Session", description: "View or set the provider service tier override for supported models.", publicDocs: true },
|
||||
{ name: "tools", args: "", section: "Project & Session", description: "Browse all callable tools with their source and parameter summary.", publicDocs: true },
|
||||
];
|
||||
|
||||
export const livePackageCommandGroups = [
|
||||
@@ -58,6 +62,7 @@ export const livePackageCommandGroups = [
|
||||
{ name: "schedule-prompt", usage: "/schedule-prompt" },
|
||||
{ name: "search", usage: "/search" },
|
||||
{ name: "preview", usage: "/preview" },
|
||||
{ name: "hotkeys", usage: "/hotkeys" },
|
||||
{ name: "new", usage: "/new" },
|
||||
{ name: "quit", usage: "/quit" },
|
||||
{ name: "exit", usage: "/exit" },
|
||||
@@ -84,6 +89,7 @@ export const cliCommandSections = [
|
||||
{ usage: "feynman model login [id]", description: "Login to a Pi OAuth model provider." },
|
||||
{ usage: "feynman model logout [id]", description: "Logout from a Pi OAuth model provider." },
|
||||
{ usage: "feynman model set <provider/model>", description: "Set the default model." },
|
||||
{ usage: "feynman model tier [value]", description: "View or set the request service tier override." },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -111,6 +117,7 @@ export const legacyFlags = [
|
||||
{ usage: "--alpha-logout", description: "Clear alphaXiv auth and exit." },
|
||||
{ usage: "--alpha-status", description: "Show alphaXiv auth status and exit." },
|
||||
{ usage: "--model <provider:model>", description: "Force a specific model." },
|
||||
{ usage: "--service-tier <tier>", description: "Override request service tier for this run." },
|
||||
{ usage: "--thinking <level>", description: "Set thinking level: off | minimal | low | medium | high | xhigh." },
|
||||
{ usage: "--cwd <path>", description: "Set the working directory for tools." },
|
||||
{ usage: "--session-dir <path>", description: "Set the session storage directory." },
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
".feynman/settings.json",
|
||||
".feynman/SYSTEM.md",
|
||||
".feynman/themes/",
|
||||
".feynman/vendor-overrides/",
|
||||
"extensions/",
|
||||
"prompts/",
|
||||
"logo.mjs",
|
||||
|
||||
1
scripts/lib/pi-extension-loader-patch.d.mts
Normal file
1
scripts/lib/pi-extension-loader-patch.d.mts
Normal file
@@ -0,0 +1 @@
|
||||
export function patchPiExtensionLoaderSource(source: string): string;
|
||||
32
scripts/lib/pi-extension-loader-patch.mjs
Normal file
32
scripts/lib/pi-extension-loader-patch.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
const PATH_TO_FILE_URL_IMPORT = 'import { fileURLToPath, pathToFileURL } from "node:url";';
|
||||
const FILE_URL_TO_PATH_IMPORT = 'import { fileURLToPath } from "node:url";';
|
||||
|
||||
const IMPORT_CALL = 'const module = await jiti.import(extensionPath, { default: true });';
|
||||
const PATCHED_IMPORT_CALL = [
|
||||
' const extensionSpecifier = process.platform === "win32" && path.isAbsolute(extensionPath)',
|
||||
' ? pathToFileURL(extensionPath).href',
|
||||
' : extensionPath;',
|
||||
' const module = await jiti.import(extensionSpecifier, { default: true });',
|
||||
].join("\n");
|
||||
|
||||
export function patchPiExtensionLoaderSource(source) {
|
||||
let patched = source;
|
||||
|
||||
if (patched.includes(PATH_TO_FILE_URL_IMPORT) || patched.includes(PATCHED_IMPORT_CALL)) {
|
||||
return patched;
|
||||
}
|
||||
|
||||
if (patched.includes(FILE_URL_TO_PATH_IMPORT)) {
|
||||
patched = patched.replace(FILE_URL_TO_PATH_IMPORT, PATH_TO_FILE_URL_IMPORT);
|
||||
}
|
||||
|
||||
if (!patched.includes(PATH_TO_FILE_URL_IMPORT)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (!patched.includes(IMPORT_CALL)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return patched.replace(IMPORT_CALL, PATCHED_IMPORT_CALL);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FEYNMAN_LOGO_HTML } from "../logo.mjs";
|
||||
import { patchPiExtensionLoaderSource } from "./lib/pi-extension-loader-patch.mjs";
|
||||
import { PI_SUBAGENTS_PATCH_TARGETS, patchPiSubagentsSource } from "./lib/pi-subagents-patch.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -52,9 +53,11 @@ const cliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "cli.js") : null;
|
||||
const bunCliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "bun", "cli.js") : null;
|
||||
const interactiveModePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.js") : null;
|
||||
const interactiveThemePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "theme", "theme.js") : null;
|
||||
const extensionLoaderPath = piPackageRoot ? resolve(piPackageRoot, "dist", "core", "extensions", "loader.js") : null;
|
||||
const terminalPath = piTuiRoot ? resolve(piTuiRoot, "dist", "terminal.js") : null;
|
||||
const editorPath = piTuiRoot ? resolve(piTuiRoot, "dist", "components", "editor.js") : null;
|
||||
const workspaceRoot = resolve(appRoot, ".feynman", "npm", "node_modules");
|
||||
const vendorOverrideRoot = resolve(appRoot, ".feynman", "vendor-overrides");
|
||||
const piSubagentsRoot = resolve(workspaceRoot, "pi-subagents");
|
||||
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
|
||||
const sessionSearchIndexerPath = resolve(
|
||||
@@ -181,6 +184,18 @@ function resolveExecutable(name, fallbackPaths = []) {
|
||||
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() {
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
@@ -352,6 +367,14 @@ if (interactiveModePath && existsSync(interactiveModePath)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionLoaderPath && existsSync(extensionLoaderPath)) {
|
||||
const source = readFileSync(extensionLoaderPath, "utf8");
|
||||
const patched = patchPiExtensionLoaderSource(source);
|
||||
if (patched !== source) {
|
||||
writeFileSync(extensionLoaderPath, patched, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
if (interactiveThemePath && existsSync(interactiveThemePath)) {
|
||||
let themeSource = readFileSync(interactiveThemePath, "utf8");
|
||||
const desiredGetEditorTheme = [
|
||||
@@ -517,6 +540,16 @@ if (editorPath && existsSync(editorPath)) {
|
||||
}
|
||||
|
||||
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");
|
||||
if (source.includes('pi.registerCommand("search",')) {
|
||||
writeFileSync(
|
||||
|
||||
32
src/cli.ts
32
src/cli.ts
@@ -19,6 +19,7 @@ import { launchPiChat } from "./pi/launch.js";
|
||||
import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js";
|
||||
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
|
||||
import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js";
|
||||
import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js";
|
||||
import {
|
||||
authenticateModelProvider,
|
||||
getCurrentModelSpec,
|
||||
@@ -151,6 +152,29 @@ async function handleModelCommand(subcommand: string | undefined, args: string[]
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "tier") {
|
||||
const requested = args[0];
|
||||
if (!requested) {
|
||||
console.log(getConfiguredServiceTier(feynmanSettingsPath) ?? "not set");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requested === "unset" || requested === "clear" || requested === "off") {
|
||||
setConfiguredServiceTier(feynmanSettingsPath, undefined);
|
||||
console.log("Cleared service tier override");
|
||||
return;
|
||||
}
|
||||
|
||||
const tier = normalizeServiceTier(requested);
|
||||
if (!tier) {
|
||||
throw new Error("Usage: feynman model tier <auto|default|flex|priority|standard_only|unset>");
|
||||
}
|
||||
|
||||
setConfiguredServiceTier(feynmanSettingsPath, tier);
|
||||
console.log(`Service tier set to ${tier}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown model command: ${subcommand}`);
|
||||
}
|
||||
|
||||
@@ -311,6 +335,7 @@ export async function main(): Promise<void> {
|
||||
model: { type: "string" },
|
||||
"new-session": { type: "boolean" },
|
||||
prompt: { type: "string" },
|
||||
"service-tier": { type: "string" },
|
||||
"session-dir": { type: "string" },
|
||||
"setup-preview": { type: "boolean" },
|
||||
thinking: { type: "string" },
|
||||
@@ -437,6 +462,13 @@ export async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
|
||||
const explicitServiceTier = normalizeServiceTier(values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER);
|
||||
if ((values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER) && !explicitServiceTier) {
|
||||
throw new Error("Unknown service tier. Use auto, default, flex, priority, or standard_only.");
|
||||
}
|
||||
if (explicitServiceTier) {
|
||||
process.env.FEYNMAN_SERVICE_TIER = explicitServiceTier;
|
||||
}
|
||||
if (explicitModelSpec) {
|
||||
const modelRegistry = createModelRegistry(feynmanAuthPath);
|
||||
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
|
||||
|
||||
65
src/model/service-tier.ts
Normal file
65
src/model/service-tier.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export const FEYNMAN_SERVICE_TIERS = [
|
||||
"auto",
|
||||
"default",
|
||||
"flex",
|
||||
"priority",
|
||||
"standard_only",
|
||||
] as const;
|
||||
|
||||
export type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number];
|
||||
|
||||
const SERVICE_TIER_SET = new Set<string>(FEYNMAN_SERVICE_TIERS);
|
||||
const OPENAI_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "default", "flex", "priority"]);
|
||||
const ANTHROPIC_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "standard_only"]);
|
||||
|
||||
function readSettings(settingsPath: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined;
|
||||
}
|
||||
|
||||
export function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
|
||||
const settings = readSettings(settingsPath);
|
||||
return normalizeServiceTier(typeof settings.serviceTier === "string" ? settings.serviceTier : undefined);
|
||||
}
|
||||
|
||||
export function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void {
|
||||
const settings = readSettings(settingsPath);
|
||||
if (tier) {
|
||||
settings.serviceTier = tier;
|
||||
} else {
|
||||
delete settings.serviceTier;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
|
||||
return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath);
|
||||
}
|
||||
|
||||
export function resolveProviderServiceTier(
|
||||
provider: string | undefined,
|
||||
tier: FeynmanServiceTier | undefined,
|
||||
): FeynmanServiceTier | undefined {
|
||||
if (!provider || !tier) return undefined;
|
||||
if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) {
|
||||
return tier;
|
||||
}
|
||||
if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) {
|
||||
return tier;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini";
|
||||
export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini";
|
||||
|
||||
export type PiWebAccessConfig = Record<string, unknown> & {
|
||||
provider?: PiWebSearchProvider;
|
||||
searchProvider?: PiWebSearchProvider;
|
||||
perplexityApiKey?: string;
|
||||
exaApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
chromeProfile?: string;
|
||||
};
|
||||
@@ -17,6 +18,7 @@ export type PiWebAccessStatus = {
|
||||
searchProvider: PiWebSearchProvider;
|
||||
requestProvider: PiWebSearchProvider;
|
||||
perplexityConfigured: boolean;
|
||||
exaConfigured: boolean;
|
||||
geminiApiConfigured: boolean;
|
||||
chromeProfile?: string;
|
||||
routeLabel: string;
|
||||
@@ -28,7 +30,7 @@ export function getPiWebSearchConfigPath(home = process.env.HOME ?? homedir()):
|
||||
}
|
||||
|
||||
function normalizeProvider(value: unknown): PiWebSearchProvider | undefined {
|
||||
return value === "auto" || value === "perplexity" || value === "gemini" ? value : undefined;
|
||||
return value === "auto" || value === "perplexity" || value === "exa" || value === "gemini" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | undefined {
|
||||
@@ -52,6 +54,8 @@ function formatRouteLabel(provider: PiWebSearchProvider): string {
|
||||
switch (provider) {
|
||||
case "perplexity":
|
||||
return "Perplexity";
|
||||
case "exa":
|
||||
return "Exa";
|
||||
case "gemini":
|
||||
return "Gemini";
|
||||
default:
|
||||
@@ -63,10 +67,12 @@ function formatRouteNote(provider: PiWebSearchProvider): string {
|
||||
switch (provider) {
|
||||
case "perplexity":
|
||||
return "Pi web-access will use Perplexity for search.";
|
||||
case "exa":
|
||||
return "Pi web-access will use Exa for search.";
|
||||
case "gemini":
|
||||
return "Pi web-access will use Gemini API or Gemini Browser.";
|
||||
default:
|
||||
return "Pi web-access will try Perplexity, then Gemini API, then Gemini Browser.";
|
||||
return "Pi web-access will try Perplexity, then Exa, then Gemini API, then Gemini Browser.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +83,7 @@ export function getPiWebAccessStatus(
|
||||
const searchProvider = normalizeProvider(config.searchProvider) ?? "auto";
|
||||
const requestProvider = normalizeProvider(config.provider) ?? searchProvider;
|
||||
const perplexityConfigured = Boolean(normalizeNonEmptyString(config.perplexityApiKey));
|
||||
const exaConfigured = Boolean(normalizeNonEmptyString(config.exaApiKey));
|
||||
const geminiApiConfigured = Boolean(normalizeNonEmptyString(config.geminiApiKey));
|
||||
const chromeProfile = normalizeNonEmptyString(config.chromeProfile);
|
||||
const effectiveProvider = searchProvider;
|
||||
@@ -86,6 +93,7 @@ export function getPiWebAccessStatus(
|
||||
searchProvider,
|
||||
requestProvider,
|
||||
perplexityConfigured,
|
||||
exaConfigured,
|
||||
geminiApiConfigured,
|
||||
chromeProfile,
|
||||
routeLabel: formatRouteLabel(effectiveProvider),
|
||||
@@ -101,6 +109,7 @@ export function formatPiWebAccessDoctorLines(
|
||||
` search route: ${status.routeLabel}`,
|
||||
` request route: ${status.requestProvider}`,
|
||||
` perplexity api: ${status.perplexityConfigured ? "configured" : "not configured"}`,
|
||||
` exa api: ${status.exaConfigured ? "configured" : "not configured"}`,
|
||||
` gemini api: ${status.geminiApiConfigured ? "configured" : "not configured"}`,
|
||||
` browser profile: ${status.chromeProfile ?? "default Chromium profile"}`,
|
||||
` config path: ${status.configPath}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function printSearchStatus(): void {
|
||||
printInfo(`Search route: ${status.routeLabel}`);
|
||||
printInfo(`Request route: ${status.requestProvider}`);
|
||||
printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`);
|
||||
printInfo(`Exa API configured: ${status.exaConfigured ? "yes" : "no"}`);
|
||||
printInfo(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`);
|
||||
printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`);
|
||||
printInfo(`Config path: ${status.configPath}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { printInfo, printPanel, printSection } from "../ui/terminal.js";
|
||||
import { getCurrentModelSpec } from "../model/commands.js";
|
||||
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
|
||||
import { createModelRegistry, getModelsJsonPath } from "../model/registry.js";
|
||||
import { getConfiguredServiceTier } from "../model/service-tier.js";
|
||||
|
||||
function findProvidersMissingApiKey(modelsJsonPath: string): string[] {
|
||||
try {
|
||||
@@ -105,6 +106,7 @@ export function runStatus(options: DoctorOptions): void {
|
||||
printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`);
|
||||
printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`);
|
||||
printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`);
|
||||
printInfo(`Service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
|
||||
printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
|
||||
|
||||
printSection("Paths");
|
||||
@@ -165,6 +167,7 @@ export function runDoctor(options: DoctorOptions): void {
|
||||
console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`);
|
||||
console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`);
|
||||
console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`);
|
||||
console.log(`service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
|
||||
console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`);
|
||||
if (modelStatus.recommendedModelReason) {
|
||||
console.log(` why: ${modelStatus.recommendedModelReason}`);
|
||||
|
||||
42
tests/pi-extension-loader-patch.test.ts
Normal file
42
tests/pi-extension-loader-patch.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { patchPiExtensionLoaderSource } from "../scripts/lib/pi-extension-loader-patch.mjs";
|
||||
|
||||
test("patchPiExtensionLoaderSource rewrites Windows extension imports to file URLs", () => {
|
||||
const input = [
|
||||
'import * as path from "node:path";',
|
||||
'import { fileURLToPath } from "node:url";',
|
||||
"async function loadExtensionModule(extensionPath) {",
|
||||
" const jiti = createJiti(import.meta.url);",
|
||||
' const module = await jiti.import(extensionPath, { default: true });',
|
||||
" return module;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const patched = patchPiExtensionLoaderSource(input);
|
||||
|
||||
assert.match(patched, /pathToFileURL/);
|
||||
assert.match(patched, /process\.platform === "win32"/);
|
||||
assert.match(patched, /path\.isAbsolute\(extensionPath\)/);
|
||||
assert.match(patched, /jiti\.import\(extensionSpecifier, \{ default: true \}\)/);
|
||||
});
|
||||
|
||||
test("patchPiExtensionLoaderSource is idempotent", () => {
|
||||
const input = [
|
||||
'import * as path from "node:path";',
|
||||
'import { fileURLToPath } from "node:url";',
|
||||
"async function loadExtensionModule(extensionPath) {",
|
||||
" const jiti = createJiti(import.meta.url);",
|
||||
' const module = await jiti.import(extensionPath, { default: true });',
|
||||
" return module;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const once = patchPiExtensionLoaderSource(input);
|
||||
const twice = patchPiExtensionLoaderSource(once);
|
||||
|
||||
assert.equal(twice, once);
|
||||
});
|
||||
@@ -19,6 +19,31 @@ test("loadPiWebAccessConfig returns empty config when Pi web config is missing",
|
||||
});
|
||||
|
||||
test("getPiWebAccessStatus reads Pi web-access config directly", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
|
||||
const configPath = getPiWebSearchConfigPath(root);
|
||||
mkdirSync(join(root, ".feynman"), { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
provider: "exa",
|
||||
searchProvider: "exa",
|
||||
exaApiKey: "exa_...",
|
||||
chromeProfile: "Profile 2",
|
||||
geminiApiKey: "AIza...",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath);
|
||||
assert.equal(status.routeLabel, "Exa");
|
||||
assert.equal(status.requestProvider, "exa");
|
||||
assert.equal(status.exaConfigured, true);
|
||||
assert.equal(status.geminiApiConfigured, true);
|
||||
assert.equal(status.perplexityConfigured, false);
|
||||
assert.equal(status.chromeProfile, "Profile 2");
|
||||
});
|
||||
|
||||
test("getPiWebAccessStatus reads Gemini routes directly", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
|
||||
const configPath = getPiWebSearchConfigPath(root);
|
||||
mkdirSync(join(root, ".feynman"), { recursive: true });
|
||||
@@ -36,6 +61,7 @@ test("getPiWebAccessStatus reads Pi web-access config directly", () => {
|
||||
const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath);
|
||||
assert.equal(status.routeLabel, "Gemini");
|
||||
assert.equal(status.requestProvider, "gemini");
|
||||
assert.equal(status.exaConfigured, false);
|
||||
assert.equal(status.geminiApiConfigured, true);
|
||||
assert.equal(status.perplexityConfigured, false);
|
||||
assert.equal(status.chromeProfile, "Profile 2");
|
||||
|
||||
41
tests/service-tier.test.ts
Normal file
41
tests/service-tier.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
getConfiguredServiceTier,
|
||||
normalizeServiceTier,
|
||||
resolveProviderServiceTier,
|
||||
setConfiguredServiceTier,
|
||||
} from "../src/model/service-tier.js";
|
||||
|
||||
test("normalizeServiceTier accepts supported values only", () => {
|
||||
assert.equal(normalizeServiceTier("priority"), "priority");
|
||||
assert.equal(normalizeServiceTier("standard_only"), "standard_only");
|
||||
assert.equal(normalizeServiceTier("FAST"), undefined);
|
||||
assert.equal(normalizeServiceTier(undefined), undefined);
|
||||
});
|
||||
|
||||
test("setConfiguredServiceTier persists and clears settings.json values", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "feynman-service-tier-"));
|
||||
const settingsPath = join(dir, "settings.json");
|
||||
|
||||
setConfiguredServiceTier(settingsPath, "priority");
|
||||
assert.equal(getConfiguredServiceTier(settingsPath), "priority");
|
||||
|
||||
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string };
|
||||
assert.equal(persisted.serviceTier, "priority");
|
||||
|
||||
setConfiguredServiceTier(settingsPath, undefined);
|
||||
assert.equal(getConfiguredServiceTier(settingsPath), undefined);
|
||||
});
|
||||
|
||||
test("resolveProviderServiceTier filters unsupported provider+tier pairs", () => {
|
||||
assert.equal(resolveProviderServiceTier("openai", "priority"), "priority");
|
||||
assert.equal(resolveProviderServiceTier("openai-codex", "flex"), "flex");
|
||||
assert.equal(resolveProviderServiceTier("anthropic", "standard_only"), "standard_only");
|
||||
assert.equal(resolveProviderServiceTier("anthropic", "priority"), undefined);
|
||||
assert.equal(resolveProviderServiceTier("google", "priority"), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user