Polish Feynman harness and stabilize Pi web runtime

This commit is contained in:
Advait Paliwal
2026-03-22 20:20:26 -07:00
parent 7f0def3a4c
commit 46810f97b7
47 changed files with 3178 additions and 869 deletions

136
src/bootstrap/sync.ts Normal file
View File

@@ -0,0 +1,136 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { getBootstrapStatePath } from "../config/paths.js";
type BootstrapRecord = {
lastAppliedSourceHash: string;
lastAppliedTargetHash: string;
};
type BootstrapState = {
version: 1;
files: Record<string, BootstrapRecord>;
};
export type BootstrapSyncResult = {
copied: string[];
updated: string[];
skipped: string[];
};
function sha256(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
function readBootstrapState(path: string): BootstrapState {
if (!existsSync(path)) {
return { version: 1, files: {} };
}
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as BootstrapState;
return {
version: 1,
files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
};
} catch {
return { version: 1, files: {} };
}
}
function writeBootstrapState(path: string, state: BootstrapState): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
}
function listFiles(root: string): string[] {
if (!existsSync(root)) {
return [];
}
const files: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = resolve(root, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(path));
continue;
}
if (entry.isFile()) {
files.push(path);
}
}
return files.sort();
}
function syncManagedFiles(
sourceRoot: string,
targetRoot: string,
state: BootstrapState,
result: BootstrapSyncResult,
): void {
for (const sourcePath of listFiles(sourceRoot)) {
const key = relative(sourceRoot, sourcePath);
const targetPath = resolve(targetRoot, key);
const sourceText = readFileSync(sourcePath, "utf8");
const sourceHash = sha256(sourceText);
const previous = state.files[key];
mkdirSync(dirname(targetPath), { recursive: true });
if (!existsSync(targetPath)) {
writeFileSync(targetPath, sourceText, "utf8");
state.files[key] = {
lastAppliedSourceHash: sourceHash,
lastAppliedTargetHash: sourceHash,
};
result.copied.push(key);
continue;
}
const currentTargetText = readFileSync(targetPath, "utf8");
const currentTargetHash = sha256(currentTargetText);
if (currentTargetHash === sourceHash) {
state.files[key] = {
lastAppliedSourceHash: sourceHash,
lastAppliedTargetHash: currentTargetHash,
};
continue;
}
if (!previous) {
result.skipped.push(key);
continue;
}
if (currentTargetHash !== previous.lastAppliedTargetHash) {
result.skipped.push(key);
continue;
}
writeFileSync(targetPath, sourceText, "utf8");
state.files[key] = {
lastAppliedSourceHash: sourceHash,
lastAppliedTargetHash: sourceHash,
};
result.updated.push(key);
}
}
export function syncBundledAssets(appRoot: string, agentDir: string): BootstrapSyncResult {
const statePath = getBootstrapStatePath();
const state = readBootstrapState(statePath);
const result: BootstrapSyncResult = {
copied: [],
updated: [],
skipped: [],
};
syncManagedFiles(resolve(appRoot, ".pi", "themes"), resolve(agentDir, "themes"), state, result);
syncManagedFiles(resolve(appRoot, ".pi", "agents"), resolve(agentDir, "agents"), state, result);
writeBootstrapState(statePath, state);
return result;
}

445
src/cli.ts Normal file
View File

