Finish backlog cleanup for Pi integration

This commit is contained in:
Advait Paliwal
2026-03-31 11:02:07 -07:00
parent d9812cf4f2
commit 554350cc0e
22 changed files with 4209 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View 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";
},
});
});
});
}

View 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;
}
}

View 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 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -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. - 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. - 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. - 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.

View File

@@ -1,10 +1,12 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { registerAlphaTools } from "./research-tools/alpha.js"; import { registerAlphaTools } from "./research-tools/alpha.js";
import { registerDiscoveryCommands } from "./research-tools/discovery.js";
import { registerFeynmanModelCommand } from "./research-tools/feynman-model.js"; import { registerFeynmanModelCommand } from "./research-tools/feynman-model.js";
import { installFeynmanHeader } from "./research-tools/header.js"; import { installFeynmanHeader } from "./research-tools/header.js";
import { registerHelpCommand } from "./research-tools/help.js"; import { registerHelpCommand } from "./research-tools/help.js";
import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js"; import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js";
import { registerServiceTierControls } from "./research-tools/service-tier.js";
export default function researchTools(pi: ExtensionAPI): void { export default function researchTools(pi: ExtensionAPI): void {
const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {}; const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {};
@@ -18,8 +20,10 @@ export default function researchTools(pi: ExtensionAPI): void {
}); });
registerAlphaTools(pi); registerAlphaTools(pi);
registerDiscoveryCommands(pi);
registerFeynmanModelCommand(pi); registerFeynmanModelCommand(pi);
registerHelpCommand(pi); registerHelpCommand(pi);
registerInitCommand(pi); registerInitCommand(pi);
registerOutputsCommand(pi); registerOutputsCommand(pi);
registerServiceTierControls(pi);
} }

View 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");
}
},
});
}

View 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");
},
});
}

View File

