Polish Feynman harness and stabilize Pi web runtime
This commit is contained in:
136
src/bootstrap/sync.ts
Normal file
136
src/bootstrap/sync.ts
Normal 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
445
src/cli.ts
Normal 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
78
src/config/commands.ts
Normal 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}`);
|
||||
}
|
||||
270
src/config/feynman-config.ts
Normal file
270
src/config/feynman-config.ts
Normal 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
43
src/config/paths.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
727
src/index.ts
727
src/index.ts
@@ -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
282
src/model/catalog.ts
Normal 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
273
src/model/commands.ts
Normal 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
32
src/pi/launch.ts
Normal 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
99
src/pi/runtime.ts
Normal 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
121
src/pi/settings.ts
Normal 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
49
src/search/commands.ts
Normal 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
180
src/setup/doctor.ts
Normal 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
29
src/setup/preview.ts
Normal 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
30
src/setup/prompts.ts
Normal 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
316
src/setup/setup.ts
Normal 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
46
src/system/executables.ts
Normal 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;
|
||||
}
|
||||
26
src/system/promise-polyfill.ts
Normal file
26
src/system/promise-polyfill.ts
Normal 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
63
src/ui/terminal.ts
Normal 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
19
src/web-search.ts
Normal 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";
|
||||
Reference in New Issue
Block a user