@@ -0,0 +1,445 @@
import "dotenv/config";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
import {
getUserName as getAlphaUserName,
isLoggedIn as isAlphaLoggedIn,
login as loginAlpha,
logout as logoutAlpha,
} from "@companion-ai/alpha-hub/lib";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import { syncBundledAssets } from "./bootstrap/sync.js";
import { editConfig, printConfig, printConfigPath, printConfigValue, setConfigValue } from "./config/commands.js";
import { getConfiguredSessionDir, loadFeynmanConfig } from "./config/feynman-config.js";
import { ensureFeynmanHome, getFeynmanAgentDir, getFeynmanHome } from "./config/paths.js";
import { buildFeynmanSystemPrompt } from "./feynman-prompt.js";
import { launchPiChat } from "./pi/launch.js";
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
import {
loginModelProvider,
logoutModelProvider,
printModelList,
printModelProviders,
printModelRecommendation,
printModelStatus,
setDefaultModelSpec,
} from "./model/commands.js";
import { printSearchProviders, printSearchStatus, setSearchProvider } from "./search/commands.js";
import { runDoctor, runStatus } from "./setup/doctor.js";
import { setupPreviewDependencies } from "./setup/preview.js";
import { runSetup } from "./setup/setup.js";
import { printInfo, printPanel, printSection } from "./ui/terminal.js";
const TOP_LEVEL_COMMANDS = new Set(["alpha", "chat", "config", "doctor", "help", "model", "search", "setup", "status"]);
const RESEARCH_WORKFLOW_COMMANDS = new Set([
"ablate",
"audit",
"autoresearch",
"compare",
"deepresearch",
"draft",
"jobs",
"lit",
"log",
"memo",
"reading",
"related",
"replicate",
"rebuttal",
"review",
"watch",
]);
function printHelp(): void {
printPanel("Feynman", [
"Research-first agent shell built on Pi.",
"Use `feynman setup` first if this is a new machine.",
]);
printSection("Getting Started");
printInfo("feynman");
printInfo("feynman setup");
printInfo("feynman setup quick");
printInfo("feynman doctor");
printInfo("feynman model");
printInfo("feynman search");
printSection("Commands");
printInfo("feynman chat [prompt] Start chat explicitly, optionally with an initial prompt");
printInfo("feynman setup [section] Run setup for model, alpha, web, preview, or all");
printInfo("feynman setup quick Configure only missing items");
printInfo("feynman doctor Diagnose config, auth, Pi runtime, and preview deps");
printInfo("feynman status Show the current setup summary");
printInfo("feynman model list Show available models in auth storage");
printInfo("feynman model providers Show Pi-supported providers and auth state");
printInfo("feynman model recommend Show the recommended research model");
printInfo("feynman model login [id] Login to a Pi OAuth model provider");
printInfo("feynman model logout [id] Logout from a Pi OAuth model provider");
printInfo("feynman model set <spec> Set the default model");
printInfo("feynman search status Show web research provider status");
printInfo("feynman search set <id> Set web research provider");
printInfo("feynman config show Print ~/.feynman/config.json");
printInfo("feynman config get <key> Read a config value");
printInfo("feynman config set <key> <value>");
printInfo("feynman config edit Open config in $EDITOR");
printInfo("feynman config path Print the config path");
printInfo("feynman alpha login|logout|status");
printSection("Research Workflows");
printInfo("feynman lit <topic> Start the literature-review workflow");
printInfo("feynman review <artifact> Start the peer-review workflow");
printInfo("feynman audit <item> Start the paper/code audit workflow");
printInfo("feynman replicate <target> Start the replication workflow");
printInfo("feynman memo <topic> Start the research memo workflow");
printInfo("feynman draft <topic> Start the paper-style draft workflow");
printInfo("feynman watch <topic> Start the recurring research watch workflow");
printSection("Legacy Flags");
printInfo('--prompt "<text>" Run one prompt and exit');
printInfo("--alpha-login Sign in to alphaXiv and exit");
printInfo("--alpha-logout Clear alphaXiv auth and exit");
printInfo("--alpha-status Show alphaXiv auth status and exit");
printInfo("--model provider:model Force a specific model");
printInfo("--thinking level off | low | medium | high");
printInfo("--cwd /path/to/workdir Working directory for tools");
printInfo("--session-dir /path Session storage directory");
printInfo("--doctor Alias for `feynman doctor`");
printInfo("--setup-preview Alias for `feynman setup preview`");
printSection("REPL");
printInfo("Inside the REPL, slash workflows come from the live prompt-template and extension command set.");
printInfo("Use `/help` in chat to browse the commands actually loaded in this session.");
}
async function handleAlphaCommand(action: string | undefined): Promise<void> {
if (action === "login") {
const result = await loginAlpha();
const name =
result.userInfo &&
typeof result.userInfo === "object" &&
"name" in result.userInfo &&
typeof result.userInfo.name === "string"
? result.userInfo.name
: getAlphaUserName();
console.log(name ? `alphaXiv login complete: ${name}` : "alphaXiv login complete");
return;
}
if (action === "logout") {
logoutAlpha();
console.log("alphaXiv auth cleared");
return;
}
if (!action || action === "status") {
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
console.log(name ? `alphaXiv logged in as ${name}` : "alphaXiv logged in");
} else {
console.log("alphaXiv not logged in");
}
return;
}
throw new Error(`Unknown alpha command: ${action}`);
}
function handleConfigCommand(subcommand: string | undefined, args: string[]): void {
if (!subcommand || subcommand === "show") {
printConfig();
return;
}
if (subcommand === "path") {
printConfigPath();
return;
}
if (subcommand === "edit") {
editConfig();
return;
}
if (subcommand === "get") {
const key = args[0];
if (!key) {
throw new Error("Usage: feynman config get <key>");
}
printConfigValue(key);
return;
}
if (subcommand === "set") {
const [key, ...valueParts] = args;
if (!key || valueParts.length === 0) {
throw new Error("Usage: feynman config set <key> <value>");
}
setConfigValue(key, valueParts.join(" "));
return;
}
throw new Error(`Unknown config command: ${subcommand}`);
}
async function handleModelCommand(subcommand: string | undefined, args: string[], settingsPath: string, authPath: string): Promise<void> {
if (!subcommand || subcommand === "status" || subcommand === "current") {
printModelStatus(settingsPath, authPath);
return;
}
if (subcommand === "list") {
printModelList(settingsPath, authPath);
return;
}
if (subcommand === "providers") {
printModelProviders(settingsPath, authPath);
return;
}
if (subcommand === "recommend") {
printModelRecommendation(authPath);
return;
}
if (subcommand === "login") {
await loginModelProvider(authPath, args[0]);
return;
}
if (subcommand === "logout") {
await logoutModelProvider(authPath, args[0]);
return;
}
if (subcommand === "set") {
const spec = args[0];
if (!spec) {
throw new Error("Usage: feynman model set <provider/model>");
}
setDefaultModelSpec(settingsPath, authPath, spec);
return;
}
throw new Error(`Unknown model command: ${subcommand}`);
}
function handleSearchCommand(subcommand: string | undefined, args: string[]): void {
if (!subcommand || subcommand === "status") {
printSearchStatus();
return;
}
if (subcommand === "providers" || subcommand === "list") {
printSearchProviders();
return;
}
if (subcommand === "set") {
const provider = args[0];
if (!provider) {
throw new Error("Usage: feynman search set <provider> [value]");
}
setSearchProvider(provider, args[1]);
return;
}
throw new Error(`Unknown search command: ${subcommand}`);
}
function loadPackageVersion(appRoot: string): { version?: string } {
try {
return JSON.parse(readFileSync(resolve(appRoot, "package.json"), "utf8")) as { version?: string };
} catch {
return {};
}
}
export function resolveInitialPrompt(
command: string | undefined,
rest: string[],
oneShotPrompt: string | undefined,
): string | undefined {
if (oneShotPrompt) {
return oneShotPrompt;
}
if (!command) {
return undefined;
}
if (command === "chat") {
return rest.length > 0 ? rest.join(" ") : undefined;
}
if (RESEARCH_WORKFLOW_COMMANDS.has(command)) {
return [`/${command}`, ...rest].join(" ").trim();
}
if (!TOP_LEVEL_COMMANDS.has(command)) {
return [command, ...rest].join(" ");
}
return undefined;
}
export async function main(): Promise<void> {
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const feynmanVersion = loadPackageVersion(appRoot).version;
const bundledSettingsPath = resolve(appRoot, ".pi", "settings.json");
const feynmanHome = getFeynmanHome();
const feynmanAgentDir = getFeynmanAgentDir(feynmanHome);
ensureFeynmanHome(feynmanHome);
syncBundledAssets(appRoot, feynmanAgentDir);
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: {
cwd: { type: "string" },
doctor: { type: "boolean" },
help: { type: "boolean" },
"alpha-login": { type: "boolean" },
"alpha-logout": { type: "boolean" },
"alpha-status": { type: "boolean" },
model: { type: "string" },
"new-session": { type: "boolean" },
prompt: { type: "string" },
"session-dir": { type: "string" },
"setup-preview": { type: "boolean" },
thinking: { type: "string" },
},
});
if (values.help) {
printHelp();
return;
}
const config = loadFeynmanConfig();
const workingDir = resolve(values.cwd ?? process.cwd());
const sessionDir = resolve(values["session-dir"] ?? getConfiguredSessionDir(config));
const feynmanSettingsPath = resolve(feynmanAgentDir, "settings.json");
const feynmanAuthPath = resolve(feynmanAgentDir, "auth.json");
const thinkingLevel = normalizeThinkingLevel(values.thinking ?? process.env.FEYNMAN_THINKING) ?? "medium";
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
if (values.doctor) {
runDoctor({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (values["setup-preview"]) {
const result = setupPreviewDependencies();
console.log(result.message);
return;
}
if (values["alpha-login"]) {
await handleAlphaCommand("login");
return;
}
if (values["alpha-logout"]) {
await handleAlphaCommand("logout");
return;
}
if (values["alpha-status"]) {
await handleAlphaCommand("status");
return;
}
const [command, ...rest] = positionals;
if (command === "help") {
printHelp();
return;
}
if (command === "setup") {
await runSetup({
section: rest[0],
settingsPath: feynmanSettingsPath,
bundledSettingsPath,
authPath: feynmanAuthPath,
workingDir,
sessionDir,
appRoot,
defaultThinkingLevel: thinkingLevel,
});
return;
}
if (command === "doctor") {
runDoctor({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (command === "status") {
runStatus({
settingsPath: feynmanSettingsPath,
authPath: feynmanAuthPath,
sessionDir,
workingDir,
appRoot,
});
return;
}
if (command === "config") {
handleConfigCommand(rest[0], rest.slice(1));
return;
}
if (command === "model") {
await handleModelCommand(rest[0], rest.slice(1), feynmanSettingsPath, feynmanAuthPath);
return;
}
if (command === "search") {
handleSearchCommand(rest[0], rest.slice(1));
return;
}
if (command === "alpha") {
await handleAlphaCommand(rest[0]);
return;
}
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
if (explicitModelSpec) {
const modelRegistry = new ModelRegistry(AuthStorage.create(feynmanAuthPath));
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
if (!explicitModel) {
throw new Error(`Unknown model: ${explicitModelSpec}`);
}
}
await launchPiChat({
appRoot,
workingDir,
sessionDir,
feynmanAgentDir,
feynmanVersion,
thinkingLevel,
explicitModelSpec,
oneShotPrompt: values.prompt,
initialPrompt: resolveInitialPrompt(command, rest, values.prompt),
systemPrompt: buildFeynmanSystemPrompt(),
});
}

78
src/config/commands.ts Normal file
View File

@@ -0,0 +1,78 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { FEYNMAN_CONFIG_PATH, loadFeynmanConfig, saveFeynmanConfig } from "./feynman-config.js";
function coerceConfigValue(raw: string): unknown {
const trimmed = raw.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "null") return null;
if (trimmed === "") return "";
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
try {
return JSON.parse(trimmed);
} catch {
return raw;
}
}
function getNestedValue(record: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object") {
return undefined;
}
return (current as Record<string, unknown>)[segment];
}, record);
}
function setNestedValue(record: Record<string, unknown>, path: string, value: unknown): void {
const segments = path.split(".");
let current: Record<string, unknown> = record;
for (const segment of segments.slice(0, -1)) {
const existing = current[segment];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
current[segments[segments.length - 1]!] = value;
}
export function printConfig(): void {
console.log(JSON.stringify(loadFeynmanConfig(), null, 2));
}
export function printConfigPath(): void {
console.log(FEYNMAN_CONFIG_PATH);
}
export function editConfig(): void {
if (!existsSync(FEYNMAN_CONFIG_PATH)) {
saveFeynmanConfig(loadFeynmanConfig());
}
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
const result = spawnSync(editor, [FEYNMAN_CONFIG_PATH], {
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`Failed to open editor: ${editor}`);
}
}
export function printConfigValue(key: string): void {
const config = loadFeynmanConfig() as Record<string, unknown>;
const value = getNestedValue(config, key);
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
}
export function setConfigValue(key: string, rawValue: string): void {
const config = loadFeynmanConfig() as Record<string, unknown>;
setNestedValue(config, key, coerceConfigValue(rawValue));
saveFeynmanConfig(config as ReturnType<typeof loadFeynmanConfig>);
console.log(`Updated ${key}`);
}

View File

@@ -0,0 +1,270 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { getDefaultSessionDir, getFeynmanConfigPath } from "./paths.js";
export type WebSearchProviderId = "auto" | "perplexity" | "gemini-api" | "gemini-browser";
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini";
export type WebSearchConfig = Record<string, unknown> & {
provider?: PiWebSearchProvider;
perplexityApiKey?: string;
geminiApiKey?: string;
chromeProfile?: string;
feynmanWebProvider?: WebSearchProviderId;
};
export type FeynmanConfig = {
version: 1;
sessionDir?: string;
webSearch?: WebSearchConfig;
preview?: {
lastSetupAt?: string;
};
};
export type WebSearchProviderDefinition = {
id: WebSearchProviderId;
label: string;
description: string;
runtimeProvider: PiWebSearchProvider;
requiresApiKey: boolean;
};
export type WebSearchStatus = {
selected: WebSearchProviderDefinition;
configPath: string;
perplexityConfigured: boolean;
geminiApiConfigured: boolean;
chromeProfile?: string;
browserHint: string;
};
export const FEYNMAN_CONFIG_PATH = getFeynmanConfigPath();
export const LEGACY_WEB_SEARCH_CONFIG_PATH = resolve(process.env.HOME ?? "", ".pi", "web-search.json");
export const DEFAULT_WEB_SEARCH_PROVIDER: WebSearchProviderId = "gemini-browser";
export const WEB_SEARCH_PROVIDERS: ReadonlyArray<WebSearchProviderDefinition> = [
{
id: "auto",
label: "Auto",
description: "Prefer Perplexity when configured, otherwise fall back to Gemini.",
runtimeProvider: "auto",
requiresApiKey: false,
},
{
id: "perplexity",
label: "Perplexity API",
description: "Use Perplexity Sonar directly for web answers and source lists.",
runtimeProvider: "perplexity",
requiresApiKey: true,
},
{
id: "gemini-api",
label: "Gemini API",
description: "Use Gemini directly with an API key.",
runtimeProvider: "gemini",
requiresApiKey: true,
},
{
id: "gemini-browser",
label: "Gemini Browser",
description: "Use your signed-in Chromium browser session through pi-web-access.",
runtimeProvider: "gemini",
requiresApiKey: false,
},
] as const;
function readJsonFile<T>(path: string): T | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch {
return undefined;
}
}
function normalizeWebSearchConfig(value: unknown): WebSearchConfig | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return { ...(value as WebSearchConfig) };
}
function migrateLegacyWebSearchConfig(): WebSearchConfig | undefined {
return normalizeWebSearchConfig(readJsonFile<WebSearchConfig>(LEGACY_WEB_SEARCH_CONFIG_PATH));
}
export function loadFeynmanConfig(configPath = FEYNMAN_CONFIG_PATH): FeynmanConfig {
const config = readJsonFile<FeynmanConfig>(configPath);
if (config && typeof config === "object") {
return {
version: 1,
sessionDir: typeof config.sessionDir === "string" && config.sessionDir.trim() ? config.sessionDir : undefined,
webSearch: normalizeWebSearchConfig(config.webSearch),
preview: config.preview && typeof config.preview === "object" ? { ...config.preview } : undefined,
};
}
const legacyWebSearch = migrateLegacyWebSearchConfig();
return {
version: 1,
sessionDir: getDefaultSessionDir(),
webSearch: legacyWebSearch,
};
}
export function saveFeynmanConfig(config: FeynmanConfig, configPath = FEYNMAN_CONFIG_PATH): void {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(
configPath,
JSON.stringify(
{
version: 1,
...(config.sessionDir ? { sessionDir: config.sessionDir } : {}),
...(config.webSearch ? { webSearch: config.webSearch } : {}),
...(config.preview ? { preview: config.preview } : {}),
},
null,
2,
) + "\n",
"utf8",
);
}
export function getConfiguredSessionDir(config = loadFeynmanConfig()): string {
return typeof config.sessionDir === "string" && config.sessionDir.trim()
? config.sessionDir
: getDefaultSessionDir();
}
export function loadWebSearchConfig(): WebSearchConfig {
return loadFeynmanConfig().webSearch ?? {};
}
export function saveWebSearchConfig(config: WebSearchConfig): void {
const current = loadFeynmanConfig();
saveFeynmanConfig({
...current,
webSearch: config,
});
}
export function getWebSearchProviderById(id: WebSearchProviderId): WebSearchProviderDefinition {
return WEB_SEARCH_PROVIDERS.find((provider) => provider.id === id) ?? WEB_SEARCH_PROVIDERS[0];
}
export function hasPerplexityApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
return typeof config.perplexityApiKey === "string" && config.perplexityApiKey.trim().length > 0;
}
export function hasGeminiApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
return typeof config.geminiApiKey === "string" && config.geminiApiKey.trim().length > 0;
}
export function hasConfiguredWebProvider(config: WebSearchConfig = loadWebSearchConfig()): boolean {
return hasPerplexityApiKey(config) || hasGeminiApiKey(config) || getConfiguredWebSearchProvider(config).id === DEFAULT_WEB_SEARCH_PROVIDER;
}
export function getConfiguredWebSearchProvider(
config: WebSearchConfig = loadWebSearchConfig(),
): WebSearchProviderDefinition {
const explicit = config.feynmanWebProvider;
if (explicit === "auto" || explicit === "perplexity" || explicit === "gemini-api" || explicit === "gemini-browser") {
return getWebSearchProviderById(explicit);
}
if (config.provider === "perplexity") {
return getWebSearchProviderById("perplexity");
}
if (config.provider === "gemini") {
return hasGeminiApiKey(config)
? getWebSearchProviderById("gemini-api")
: getWebSearchProviderById("gemini-browser");
}
return getWebSearchProviderById(DEFAULT_WEB_SEARCH_PROVIDER);
}
export function configureWebSearchProvider(
current: WebSearchConfig,
providerId: WebSearchProviderId,
values: { apiKey?: string; chromeProfile?: string } = {},
): WebSearchConfig {
const next: WebSearchConfig = { ...current };
next.feynmanWebProvider = providerId;
switch (providerId) {
case "auto":
next.provider = "auto";
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
next.chromeProfile = values.chromeProfile.trim();
}
return next;
case "perplexity":
next.provider = "perplexity";
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
next.perplexityApiKey = values.apiKey.trim();
}
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
next.chromeProfile = values.chromeProfile.trim();
}
return next;
case "gemini-api":
next.provider = "gemini";
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
next.geminiApiKey = values.apiKey.trim();
}
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
next.chromeProfile = values.chromeProfile.trim();
}
return next;
case "gemini-browser":
next.provider = "gemini";
delete next.geminiApiKey;
if (typeof values.chromeProfile === "string") {
const profile = values.chromeProfile.trim();
if (profile) {
next.chromeProfile = profile;
} else {
delete next.chromeProfile;
}
}
return next;
}
}
export function getWebSearchStatus(config: WebSearchConfig = loadWebSearchConfig()): WebSearchStatus {
const selected = getConfiguredWebSearchProvider(config);
return {
selected,
configPath: FEYNMAN_CONFIG_PATH,
perplexityConfigured: hasPerplexityApiKey(config),
geminiApiConfigured: hasGeminiApiKey(config),
chromeProfile: typeof config.chromeProfile === "string" && config.chromeProfile.trim()
? config.chromeProfile.trim()
: undefined,
browserHint: selected.id === "gemini-browser" ? "selected" : "fallback only",
};
}
export function formatWebSearchDoctorLines(config: WebSearchConfig = loadWebSearchConfig()): string[] {
const status = getWebSearchStatus(config);
const configured = [];
if (status.perplexityConfigured) configured.push("Perplexity API");
if (status.geminiApiConfigured) configured.push("Gemini API");
if (status.selected.id === "gemini-browser" || status.chromeProfile) configured.push("Gemini Browser");
return [
`web research provider: ${status.selected.label}`,
` runtime route: ${status.selected.runtimeProvider}`,
` configured credentials: ${configured.length > 0 ? configured.join(", ") : "none"}`,
` browser mode: ${status.browserHint}${status.chromeProfile ? ` (profile: ${status.chromeProfile})` : ""}`,
` config path: ${status.configPath}`,
];
}

