diff --git a/.pi/agents/deep.chain.md b/.pi/agents/deep.chain.md deleted file mode 100644 index 5ab5678..0000000 --- a/.pi/agents/deep.chain.md +++ /dev/null @@ -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. diff --git a/.pi/agents/researcher.md b/.pi/agents/researcher.md index b92497b..3c7cb2a 100644 --- a/.pi/agents/researcher.md +++ b/.pi/agents/researcher.md @@ -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. diff --git a/.pi/agents/verifier.md b/.pi/agents/verifier.md index aa33b68..13ced61 100644 --- a/.pi/agents/verifier.md +++ b/.pi/agents/verifier.md @@ -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. diff --git a/.pi/agents/writer.md b/.pi/agents/writer.md index d9348e1..fef6e00 100644 --- a/.pi/agents/writer.md +++ b/.pi/agents/writer.md @@ -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. diff --git a/README.md b/README.md index 1343b4a..3ad8eb6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Inside the REPL: - `/replicate ` expands the replication prompt template - `/reading ` expands the reading-list prompt template - `/memo ` expands the general research memo prompt template -- `/deepresearch ` expands the thorough source-heavy research prompt template +- `/deepresearch ` runs a thorough source-heavy investigation workflow - `/autoresearch ` expands the end-to-end idea-to-paper prompt template - `/compare ` expands the source comparison prompt template - `/audit ` 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 ` 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 diff --git a/extensions/research-tools.ts b/extensions/research-tools.ts index 84d351a..8e49de4 100644 --- a/extensions/research-tools.ts +++ b/extensions/research-tools.ts @@ -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 { + const categories = new Map(); + let count = 0; + + async function walk(dir: string, segments: string[] = []): Promise { + 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 { + 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 ", description: "Survey papers on a topic." }, - { usage: "/related ", description: "Map related work and justify the gap." }, - { usage: "/review ", description: "Simulate a peer review for an AI research artifact." }, - { usage: "/ablate ", description: "Design the minimum convincing ablation set." }, - { usage: "/rebuttal ", description: "Draft a rebuttal and revision matrix." }, - { usage: "/replicate ", description: "Plan or execute a replication workflow." }, - { usage: "/reading ", description: "Build a prioritized reading list." }, - { usage: "/memo ", description: "Write a source-grounded research memo." }, - { usage: "/compare ", description: "Compare sources and disagreements." }, - { usage: "/audit ", description: "Audit a paper against its codebase." }, - { usage: "/draft ", description: "Write a paper-style draft." }, - { usage: "/deepresearch ", description: "Run a source-heavy research pass." }, - { usage: "/autoresearch ", 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 ", 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 ", 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 | undefined; + let agentSummaryPromise: Promise | undefined; + + async function installFeynmanHeader(ctx: ExtensionContext): Promise { 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) }], diff --git a/package.json b/package.json index 198a4d8..a08c509 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/prompts/ablate.md b/prompts/ablate.md index 85a7817..4ed08d9 100644 --- a/prompts/ablate.md +++ b/prompts/ablate.md @@ -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. diff --git a/prompts/audit.md b/prompts/audit.md index 4d15b88..7814588 100644 --- a/prompts/audit.md +++ b/prompts/audit.md @@ -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. diff --git a/prompts/autoresearch.md b/prompts/autoresearch.md index 47dc006..a024e1a 100644 --- a/prompts/autoresearch.md +++ b/prompts/autoresearch.md @@ -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. diff --git a/prompts/compare.md b/prompts/compare.md index 1ffa888..d795505 100644 --- a/prompts/compare.md +++ b/prompts/compare.md @@ -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. diff --git a/prompts/deepresearch.md b/prompts/deepresearch.md index 5107887..fca3532 100644 --- a/prompts/deepresearch.md +++ b/prompts/deepresearch.md @@ -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. diff --git a/prompts/draft.md b/prompts/draft.md index af16eac..2798ec9 100644 --- a/prompts/draft.md +++ b/prompts/draft.md @@ -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. diff --git a/prompts/lit.md b/prompts/lit.md index 055972f..16c3549 100644 --- a/prompts/lit.md +++ b/prompts/lit.md @@ -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. diff --git a/prompts/memo.md b/prompts/memo.md index b892636..8ea6e67 100644 --- a/prompts/memo.md +++ b/prompts/memo.md @@ -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. diff --git a/prompts/reading.md b/prompts/reading.md index f7cd882..54fcb74 100644 --- a/prompts/reading.md +++ b/prompts/reading.md @@ -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. diff --git a/prompts/rebuttal.md b/prompts/rebuttal.md index e420ab6..4453a77 100644 --- a/prompts/rebuttal.md +++ b/prompts/rebuttal.md @@ -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. diff --git a/prompts/related.md b/prompts/related.md index a1fa068..0338c51 100644 --- a/prompts/related.md +++ b/prompts/related.md @@ -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. diff --git a/prompts/review.md b/prompts/review.md index 99486a8..acbb661 100644 --- a/prompts/review.md +++ b/prompts/review.md @@ -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. diff --git a/prompts/watch.md b/prompts/watch.md index f08dd59..e596df1 100644 --- a/prompts/watch.md +++ b/prompts/watch.md @@ -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. diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index b74b29f..74b201a 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -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(),", " };", diff --git a/src/bootstrap/sync.ts b/src/bootstrap/sync.ts new file mode 100644 index 0000000..3af589a --- /dev/null +++ b/src/bootstrap/sync.ts @@ -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; +}; + +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; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..0c49b39 --- /dev/null +++ b/src/cli.ts @@ -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 Set the default model"); + printInfo("feynman search status Show web research provider status"); + printInfo("feynman search set Set web research provider"); + printInfo("feynman config show Print ~/.feynman/config.json"); + printInfo("feynman config get Read a config value"); + printInfo("feynman config set "); + 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 Start the literature-review workflow"); + printInfo("feynman review Start the peer-review workflow"); + printInfo("feynman audit Start the paper/code audit workflow"); + printInfo("feynman replicate Start the replication workflow"); + printInfo("feynman memo Start the research memo workflow"); + printInfo("feynman draft Start the paper-style draft workflow"); + printInfo("feynman watch Start the recurring research watch workflow"); + + printSection("Legacy Flags"); + printInfo('--prompt "" 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 { + 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 "); + } + printConfigValue(key); + return; + } + + if (subcommand === "set") { + const [key, ...valueParts] = args; + if (!key || valueParts.length === 0) { + throw new Error("Usage: feynman config set "); + } + 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 { + 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 "); + } + 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 [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 { + 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(), + }); +} diff --git a/src/config/commands.ts b/src/config/commands.ts new file mode 100644 index 0000000..fe5e5b3 --- /dev/null +++ b/src/config/commands.ts @@ -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, path: string): unknown { + return path.split(".").reduce((current, segment) => { + if (!current || typeof current !== "object") { + return undefined; + } + return (current as Record)[segment]; + }, record); +} + +function setNestedValue(record: Record, path: string, value: unknown): void { + const segments = path.split("."); + let current: Record = 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; + } + + 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; + 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; + setNestedValue(config, key, coerceConfigValue(rawValue)); + saveFeynmanConfig(config as ReturnType); + console.log(`Updated ${key}`); +} diff --git a/src/config/feynman-config.ts b/src/config/feynman-config.ts new file mode 100644 index 0000000..cc95b8a --- /dev/null +++ b/src/config/feynman-config.ts @@ -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 & { + 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 = [ + { + 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(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(LEGACY_WEB_SEARCH_CONFIG_PATH)); +} + +export function loadFeynmanConfig(configPath = FEYNMAN_CONFIG_PATH): FeynmanConfig { + const config = readJsonFile(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}`, + ]; +} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 0000000..1dbc425 --- /dev/null +++ b/src/config/paths.ts @@ -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 }); + } +} diff --git a/src/feynman-prompt.ts b/src/feynman-prompt.ts index 55a0ebe..fc25f04 100644 --- a/src/feynman-prompt.ts +++ b/src/feynman-prompt.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 30aad68..9139557 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,729 +1,4 @@ -import "dotenv/config"; - -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; -import { parseArgs } from "node:util"; -import { fileURLToPath } from "node:url"; - -import { - getUserName as getAlphaUserName, - isLoggedIn as isAlphaLoggedIn, - login as loginAlpha, - logout as logoutAlpha, -} from "@companion-ai/alpha-hub/lib"; -import { - ModelRegistry, - AuthStorage, -} from "@mariozechner/pi-coding-agent"; - -import { buildFeynmanSystemPrompt } from "./feynman-prompt.js"; - -type ThinkingLevel = "off" | "low" | "medium" | "high"; - -function printHelp(): void { - console.log(`Feynman commands: - /help Show this help - /init Initialize AGENTS.md and session-log folders - /alpha-login Sign in to alphaXiv - /alpha-logout Clear alphaXiv auth - /alpha-status Show alphaXiv auth status - /new Start a fresh persisted session - /exit Quit the REPL - /lit Expand the literature review prompt template - /related Map related work and justify the research gap - /review Simulate a peer review for an AI research artifact - /ablate Design the minimum convincing ablation set - /rebuttal Draft a rebuttal and revision matrix - /replicate Expand the replication prompt template - /reading Expand the reading list prompt template - /memo Expand the general research memo prompt template - /deepresearch Expand the thorough source-heavy research prompt template - /autoresearch Expand the idea-to-paper autoresearch prompt template - /compare Expand the source comparison prompt template - /audit Expand the paper/code audit prompt template - /draft Expand the paper-style writing prompt template - /log Write a durable session log - /watch 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 "" 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 = {}; - - 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 { - 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 { - return readJson(getWebSearchConfigPath()); -} - -function saveWebSearchConfig(config: Record): 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 { - 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 { - 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 { - 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 { - 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 { - 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((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)); diff --git a/src/model/catalog.ts b/src/model/catalog.ts new file mode 100644 index 0000000..a6b617b --- /dev/null +++ b/src/model/catalog.ts @@ -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 = { + 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(); + + 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 ` 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 ` 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, + }; +} diff --git a/src/model/commands.ts b/src/model/commands.ts new file mode 100644 index 0000000..e373915 --- /dev/null +++ b/src/model/commands.ts @@ -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 { + 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 ` 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 { + 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 { + 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 { + 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 ` 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]!); +} diff --git a/src/pi/launch.ts b/src/pi/launch.ts new file mode 100644 index 0000000..6dbee8f --- /dev/null +++ b/src/pi/launch.ts @@ -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 { + 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((resolvePromise, reject) => { + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 0; + resolvePromise(); + }); + }); +} diff --git a/src/pi/runtime.ts b/src/pi/runtime.ts new file mode 100644 index 0000000..a56c24b --- /dev/null +++ b/src/pi/runtime.ts @@ -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), + }; +} diff --git a/src/pi/settings.ts b/src/pi/settings.ts new file mode 100644 index 0000000..8fbfb86 --- /dev/null +++ b/src/pi/settings.ts @@ -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 { + 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 = {}; + + 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"); +} diff --git a/src/search/commands.ts b/src/search/commands.ts new file mode 100644 index 0000000..e806039 --- /dev/null +++ b/src/search/commands.ts @@ -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}`); +} diff --git a/src/setup/doctor.ts b/src/setup/doctor.ts new file mode 100644 index 0000000..f68c1ed --- /dev/null +++ b/src/setup/doctor.ts @@ -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"); +} diff --git a/src/setup/preview.ts b/src/setup/preview.ts new file mode 100644 index 0000000..df06bfe --- /dev/null +++ b/src/setup/preview.ts @@ -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`.", + }; +} diff --git a/src/setup/prompts.ts b/src/setup/prompts.ts new file mode 100644 index 0000000..46fdcc0 --- /dev/null +++ b/src/setup/prompts.ts @@ -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 { + 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 { + 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; +} diff --git a/src/setup/setup.ts b/src/setup/setup.ts new file mode 100644 index 0000000..5797530 --- /dev/null +++ b/src/setup/setup.ts @@ -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 { + 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 "); + printInfo(" feynman model list"); + printInfo(" feynman model recommend"); + printInfo(" feynman model set "); + printInfo(" feynman search providers"); + printInfo(" feynman search set [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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/system/executables.ts b/src/system/executables.ts new file mode 100644 index 0000000..ad2d5a9 --- /dev/null +++ b/src/system/executables.ts @@ -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; +} diff --git a/src/system/promise-polyfill.ts b/src/system/promise-polyfill.ts new file mode 100644 index 0000000..1c86732 --- /dev/null +++ b/src/system/promise-polyfill.ts @@ -0,0 +1,26 @@ +type PromiseWithResolvers = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +declare global { + interface PromiseConstructor { + withResolvers?(): PromiseWithResolvers; + } +} + +if (typeof Promise.withResolvers !== "function") { + Promise.withResolvers = function withResolvers(): PromiseWithResolvers { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; +} + +export {}; + diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts new file mode 100644 index 0000000..b7f6078 --- /dev/null +++ b/src/ui/terminal.ts @@ -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(""); +} diff --git a/src/web-search.ts b/src/web-search.ts new file mode 100644 index 0000000..cff527e --- /dev/null +++ b/src/web-search.ts @@ -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"; diff --git a/tests/bootstrap-sync.test.ts b/tests/bootstrap-sync.test.ts new file mode 100644 index 0000000..253fcd4 --- /dev/null +++ b/tests/bootstrap-sync.test.ts @@ -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"); +}); diff --git a/tests/feynman-config.test.ts b/tests/feynman-config.test.ts new file mode 100644 index 0000000..ef2d050 --- /dev/null +++ b/tests/feynman-config.test.ts @@ -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"); +}); diff --git a/tests/model-harness.test.ts b/tests/model-harness.test.ts new file mode 100644 index 0000000..99a787e --- /dev/null +++ b/tests/model-harness.test.ts @@ -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 { + 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"); +}); + diff --git a/tests/pi-runtime.test.ts b/tests/pi-runtime.test.ts new file mode 100644 index 0000000..9d13deb --- /dev/null +++ b/tests/pi-runtime.test.ts @@ -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"); +}); diff --git a/tsconfig.json b/tsconfig.json index 8f9f3d9..8e0abf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ }, "include": [ "src/**/*.ts", - "extensions/**/*.ts" + "extensions/**/*.ts", + "tests/**/*.ts" ] }