Polish Feynman harness and stabilize Pi web runtime

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

View File

@@ -1,22 +0,0 @@
---
name: deep
description: Gather, verify, and synthesize a deep research brief.
---
## researcher
output: research.md
Investigate {task}. Gather the strongest relevant primary sources, inspect them directly, and produce an evidence-first research brief.
## verifier
reads: research.md
output: verification.md
Verify the claims, source quality, and unresolved gaps in research.md for {task}. Produce a verification table and prioritized corrections.
## writer
reads: research.md+verification.md
output: deepresearch.md
progress: true
Write the final deep research brief for {task} using research.md and verification.md. Keep only supported claims, preserve caveats, and end with Sources.

View File

@@ -8,21 +8,30 @@ defaultProgress: true
You are Feynman's evidence-gathering subagent.
Operating rules:
## Integrity commandments
1. **Never fabricate a source.** Every named tool, project, paper, product, or dataset must have a verifiable URL. If you cannot find a URL, do not mention it.
2. **Never claim a project exists without checking.** Before citing a GitHub repo, search for it. Before citing a paper, find it. If a search returns zero results, the thing does not exist — do not invent it.
3. **Never extrapolate details you haven't read.** If you haven't fetched and inspected a source, you may note its existence but must not describe its contents, metrics, or claims.
4. **URL or it didn't happen.** Every entry in your evidence table must include a direct, checkable URL. No URL = not included.
## Operating rules
- Prefer primary sources: official docs, papers, datasets, repos, benchmarks, and direct experimental outputs.
- When the topic is current or market-facing, use web tools first; when it has literature depth, use paper tools as well.
- Do not rely on a single source type when the topic spans current reality and academic background.
- Inspect the strongest sources directly before summarizing them.
- Inspect the strongest sources directly before summarizing them — use fetch_content, alpha_get_paper, or alpha_ask_paper to read actual content.
- Build a compact evidence table with:
- source
- source (with URL)
- key claim
- evidence type
- evidence type (primary / secondary / self-reported / inferred)
- caveats
- confidence
- confidence (high / medium / low)
- Preserve uncertainty explicitly and note disagreements across sources.
- Produce durable markdown that another agent can verify and another agent can turn into a polished artifact.
- End with a `Sources` section containing direct URLs.
Default output expectations:
- Save the main artifact to `research.md`.
## Output contract
- Save the main artifact to the output file (default: `research.md`).
- The output MUST be a complete, structured document — not a summary of what you found.
- Minimum viable output: evidence table with ≥5 entries, each with a URL, plus a Sources section.
- If you cannot produce a complete output, say so explicitly rather than writing a truncated summary.
- Keep it structured, terse, and evidence-first.

View File

@@ -10,19 +10,26 @@ You are Feynman's verification subagent.
Your job is to audit evidence, not to write a polished final narrative.
Operating rules:
- Check every strong claim against inspected sources or explicit experimental evidence.
- Label claims as:
- supported
- plausible inference
- disputed
- unsupported
## Verification protocol
1. **Check every URL.** For each source cited, use fetch_content to confirm the URL resolves and the cited content actually exists there. Flag dead links, redirects to unrelated content, and fabricated URLs.
2. **Spot-check strong claims.** For the 3-5 strongest claims, independently search for corroborating or contradicting evidence using web_search, alpha_search, or fetch_content. Don't just read the research.md — go look.
3. **Check named entities.** If the artifact names a tool, framework, or dataset, verify it exists (e.g., search GitHub, search the web). Flag anything that returns zero results.
4. **Grade every claim:**
- **supported** — verified against inspected source
- **plausible inference** — consistent with evidence but not directly verified
- **disputed** — contradicted by another source
- **unsupported** — no verifiable evidence found
- **fabricated** — named entity or source does not exist
5. **Check for staleness.** Flag sources older than 2 years on rapidly-evolving topics.
## Operating rules
- Look for stale sources, benchmark leakage, repo-paper mismatches, missing defaults, ambiguous methodology, and citation quality problems.
- Prefer precise corrections over broad rewrites.
- Produce a verification table plus a short prioritized list of fixes.
- Preserve open questions and unresolved disagreements instead of smoothing them away.
- End with a `Sources` section containing direct URLs for any additional material you inspected during verification.
Default output expectations:
- Save the main artifact to `verification.md`.
## Output contract
- Save the main artifact to the output file (default: `verification.md`).
- The verification table must cover every major claim in the input artifact.
- Optimize for factual pressure-testing, not prose.

View File

@@ -8,15 +8,18 @@ defaultProgress: true
You are Feynman's writing subagent.
Operating rules:
- Write only from supplied evidence and clearly marked inference.
- Do not introduce unsupported claims.
- Preserve caveats, disagreements, and open questions instead of hiding them.
## Integrity commandments
1. **Write only from supplied evidence.** Do not introduce claims, tools, or sources that are not in the research.md or verification.md inputs.
2. **Drop anything the verifier flagged as fabricated or unsupported.** If verification.md marks a claim as "fabricated" or "unsupported", omit it entirely — do not soften it into hedged language.
3. **Preserve caveats and disagreements.** Never smooth away uncertainty.
## Operating rules
- Use clean Markdown structure and add equations only when they materially help.
- Keep the narrative readable, but never outrun the evidence.
- Produce artifacts that are ready to review in a browser or PDF preview.
- End with a `Sources` appendix containing direct URLs.
- If a source URL was flagged as dead by the verifier, either find a working alternative or drop the source.
Default output expectations:
- Save the main artifact to `draft.md` unless the caller specifies a different output path.
## Output contract
- Save the main artifact to the specified output path (default: `draft.md`).
- Optimize for clarity, structure, and evidence traceability.

View File