43
src/config/paths.ts Normal file
View File

@@ -0,0 +1,43 @@
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
export function getFeynmanHome(): string {
return resolve(process.env.FEYNMAN_HOME ?? homedir(), ".feynman");
}
export function getFeynmanAgentDir(home = getFeynmanHome()): string {
return resolve(home, "agent");
}
export function getFeynmanMemoryDir(home = getFeynmanHome()): string {
return resolve(home, "memory");
}
export function getFeynmanStateDir(home = getFeynmanHome()): string {
return resolve(home, ".state");
}
export function getDefaultSessionDir(home = getFeynmanHome()): string {
return resolve(home, "sessions");
}
export function getFeynmanConfigPath(home = getFeynmanHome()): string {
return resolve(home, "config.json");
}
export function getBootstrapStatePath(home = getFeynmanHome()): string {
return resolve(getFeynmanStateDir(home), "bootstrap.json");
}
export function ensureFeynmanHome(home = getFeynmanHome()): void {
for (const dir of [
home,
getFeynmanAgentDir(home),
getFeynmanMemoryDir(home),
getFeynmanStateDir(home),
getDefaultSessionDir(home),
]) {
mkdirSync(dir, { recursive: true });
}
}

View File

@@ -16,8 +16,10 @@ Operating rules:
- Never answer a latest/current question from arXiv or alpha-backed paper search alone.
- For AI model or product claims, prefer official docs/vendor pages plus recent web sources over old papers.
- Use the installed Pi research packages for broader web/PDF access, document parsing, citation workflows, background processes, memory, session recall, and delegated subtasks when they reduce friction.
- Feynman ships project subagents for research work. Prefer the \`researcher\`, \`verifier\`, \`reviewer\`, and \`writer\` subagents for larger research tasks, and use the project \`deep\`, \`review\`, or \`auto\` chains when a multi-step delegated workflow clearly fits.
- Feynman ships project subagents for research work. Prefer the \`researcher\`, \`verifier\`, \`reviewer\`, and \`writer\` subagents for larger research tasks when decomposition clearly helps.
- Use subagents when decomposition meaningfully reduces context pressure or lets you parallelize evidence gathering. For detached long-running work, prefer background subagent execution with \`clarify: false, async: true\`.
- For deep research, act like a lead researcher by default: plan first, use hidden worker batches only when breadth justifies them, synthesize batch results, and finish with a verification/citation pass.
- Do not force chain-shaped orchestration onto the user. Multi-agent decomposition is an internal tactic, not the primary UX.
- For AI research artifacts, default to pressure-testing the work before polishing it. Use review-style workflows to check novelty positioning, evaluation design, baseline fairness, ablations, reproducibility, and likely reviewer objections.
- Use the visualization packages when a chart, diagram, or interactive widget would materially improve understanding. Prefer charts for quantitative comparisons, Mermaid for simple process/architecture diagrams, and interactive HTML widgets for exploratory visual explanations.
- Persistent memory is package-backed. Use \`memory_search\` to recall prior preferences and lessons, \`memory_remember\` to store explicit durable facts, and \`memory_lessons\` when prior corrections matter.
@@ -32,8 +34,11 @@ Operating rules:
- Treat polished scientific communication as part of the job: structure reports cleanly, use Markdown deliberately, and use LaTeX math when equations clarify the argument.
- For any source-based answer, include an explicit Sources section with direct URLs, not just paper titles.
- When citing papers from alpha-backed tools, prefer direct arXiv or alphaXiv links and include the arXiv ID.
- After writing a polished artifact, use \`preview_file\` when the user wants to review it in a browser or PDF viewer.
- After writing a polished artifact, use \`preview_file\` only when the user wants review or export. Prefer browser preview by default; use PDF only when explicitly requested.
- Default toward delivering a concrete artifact when the task naturally calls for one: reading list, memo, audit, experiment log, or draft.
- For user-facing workflows, produce exactly one canonical durable Markdown artifact unless the user explicitly asks for multiple deliverables.
- Do not create extra user-facing intermediate markdown files just because the workflow has multiple reasoning stages.
- Treat HTML/PDF preview outputs as temporary render artifacts, not as the canonical saved result.
- Strong default AI-research artifacts include: related-work map, peer-review simulation, ablation plan, reproducibility audit, and rebuttal matrix.
- Default artifact locations:
- outputs/ for reviews, reading lists, and summaries

View File

@@ -1,729 +1,4 @@
import "dotenv/config";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
import {
getUserName as getAlphaUserName,
isLoggedIn as isAlphaLoggedIn,
login as loginAlpha,
logout as logoutAlpha,
} from "@companion-ai/alpha-hub/lib";
import {
ModelRegistry,
AuthStorage,
} from "@mariozechner/pi-coding-agent";
import { buildFeynmanSystemPrompt } from "./feynman-prompt.js";
type ThinkingLevel = "off" | "low" | "medium" | "high";
function printHelp(): void {
console.log(`Feynman commands:
/help Show this help
/init Initialize AGENTS.md and session-log folders
/alpha-login Sign in to alphaXiv
/alpha-logout Clear alphaXiv auth
/alpha-status Show alphaXiv auth status
/new Start a fresh persisted session
/exit Quit the REPL
/lit <topic> Expand the literature review prompt template
/related <topic> Map related work and justify the research gap
/review <artifact> Simulate a peer review for an AI research artifact
/ablate <artifact> Design the minimum convincing ablation set
/rebuttal <artifact> Draft a rebuttal and revision matrix
/replicate <paper> Expand the replication prompt template
/reading <topic> Expand the reading list prompt template
/memo <topic> Expand the general research memo prompt template
/deepresearch <topic> Expand the thorough source-heavy research prompt template
/autoresearch <idea> Expand the idea-to-paper autoresearch prompt template
/compare <topic> Expand the source comparison prompt template
/audit <item> Expand the paper/code audit prompt template
/draft <topic> Expand the paper-style writing prompt template
/log Write a durable session log
/watch <topic> Create a recurring or deferred research watch
/jobs Inspect active background work
Package-powered workflows:
/agents Open the subagent and chain manager
/run /chain /parallel Delegate research work to subagents
/ps Open the background process panel
/schedule-prompt Manage deferred and recurring jobs
/search Search prior indexed sessions
/preview Preview generated markdown or code artifacts
CLI flags:
--prompt "<text>" Run one prompt and exit
--alpha-login Sign in to alphaXiv and exit
--alpha-logout Clear alphaXiv auth and exit
--alpha-status Show alphaXiv auth status and exit
--model provider:model Force a specific model
--thinking level off | low | medium | high
--cwd /path/to/workdir Working directory for tools
--session-dir /path Session storage directory
--doctor Check Feynman auth, models, preview tools, and paths
--setup-preview Install preview dependencies when possible
Top-level:
feynman setup Configure alpha login, web search, and preview deps
feynman setup alpha Configure alphaXiv login
feynman setup web Configure web search provider
feynman setup preview Install preview dependencies`);
}
function parseModelSpec(spec: string, modelRegistry: ModelRegistry) {
const trimmed = spec.trim();
const separator = trimmed.includes(":") ? ":" : trimmed.includes("/") ? "/" : null;
if (!separator) {
return undefined;
}
const [provider, ...rest] = trimmed.split(separator);
const id = rest.join(separator);
if (!provider || !id) {
return undefined;
}
return modelRegistry.find(provider, id);
}
function normalizeThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
if (!value) {
return undefined;
}
const normalized = value.toLowerCase();
if (normalized === "off" || normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return undefined;
}
function resolveExecutable(name: string, fallbackPaths: string[] = []): string | undefined {
for (const candidate of fallbackPaths) {
if (existsSync(candidate)) {
return candidate;
}
}
const result = spawnSync("sh", ["-lc", `command -v ${name}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const resolved = result.stdout.trim();
if (resolved) {
return resolved;
}
}
return undefined;
}
function patchEmbeddedPiBranding(piPackageRoot: string): void {
const packageJsonPath = resolve(piPackageRoot, "package.json");
const cliPath = resolve(piPackageRoot, "dist", "cli.js");
const interactiveModePath = resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.js");
if (existsSync(packageJsonPath)) {
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
piConfig?: { name?: string; configDir?: string };
};
if (pkg.piConfig?.name !== "feynman") {
pkg.piConfig = {
...pkg.piConfig,
name: "feynman",
};
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, "\t") + "\n", "utf8");
}
}
if (existsSync(cliPath)) {
const cliSource = readFileSync(cliPath, "utf8");
if (cliSource.includes('process.title = "pi";')) {
writeFileSync(cliPath, cliSource.replace('process.title = "pi";', 'process.title = "feynman";'), "utf8");
}
}
if (existsSync(interactiveModePath)) {
const interactiveModeSource = readFileSync(interactiveModePath, "utf8");
if (interactiveModeSource.includes("`π - ${sessionName} - ${cwdBasename}`")) {
writeFileSync(
interactiveModePath,
interactiveModeSource
.replace("`π - ${sessionName} - ${cwdBasename}`", "`feynman - ${sessionName} - ${cwdBasename}`")
.replace("`π - ${cwdBasename}`", "`feynman - ${cwdBasename}`"),
"utf8",
);
}
}
}
function patchPackageWorkspace(appRoot: string): void {
const workspaceRoot = resolve(appRoot, ".pi", "npm", "node_modules");
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
const sessionSearchIndexerPath = resolve(
workspaceRoot,
"@kaiserlich-dev",
"pi-session-search",
"extensions",
"indexer.ts",
);
const piMemoryPath = resolve(workspaceRoot, "@samfp", "pi-memory", "src", "index.ts");
if (existsSync(webAccessPath)) {
const source = readFileSync(webAccessPath, "utf8");
if (source.includes('pi.registerCommand("search",')) {
writeFileSync(
webAccessPath,
source.replace('pi.registerCommand("search",', 'pi.registerCommand("web-results",'),
"utf8",
);
}
}
if (existsSync(sessionSearchIndexerPath)) {
const source = readFileSync(sessionSearchIndexerPath, "utf8");
const original = 'const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");';
const replacement =
'const sessionsDir = process.env.FEYNMAN_SESSION_DIR ?? process.env.PI_SESSION_DIR ?? path.join(os.homedir(), ".pi", "agent", "sessions");';
if (source.includes(original)) {
writeFileSync(sessionSearchIndexerPath, source.replace(original, replacement), "utf8");
}
}
if (existsSync(piMemoryPath)) {
let source = readFileSync(piMemoryPath, "utf8");
const memoryOriginal = 'const MEMORY_DIR = join(homedir(), ".pi", "memory");';
const memoryReplacement =
'const MEMORY_DIR = process.env.FEYNMAN_MEMORY_DIR ?? process.env.PI_MEMORY_DIR ?? join(homedir(), ".pi", "memory");';
if (source.includes(memoryOriginal)) {
source = source.replace(memoryOriginal, memoryReplacement);
}
const execOriginal = 'const result = await pi.exec("pi", ["-p", prompt, "--print"], {';
const execReplacement = [
'const execBinary = process.env.FEYNMAN_NODE_EXECUTABLE || process.env.FEYNMAN_EXECUTABLE || "pi";',
' const execArgs = process.env.FEYNMAN_BIN_PATH',
' ? [process.env.FEYNMAN_BIN_PATH, "--prompt", prompt]',
' : ["-p", prompt, "--print"];',
' const result = await pi.exec(execBinary, execArgs, {',
].join("\n");
if (source.includes(execOriginal)) {
source = source.replace(execOriginal, execReplacement);
}
writeFileSync(piMemoryPath, source, "utf8");
}
}
function choosePreferredModel(
availableModels: Array<{ provider: string; id: string }>,
): { provider: string; id: string } | undefined {
const preferences = [
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "anthropic", id: "claude-opus-4-5" },
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5.4" },
{ provider: "openai", id: "gpt-5" },
];
for (const preferred of preferences) {
const match = availableModels.find(
(model) => model.provider === preferred.provider && model.id === preferred.id,
);
if (match) {
return match;
}
}
return availableModels[0];
}
function normalizeFeynmanSettings(
settingsPath: string,
bundledSettingsPath: string,
defaultThinkingLevel: ThinkingLevel,
authPath: string,
): void {
let settings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch {
settings = {};
}
}
else if (existsSync(bundledSettingsPath)) {
try {
settings = JSON.parse(readFileSync(bundledSettingsPath, "utf8"));
} catch {
settings = {};
}
}
if (!settings.defaultThinkingLevel) {
settings.defaultThinkingLevel = defaultThinkingLevel;
}
if (settings.editorPaddingX === undefined) {
settings.editorPaddingX = 1;
}
settings.theme = "feynman";
settings.quietStartup = true;
settings.collapseChangelog = true;
const authStorage = AuthStorage.create(authPath);
const modelRegistry = new ModelRegistry(authStorage);
const availableModels = modelRegistry.getAvailable().map((model) => ({
provider: model.provider,
id: model.id,
}));
if ((!settings.defaultProvider || !settings.defaultModel) && availableModels.length > 0) {
const preferredModel = choosePreferredModel(availableModels);
if (preferredModel) {
settings.defaultProvider = preferredModel.provider;
settings.defaultModel = preferredModel.id;
}
}
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
}
function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) {
return {};
}
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return {};
}
}
function getWebSearchConfigPath(): string {
return resolve(homedir(), ".pi", "web-search.json");
}
function loadWebSearchConfig(): Record<string, unknown> {
return readJson(getWebSearchConfigPath());
}
function saveWebSearchConfig(config: Record<string, unknown>): void {
const path = getWebSearchConfigPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
}
function hasConfiguredWebProvider(): boolean {
const config = loadWebSearchConfig();
return typeof config.perplexityApiKey === "string" && config.perplexityApiKey.trim().length > 0
|| typeof config.geminiApiKey === "string" && config.geminiApiKey.trim().length > 0;
}
async function promptText(question: string, defaultValue = ""): Promise<string> {
if (!input.isTTY || !output.isTTY) {
throw new Error("feynman setup requires an interactive terminal.");
}
const rl = createInterface({ input, output });
try {
const suffix = defaultValue ? ` [${defaultValue}]` : "";
const value = (await rl.question(`${question}${suffix}: `)).trim();
return value || defaultValue;
} finally {
rl.close();
}
}
async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
console.log(question);
for (const [index, choice] of choices.entries()) {
const marker = index === defaultIndex ? "*" : " ";
console.log(` ${marker} ${index + 1}. ${choice}`);
}
const answer = await promptText("Select", String(defaultIndex + 1));
const parsed = Number(answer);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) {
return defaultIndex;
}
return parsed - 1;
}
async function setupWebProvider(): Promise<void> {
const config = loadWebSearchConfig();
const choices = [
"Gemini API key",
"Perplexity API key",
"Browser Gemini (manual sign-in only)",
"Skip",
];
const selection = await promptChoice("Choose a web search provider for Feynman:", choices, hasConfiguredWebProvider() ? 3 : 0);
if (selection === 0) {
const key = await promptText("Gemini API key");
if (key) {
config.geminiApiKey = key;
delete config.perplexityApiKey;
saveWebSearchConfig(config);
console.log("Saved Gemini API key to ~/.pi/web-search.json");
}
return;
}
if (selection === 1) {
const key = await promptText("Perplexity API key");
if (key) {
config.perplexityApiKey = key;
delete config.geminiApiKey;
saveWebSearchConfig(config);
console.log("Saved Perplexity API key to ~/.pi/web-search.json");
}
return;
}
if (selection === 2) {
console.log("Sign into gemini.google.com in Chrome, Chromium, Brave, or Edge, then restart Feynman.");
return;
}
}
async function runSetup(
section: string | undefined,
settingsPath: string,
bundledSettingsPath: string,
authPath: string,
workingDir: string,
sessionDir: string,
): Promise<void> {
if (section === "alpha" || !section) {
if (!isAlphaLoggedIn()) {
await loginAlpha();
console.log("alphaXiv login complete");
} else {
console.log("alphaXiv login already configured");
}
if (section === "alpha") return;
}
if (section === "web" || !section) {
await setupWebProvider();
if (section === "web") return;
}
if (section === "preview" || !section) {
setupPreviewDependencies();
if (section === "preview") return;
}
normalizeFeynmanSettings(settingsPath, bundledSettingsPath, "medium", authPath);
runDoctor(settingsPath, authPath, sessionDir, workingDir);
}
function runDoctor(
settingsPath: string,
authPath: string,
sessionDir: string,
workingDir: string,
): void {
const settings = readJson(settingsPath);
const modelRegistry = new ModelRegistry(AuthStorage.create(authPath));
const availableModels = modelRegistry.getAvailable();
const pandocPath = resolveExecutable("pandoc", [
"/opt/homebrew/bin/pandoc",
"/usr/local/bin/pandoc",
]);
const browserPath =
process.env.PUPPETEER_EXECUTABLE_PATH ??
resolveExecutable("google-chrome", [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]);
console.log("Feynman doctor");
console.log("");
console.log(`working dir: ${workingDir}`);
console.log(`session dir: ${sessionDir}`);
console.log("");
console.log(`alphaXiv auth: ${isAlphaLoggedIn() ? "ok" : "missing"}`);
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
if (name) {
console.log(` user: ${name}`);
}
}
console.log(`models available: ${availableModels.length}`);
if (availableModels.length > 0) {
const sample = availableModels
.slice(0, 6)
.map((model) => `${model.provider}/${model.id}`)
.join(", ");
console.log(` sample: ${sample}`);
}
console.log(
`default model: ${typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string"
? `${settings.defaultProvider}/${settings.defaultModel}`
: "not set"}`,
);
console.log(`pandoc: ${pandocPath ?? "missing"}`);
console.log(`browser preview runtime: ${browserPath ?? "missing"}`);
console.log(`web research provider: ${hasConfiguredWebProvider() ? "configured" : "missing"}`);
console.log(`quiet startup: ${settings.quietStartup === true ? "enabled" : "disabled"}`);
console.log(`theme: ${typeof settings.theme === "string" ? settings.theme : "not set"}`);
console.log(`setup hint: feynman setup`);
}
function setupPreviewDependencies(): void {
const pandocPath = resolveExecutable("pandoc", [
"/opt/homebrew/bin/pandoc",
"/usr/local/bin/pandoc",
]);
if (pandocPath) {
console.log(`pandoc already installed at ${pandocPath}`);
return;
}
const brewPath = resolveExecutable("brew", [
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
]);
if (process.platform === "darwin" && brewPath) {
const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" });
if (result.status !== 0) {
throw new Error("Failed to install pandoc via Homebrew.");
}
console.log("Preview dependency installed: pandoc");
return;
}
throw new Error("Automatic preview setup is only supported on macOS with Homebrew right now.");
}
function syncDirectory(sourceDir: string, targetDir: string): void {
if (!existsSync(sourceDir)) {
return;
}
mkdirSync(targetDir, { recursive: true });
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = resolve(sourceDir, entry.name);
const targetPath = resolve(targetDir, entry.name);
if (entry.isDirectory()) {
syncDirectory(sourcePath, targetPath);
continue;
}
if (entry.isFile()) {
writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), "utf8");
}
}
}
function syncFeynmanTheme(appRoot: string, agentDir: string): void {
const sourceThemePath = resolve(appRoot, ".pi", "themes", "feynman.json");
const targetThemeDir = resolve(agentDir, "themes");
const targetThemePath = resolve(targetThemeDir, "feynman.json");
if (!existsSync(sourceThemePath)) {
return;
}
mkdirSync(targetThemeDir, { recursive: true });
writeFileSync(targetThemePath, readFileSync(sourceThemePath, "utf8"), "utf8");
}
function syncFeynmanAgents(appRoot: string, agentDir: string): void {
syncDirectory(resolve(appRoot, ".pi", "agents"), resolve(agentDir, "agents"));
}
async function main(): Promise<void> {
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const piPackageRoot = resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent");
const piCliPath = resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
const feynmanAgentDir = resolve(homedir(), ".feynman", "agent");
const bundledSettingsPath = resolve(appRoot, ".pi", "settings.json");
patchEmbeddedPiBranding(piPackageRoot);
patchPackageWorkspace(appRoot);
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
cwd: { type: "string" },
doctor: { type: "boolean" },
help: { type: "boolean" },
"alpha-login": { type: "boolean" },
"alpha-logout": { type: "boolean" },
"alpha-status": { type: "boolean" },
model: { type: "string" },
"new-session": { type: "boolean" },
prompt: { type: "string" },
"session-dir": { type: "string" },
"setup-preview": { type: "boolean" },
thinking: { type: "string" },
},
});
if (values.help) {
printHelp();
return;
}
const workingDir = resolve(values.cwd ?? process.cwd());
const sessionDir = resolve(values["session-dir"] ?? resolve(homedir(), ".feynman", "sessions"));
mkdirSync(sessionDir, { recursive: true });
mkdirSync(feynmanAgentDir, { recursive: true });
syncFeynmanTheme(appRoot, feynmanAgentDir);
syncFeynmanAgents(appRoot, feynmanAgentDir);
const feynmanSettingsPath = resolve(feynmanAgentDir, "settings.json");
const feynmanAuthPath = resolve(feynmanAgentDir, "auth.json");
const thinkingLevel = normalizeThinkingLevel(values.thinking ?? process.env.FEYNMAN_THINKING) ?? "medium";
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
if (positionals[0] === "setup") {
await runSetup(positionals[1], feynmanSettingsPath, bundledSettingsPath, feynmanAuthPath, workingDir, sessionDir);
return;
}
if (values.doctor) {
runDoctor(feynmanSettingsPath, feynmanAuthPath, sessionDir, workingDir);
return;
}
if (values["setup-preview"]) {
setupPreviewDependencies();
return;
}
if (values["alpha-login"]) {
const result = await loginAlpha();
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
const name =
(result.userInfo &&
typeof result.userInfo === "object" &&
"name" in result.userInfo &&
typeof result.userInfo.name === "string")
? result.userInfo.name
: getAlphaUserName();
console.log(name ? `alphaXiv login complete: ${name}` : "alphaXiv login complete");
return;
}
if (values["alpha-logout"]) {
logoutAlpha();
console.log("alphaXiv auth cleared");
return;
}
if (values["alpha-status"]) {
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
console.log(name ? `alphaXiv logged in as ${name}` : "alphaXiv logged in");
} else {
console.log("alphaXiv not logged in");
}
return;
}
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
if (explicitModelSpec) {
const modelRegistry = new ModelRegistry(AuthStorage.create(feynmanAuthPath));
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
if (!explicitModel) {
throw new Error(`Unknown model: ${explicitModelSpec}`);
}
}
const oneShotPrompt = values.prompt;
const initialPrompt = oneShotPrompt ?? (positionals.length > 0 ? positionals.join(" ") : undefined);
const systemPrompt = buildFeynmanSystemPrompt();
const piArgs = [
"--session-dir",
sessionDir,
"--extension",
resolve(appRoot, "extensions", "research-tools.ts"),
"--skill",
resolve(appRoot, "skills"),
"--prompt-template",
resolve(appRoot, "prompts"),
"--system-prompt",
systemPrompt,
];
if (explicitModelSpec) {
piArgs.push("--model", explicitModelSpec);
}
if (thinkingLevel) {
piArgs.push("--thinking", thinkingLevel);
}
if (oneShotPrompt) {
piArgs.push("-p", oneShotPrompt);
}
else if (initialPrompt) {
piArgs.push(initialPrompt);
}
const child = spawn(process.execPath, [piCliPath, ...piArgs], {
cwd: workingDir,
stdio: "inherit",
env: {
...process.env,
PI_CODING_AGENT_DIR: feynmanAgentDir,
FEYNMAN_CODING_AGENT_DIR: feynmanAgentDir,
FEYNMAN_PI_NPM_ROOT: resolve(appRoot, ".pi", "npm", "node_modules"),
FEYNMAN_SESSION_DIR: sessionDir,
PI_SESSION_DIR: sessionDir,
FEYNMAN_MEMORY_DIR: resolve(dirname(feynmanAgentDir), "memory"),
FEYNMAN_NODE_EXECUTABLE: process.execPath,
FEYNMAN_BIN_PATH: resolve(appRoot, "bin", "feynman.js"),
PANDOC_PATH:
process.env.PANDOC_PATH ??
resolveExecutable("pandoc", [
"/opt/homebrew/bin/pandoc",
"/usr/local/bin/pandoc",
]),
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
MERMAID_CLI_PATH:
process.env.MERMAID_CLI_PATH ??
resolveExecutable("mmdc", [
"/opt/homebrew/bin/mmdc",
"/usr/local/bin/mmdc",
]),
PUPPETEER_EXECUTABLE_PATH:
process.env.PUPPETEER_EXECUTABLE_PATH ??
resolveExecutable("google-chrome", [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]),
},
});
await new Promise<void>((resolvePromise, reject) => {
child.on("error", reject);
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exitCode = code ?? 0;
resolvePromise();
});
});
}
import { main } from "./cli.js";
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));