@@ -35,10 +35,14 @@ export function readPromptSpecs(appRoot) {
} }
export const extensionCommandSpecs = [ 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: "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: "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: "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: "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 = [ export const livePackageCommandGroups = [
@@ -58,6 +62,7 @@ export const livePackageCommandGroups = [
{ name: "schedule-prompt", usage: "/schedule-prompt" }, { name: "schedule-prompt", usage: "/schedule-prompt" },
{ name: "search", usage: "/search" }, { name: "search", usage: "/search" },
{ name: "preview", usage: "/preview" }, { name: "preview", usage: "/preview" },
{ name: "hotkeys", usage: "/hotkeys" },
{ name: "new", usage: "/new" }, { name: "new", usage: "/new" },
{ name: "quit", usage: "/quit" }, { name: "quit", usage: "/quit" },
{ name: "exit", usage: "/exit" }, { 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 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 logout [id]", description: "Logout from a Pi OAuth model provider." },
{ usage: "feynman model set <provider/model>", description: "Set the default model." }, { 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-logout", description: "Clear alphaXiv auth and exit." },
{ usage: "--alpha-status", description: "Show alphaXiv auth status and exit." }, { usage: "--alpha-status", description: "Show alphaXiv auth status and exit." },
{ usage: "--model <provider:model>", description: "Force a specific model." }, { 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: "--thinking <level>", description: "Set thinking level: off | minimal | low | medium | high | xhigh." },
{ usage: "--cwd <path>", description: "Set the working directory for tools." }, { usage: "--cwd <path>", description: "Set the working directory for tools." },
{ usage: "--session-dir <path>", description: "Set the session storage directory." }, { usage: "--session-dir <path>", description: "Set the session storage directory." },

View File

@@ -19,6 +19,7 @@
".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",

View File

@@ -0,0 +1 @@
export function patchPiExtensionLoaderSource(source: string): string;

View 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);
}

View File

@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { FEYNMAN_LOGO_HTML } from "../logo.mjs"; 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"; import { PI_SUBAGENTS_PATCH_TARGETS, patchPiSubagentsSource } from "./lib/pi-subagents-patch.mjs";
const here = dirname(fileURLToPath(import.meta.url)); 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 bunCliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "bun", "cli.js") : null;
const interactiveModePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.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 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 terminalPath = piTuiRoot ? resolve(piTuiRoot, "dist", "terminal.js") : null;
const editorPath = piTuiRoot ? resolve(piTuiRoot, "dist", "components", "editor.js") : null; const editorPath = piTuiRoot ? resolve(piTuiRoot, "dist", "components", "editor.js") : null;
const workspaceRoot = resolve(appRoot, ".feynman", "npm", "node_modules"); const workspaceRoot = resolve(appRoot, ".feynman", "npm", "node_modules");
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(
@@ -181,6 +184,18 @@ 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;
@@ -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)) { if (interactiveThemePath && existsSync(interactiveThemePath)) {
let themeSource = readFileSync(interactiveThemePath, "utf8"); let themeSource = readFileSync(interactiveThemePath, "utf8");
const desiredGetEditorTheme = [ const desiredGetEditorTheme = [
@@ -517,6 +540,16 @@ 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(

View File

@@ -19,6 +19,7 @@ import { launchPiChat } from "./pi/launch.js";
import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js"; import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js";
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js"; import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js"; import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js";
import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js";
import { import {
authenticateModelProvider, authenticateModelProvider,
getCurrentModelSpec, getCurrentModelSpec,
@@ -151,6 +152,29 @@ async function handleModelCommand(subcommand: string | undefined, args: string[]
return; 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}`); throw new Error(`Unknown model command: ${subcommand}`);
} }
@@ -311,6 +335,7 @@ export async function main(): Promise<void> {
model: { type: "string" }, model: { type: "string" },
"new-session": { type: "boolean" }, "new-session": { type: "boolean" },
prompt: { type: "string" }, prompt: { type: "string" },
"service-tier": { type: "string" },
"session-dir": { type: "string" }, "session-dir": { type: "string" },
"setup-preview": { type: "boolean" }, "setup-preview": { type: "boolean" },
thinking: { type: "string" }, thinking: { type: "string" },
@@ -437,6 +462,13 @@ export async function main(): Promise<void> {
} }
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL; 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) { if (explicitModelSpec) {
const modelRegistry = createModelRegistry(feynmanAuthPath); const modelRegistry = createModelRegistry(feynmanAuthPath);
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry); const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);

65
src/model/service-tier.ts Normal file
View 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;
}

View File

@@ -2,12 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini"; export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini";
export type PiWebAccessConfig = Record<string, unknown> & { export type PiWebAccessConfig = Record<string, unknown> & {
provider?: PiWebSearchProvider; provider?: PiWebSearchProvider;
searchProvider?: PiWebSearchProvider; searchProvider?: PiWebSearchProvider;
perplexityApiKey?: string; perplexityApiKey?: string;
exaApiKey?: string;
geminiApiKey?: string; geminiApiKey?: string;
chromeProfile?: string; chromeProfile?: string;
}; };
@@ -17,6 +18,7 @@ export type PiWebAccessStatus = {
searchProvider: PiWebSearchProvider; searchProvider: PiWebSearchProvider;
requestProvider: PiWebSearchProvider; requestProvider: PiWebSearchProvider;
perplexityConfigured: boolean; perplexityConfigured: boolean;
exaConfigured: boolean;
geminiApiConfigured: boolean; geminiApiConfigured: boolean;
chromeProfile?: string; chromeProfile?: string;
routeLabel: string; routeLabel: string;
@@ -28,7 +30,7 @@ export function getPiWebSearchConfigPath(home = process.env.HOME ?? homedir()):
} }
function normalizeProvider(value: unknown): PiWebSearchProvider | undefined { 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 { function normalizeNonEmptyString(value: unknown): string | undefined {
@@ -52,6 +54,8 @@ function formatRouteLabel(provider: PiWebSearchProvider): string {
switch (provider) { switch (provider) {
case "perplexity": case "perplexity":
return "Perplexity"; return "Perplexity";
case "exa":
return "Exa";
case "gemini": case "gemini":
return "Gemini"; return "Gemini";
default: default:
@@ -63,10 +67,12 @@ function formatRouteNote(provider: PiWebSearchProvider): string {
switch (provider) { switch (provider) {
case "perplexity": case "perplexity":
return "Pi web-access will use Perplexity for search."; return "Pi web-access will use Perplexity for search.";
case "exa":
return "Pi web-access will use Exa for search.";
case "gemini": case "gemini":
return "Pi web-access will use Gemini API or Gemini Browser."; return "Pi web-access will use Gemini API or Gemini Browser.";
default: 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 searchProvider = normalizeProvider(config.searchProvider) ?? "auto";
const requestProvider = normalizeProvider(config.provider) ?? searchProvider; const requestProvider = normalizeProvider(config.provider) ?? searchProvider;
const perplexityConfigured = Boolean(normalizeNonEmptyString(config.perplexityApiKey)); const perplexityConfigured = Boolean(normalizeNonEmptyString(config.perplexityApiKey));
const exaConfigured = Boolean(normalizeNonEmptyString(config.exaApiKey));
const geminiApiConfigured = Boolean(normalizeNonEmptyString(config.geminiApiKey)); const geminiApiConfigured = Boolean(normalizeNonEmptyString(config.geminiApiKey));
const chromeProfile = normalizeNonEmptyString(config.chromeProfile); const chromeProfile = normalizeNonEmptyString(config.chromeProfile);
const effectiveProvider = searchProvider; const effectiveProvider = searchProvider;
@@ -86,6 +93,7 @@ export function getPiWebAccessStatus(
searchProvider, searchProvider,
requestProvider, requestProvider,
perplexityConfigured, perplexityConfigured,
exaConfigured,
geminiApiConfigured, geminiApiConfigured,
chromeProfile, chromeProfile,
routeLabel: formatRouteLabel(effectiveProvider), routeLabel: formatRouteLabel(effectiveProvider),
@@ -101,6 +109,7 @@ export function formatPiWebAccessDoctorLines(
` search route: ${status.routeLabel}`, ` search route: ${status.routeLabel}`,
` request route: ${status.requestProvider}`, ` request route: ${status.requestProvider}`,
` perplexity api: ${status.perplexityConfigured ? "configured" : "not configured"}`, ` perplexity api: ${status.perplexityConfigured ? "configured" : "not configured"}`,
` exa api: ${status.exaConfigured ? "configured" : "not configured"}`,
` gemini api: ${status.geminiApiConfigured ? "configured" : "not configured"}`, ` gemini api: ${status.geminiApiConfigured ? "configured" : "not configured"}`,
` browser profile: ${status.chromeProfile ?? "default Chromium profile"}`, ` browser profile: ${status.chromeProfile ?? "default Chromium profile"}`,
` config path: ${status.configPath}`, ` config path: ${status.configPath}`,

View File

@@ -7,6 +7,7 @@ export function printSearchStatus(): void {
printInfo(`Search route: ${status.routeLabel}`); printInfo(`Search route: ${status.routeLabel}`);
printInfo(`Request route: ${status.requestProvider}`); printInfo(`Request route: ${status.requestProvider}`);
printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`); 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(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`);
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}`);

View File

@@ -10,6 +10,7 @@ import { printInfo, printPanel, printSection } from "../ui/terminal.js";
import { getCurrentModelSpec } from "../model/commands.js"; import { getCurrentModelSpec } from "../model/commands.js";
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js"; import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
import { createModelRegistry, getModelsJsonPath } from "../model/registry.js"; import { createModelRegistry, getModelsJsonPath } from "../model/registry.js";
import { getConfiguredServiceTier } from "../model/service-tier.js";
function findProvidersMissingApiKey(modelsJsonPath: string): string[] { function findProvidersMissingApiKey(modelsJsonPath: string): string[] {
try { try {
@@ -105,6 +106,7 @@ export function runStatus(options: DoctorOptions): void {
printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`); printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`);
printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`); printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`);
printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`); printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`);
printInfo(`Service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`); printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
printSection("Paths"); printSection("Paths");
@@ -165,6 +167,7 @@ export function runDoctor(options: DoctorOptions): void {
console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`); console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`);
console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`); console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`);
console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`); console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`);
console.log(`service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`); console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`);
if (modelStatus.recommendedModelReason) { if (modelStatus.recommendedModelReason) {
console.log(` why: ${modelStatus.recommendedModelReason}`); console.log(` why: ${modelStatus.recommendedModelReason}`);

View 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);
});

View File

@@ -19,6 +19,31 @@ test("loadPiWebAccessConfig returns empty config when Pi web config is missing",
}); });
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 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 root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
const configPath = getPiWebSearchConfigPath(root); const configPath = getPiWebSearchConfigPath(root);
mkdirSync(join(root, ".feynman"), { recursive: true }); mkdirSync(join(root, ".feynman"), { recursive: true });
@@ -36,6 +61,7 @@ test("getPiWebAccessStatus reads Pi web-access config directly", () => {
const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath); const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath);
assert.equal(status.routeLabel, "Gemini"); assert.equal(status.routeLabel, "Gemini");
assert.equal(status.requestProvider, "gemini"); assert.equal(status.requestProvider, "gemini");
assert.equal(status.exaConfigured, false);
assert.equal(status.geminiApiConfigured, true); assert.equal(status.geminiApiConfigured, true);
assert.equal(status.perplexityConfigured, false); assert.equal(status.perplexityConfigured, false);
assert.equal(status.chromeProfile, "Profile 2"); assert.equal(status.chromeProfile, "Profile 2");

View 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);
});