@@ -70,7 +70,7 @@ Inside the REPL:
- `/replicate <paper or claim>` expands the replication prompt template
- `/reading <topic>` expands the reading-list prompt template
- `/memo <topic>` expands the general research memo prompt template
- `/deepresearch <topic>` expands the thorough source-heavy research prompt template
- `/deepresearch <topic>` runs a thorough source-heavy investigation workflow
- `/autoresearch <idea>` expands the end-to-end idea-to-paper prompt template
- `/compare <topic>` expands the source comparison prompt template
- `/audit <item>` expands the paper/code audit prompt template
@@ -82,7 +82,7 @@ Inside the REPL:
Package-powered workflows inside the REPL:
- `/agents` opens the subagent and chain manager
- `/run`, `/chain`, and `/parallel` delegate work to subagents
- `/run` and `/parallel` delegate work to subagents when you want explicit decomposition
- `/ps` opens the background process panel
- `/schedule-prompt` manages recurring and deferred jobs
- `/search` opens indexed session search
@@ -90,12 +90,31 @@ Package-powered workflows inside the REPL:
Outside the REPL:
- `feynman setup` configures alpha login, web research, and preview deps
- `feynman setup` runs the full guided setup for model auth, alpha login, Pi web, and preview deps
- `feynman model login <provider>` logs into a Pi OAuth model provider from the outer Feynman CLI
- `feynman --alpha-login` signs in to alphaXiv
- `feynman --alpha-status` checks alphaXiv auth
- `feynman --doctor` checks models, auth, preview dependencies, and branded settings
- `feynman --setup-preview` installs `pandoc` automatically on macOS/Homebrew systems when preview support is missing
## Web Search Routing
Feynman now treats web search as a small provider subsystem instead of a one-off prompt.
The current Pi web stack underneath supports three runtime routes:
- `auto` — prefer Perplexity when configured, otherwise fall back to Gemini
- `perplexity` — force Perplexity Sonar
- `gemini` — force Gemini
Feynman exposes those through four user-facing choices in `feynman setup web`, but defaults to Pi web through `Gemini Browser` when nothing explicit is configured:
- `Auto`
- `Perplexity API`
- `Gemini API`
- `Gemini Browser`
`Gemini Browser` is still the same Pi web-access path under the hood: it forces the Gemini route and expects a signed-in Chromium profile rather than an API key.
## Custom Tools
The starter extension adds:
@@ -115,7 +134,6 @@ Feynman also ships bundled research subagents in `.pi/agents/`:
- `verifier` for claim and source checking
- `reviewer` for peer-review style criticism
- `writer` for polished memo and draft writing
- `deep` chain for gather → verify → synthesize
- `review` chain for gather → verify → peer review
- `auto` chain for plan → gather → verify → draft

View File