282
src/model/catalog.ts Normal file
View File

@@ -0,0 +1,282 @@
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
type ModelRecord = {
provider: string;
id: string;
name?: string;
};
export type ProviderStatus = {
id: string;
label: string;
supportedModels: number;
availableModels: number;
configured: boolean;
current: boolean;
recommended: boolean;
};
export type ModelStatusSnapshot = {
current?: string;
currentValid: boolean;
recommended?: string;
recommendationReason?: string;
availableModels: string[];
providers: ProviderStatus[];
guidance: string[];
};
const PROVIDER_LABELS: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
"openai-codex": "OpenAI Codex",
openrouter: "OpenRouter",
google: "Google",
"google-gemini-cli": "Google Gemini CLI",
zai: "Z.AI / GLM",
minimax: "MiniMax",
"minimax-cn": "MiniMax (China)",
"github-copilot": "GitHub Copilot",
"vercel-ai-gateway": "Vercel AI Gateway",
opencode: "OpenCode",
"opencode-go": "OpenCode Go",
"kimi-coding": "Kimi / Moonshot",
xai: "xAI",
groq: "Groq",
mistral: "Mistral",
cerebras: "Cerebras",
huggingface: "Hugging Face",
"amazon-bedrock": "Amazon Bedrock",
"azure-openai-responses": "Azure OpenAI Responses",
};
const RESEARCH_MODEL_PREFERENCES = [
{
spec: "anthropic/claude-opus-4-6",
reason: "strong long-context reasoning for source-heavy research work",
},
{
spec: "anthropic/claude-opus-4-5",
reason: "strong long-context reasoning for source-heavy research work",
},
{
spec: "anthropic/claude-sonnet-4-6",
reason: "balanced reasoning and speed for iterative research sessions",
},
{
spec: "anthropic/claude-sonnet-4-5",
reason: "balanced reasoning and speed for iterative research sessions",
},
{
spec: "openai/gpt-5.4",
reason: "strong general reasoning and drafting quality for research tasks",
},
{
spec: "openai/gpt-5",
reason: "strong general reasoning and drafting quality for research tasks",
},
{
spec: "openai-codex/gpt-5.4",
reason: "strong research + coding balance when Pi exposes Codex directly",
},
{
spec: "google/gemini-3-pro-preview",
reason: "good fallback for broad web-and-doc research work",
},
{
spec: "google/gemini-2.5-pro",
reason: "good fallback for broad web-and-doc research work",
},
{
spec: "openrouter/openai/gpt-5.1-codex",
reason: "good routed fallback when only OpenRouter is configured",
},
{
spec: "zai/glm-5",
reason: "good fallback when GLM is the available research model",
},
{
spec: "kimi-coding/kimi-k2-thinking",
reason: "good fallback when Kimi is the available research model",
},
];
const PROVIDER_SORT_ORDER = [
"anthropic",
"openai",
"openai-codex",
"google",
"openrouter",
"zai",
"kimi-coding",
"minimax",
"minimax-cn",
"github-copilot",
"vercel-ai-gateway",
];
function formatProviderLabel(provider: string): string {
return PROVIDER_LABELS[provider] ?? provider;
}
function modelSpec(model: ModelRecord): string {
return `${model.provider}/${model.id}`;
}
function compareByResearchPreference(left: ModelRecord, right: ModelRecord): number {
const leftSpec = modelSpec(left);
const rightSpec = modelSpec(right);
const leftIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === leftSpec);
const rightIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === rightSpec);
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1;
if (rightIndex === -1) return -1;
return leftIndex - rightIndex;
}
const leftProviderIndex = PROVIDER_SORT_ORDER.indexOf(left.provider);
const rightProviderIndex = PROVIDER_SORT_ORDER.indexOf(right.provider);
if (leftProviderIndex !== -1 || rightProviderIndex !== -1) {
if (leftProviderIndex === -1) return 1;
if (rightProviderIndex === -1) return -1;
return leftProviderIndex - rightProviderIndex;
}
return modelSpec(left).localeCompare(modelSpec(right));
}
function sortProviders(left: ProviderStatus, right: ProviderStatus): number {
if (left.configured !== right.configured) {
return left.configured ? -1 : 1;
}
if (left.current !== right.current) {
return left.current ? -1 : 1;
}
if (left.recommended !== right.recommended) {
return left.recommended ? -1 : 1;
}
const leftIndex = PROVIDER_SORT_ORDER.indexOf(left.id);
const rightIndex = PROVIDER_SORT_ORDER.indexOf(right.id);
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1;
if (rightIndex === -1) return -1;
return leftIndex - rightIndex;
}
return left.label.localeCompare(right.label);
}
function createModelRegistry(authPath: string): ModelRegistry {
return new ModelRegistry(AuthStorage.create(authPath));
}
export function getAvailableModelRecords(authPath: string): ModelRecord[] {
return createModelRegistry(authPath)
.getAvailable()
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
}
export function getSupportedModelRecords(authPath: string): ModelRecord[] {
return createModelRegistry(authPath)
.getAll()
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
}
export function chooseRecommendedModel(authPath: string): { spec: string; reason: string } | undefined {
const available = getAvailableModelRecords(authPath).sort(compareByResearchPreference);
if (available.length === 0) {
return undefined;
}
const matchedPreference = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(available[0]!));
if (matchedPreference) {
return matchedPreference;
}
return {
spec: modelSpec(available[0]!),
reason: "best currently authenticated fallback for research work",
};
}
export function buildModelStatusSnapshotFromRecords(
supported: ModelRecord[],
available: ModelRecord[],
current: string | undefined,
): ModelStatusSnapshot {
const availableSpecs = available
.slice()
.sort(compareByResearchPreference)
.map((model) => modelSpec(model));
const recommended = available.length > 0
? (() => {
const preferred = available.slice().sort(compareByResearchPreference)[0]!;
const matched = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(preferred));
return {
spec: modelSpec(preferred),
reason: matched?.reason ?? "best currently authenticated fallback for research work",
};
})()
: undefined;
const currentValid = current ? availableSpecs.includes(current) : false;
const providerMap = new Map<string, ProviderStatus>();
for (const model of supported) {
const provider = providerMap.get(model.provider) ?? {
id: model.provider,
label: formatProviderLabel(model.provider),
supportedModels: 0,
availableModels: 0,
configured: false,
current: false,
recommended: false,
};
provider.supportedModels += 1;
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
providerMap.set(model.provider, provider);
}
for (const model of available) {
const provider = providerMap.get(model.provider) ?? {
id: model.provider,
label: formatProviderLabel(model.provider),
supportedModels: 0,
availableModels: 0,
configured: false,
current: false,
recommended: false,
};
provider.availableModels += 1;
provider.configured = true;
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
providerMap.set(model.provider, provider);
}
const guidance: string[] = [];
if (available.length === 0) {
guidance.push("No authenticated Pi models are available yet.");
guidance.push("Run `feynman model login <provider>` or add provider credentials that Pi can see.");
guidance.push("After auth is in place, rerun `feynman model list` or `feynman setup model`.");
} else if (!current) {
guidance.push(`No default research model is set. Recommended: ${recommended?.spec}.`);
guidance.push("Run `feynman model set <provider/model>` or `feynman setup model`.");
} else if (!currentValid) {
guidance.push(`Configured default model is unavailable: ${current}.`);
if (recommended) {
guidance.push(`Switch to the current research recommendation: ${recommended.spec}.`);
}
}
return {
current,
currentValid,
recommended: recommended?.spec,
recommendationReason: recommended?.reason,
availableModels: availableSpecs,
providers: Array.from(providerMap.values()).sort(sortProviders),
guidance,
};
}

273
src/model/commands.ts Normal file
View File

@@ -0,0 +1,273 @@
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import { writeFileSync } from "node:fs";
import { readJson } from "../pi/settings.js";
import { promptChoice, promptText } from "../setup/prompts.js";
import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js";
import {
buildModelStatusSnapshotFromRecords,
chooseRecommendedModel,
getAvailableModelRecords,
getSupportedModelRecords,
type ModelStatusSnapshot,
} from "./catalog.js";
function formatProviderSummaryLine(status: ModelStatusSnapshot["providers"][number]): string {
const state = status.configured ? `${status.availableModels} authenticated` : "not authenticated";
const flags = [
status.current ? "current" : undefined,
status.recommended ? "recommended" : undefined,
].filter(Boolean);
return `${status.label}: ${state}, ${status.supportedModels} supported${flags.length > 0 ? ` (${flags.join(", ")})` : ""}`;
}
function collectModelStatus(settingsPath: string, authPath: string): ModelStatusSnapshot {
return buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(authPath),
getAvailableModelRecords(authPath),
getCurrentModelSpec(settingsPath),
);
}
type OAuthProviderInfo = {
id: string;
name?: string;
usesCallbackServer?: boolean;
};
function getOAuthProviders(authPath: string): OAuthProviderInfo[] {
return AuthStorage.create(authPath).getOAuthProviders() as OAuthProviderInfo[];
}
function resolveOAuthProvider(authPath: string, input: string): OAuthProviderInfo | undefined {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return undefined;
}
return getOAuthProviders(authPath).find((provider) => provider.id.toLowerCase() === normalizedInput);
}
async function selectOAuthProvider(authPath: string, action: "login" | "logout"): Promise<OAuthProviderInfo | undefined> {
const providers = getOAuthProviders(authPath);
if (providers.length === 0) {
printWarning("No Pi OAuth model providers are available.");
return undefined;
}
if (providers.length === 1) {
return providers[0];
}
const choices = providers.map((provider) => `${provider.id}${provider.name ?? provider.id}`);
choices.push("Cancel");
const selection = await promptChoice(`Choose an OAuth provider to ${action}:`, choices, 0);
if (selection >= providers.length) {
return undefined;
}
return providers[selection];
}
function resolveAvailableModelSpec(authPath: string, input: string): string | undefined {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return undefined;
}
const available = getAvailableModelRecords(authPath);
const fullSpecMatch = available.find((model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedInput);
if (fullSpecMatch) {
return `${fullSpecMatch.provider}/${fullSpecMatch.id}`;
}
const exactIdMatches = available.filter((model) => model.id.toLowerCase() === normalizedInput);
if (exactIdMatches.length === 1) {
return `${exactIdMatches[0]!.provider}/${exactIdMatches[0]!.id}`;
}
return undefined;
}
export function getCurrentModelSpec(settingsPath: string): string | undefined {
const settings = readJson(settingsPath);
if (typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string") {
return `${settings.defaultProvider}/${settings.defaultModel}`;
}
return undefined;
}
export function printModelStatus(settingsPath: string, authPath: string): void {
const status = collectModelStatus(settingsPath, authPath);
printInfo(`Current default model: ${status.current ?? "not set"}`);
printInfo(`Current default valid: ${status.currentValid ? "yes" : "no"}`);
printInfo(`Authenticated models: ${status.availableModels.length}`);
printInfo(`Providers with auth: ${status.providers.filter((provider) => provider.configured).length}`);
printInfo(`Research recommendation: ${status.recommended ?? "none available"}`);
if (status.recommendationReason) {
printInfo(`Recommendation reason: ${status.recommendationReason}`);
}
if (status.providers.length > 0) {
printSection("Providers");
for (const provider of status.providers) {
printInfo(formatProviderSummaryLine(provider));
}
}
if (status.guidance.length > 0) {
printSection("Next Steps");
for (const line of status.guidance) {
printWarning(line);
}
}
}
export function printModelProviders(settingsPath: string, authPath: string): void {
const status = collectModelStatus(settingsPath, authPath);
for (const provider of status.providers) {
printInfo(formatProviderSummaryLine(provider));
}
const oauthProviders = getOAuthProviders(authPath);
if (oauthProviders.length > 0) {
printSection("OAuth Login");
for (const provider of oauthProviders) {
printInfo(`${provider.id}${provider.name ?? provider.id}`);
}
}
if (status.providers.length === 0) {
printWarning("No Pi model providers are visible in the current runtime.");
}
}
export function printModelList(settingsPath: string, authPath: string): void {
const status = collectModelStatus(settingsPath, authPath);
if (status.availableModels.length === 0) {
printWarning("No authenticated Pi models are currently available.");
for (const line of status.guidance) {
printInfo(line);
}
return;
}
let lastProvider: string | undefined;
for (const spec of status.availableModels) {
const [provider] = spec.split("/", 1);
if (provider !== lastProvider) {
lastProvider = provider;
printSection(provider);
}
const markers = [
spec === status.current ? "current" : undefined,
spec === status.recommended ? "recommended" : undefined,
].filter(Boolean);
printInfo(`${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`);
}
}
export function printModelRecommendation(authPath: string): void {
const recommendation = chooseRecommendedModel(authPath);
if (!recommendation) {
printWarning("No authenticated Pi models are available to recommend.");
printInfo("Run `feynman model login <provider>` or add provider credentials that Pi can see, then rerun this command.");
return;
}
printSuccess(`Recommended model: ${recommendation.spec}`);
printInfo(recommendation.reason);
}
export async function loginModelProvider(authPath: string, providerId?: string): Promise<void> {
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "login");
if (!provider) {
if (providerId) {
throw new Error(`Unknown OAuth model provider: ${providerId}`);
}
printInfo("Login cancelled.");
return;
}
const authStorage = AuthStorage.create(authPath);
const abortController = new AbortController();
await authStorage.login(provider.id, {
onAuth: (info: { url: string; instructions?: string }) => {
printSection(`Login: ${provider.name ?? provider.id}`);
printInfo(`Open this URL: ${info.url}`);
if (info.instructions) {
printInfo(info.instructions);
}
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
return promptText(prompt.message, prompt.placeholder ?? "");
},
onProgress: (message: string) => {
printInfo(message);
},
onManualCodeInput: async () => {
return promptText("Paste redirect URL or auth code");
},
signal: abortController.signal,
});
printSuccess(`Model provider login complete: ${provider.id}`);
}
export async function logoutModelProvider(authPath: string, providerId?: string): Promise<void> {
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "logout");
if (!provider) {
if (providerId) {
throw new Error(`Unknown OAuth model provider: ${providerId}`);
}
printInfo("Logout cancelled.");
return;
}
AuthStorage.create(authPath).logout(provider.id);
printSuccess(`Model provider logout complete: ${provider.id}`);
}
export function setDefaultModelSpec(settingsPath: string, authPath: string, spec: string): void {
const resolvedSpec = resolveAvailableModelSpec(authPath, spec);
if (!resolvedSpec) {
throw new Error(`Model not available in Pi auth storage: ${spec}. Run \`feynman model list\` first.`);
}
const [provider, ...rest] = resolvedSpec.split("/");
const modelId = rest.join("/");
const settings = readJson(settingsPath);
settings.defaultProvider = provider;
settings.defaultModel = modelId;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
printSuccess(`Default model set to ${resolvedSpec}`);
}
export async function runModelSetup(settingsPath: string, authPath: string): Promise<void> {
const status = collectModelStatus(settingsPath, authPath);
if (status.availableModels.length === 0) {
printWarning("No Pi models are currently authenticated for Feynman.");
for (const line of status.guidance) {
printInfo(line);
}
printInfo("Tip: run `feynman model login <provider>` if your provider supports Pi OAuth login.");
return;
}
const choices = status.availableModels.map((spec) => {
const markers = [
spec === status.recommended ? "recommended" : undefined,
spec === status.current ? "current" : undefined,
].filter(Boolean);
return `${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`;
});
choices.push(`Keep current (${status.current ?? "unset"})`);
const defaultIndex = status.current ? Math.max(0, status.availableModels.indexOf(status.current)) : 0;
const selection = await promptChoice("Select your default research model:", choices, defaultIndex >= 0 ? defaultIndex : 0);
if (selection >= status.availableModels.length) {
printInfo("Skipped (keeping current model)");
return;
}
setDefaultModelSpec(settingsPath, authPath, status.availableModels[selection]!);
}