@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve as resolvePath } from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import {
annotatePaper,
@@ -19,7 +19,7 @@ import {
readPaperCode,
searchPapers,
} from "@companion-ai/alpha-hub/lib";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext, SlashCommandInfo } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
const execFileAsync = promisify(execFile);
@@ -33,6 +33,39 @@ const FEYNMAN_VERSION = (() => {
}
})();
const APP_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..");
const FEYNMAN_AGENT_LOGO = [
"███████╗███████╗██╗ ██╗███╗ ██╗███╗ ███╗ █████╗ ███╗ ██╗",
"██╔════╝██╔════╝╚██╗ ██╔╝████╗ ██║████╗ ████║██╔══██╗████╗ ██║",
"█████╗ █████╗ ╚████╔╝ ██╔██╗ ██║██╔████╔██║███████║██╔██╗ ██║",
"██╔══╝ ██╔══╝ ╚██╔╝ ██║╚██╗██║██║╚██╔╝██║██╔══██║██║╚██╗██║",
"██║ ███████╗ ██║ ██║ ╚████║██║ ╚═╝ ██║██║ ██║██║ ╚████║",
"╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝",
];
const FEYNMAN_MARK_ART = [
" .-.",
" /___\\\\",
" |:::|",
" |:::|",
" .-'`:::::`'-.",
" /:::::::::::::\\\\",
" \\\\:::::::::::://",
" '-.______.-'",
];
const FEYNMAN_RESEARCH_TOOLS = [
"alpha_search",
"alpha_get_paper",
"alpha_ask_paper",
"alpha_annotate_paper",
"alpha_list_annotations",
"alpha_read_code",
"session_search",
"preview_file",
];
function formatToolText(result: unknown): string {
return typeof result === "string" ? result : JSON.stringify(result, null, 2);
}
@@ -454,6 +487,15 @@ function padCell(text: string, width: number): string {
return `${truncated}${" ".repeat(Math.max(0, width - truncated.length))}`;
}
function centerText(text: string, width: number): string {
if (text.length >= width) {
return truncateForWidth(text, width);
}
const left = Math.floor((width - text.length) / 2);
const right = width - text.length - left;
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
}
function wrapForWidth(text: string, width: number, maxLines: number): string[] {
if (width <= 0 || maxLines <= 0) {
return [];
@@ -542,6 +584,112 @@ function buildTitledBorder(width: number, title: string): { left: string; right:
};
}
type CatalogSummary = {
count: number;
lines: string[];
};
function sortCommands(commands: SlashCommandInfo[]): SlashCommandInfo[] {
return [...commands].sort((a, b) => a.name.localeCompare(b.name));
}
function buildCommandCatalogSummary(pi: ExtensionAPI): CatalogSummary {
const commands = pi.getCommands();
const promptCommands = sortCommands(commands.filter((command) => command.source === "prompt")).map((command) => `/${command.name}`);
const extensionCommands = sortCommands(commands.filter((command) => command.source === "extension")).map((command) =>
`/${command.name}`
);
const lines: string[] = [];
if (promptCommands.length > 0) {
lines.push(`prompts: ${promptCommands.join(", ")}`);
}
if (extensionCommands.length > 0) {
lines.push(`commands: ${extensionCommands.join(", ")}`);
}
return {
count: promptCommands.length + extensionCommands.length,
lines,
};
}
function buildToolCatalogSummary(pi: ExtensionAPI): CatalogSummary {
const available = new Set(pi.getAllTools().map((tool) => tool.name));
const tools = FEYNMAN_RESEARCH_TOOLS.filter((tool) => available.has(tool));
const lines = [
`alpha_search, alpha_get_paper, alpha_ask_paper`,
`alpha_annotate_paper, alpha_list_annotations, alpha_read_code`,
`session_search, preview_file`,
].filter((line) => line.split(", ").some((tool) => available.has(tool)));
return {
count: tools.length,
lines,
};
}
async function buildSkillCatalogSummary(): Promise<CatalogSummary> {
const categories = new Map<string, string[]>();
let count = 0;
async function walk(dir: string, segments: string[] = []): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const nextPath = resolvePath(dir, entry.name);
if (entry.isDirectory()) {
await walk(nextPath, [...segments, entry.name]);
continue;
}
if (!entry.isFile() || entry.name !== "SKILL.md") {
continue;
}
const category = segments[0] ?? "general";
const skillName = segments[segments.length - 1] ?? "skill";
const bucket = categories.get(category) ?? [];
bucket.push(skillName);
categories.set(category, bucket);
count += 1;
}
}
try {
await walk(resolvePath(APP_ROOT, "skills"));
} catch {
return { count: 0, lines: [] };
}
const lines = [...categories.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.slice(0, 8)
.map(([category, names]) => `${category}: ${names.join(", ")}`);
return { count, lines };
}
async function buildAgentCatalogSummary(): Promise<CatalogSummary> {
const names: string[] = [];
try {
const entries = await readdir(resolvePath(APP_ROOT, ".pi", "agents"), { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) {
continue;
}
const base = entry.name.replace(/\.chain\.md$/i, "").replace(/\.md$/i, "");
names.push(base);
}
} catch {
return { count: 0, lines: [] };
}
names.sort((a, b) => a.localeCompare(b));
return {
count: names.length,
lines: names.length > 0 ? wrapForWidth(names.join(", "), 80, 4) : [],
};
}
function formatShortcutLine(command: string, description: string, width: number): string {
const commandWidth = Math.min(18, Math.max(13, Math.floor(width * 0.3)));
return truncateForWidth(`${padCell(command, commandWidth)} ${description}`, width);
@@ -611,84 +759,58 @@ type HelpCommand = {
description: string;
};
function buildFeynmanHelpSections(): Array<{ title: string; commands: HelpCommand[] }> {
function buildFeynmanHelpSections(pi: ExtensionAPI): Array<{ title: string; commands: HelpCommand[] }> {
const commands = pi.getCommands();
const promptCommands = sortCommands(commands.filter((command) => command.source === "prompt")).map((command) => ({
usage: `/${command.name}`,
description: command.description ?? "Prompt workflow",
}));
const extensionCommands = sortCommands(commands.filter((command) => command.source === "extension")).map((command) => ({
usage: `/${command.name}`,
description: command.description ?? "Extension command",
}));
return [
{
title: "Core Research Workflows",
commands: [
{ usage: "/lit <topic>", description: "Survey papers on a topic." },
{ usage: "/related <topic>", description: "Map related work and justify the gap." },
{ usage: "/review <artifact>", description: "Simulate a peer review for an AI research artifact." },
{ usage: "/ablate <artifact>", description: "Design the minimum convincing ablation set." },
{ usage: "/rebuttal <artifact>", description: "Draft a rebuttal and revision matrix." },
{ usage: "/replicate <paper or claim>", description: "Plan or execute a replication workflow." },
{ usage: "/reading <topic>", description: "Build a prioritized reading list." },
{ usage: "/memo <topic>", description: "Write a source-grounded research memo." },
{ usage: "/compare <topic>", description: "Compare sources and disagreements." },
{ usage: "/audit <item>", description: "Audit a paper against its codebase." },
{ usage: "/draft <topic>", description: "Write a paper-style draft." },
{ usage: "/deepresearch <topic>", description: "Run a source-heavy research pass." },
{ usage: "/autoresearch <idea>", description: "Run an end-to-end idea-to-paper workflow." },
],
title: "Prompt Workflows",
commands: promptCommands,
},
{
title: "Project Memory And Tracking",
commands: [
{ usage: "/init", description: "Bootstrap AGENTS.md and session-log folders." },
{ usage: "/log", description: "Write a durable session log into notes/." },
{ usage: "/watch <topic>", description: "Create a recurring or deferred research watch." },
{ usage: "/jobs", description: "Inspect active background work." },
{ usage: "/search", description: "Search prior indexed sessions." },
],
},
{
title: "Delegation And Background Work",
commands: [
{ usage: "/agents", description: "Open the agent and chain manager." },
{ usage: "/run <agent> <task>", description: "Run one subagent." },
{ usage: "/chain ...", description: "Run a sequential multi-agent chain." },
{ usage: "/parallel ...", description: "Run agents in parallel." },
{ usage: "/ps", description: "Open the background process panel." },
{ usage: "/schedule-prompt", description: "Manage recurring and deferred jobs." },
],
},
{
title: "Setup And Utilities",
commands: [
{ usage: "/alpha-login", description: "Sign in to alphaXiv." },
{ usage: "/alpha-status", description: "Check alphaXiv auth." },
{ usage: "/alpha-logout", description: "Clear alphaXiv auth." },
{ usage: "/preview", description: "Preview generated artifacts." },
],
title: "Commands",
commands: extensionCommands,
},
];
}
export default function researchTools(pi: ExtensionAPI): void {
function installFeynmanHeader(ctx: ExtensionContext): void {
let skillSummaryPromise: Promise<CatalogSummary> | undefined;
let agentSummaryPromise: Promise<CatalogSummary> | undefined;
async function installFeynmanHeader(ctx: ExtensionContext): Promise<void> {
if (!ctx.hasUI) {
return;
}
skillSummaryPromise ??= buildSkillCatalogSummary();
agentSummaryPromise ??= buildAgentCatalogSummary();
const commandSummary = buildCommandCatalogSummary(pi);
const skillSummary = await skillSummaryPromise;
const agentSummary = await agentSummaryPromise;
const toolSummary = buildToolCatalogSummary(pi);
ctx.ui.setHeader((_tui, theme) => ({
render(width: number): string[] {
const maxAvailableWidth = Math.max(width - 2, 1);
const preferredWidth = Math.min(104, Math.max(56, width - 4));
const preferredWidth = Math.min(136, Math.max(72, maxAvailableWidth));
const cardWidth = Math.min(maxAvailableWidth, preferredWidth);
const innerWidth = cardWidth - 2;
const outerPadding = " ".repeat(Math.max(0, Math.floor((width - cardWidth) / 2)));
const title = truncateForWidth(` Feynman v${FEYNMAN_VERSION} `, innerWidth);
const title = truncateForWidth(` Feynman Research Agent v${FEYNMAN_VERSION} `, innerWidth);
const titledBorder = buildTitledBorder(innerWidth, title);
const modelLabel = getCurrentModelLabel(ctx);
const sessionLabel = ctx.sessionManager.getSessionName()?.trim() || "default session";
const sessionLabel = ctx.sessionManager.getSessionName()?.trim() || ctx.sessionManager.getSessionId();
const directoryLabel = formatHeaderPath(ctx.cwd);
const recentActivity = getRecentActivitySummary(ctx);
const shortcuts = [
["/lit", "survey papers on a topic"],
["/review", "simulate a peer review"],
["/draft", "draft a paper-style writeup"],
["/deepresearch", "run a source-heavy research pass"],
];
const lines: string[] = [];
const push = (line: string): void => {
@@ -703,7 +825,15 @@ export default function researchTools(pi: ExtensionAPI): void {
theme.fg("accent", theme.bold(padCell(text, cellWidth)));
const styleMutedCell = (text: string, cellWidth: number): string =>
theme.fg("muted", padCell(text, cellWidth));
const styleSuccessCell = (text: string, cellWidth: number): string =>
theme.fg("success", theme.bold(padCell(text, cellWidth)));
const styleWarningCell = (text: string, cellWidth: number): string =>
theme.fg("warning", theme.bold(padCell(text, cellWidth)));
push("");
for (const logoLine of FEYNMAN_AGENT_LOGO) {
push(theme.fg("accent", theme.bold(centerText(logoLine, cardWidth))));
}
push("");
push(
theme.fg("borderMuted", `${titledBorder.left}`) +
@@ -711,7 +841,7 @@ export default function researchTools(pi: ExtensionAPI): void {
theme.fg("borderMuted", `${titledBorder.right}`),
);
if (innerWidth < 88) {
if (innerWidth < 72) {
const activityLines = wrapForWidth(recentActivity, innerWidth, 2);
push(renderBoxLine(padCell("", innerWidth)));
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Research session ready", innerWidth)))));
@@ -719,19 +849,82 @@ export default function researchTools(pi: ExtensionAPI): void {
push(renderBoxLine(padCell(`session: ${sessionLabel}`, innerWidth)));
push(renderBoxLine(padCell(`directory: ${directoryLabel}`, innerWidth)));
push(renderDivider());
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Quick starts", innerWidth)))));
for (const [command, description] of shortcuts) {
push(renderBoxLine(padCell(formatShortcutLine(command, description, innerWidth), innerWidth)));
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Available tools", innerWidth)))));
for (const toolLine of toolSummary.lines.slice(0, 4)) {
push(renderBoxLine(padCell(toolLine, innerWidth)));
}
push(renderDivider());
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Slash Commands", innerWidth)))));
for (const commandLine of commandSummary.lines.slice(0, 4)) {
push(renderBoxLine(padCell(commandLine, innerWidth)));
}
push(renderDivider());
push(renderBoxLine(theme.fg("success", theme.bold(padCell("Research Skills", innerWidth)))));
for (const skillLine of skillSummary.lines.slice(0, 4)) {
push(renderBoxLine(padCell(skillLine, innerWidth)));
}
if (agentSummary.lines.length > 0) {
push(renderDivider());
push(renderBoxLine(theme.fg("warning", theme.bold(padCell("Project Agents", innerWidth)))));
for (const agentLine of agentSummary.lines.slice(0, 3)) {
push(renderBoxLine(padCell(agentLine, innerWidth)));
}
}
push(renderDivider());
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Recent activity", innerWidth)))));
for (const activityLine of activityLines.length > 0 ? activityLines : ["No messages yet in this session."]) {
push(renderBoxLine(padCell(activityLine, innerWidth)));
}
push(renderDivider());
push(
renderBoxLine(
padCell(
`${toolSummary.count} tools · ${commandSummary.count} commands · ${skillSummary.count} skills · /help`,
innerWidth,
),
),
);
} else {
const leftWidth = Math.min(44, Math.max(38, Math.floor(innerWidth * 0.43)));
const leftWidth = Math.min(44, Math.max(30, Math.floor(innerWidth * 0.36)));
const rightWidth = innerWidth - leftWidth - 3;
const activityLines = wrapForWidth(recentActivity, innerWidth, 2);
const wrappedToolLines = toolSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 3));
const wrappedCommandLines = commandSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
const wrappedSkillLines = skillSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
const wrappedAgentLines = agentSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
const wrappedModelLines = wrapForWidth(`model: ${modelLabel}`, leftWidth, 2);
const wrappedDirectoryLines = wrapForWidth(`directory: ${directoryLabel}`, leftWidth, 2);
const wrappedSessionLines = wrapForWidth(`session: ${sessionLabel}`, leftWidth, 2);
const wrappedFooterLines = wrapForWidth(
`${toolSummary.count} tools · ${commandSummary.count} commands · ${skillSummary.count} skills · /help`,
leftWidth,
2,
);
const leftLines = [
...FEYNMAN_MARK_ART.map((line) => centerText(line, leftWidth)),
"",
centerText("Research shell ready", leftWidth),
"",
...wrappedModelLines,
...wrappedDirectoryLines,
...wrappedSessionLines,
"",
...wrappedFooterLines,
];
const rightLines = [
"Available Tools",
...wrappedToolLines,
"",
"Slash Commands",
...wrappedCommandLines,
"",
"Research Skills",
...wrappedSkillLines,
...(wrappedAgentLines.length > 0 ? ["", "Project Agents", ...wrappedAgentLines] : []),
"",
"Recent Activity",
...(activityLines.length > 0 ? activityLines : ["No messages yet in this session."]),
];
const row = (
left: string,
right: string,
@@ -751,15 +944,35 @@ export default function researchTools(pi: ExtensionAPI): void {
};
push(renderBoxLine(padCell("", innerWidth)));
push(row("Research session ready", "Quick starts", { leftAccent: true, rightAccent: true }));
push(row(`model: ${modelLabel}`, formatShortcutLine(shortcuts[0][0], shortcuts[0][1], rightWidth)));
push(row(`session: ${sessionLabel}`, formatShortcutLine(shortcuts[1][0], shortcuts[1][1], rightWidth)));
push(row(`directory: ${directoryLabel}`, formatShortcutLine(shortcuts[2][0], shortcuts[2][1], rightWidth)));
push(row("ask naturally; slash commands are optional", formatShortcutLine(shortcuts[3][0], shortcuts[3][1], rightWidth), { leftMuted: true }));
push(renderDivider());
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Recent activity", innerWidth)))));
for (const activityLine of activityLines.length > 0 ? activityLines : ["No messages yet in this session."]) {
push(renderBoxLine(padCell(activityLine, innerWidth)));
for (let index = 0; index < Math.max(leftLines.length, rightLines.length); index += 1) {
const left = leftLines[index] ?? "";
const right = rightLines[index] ?? "";
const isLogoLine = index < FEYNMAN_MARK_ART.length;
const isRightSectionHeading =
right === "Available Tools" || right === "Slash Commands" || right === "Research Skills" || right === "Project Agents" ||
right === "Recent Activity";
const isResearchHeading = right === "Research Skills";
const isAgentHeading = right === "Project Agents";
const isFooterLine = left.includes("/help");
push(
(() => {
const leftCell = isLogoLine
? styleAccentCell(left, leftWidth)
: !isFooterLine && index >= FEYNMAN_MARK_ART.length + 2
? styleMutedCell(left, leftWidth)
: padCell(left, leftWidth);
const rightCell = isResearchHeading
? styleSuccessCell(right, rightWidth)
: isAgentHeading
? styleWarningCell(right, rightWidth)
: isRightSectionHeading
? styleAccentCell(right, rightWidth)
: right.length > 0
? styleMutedCell(right, rightWidth)
: padCell(right, rightWidth);
return renderBoxLine(`${leftCell}${theme.fg("borderMuted", " │ ")}${rightCell}`);
})(),
);
}
}
@@ -772,11 +985,11 @@ export default function researchTools(pi: ExtensionAPI): void {
}
pi.on("session_start", async (_event, ctx) => {
installFeynmanHeader(ctx);
await installFeynmanHeader(ctx);
});
pi.on("session_switch", async (_event, ctx) => {
installFeynmanHeader(ctx);
await installFeynmanHeader(ctx);
});
pi.registerCommand("alpha-login", {
@@ -818,11 +1031,16 @@ export default function researchTools(pi: ExtensionAPI): void {
pi.registerCommand("help", {
description: "Show grouped Feynman commands and prefill the editor with a selected command.",
handler: async (_args, ctx) => {
const sections = buildFeynmanHelpSections();
const sections = buildFeynmanHelpSections(pi);
const items = sections.flatMap((section) => [
`--- ${section.title} ---`,
...section.commands.map((command) => `${command.usage}${command.description}`),
]);
]).filter((item, index, array) => {
if (!item.startsWith("---")) {
return true;
}
return array[index + 1] !== undefined && !array[index + 1].startsWith("---");
});
const selected = await ctx.ui.select("Feynman Help", items);
if (!selected || selected.startsWith("---")) {
@@ -1047,7 +1265,7 @@ export default function researchTools(pi: ExtensionAPI): void {
pi.registerTool({
name: "preview_file",
label: "Preview File",
description: "Open a markdown, LaTeX, PDF, or code artifact in the browser or a PDF viewer for human review.",
description: "Open a markdown, LaTeX, PDF, or code artifact in the browser or a PDF viewer for human review. Rendered HTML/PDF previews are temporary and do not replace the source artifact.",
parameters: Type.Object({
path: Type.String({
description: "Path to the file to preview.",
@@ -1079,6 +1297,7 @@ export default function researchTools(pi: ExtensionAPI): void {
sourcePath: resolvedPath,
target,
openedPath,
temporaryPreview: openedPath !== resolvedPath,
};
return {
content: [{ type: "text", text: formatToolText(result) }],

View File

@@ -25,6 +25,7 @@
"postinstall": "node ./scripts/patch-embedded-pi.mjs",
"start": "tsx src/index.ts",
"start:dist": "node ./bin/feynman.js",
"test": "node --import tsx --test --test-concurrency=1 tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [

View File

@@ -13,5 +13,5 @@ Requirements:
- unnecessary experiments
- Call out where benchmark norms imply mandatory controls.
- Optimize for the minimum convincing set, not experiment sprawl.
- Save the plan to `outputs/` as markdown if the user wants a durable artifact.
- If the user wants a durable artifact, save exactly one plan to `outputs/` as markdown.
- End with a `Sources` section containing direct URLs for any external sources used.

View File

@@ -11,4 +11,4 @@ Requirements:
- Compare claimed methods, defaults, metrics, and data handling against the repository.
- Call out missing code, mismatches, ambiguous defaults, and reproduction risks.
- End with a `Sources` section containing paper and repository URLs.
- Save the audit to `outputs/` as markdown.
- Save exactly one audit artifact to `outputs/` as markdown.

View File

@@ -13,6 +13,7 @@ Requirements:
- Build a compact evidence table before committing to a paper narrative.
- If experiments are feasible in the current environment, design and run the smallest experiment that materially reduces uncertainty.
- If experiments are not feasible, produce a paper-style draft that is explicit about missing validation and limitations.
- Save intermediate planning or synthesis artifacts to `notes/` or `outputs/`.
- Save the final paper-style draft to `papers/`.
- Produce one final durable markdown artifact for the user-facing result.
- If the result is a paper-style draft, save it to `papers/`; otherwise save it to `outputs/`.
- Do not create extra user-facing intermediate markdown files unless the user explicitly asks for them.
- End with a `Sources` section containing direct URLs for every source used.

View File

@@ -17,4 +17,4 @@ Requirements:
- confidence
- Distinguish agreement, disagreement, and uncertainty clearly.
- End with a `Sources` section containing direct URLs for every source used.
- Save the comparison to `outputs/` as markdown if the user wants a durable artifact.
- If the user wants a durable artifact, save exactly one comparison to `outputs/` as markdown.

View File

@@ -4,12 +4,31 @@ description: Run a thorough, source-heavy investigation on a topic and produce a
Run a deep research workflow for: $@
Requirements:
- If the task is broad, multi-source, or obviously long-running, prefer delegating through the `subagent` tool. Use the project `researcher`, `verifier`, and `writer` agents, or the project `deep` chain when that decomposition fits.
- Treat `/deepresearch` as one coherent Feynman workflow from the user's perspective. Do not expose internal orchestration primitives unless the user explicitly asks.
- Start as the lead researcher. First make a compact plan: what must be answered, what evidence types are needed, and which sub-questions are worth splitting out.
- Stay single-agent by default for narrow topics. Only use `subagent` when the task is broad enough that separate context windows materially improve breadth or speed.
- If you use subagents, launch them as one worker batch around clearly disjoint sub-questions. Wait for the batch to finish, synthesize the results, and only then decide whether a second batch is needed.
- Prefer breadth-first worker batches for deep research: different market segments, different source types, different time periods, different technical angles, or different competing explanations.
- Use `researcher` workers for evidence gathering, `verifier` workers for adversarial claim-checking, and `writer` only if you already have solid evidence and need help polishing the final artifact.
- Do not make the workflow chain-shaped by default. Hidden worker batches are optional implementation details, not the user-facing model.
- If the user wants it to run unattended, or the sweep will clearly take a while, prefer background execution with `subagent` using `clarify: false, async: true`, then report how to inspect status.
- If the topic is current, product-oriented, market-facing, regulatory, or asks about latest developments, start with `web_search` and `fetch_content`.
- If the topic has an academic literature component, use `alpha_search`, `alpha_get_paper`, and `alpha_ask_paper` for the strongest papers.
- Do not rely on a single source type when the topic spans both current reality and academic background.
- Build a compact evidence table before synthesizing conclusions.
- After synthesis, run a final verification/citation pass. For the strongest claims, independently confirm support and remove anything unsupported, fabricated, or stale.
- Distinguish clearly between established facts, plausible inferences, disagreements, and unresolved questions.
- Produce a durable markdown artifact in `outputs/`.
- Produce exactly one durable markdown artifact in `outputs/`.
- The final artifact should read like one deep research memo, not like stitched-together worker transcripts.
- Do not leave extra user-facing intermediate markdown files behind unless the user explicitly asks for them.
- End with a `Sources` section containing direct URLs for every source used.
Default execution shape:
1. Clarify the actual research objective if needed.
2. Make a short plan and identify the key sub-questions.
3. Decide single-agent versus worker-batch execution.
4. Gather evidence across the needed source types.
5. Synthesize findings and identify remaining gaps.
6. If needed, run one more worker batch for unresolved gaps.
7. Perform a verification/citation pass.
8. Write the final brief with a strict `Sources` section.

View File

@@ -18,4 +18,4 @@ Requirements:
- conclusion
- If citations are available, include citation placeholders or references clearly enough to convert later.
- Add a `Sources` appendix with direct URLs for all primary references used while drafting.
- Save the draft to `papers/` as markdown.
- Save exactly one draft to `papers/` as markdown.

View File

@@ -13,4 +13,4 @@ Requirements:
- Separate consensus, disagreements, and open questions.
- When useful, propose concrete next experiments or follow-up reading.
- End with a `Sources` section containing direct URLs for every paper or source used.
- If the user wants an artifact, write the review to disk as markdown.
- If the user wants an artifact, write exactly one review to disk as markdown.

View File

@@ -11,4 +11,4 @@ Requirements:
- Read or inspect the top sources directly before making strong claims.
- Distinguish facts, interpretations, and open questions.
- End with a `Sources` section containing direct URLs for every source used.
- Save the memo to `outputs/` as markdown if the user wants a durable artifact.
- If the user wants a durable artifact, save exactly one memo to `outputs/` as markdown.

View File

@@ -12,4 +12,4 @@ Requirements:
- Group papers by role when useful: foundational, strongest recent work, methods, benchmarks, critiques, replication targets.
- For each paper, explain why it is on the list.
- Include direct URLs for each recommended source.
- Save the final reading list to `outputs/` as markdown.
- Save exactly one final reading list to `outputs/` as markdown.

View File

@@ -14,5 +14,5 @@ Requirements:
- paper changes needed
- rebuttal language
- Do not overclaim fixes that have not been implemented.
- Save the rebuttal matrix to `outputs/` as markdown.
- Save exactly one rebuttal matrix to `outputs/` as markdown.
- End with a `Sources` section containing direct URLs for all inspected external sources.

View File

@@ -15,5 +15,5 @@ Requirements:
- For each important paper, explain why it matters to this project.
- Be explicit about what real gap remains after considering the strongest prior work.
- If the project is not differentiated enough, say so clearly.
- Save the artifact to `outputs/` as markdown if the user wants a durable result.
- If the user wants a durable result, save exactly one artifact to `outputs/` as markdown.
- End with a `Sources` section containing direct URLs.

View File

@@ -20,5 +20,5 @@ Requirements:
- likely reviewer objections
- severity for each issue
- revision plan in priority order
- Save the review to `outputs/` as markdown.
- Save exactly one review artifact to `outputs/` as markdown.
- End with a `Sources` section containing direct URLs for every inspected external source.

View File

@@ -10,5 +10,5 @@ Requirements:
- Summarize what should be monitored, what signals matter, and what counts as a meaningful change.
- Use `schedule_prompt` to create the recurring or delayed follow-up instead of merely promising to check later.
- If the user wants detached execution for the initial sweep, use `subagent` in background mode and report how to inspect status.
- Save a durable baseline artifact to `outputs/`.
- Save exactly one durable baseline artifact to `outputs/`.
- End with a `Sources` section containing direct URLs for every source used.

View File

@@ -112,7 +112,7 @@ if (existsSync(interactiveThemePath)) {
" return {",
' borderColor: (text) => " ".repeat(text.length),',
' bgColor: (text) => theme.bg("userMessageBg", text),',
' placeholderText: "Ask Feynman to research anything",',
' placeholderText: "Type your message or /help for commands",',
' placeholder: (text) => theme.fg("dim", text),',
" selectList: getSelectListTheme(),",
" };",

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

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

445
src/cli.ts Normal file
View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { syncBundledAssets } from "../src/bootstrap/sync.js";
function createAppRoot(): string {
const appRoot = mkdtempSync(join(tmpdir(), "feynman-app-"));
mkdirSync(join(appRoot, ".pi", "themes"), { recursive: true });
mkdirSync(join(appRoot, ".pi", "agents"), { recursive: true });
writeFileSync(join(appRoot, ".pi", "themes", "feynman.json"), '{"theme":"v1"}\n', "utf8");
writeFileSync(join(appRoot, ".pi", "agents", "researcher.md"), "# v1\n", "utf8");
return appRoot;
}
test("syncBundledAssets copies missing bundled files", () => {
const appRoot = createAppRoot();
const home = mkdtempSync(join(tmpdir(), "feynman-home-"));
process.env.FEYNMAN_HOME = home;
const agentDir = join(home, "agent");
mkdirSync(agentDir, { recursive: true });
const result = syncBundledAssets(appRoot, agentDir);
assert.deepEqual(result.copied.sort(), ["feynman.json", "researcher.md"]);
assert.equal(readFileSync(join(agentDir, "themes", "feynman.json"), "utf8"), '{"theme":"v1"}\n');
assert.equal(readFileSync(join(agentDir, "agents", "researcher.md"), "utf8"), "# v1\n");
});
test("syncBundledAssets preserves user-modified files and updates managed files", () => {
const appRoot = createAppRoot();
const home = mkdtempSync(join(tmpdir(), "feynman-home-"));
process.env.FEYNMAN_HOME = home;
const agentDir = join(home, "agent");
mkdirSync(agentDir, { recursive: true });
syncBundledAssets(appRoot, agentDir);
writeFileSync(join(appRoot, ".pi", "themes", "feynman.json"), '{"theme":"v2"}\n', "utf8");
writeFileSync(join(appRoot, ".pi", "agents", "researcher.md"), "# v2\n", "utf8");
writeFileSync(join(agentDir, "agents", "researcher.md"), "# user-custom\n", "utf8");
const result = syncBundledAssets(appRoot, agentDir);
assert.deepEqual(result.updated, ["feynman.json"]);
assert.deepEqual(result.skipped, ["researcher.md"]);
assert.equal(readFileSync(join(agentDir, "themes", "feynman.json"), "utf8"), '{"theme":"v2"}\n');
assert.equal(readFileSync(join(agentDir, "agents", "researcher.md"), "utf8"), "# user-custom\n");
});

View File

@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
configureWebSearchProvider,
getConfiguredWebSearchProvider,
loadFeynmanConfig,
saveFeynmanConfig,
} from "../src/config/feynman-config.js";
test("loadFeynmanConfig falls back to legacy web-search config", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-config-"));
const configPath = join(root, "config.json");
const legacyDir = join(process.env.HOME ?? root, ".pi");
const legacyPath = join(legacyDir, "web-search.json");
mkdirSync(legacyDir, { recursive: true });
writeFileSync(
legacyPath,
JSON.stringify({
feynmanWebProvider: "perplexity",
perplexityApiKey: "legacy-key",
}),
"utf8",
);
const config = loadFeynmanConfig(configPath);
assert.equal(config.version, 1);
assert.equal(config.webSearch?.feynmanWebProvider, "perplexity");
assert.equal(config.webSearch?.perplexityApiKey, "legacy-key");
});
test("saveFeynmanConfig persists sessionDir and webSearch", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-config-"));
const configPath = join(root, "config.json");
const webSearch = configureWebSearchProvider({}, "gemini-browser", { chromeProfile: "Profile 2" });
saveFeynmanConfig(
{
version: 1,
sessionDir: "/tmp/feynman-sessions",
webSearch,
},
configPath,
);
const config = loadFeynmanConfig(configPath);
assert.equal(config.sessionDir, "/tmp/feynman-sessions");
assert.equal(config.webSearch?.feynmanWebProvider, "gemini-browser");
assert.equal(config.webSearch?.chromeProfile, "Profile 2");
});
test("default web provider falls back to Pi web via gemini-browser", () => {
const provider = getConfiguredWebSearchProvider({});
assert.equal(provider.id, "gemini-browser");
assert.equal(provider.runtimeProvider, "gemini");
});

View File

@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveInitialPrompt } from "../src/cli.js";
import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js";
import { setDefaultModelSpec } from "../src/model/commands.js";
function createAuthPath(contents: Record<string, unknown>): string {
const root = mkdtempSync(join(tmpdir(), "feynman-auth-"));
const authPath = join(root, "auth.json");
writeFileSync(authPath, JSON.stringify(contents, null, 2) + "\n", "utf8");
return authPath;
}
test("chooseRecommendedModel prefers the strongest authenticated research model", () => {
const authPath = createAuthPath({
openai: { type: "api_key", key: "openai-test-key" },
anthropic: { type: "api_key", key: "anthropic-test-key" },
});
const recommendation = chooseRecommendedModel(authPath);
assert.equal(recommendation?.spec, "anthropic/claude-opus-4-6");
});
test("setDefaultModelSpec accepts a unique bare model id from authenticated models", () => {
const authPath = createAuthPath({
openai: { type: "api_key", key: "openai-test-key" },
});
const settingsPath = join(mkdtempSync(join(tmpdir(), "feynman-settings-")), "settings.json");
setDefaultModelSpec(settingsPath, authPath, "gpt-5.4");
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
defaultProvider?: string;
defaultModel?: string;
};
assert.equal(settings.defaultProvider, "openai");
assert.equal(settings.defaultModel, "gpt-5.4");
});
test("buildModelStatusSnapshotFromRecords flags an invalid current model and suggests a replacement", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "openai", id: "gpt-5.4" },
],
[{ provider: "openai", id: "gpt-5.4" }],
"anthropic/claude-opus-4-6",
);
assert.equal(snapshot.currentValid, false);
assert.equal(snapshot.recommended, "openai/gpt-5.4");
assert.ok(snapshot.guidance.some((line) => line.includes("Configured default model is unavailable")));
});
test("resolveInitialPrompt maps top-level research commands to Pi slash workflows", () => {
assert.equal(resolveInitialPrompt("lit", ["tool-using", "agents"], undefined), "/lit tool-using agents");
assert.equal(resolveInitialPrompt("watch", ["openai"], undefined), "/watch openai");
assert.equal(resolveInitialPrompt("jobs", [], undefined), "/jobs");
assert.equal(resolveInitialPrompt("chat", ["hello"], undefined), "hello");
assert.equal(resolveInitialPrompt("unknown", ["topic"], undefined), "unknown topic");
});

58
tests/pi-runtime.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildPiArgs, buildPiEnv, resolvePiPaths } from "../src/pi/runtime.js";
test("buildPiArgs includes configured runtime paths and prompt", () => {
const args = buildPiArgs({
appRoot: "/repo/feynman",
workingDir: "/workspace",
sessionDir: "/sessions",
feynmanAgentDir: "/home/.feynman/agent",
systemPrompt: "system",
initialPrompt: "hello",
explicitModelSpec: "openai:gpt-5.4",
thinkingLevel: "medium",
});
assert.deepEqual(args, [
"--session-dir",
"/sessions",
"--extension",
"/repo/feynman/extensions/research-tools.ts",
"--skill",
"/repo/feynman/skills",
"--prompt-template",
"/repo/feynman/prompts",
"--system-prompt",
"system",
"--model",
"openai:gpt-5.4",
"--thinking",
"medium",
"hello",
]);
});
test("buildPiEnv wires Feynman paths into the Pi environment", () => {
const env = buildPiEnv({
appRoot: "/repo/feynman",
workingDir: "/workspace",
sessionDir: "/sessions",
feynmanAgentDir: "/home/.feynman/agent",
systemPrompt: "system",
feynmanVersion: "0.1.5",
});
assert.equal(env.PI_CODING_AGENT_DIR, "/home/.feynman/agent");
assert.equal(env.FEYNMAN_SESSION_DIR, "/sessions");
assert.equal(env.FEYNMAN_BIN_PATH, "/repo/feynman/bin/feynman.js");
assert.equal(env.FEYNMAN_PI_NPM_ROOT, "/repo/feynman/.pi/npm/node_modules");
assert.equal(env.FEYNMAN_MEMORY_DIR, "/home/.feynman/memory");
});
test("resolvePiPaths includes the Promise.withResolvers polyfill path", () => {
const paths = resolvePiPaths("/repo/feynman");
assert.equal(paths.promisePolyfillPath, "/repo/feynman/dist/system/promise-polyfill.js");
});

View File

@@ -14,6 +14,7 @@
},
"include": [
"src/**/*.ts",
"extensions/**/*.ts"
"extensions/**/*.ts",
"tests/**/*.ts"
]
}