32
src/pi/launch.ts Normal file
View File

@@ -0,0 +1,32 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths } from "./runtime.js";
export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
const { piCliPath, promisePolyfillPath } = resolvePiPaths(options.appRoot);
if (!existsSync(piCliPath)) {
throw new Error(`Pi CLI not found: ${piCliPath}`);
}
if (!existsSync(promisePolyfillPath)) {
throw new Error(`Promise polyfill not found: ${promisePolyfillPath}`);
}
const child = spawn(process.execPath, ["--import", promisePolyfillPath, piCliPath, ...buildPiArgs(options)], {
cwd: options.workingDir,
stdio: "inherit",
env: buildPiEnv(options),
});
await new Promise<void>((resolvePromise, reject) => {
child.on("error", reject);
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exitCode = code ?? 0;
resolvePromise();
});
});
}

99
src/pi/runtime.ts Normal file
View File

@@ -0,0 +1,99 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import {
BROWSER_FALLBACK_PATHS,
MERMAID_FALLBACK_PATHS,
PANDOC_FALLBACK_PATHS,
resolveExecutable,
} from "../system/executables.js";
export type PiRuntimeOptions = {
appRoot: string;
workingDir: string;
sessionDir: string;
feynmanAgentDir: string;
feynmanVersion?: string;
thinkingLevel?: string;
explicitModelSpec?: string;
oneShotPrompt?: string;
initialPrompt?: string;
systemPrompt: string;
};
export function resolvePiPaths(appRoot: string) {
return {
piPackageRoot: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent"),
piCliPath: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"),
promisePolyfillPath: resolve(appRoot, "dist", "system", "promise-polyfill.js"),
researchToolsPath: resolve(appRoot, "extensions", "research-tools.ts"),
skillsPath: resolve(appRoot, "skills"),
promptTemplatePath: resolve(appRoot, "prompts"),
piWorkspaceNodeModulesPath: resolve(appRoot, ".pi", "npm", "node_modules"),
};
}
export function validatePiInstallation(appRoot: string): string[] {
const paths = resolvePiPaths(appRoot);
const missing: string[] = [];
if (!existsSync(paths.piCliPath)) missing.push(paths.piCliPath);
if (!existsSync(paths.promisePolyfillPath)) missing.push(paths.promisePolyfillPath);
if (!existsSync(paths.researchToolsPath)) missing.push(paths.researchToolsPath);
if (!existsSync(paths.skillsPath)) missing.push(paths.skillsPath);
if (!existsSync(paths.promptTemplatePath)) missing.push(paths.promptTemplatePath);
return missing;
}
export function buildPiArgs(options: PiRuntimeOptions): string[] {
const paths = resolvePiPaths(options.appRoot);
const args = [
"--session-dir",
options.sessionDir,
"--extension",
paths.researchToolsPath,
"--skill",
paths.skillsPath,
"--prompt-template",
paths.promptTemplatePath,
"--system-prompt",
options.systemPrompt,
];
if (options.explicitModelSpec) {
args.push("--model", options.explicitModelSpec);
}
if (options.thinkingLevel) {
args.push("--thinking", options.thinkingLevel);
}
if (options.oneShotPrompt) {
args.push("-p", options.oneShotPrompt);
} else if (options.initialPrompt) {
args.push(options.initialPrompt);
}
return args;
}
export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv {
const paths = resolvePiPaths(options.appRoot);
return {
...process.env,
PI_CODING_AGENT_DIR: options.feynmanAgentDir,
FEYNMAN_CODING_AGENT_DIR: options.feynmanAgentDir,
FEYNMAN_VERSION: options.feynmanVersion,
FEYNMAN_PI_NPM_ROOT: paths.piWorkspaceNodeModulesPath,
FEYNMAN_SESSION_DIR: options.sessionDir,
PI_SESSION_DIR: options.sessionDir,
FEYNMAN_MEMORY_DIR: resolve(dirname(options.feynmanAgentDir), "memory"),
FEYNMAN_NODE_EXECUTABLE: process.execPath,
FEYNMAN_BIN_PATH: resolve(options.appRoot, "bin", "feynman.js"),
PANDOC_PATH: process.env.PANDOC_PATH ?? resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS),
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
MERMAID_CLI_PATH: process.env.MERMAID_CLI_PATH ?? resolveExecutable("mmdc", MERMAID_FALLBACK_PATHS),
PUPPETEER_EXECUTABLE_PATH:
process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS),
};
}

121
src/pi/settings.ts Normal file
View File

@@ -0,0 +1,121 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
export type ThinkingLevel = "off" | "low" | "medium" | "high";
export function parseModelSpec(spec: string, modelRegistry: ModelRegistry) {
const trimmed = spec.trim();
const separator = trimmed.includes(":") ? ":" : trimmed.includes("/") ? "/" : null;
if (!separator) {
return undefined;
}
const [provider, ...rest] = trimmed.split(separator);
const id = rest.join(separator);
if (!provider || !id) {
return undefined;
}
return modelRegistry.find(provider, id);
}
export function normalizeThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
if (!value) {
return undefined;
}
const normalized = value.toLowerCase();
if (normalized === "off" || normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return undefined;
}
function choosePreferredModel(
availableModels: Array<{ provider: string; id: string }>,
): { provider: string; id: string } | undefined {
const preferences = [
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "anthropic", id: "claude-opus-4-5" },
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5.4" },
{ provider: "openai", id: "gpt-5" },
];
for (const preferred of preferences) {
const match = availableModels.find(
(model) => model.provider === preferred.provider && model.id === preferred.id,
);
if (match) {
return match;
}
}
return availableModels[0];
}
export function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) {
return {};
}
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return {};
}
}
export function normalizeFeynmanSettings(
settingsPath: string,
bundledSettingsPath: string,
defaultThinkingLevel: ThinkingLevel,
authPath: string,
): void {
let settings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch {
settings = {};
}
} else if (existsSync(bundledSettingsPath)) {
try {
settings = JSON.parse(readFileSync(bundledSettingsPath, "utf8"));
} catch {
settings = {};
}
}
if (!settings.defaultThinkingLevel) {
settings.defaultThinkingLevel = defaultThinkingLevel;
}
if (settings.editorPaddingX === undefined) {
settings.editorPaddingX = 1;
}
settings.theme = "feynman";
settings.quietStartup = true;
settings.collapseChangelog = true;
const authStorage = AuthStorage.create(authPath);
const modelRegistry = new ModelRegistry(authStorage);
const availableModels = modelRegistry.getAvailable().map((model) => ({
provider: model.provider,
id: model.id,
}));
if ((!settings.defaultProvider || !settings.defaultModel) && availableModels.length > 0) {
const preferredModel = choosePreferredModel(availableModels);
if (preferredModel) {
settings.defaultProvider = preferredModel.provider;
settings.defaultModel = preferredModel.id;
}
}
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
}

49
src/search/commands.ts Normal file
View File

@@ -0,0 +1,49 @@
import {
DEFAULT_WEB_SEARCH_PROVIDER,
WEB_SEARCH_PROVIDERS,
configureWebSearchProvider,
getWebSearchStatus,
loadFeynmanConfig,
saveFeynmanConfig,
type WebSearchProviderId,
} from "../config/feynman-config.js";
import { printInfo, printSuccess } from "../ui/terminal.js";
export function printSearchStatus(): void {
const status = getWebSearchStatus(loadFeynmanConfig().webSearch ?? {});
printInfo(`Provider: ${status.selected.label}`);
printInfo(`Runtime route: ${status.selected.runtimeProvider}`);
printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`);
printInfo(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`);
printInfo(`Browser mode: ${status.browserHint}${status.chromeProfile ? ` (${status.chromeProfile})` : ""}`);
}
export function printSearchProviders(): void {
for (const provider of WEB_SEARCH_PROVIDERS) {
const marker = provider.id === DEFAULT_WEB_SEARCH_PROVIDER ? " (default)" : "";
printInfo(`${provider.id}${provider.label}${marker}: ${provider.description}`);
}
}
export function setSearchProvider(providerId: string, value?: string): void {
if (!WEB_SEARCH_PROVIDERS.some((provider) => provider.id === providerId)) {
throw new Error(`Unknown search provider: ${providerId}`);
}
const config = loadFeynmanConfig();
const nextWebSearch = configureWebSearchProvider(
config.webSearch ?? {},
providerId as WebSearchProviderId,
providerId === "gemini-browser"
? { chromeProfile: value }
: providerId === "perplexity" || providerId === "gemini-api"
? { apiKey: value }
: {},
);
saveFeynmanConfig({
...config,
webSearch: nextWebSearch,
});
printSuccess(`Search provider set to ${providerId}`);
}

180
src/setup/doctor.ts Normal file
View File

@@ -0,0 +1,180 @@
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import { getUserName as getAlphaUserName, isLoggedIn as isAlphaLoggedIn } from "@companion-ai/alpha-hub/lib";
import {
FEYNMAN_CONFIG_PATH,
formatWebSearchDoctorLines,
getWebSearchStatus,
loadFeynmanConfig,
} from "../config/feynman-config.js";
import { BROWSER_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
import { readJson } from "../pi/settings.js";
import { validatePiInstallation } from "../pi/runtime.js";
import { printInfo, printPanel, printSection } from "../ui/terminal.js";
import { getCurrentModelSpec } from "../model/commands.js";
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
export type DoctorOptions = {
settingsPath: string;
authPath: string;
sessionDir: string;
workingDir: string;
appRoot: string;
};
export type FeynmanStatusSnapshot = {
model?: string;
modelValid: boolean;
recommendedModel?: string;
recommendedModelReason?: string;
authenticatedModelCount: number;
authenticatedProviderCount: number;
modelGuidance: string[];
alphaLoggedIn: boolean;
alphaUser?: string;
webProviderLabel: string;
webConfigured: boolean;
previewConfigured: boolean;
sessionDir: string;
configPath: string;
pandocReady: boolean;
browserReady: boolean;
piReady: boolean;
missingPiBits: string[];
};
export function collectStatusSnapshot(options: DoctorOptions): FeynmanStatusSnapshot {
const config = loadFeynmanConfig();
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS);
const missingPiBits = validatePiInstallation(options.appRoot);
const webStatus = getWebSearchStatus(config.webSearch ?? {});
const modelStatus = buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(options.authPath),
getAvailableModelRecords(options.authPath),
getCurrentModelSpec(options.settingsPath),
);
return {
model: modelStatus.current,
modelValid: modelStatus.currentValid,
recommendedModel: modelStatus.recommended,
recommendedModelReason: modelStatus.recommendationReason,
authenticatedModelCount: modelStatus.availableModels.length,
authenticatedProviderCount: modelStatus.providers.filter((provider) => provider.configured).length,
modelGuidance: modelStatus.guidance,
alphaLoggedIn: isAlphaLoggedIn(),
alphaUser: isAlphaLoggedIn() ? getAlphaUserName() ?? undefined : undefined,
webProviderLabel: webStatus.selected.label,
webConfigured: webStatus.perplexityConfigured || webStatus.geminiApiConfigured || webStatus.selected.id === "gemini-browser",
previewConfigured: Boolean(config.preview?.lastSetupAt),
sessionDir: options.sessionDir,
configPath: FEYNMAN_CONFIG_PATH,
pandocReady: Boolean(pandocPath),
browserReady: Boolean(browserPath),
piReady: missingPiBits.length === 0,
missingPiBits,
};
}
export function runStatus(options: DoctorOptions): void {
const snapshot = collectStatusSnapshot(options);
printPanel("Feynman Status", [
"Current setup summary for the research shell.",
]);
printSection("Core");
printInfo(`Model: ${snapshot.model ?? "not configured"}`);
printInfo(`Model valid: ${snapshot.modelValid ? "yes" : "no"}`);
printInfo(`Authenticated models: ${snapshot.authenticatedModelCount}`);
printInfo(`Authenticated providers: ${snapshot.authenticatedProviderCount}`);
printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`);
printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`);
printInfo(`Web research: ${snapshot.webConfigured ? snapshot.webProviderLabel : "not configured"}`);
printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
printSection("Paths");
printInfo(`Config: ${snapshot.configPath}`);
printInfo(`Sessions: ${snapshot.sessionDir}`);
printSection("Runtime");
printInfo(`Pi runtime: ${snapshot.piReady ? "ready" : "missing files"}`);
printInfo(`Pandoc: ${snapshot.pandocReady ? "ready" : "missing"}`);
printInfo(`Browser preview: ${snapshot.browserReady ? "ready" : "missing"}`);
if (snapshot.missingPiBits.length > 0) {
for (const entry of snapshot.missingPiBits) {
printInfo(` missing: ${entry}`);
}
}
if (snapshot.modelGuidance.length > 0) {
printSection("Next Steps");
for (const line of snapshot.modelGuidance) {
printInfo(line);
}
}
}
export function runDoctor(options: DoctorOptions): void {
const settings = readJson(options.settingsPath);
const config = loadFeynmanConfig();
const modelRegistry = new ModelRegistry(AuthStorage.create(options.authPath));
const availableModels = modelRegistry.getAvailable();
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS);
const missingPiBits = validatePiInstallation(options.appRoot);
printPanel("Feynman Doctor", [
"Checks config, auth, runtime wiring, and preview dependencies.",
]);
console.log(`working dir: ${options.workingDir}`);
console.log(`session dir: ${options.sessionDir}`);
console.log(`config path: ${FEYNMAN_CONFIG_PATH}`);
console.log("");
console.log(`alphaXiv auth: ${isAlphaLoggedIn() ? "ok" : "missing"}`);
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
if (name) {
console.log(` user: ${name}`);
}
}
console.log(`models available: ${availableModels.length}`);
if (availableModels.length > 0) {
const sample = availableModels
.slice(0, 6)
.map((model) => `${model.provider}/${model.id}`)
.join(", ");
console.log(` sample: ${sample}`);
}
console.log(
`default model: ${typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string"
? `${settings.defaultProvider}/${settings.defaultModel}`
: "not set"}`,
);
const modelStatus = collectStatusSnapshot(options);
console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`);
console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`);
console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`);
console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`);
if (modelStatus.recommendedModelReason) {
console.log(` why: ${modelStatus.recommendedModelReason}`);
}
console.log(`pandoc: ${pandocPath ?? "missing"}`);
console.log(`browser preview runtime: ${browserPath ?? "missing"}`);
console.log(`configured session dir: ${config.sessionDir ?? "not set"}`);
for (const line of formatWebSearchDoctorLines(config.webSearch ?? {})) {
console.log(line);
}
console.log(`quiet startup: ${settings.quietStartup === true ? "enabled" : "disabled"}`);
console.log(`theme: ${typeof settings.theme === "string" ? settings.theme : "not set"}`);
if (missingPiBits.length > 0) {
console.log("pi runtime: missing files");
for (const entry of missingPiBits) {
console.log(` ${entry}`);
}
} else {
console.log("pi runtime: ok");
}
for (const line of modelStatus.modelGuidance) {
console.log(`next step: ${line}`);
}
console.log("setup hint: feynman setup");
}

29
src/setup/preview.ts Normal file
View File

@@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
import { BREW_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
export type PreviewSetupResult =
| { status: "ready"; message: string }
| { status: "installed"; message: string }
| { status: "manual"; message: string };
export function setupPreviewDependencies(): PreviewSetupResult {
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
if (pandocPath) {
return { status: "ready", message: `pandoc already installed at ${pandocPath}` };
}
const brewPath = resolveExecutable("brew", BREW_FALLBACK_PATHS);
if (process.platform === "darwin" && brewPath) {
const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" });
if (result.status !== 0) {
throw new Error("Failed to install pandoc via Homebrew.");
}
return { status: "installed", message: "Preview dependency installed: pandoc" };
}
return {
status: "manual",
message: "pandoc is required for preview support. Install it manually and rerun `feynman --doctor`.",
};
}

30
src/setup/prompts.ts Normal file
View File

@@ -0,0 +1,30 @@
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
export async function promptText(question: string, defaultValue = ""): Promise<string> {
if (!input.isTTY || !output.isTTY) {
throw new Error("feynman setup requires an interactive terminal.");
}
const rl = createInterface({ input, output });
try {
const suffix = defaultValue ? ` [${defaultValue}]` : "";
const value = (await rl.question(`${question}${suffix}: `)).trim();
return value || defaultValue;
} finally {
rl.close();
}
}
export async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
console.log(question);
for (const [index, choice] of choices.entries()) {
const marker = index === defaultIndex ? "*" : " ";
console.log(` ${marker} ${index + 1}. ${choice}`);
}
const answer = await promptText("Select", String(defaultIndex + 1));
const parsed = Number(answer);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) {
return defaultIndex;
}
return parsed - 1;
}

316
src/setup/setup.ts Normal file
View File

@@ -0,0 +1,316 @@
import { isLoggedIn as isAlphaLoggedIn, login as loginAlpha } from "@companion-ai/alpha-hub/lib";
import {
DEFAULT_WEB_SEARCH_PROVIDER,
FEYNMAN_CONFIG_PATH,
WEB_SEARCH_PROVIDERS,
configureWebSearchProvider,
getConfiguredWebSearchProvider,
getWebSearchStatus,
hasConfiguredWebProvider,
loadFeynmanConfig,
saveFeynmanConfig,
} from "../config/feynman-config.js";
import { getFeynmanHome } from "../config/paths.js";
import { normalizeFeynmanSettings } from "../pi/settings.js";
import type { ThinkingLevel } from "../pi/settings.js";
import { getCurrentModelSpec, runModelSetup } from "../model/commands.js";
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
import { promptChoice, promptText } from "./prompts.js";
import { setupPreviewDependencies } from "./preview.js";
import { runDoctor } from "./doctor.js";
import { printInfo, printPanel, printSection, printSuccess } from "../ui/terminal.js";
type SetupOptions = {
section: string | undefined;
settingsPath: string;
bundledSettingsPath: string;
authPath: string;
workingDir: string;
sessionDir: string;
appRoot: string;
defaultThinkingLevel?: ThinkingLevel;
};
async function setupWebProvider(): Promise<void> {
const config = loadFeynmanConfig();
const current = getConfiguredWebSearchProvider(config.webSearch ?? {});
const preferredSelectionId = config.webSearch?.feynmanWebProvider ?? DEFAULT_WEB_SEARCH_PROVIDER;
const choices = [
...WEB_SEARCH_PROVIDERS.map((provider) => `${provider.label}${provider.description}`),
"Skip",
];
const defaultIndex = WEB_SEARCH_PROVIDERS.findIndex((provider) => provider.id === preferredSelectionId);
const selection = await promptChoice(
"Choose a web search provider for Feynman:",
choices,
defaultIndex >= 0 ? defaultIndex : 0,
);
if (selection === WEB_SEARCH_PROVIDERS.length) {
return;
}
const selected = WEB_SEARCH_PROVIDERS[selection] ?? WEB_SEARCH_PROVIDERS[0];
let nextWebConfig = { ...(config.webSearch ?? {}) };
if (selected.id === "perplexity") {
const key = await promptText(
"Perplexity API key",
typeof nextWebConfig.perplexityApiKey === "string" ? nextWebConfig.perplexityApiKey : "",
);
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { apiKey: key });
} else if (selected.id === "gemini-api") {
const key = await promptText(
"Gemini API key",
typeof nextWebConfig.geminiApiKey === "string" ? nextWebConfig.geminiApiKey : "",
);
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { apiKey: key });
} else if (selected.id === "gemini-browser") {
const profile = await promptText(
"Chrome profile (optional)",
typeof nextWebConfig.chromeProfile === "string" ? nextWebConfig.chromeProfile : "",
);
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { chromeProfile: profile });
} else {
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id);
}
saveFeynmanConfig({
...config,
webSearch: nextWebConfig,
});
printSuccess(`Saved web search provider: ${selected.label}`);
if (selected.id === "gemini-browser") {
printInfo("Gemini Browser relies on a signed-in Chromium profile through pi-web-access.");
}
}
function isPreviewConfigured() {
return Boolean(loadFeynmanConfig().preview?.lastSetupAt);
}
function isInteractiveTerminal(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
function printNonInteractiveSetupGuidance(): void {
printPanel("Feynman Setup", [
"Non-interactive terminal detected.",
]);
printInfo("Use the explicit commands instead of the interactive setup wizard:");
printInfo(" feynman status");
printInfo(" feynman model providers");
printInfo(" feynman model login <provider>");
printInfo(" feynman model list");
printInfo(" feynman model recommend");
printInfo(" feynman model set <provider/model>");
printInfo(" feynman search providers");
printInfo(" feynman search set <provider> [value]");
printInfo(" feynman alpha login");
printInfo(" feynman doctor");
printInfo(" feynman # Pi's /login flow still works inside chat if you prefer it");
}
async function runPreviewSetup(): Promise<void> {
const result = setupPreviewDependencies();
printSuccess(result.message);
saveFeynmanConfig({
...loadFeynmanConfig(),
preview: {
lastSetupAt: new Date().toISOString(),
},
});
}
function printConfigurationLocation(appRoot: string): void {
printSection("Configuration Location");
printInfo(`Config file: ${FEYNMAN_CONFIG_PATH}`);
printInfo(`Data folder: ${getFeynmanHome()}`);
printInfo(`Install dir: ${appRoot}`);
printInfo("You can edit config.json directly or use `feynman config` commands.");
}
function printSetupSummary(settingsPath: string, authPath: string): void {
const config = loadFeynmanConfig();
const webStatus = getWebSearchStatus(config.webSearch ?? {});
const modelStatus = buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(authPath),
getAvailableModelRecords(authPath),
getCurrentModelSpec(settingsPath),
);
printSection("Setup Summary");
printInfo(`Model: ${getCurrentModelSpec(settingsPath) ?? "not set"}`);
printInfo(`Model valid: ${modelStatus.currentValid ? "yes" : "no"}`);
printInfo(`Recommended model: ${modelStatus.recommended ?? "not available"}`);
printInfo(`alphaXiv: ${isAlphaLoggedIn() ? "configured" : "missing"}`);
printInfo(`Web research: ${hasConfiguredWebProvider(config.webSearch ?? {}) ? webStatus.selected.label : "not configured"}`);
printInfo(`Preview: ${isPreviewConfigured() ? "configured" : "not configured"}`);
for (const line of modelStatus.guidance) {
printInfo(line);
}
}
async function runSetupSection(section: "model" | "alpha" | "web" | "preview", options: SetupOptions): Promise<void> {
if (section === "model") {
await runModelSetup(options.settingsPath, options.authPath);
return;
}
if (section === "alpha") {
if (!isAlphaLoggedIn()) {
await loginAlpha();
printSuccess("alphaXiv login complete");
} else {
printInfo("alphaXiv login already configured");
}
return;
}
if (section === "web") {
await setupWebProvider();
return;
}
if (section === "preview") {
await runPreviewSetup();
return;
}
}
async function runFullSetup(options: SetupOptions): Promise<void> {
printConfigurationLocation(options.appRoot);
await runSetupSection("model", options);
await runSetupSection("alpha", options);
await runSetupSection("web", options);
await runSetupSection("preview", options);
normalizeFeynmanSettings(
options.settingsPath,
options.bundledSettingsPath,
options.defaultThinkingLevel ?? "medium",
options.authPath,
);
runDoctor({
settingsPath: options.settingsPath,
authPath: options.authPath,
sessionDir: options.sessionDir,
workingDir: options.workingDir,
appRoot: options.appRoot,
});
printSetupSummary(options.settingsPath, options.authPath);
}
async function runQuickSetup(options: SetupOptions): Promise<void> {
printSection("Quick Setup");
let changed = false;
const modelStatus = buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(options.authPath),
getAvailableModelRecords(options.authPath),
getCurrentModelSpec(options.settingsPath),
);
if (!modelStatus.current || !modelStatus.currentValid) {
await runSetupSection("model", options);
changed = true;
}
if (!isAlphaLoggedIn()) {
await runSetupSection("alpha", options);
changed = true;
}
if (!hasConfiguredWebProvider(loadFeynmanConfig().webSearch ?? {})) {
await runSetupSection("web", options);
changed = true;
}
if (!isPreviewConfigured()) {
await runSetupSection("preview", options);
changed = true;
}
if (!changed) {
printSuccess("Everything already looks configured.");
printInfo("Run `feynman setup` and choose Full Setup if you want to reconfigure everything.");
return;
}
normalizeFeynmanSettings(
options.settingsPath,
options.bundledSettingsPath,
options.defaultThinkingLevel ?? "medium",
options.authPath,
);
printSetupSummary(options.settingsPath, options.authPath);
}
function hasExistingSetup(settingsPath: string, authPath: string): boolean {
const config = loadFeynmanConfig();
const modelStatus = buildModelStatusSnapshotFromRecords(
getSupportedModelRecords(authPath),
getAvailableModelRecords(authPath),
getCurrentModelSpec(settingsPath),
);
return Boolean(
modelStatus.current ||
modelStatus.availableModels.length > 0 ||
isAlphaLoggedIn() ||
hasConfiguredWebProvider(config.webSearch ?? {}) ||
config.preview?.lastSetupAt,
);
}
async function runDefaultInteractiveSetup(options: SetupOptions): Promise<void> {
const existing = hasExistingSetup(options.settingsPath, options.authPath);
printPanel("Feynman Setup Wizard", [
"Guided setup for the research-first Pi agent.",
"Press Ctrl+C at any time to exit.",
]);
if (existing) {
printSection("Full Setup");
printInfo("Existing configuration detected. Rerunning the full guided setup.");
printInfo("Use `feynman setup quick` if you only want to fill missing items.");
} else {
printInfo("We'll walk you through:");
printInfo(" 1. Model Selection");
printInfo(" 2. alphaXiv Login");
printInfo(" 3. Web Research Provider");
printInfo(" 4. Preview Dependencies");
}
printInfo("Press Enter to begin, or Ctrl+C to exit.");
await promptText("Press Enter to start");
await runFullSetup(options);
}
export async function runSetup(options: SetupOptions): Promise<void> {
if (!isInteractiveTerminal()) {
printNonInteractiveSetupGuidance();
return;
}
if (!options.section) {
await runDefaultInteractiveSetup(options);
return;
}
if (options.section === "model") {
await runSetupSection("model", options);
return;
}
if (options.section === "alpha") {
await runSetupSection("alpha", options);
return;
}
if (options.section === "web") {
await runSetupSection("web", options);
return;
}
if (options.section === "preview") {
await runSetupSection("preview", options);
return;
}
if (options.section === "quick") {
await runQuickSetup(options);
return;
}
await runFullSetup(options);
}

46
src/system/executables.ts Normal file
View File

@@ -0,0 +1,46 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
export const PANDOC_FALLBACK_PATHS = [
"/opt/homebrew/bin/pandoc",
"/usr/local/bin/pandoc",
];
export const BREW_FALLBACK_PATHS = [
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
];
export const BROWSER_FALLBACK_PATHS = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
export const MERMAID_FALLBACK_PATHS = [
"/opt/homebrew/bin/mmdc",
"/usr/local/bin/mmdc",
];
export function resolveExecutable(name: string, fallbackPaths: string[] = []): string | undefined {
for (const candidate of fallbackPaths) {
if (existsSync(candidate)) {
return candidate;
}
}
const result = spawnSync("sh", ["-lc", `command -v ${name}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const resolved = result.stdout.trim();
if (resolved) {
return resolved;
}
}
return undefined;
}

View File

@@ -0,0 +1,26 @@
type PromiseWithResolvers<T> = {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
declare global {
interface PromiseConstructor {
withResolvers?<T>(): PromiseWithResolvers<T>;
}
}
if (typeof Promise.withResolvers !== "function") {
Promise.withResolvers = function withResolvers<T>(): PromiseWithResolvers<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
export {};

63
src/ui/terminal.ts Normal file
View File

@@ -0,0 +1,63 @@
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
function rgb(red: number, green: number, blue: number): string {
return `\x1b[38;2;${red};${green};${blue}m`;
}
// Match the outer CLI to the bundled Feynman Pi theme instead of generic magenta panels.
const INK = rgb(211, 198, 170);
const STONE = rgb(157, 169, 160);
const ASH = rgb(133, 146, 137);
const DARK_ASH = rgb(92, 106, 114);
const SAGE = rgb(167, 192, 128);
const TEAL = rgb(127, 187, 179);
const ROSE = rgb(230, 126, 128);
function paint(text: string, ...codes: string[]): string {
return `${codes.join("")}${text}${RESET}`;
}
export function printInfo(text: string): void {
console.log(paint(` ${text}`, ASH));
}
export function printSuccess(text: string): void {
console.log(paint(`${text}`, SAGE, BOLD));
}
export function printWarning(text: string): void {
console.log(paint(`${text}`, STONE, BOLD));
}
export function printError(text: string): void {
console.log(paint(`${text}`, ROSE, BOLD));
}
export function printSection(title: string): void {
console.log("");
console.log(paint(`${title}`, TEAL, BOLD));
}
export function printPanel(title: string, subtitleLines: string[] = []): void {
const inner = 53;
const border = "─".repeat(inner + 2);
const renderLine = (text: string, color: string, bold = false): string => {
const content = text.length > inner ? `${text.slice(0, inner - 3)}...` : text;
const codes = bold ? `${color}${BOLD}` : color;
return `${DARK_ASH}${BOLD}${RESET} ${codes}${content.padEnd(inner)}${RESET} ${DARK_ASH}${BOLD}${RESET}`;
};
console.log("");
console.log(paint(`${border}`, DARK_ASH, BOLD));
console.log(renderLine(title, TEAL, true));
if (subtitleLines.length > 0) {
console.log(paint(`${border}`, DARK_ASH, BOLD));
for (const line of subtitleLines) {
console.log(renderLine(line, INK));
}
}
console.log(paint(`${border}`, DARK_ASH, BOLD));
console.log("");
}

19
src/web-search.ts Normal file
View File

@@ -0,0 +1,19 @@
export {
FEYNMAN_CONFIG_PATH as WEB_SEARCH_CONFIG_PATH,
WEB_SEARCH_PROVIDERS,
configureWebSearchProvider,
formatWebSearchDoctorLines,
getConfiguredWebSearchProvider,
getWebSearchProviderById,
getWebSearchStatus,
hasConfiguredWebProvider,
hasGeminiApiKey,
hasPerplexityApiKey,
loadWebSearchConfig,
saveWebSearchConfig,
type PiWebSearchProvider,
type WebSearchConfig,
type WebSearchProviderDefinition,
type WebSearchProviderId,
type WebSearchStatus,
} from "./config/feynman-config.js";