Polish Feynman harness and stabilize Pi web runtime
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
26
README.md
26
README.md
@@ -70,7 +70,7 @@ Inside the REPL:
|
||||
- `/replicate <paper or claim>` expands the replication prompt template
|
||||
- `/reading <topic>` expands the reading-list prompt template
|
||||
- `/memo <topic>` expands the general research memo prompt template
|
||||
- `/deepresearch <topic>` expands the thorough source-heavy research prompt template
|
||||
- `/deepresearch <topic>` runs a thorough source-heavy investigation workflow
|
||||
- `/autoresearch <idea>` expands the end-to-end idea-to-paper prompt template
|
||||
- `/compare <topic>` expands the source comparison prompt template
|
||||
- `/audit <item>` expands the paper/code audit prompt template
|
||||
@@ -82,7 +82,7 @@ Inside the REPL:
|
||||
Package-powered workflows inside the REPL:
|
||||
|
||||
- `/agents` opens the subagent and chain manager
|
||||
- `/run`, `/chain`, and `/parallel` delegate work to subagents
|
||||
- `/run` and `/parallel` delegate work to subagents when you want explicit decomposition
|
||||
- `/ps` opens the background process panel
|
||||
- `/schedule-prompt` manages recurring and deferred jobs
|
||||
- `/search` opens indexed session search
|
||||
@@ -90,12 +90,31 @@ Package-powered workflows inside the REPL:
|
||||
|
||||
Outside the REPL:
|
||||
|
||||
- `feynman setup` configures alpha login, web research, and preview deps
|
||||
- `feynman setup` runs the full guided setup for model auth, alpha login, Pi web, and preview deps
|
||||
- `feynman model login <provider>` logs into a Pi OAuth model provider from the outer Feynman CLI
|
||||
- `feynman --alpha-login` signs in to alphaXiv
|
||||
- `feynman --alpha-status` checks alphaXiv auth
|
||||
- `feynman --doctor` checks models, auth, preview dependencies, and branded settings
|
||||
- `feynman --setup-preview` installs `pandoc` automatically on macOS/Homebrew systems when preview support is missing
|
||||
|
||||
## Web Search Routing
|
||||
|
||||
Feynman now treats web search as a small provider subsystem instead of a one-off prompt.
|
||||
The current Pi web stack underneath supports three runtime routes:
|
||||
|
||||
- `auto` — prefer Perplexity when configured, otherwise fall back to Gemini
|
||||
- `perplexity` — force Perplexity Sonar
|
||||
- `gemini` — force Gemini
|
||||
|
||||
Feynman exposes those through four user-facing choices in `feynman setup web`, but defaults to Pi web through `Gemini Browser` when nothing explicit is configured:
|
||||
|
||||
- `Auto`
|
||||
- `Perplexity API`
|
||||
- `Gemini API`
|
||||
- `Gemini Browser`
|
||||
|
||||
`Gemini Browser` is still the same Pi web-access path under the hood: it forces the Gemini route and expects a signed-in Chromium profile rather than an API key.
|
||||
|
||||
## Custom Tools
|
||||
|
||||
The starter extension adds:
|
||||
@@ -115,7 +134,6 @@ Feynman also ships bundled research subagents in `.pi/agents/`:
|
||||
- `verifier` for claim and source checking
|
||||
- `reviewer` for peer-review style criticism
|
||||
- `writer` for polished memo and draft writing
|
||||
- `deep` chain for gather → verify → synthesize
|
||||
- `review` chain for gather → verify → peer review
|
||||
- `auto` chain for plan → gather → verify → draft
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
||||
import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, join, resolve as resolvePath } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
annotatePaper,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
readPaperCode,
|
||||
searchPapers,
|
||||
} from "@companion-ai/alpha-hub/lib";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionAPI, ExtensionContext, SlashCommandInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -33,6 +33,39 @@ const FEYNMAN_VERSION = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
const APP_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const FEYNMAN_AGENT_LOGO = [
|
||||
"███████╗███████╗██╗ ██╗███╗ ██╗███╗ ███╗ █████╗ ███╗ ██╗",
|
||||
"██╔════╝██╔════╝╚██╗ ██╔╝████╗ ██║████╗ ████║██╔══██╗████╗ ██║",
|
||||
"█████╗ █████╗ ╚████╔╝ ██╔██╗ ██║██╔████╔██║███████║██╔██╗ ██║",
|
||||
"██╔══╝ ██╔══╝ ╚██╔╝ ██║╚██╗██║██║╚██╔╝██║██╔══██║██║╚██╗██║",
|
||||
"██║ ███████╗ ██║ ██║ ╚████║██║ ╚═╝ ██║██║ ██║██║ ╚████║",
|
||||
"╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝",
|
||||
];
|
||||
|
||||
const FEYNMAN_MARK_ART = [
|
||||
" .-.",
|
||||
" /___\\\\",
|
||||
" |:::|",
|
||||
" |:::|",
|
||||
" .-'`:::::`'-.",
|
||||
" /:::::::::::::\\\\",
|
||||
" \\\\:::::::::::://",
|
||||
" '-.______.-'",
|
||||
];
|
||||
|
||||
const FEYNMAN_RESEARCH_TOOLS = [
|
||||
"alpha_search",
|
||||
"alpha_get_paper",
|
||||
"alpha_ask_paper",
|
||||
"alpha_annotate_paper",
|
||||
"alpha_list_annotations",
|
||||
"alpha_read_code",
|
||||
"session_search",
|
||||
"preview_file",
|
||||
];
|
||||
|
||||
function formatToolText(result: unknown): string {
|
||||
return typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
||||
}
|
||||
@@ -454,6 +487,15 @@ function padCell(text: string, width: number): string {
|
||||
return `${truncated}${" ".repeat(Math.max(0, width - truncated.length))}`;
|
||||
}
|
||||
|
||||
function centerText(text: string, width: number): string {
|
||||
if (text.length >= width) {
|
||||
return truncateForWidth(text, width);
|
||||
}
|
||||
const left = Math.floor((width - text.length) / 2);
|
||||
const right = width - text.length - left;
|
||||
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
||||
}
|
||||
|
||||
function wrapForWidth(text: string, width: number, maxLines: number): string[] {
|
||||
if (width <= 0 || maxLines <= 0) {
|
||||
return [];
|
||||
@@ -542,6 +584,112 @@ function buildTitledBorder(width: number, title: string): { left: string; right:
|
||||
};
|
||||
}
|
||||
|
||||
type CatalogSummary = {
|
||||
count: number;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
function sortCommands(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function buildCommandCatalogSummary(pi: ExtensionAPI): CatalogSummary {
|
||||
const commands = pi.getCommands();
|
||||
const promptCommands = sortCommands(commands.filter((command) => command.source === "prompt")).map((command) => `/${command.name}`);
|
||||
const extensionCommands = sortCommands(commands.filter((command) => command.source === "extension")).map((command) =>
|
||||
`/${command.name}`
|
||||
);
|
||||
const lines: string[] = [];
|
||||
|
||||
if (promptCommands.length > 0) {
|
||||
lines.push(`prompts: ${promptCommands.join(", ")}`);
|
||||
}
|
||||
if (extensionCommands.length > 0) {
|
||||
lines.push(`commands: ${extensionCommands.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
count: promptCommands.length + extensionCommands.length,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolCatalogSummary(pi: ExtensionAPI): CatalogSummary {
|
||||
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
||||
const tools = FEYNMAN_RESEARCH_TOOLS.filter((tool) => available.has(tool));
|
||||
const lines = [
|
||||
`alpha_search, alpha_get_paper, alpha_ask_paper`,
|
||||
`alpha_annotate_paper, alpha_list_annotations, alpha_read_code`,
|
||||
`session_search, preview_file`,
|
||||
].filter((line) => line.split(", ").some((tool) => available.has(tool)));
|
||||
|
||||
return {
|
||||
count: tools.length,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSkillCatalogSummary(): Promise<CatalogSummary> {
|
||||
const categories = new Map<string, string[]>();
|
||||
let count = 0;
|
||||
|
||||
async function walk(dir: string, segments: string[] = []): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const nextPath = resolvePath(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(nextPath, [...segments, entry.name]);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || entry.name !== "SKILL.md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = segments[0] ?? "general";
|
||||
const skillName = segments[segments.length - 1] ?? "skill";
|
||||
const bucket = categories.get(category) ?? [];
|
||||
bucket.push(skillName);
|
||||
categories.set(category, bucket);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await walk(resolvePath(APP_ROOT, "skills"));
|
||||
} catch {
|
||||
return { count: 0, lines: [] };
|
||||
}
|
||||
|
||||
const lines = [...categories.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.slice(0, 8)
|
||||
.map(([category, names]) => `${category}: ${names.join(", ")}`);
|
||||
|
||||
return { count, lines };
|
||||
}
|
||||
|
||||
async function buildAgentCatalogSummary(): Promise<CatalogSummary> {
|
||||
const names: string[] = [];
|
||||
try {
|
||||
const entries = await readdir(resolvePath(APP_ROOT, ".pi", "agents"), { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
||||
continue;
|
||||
}
|
||||
const base = entry.name.replace(/\.chain\.md$/i, "").replace(/\.md$/i, "");
|
||||
names.push(base);
|
||||
}
|
||||
} catch {
|
||||
return { count: 0, lines: [] };
|
||||
}
|
||||
|
||||
names.sort((a, b) => a.localeCompare(b));
|
||||
return {
|
||||
count: names.length,
|
||||
lines: names.length > 0 ? wrapForWidth(names.join(", "), 80, 4) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatShortcutLine(command: string, description: string, width: number): string {
|
||||
const commandWidth = Math.min(18, Math.max(13, Math.floor(width * 0.3)));
|
||||
return truncateForWidth(`${padCell(command, commandWidth)} ${description}`, width);
|
||||
@@ -611,84 +759,58 @@ type HelpCommand = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
function buildFeynmanHelpSections(): Array<{ title: string; commands: HelpCommand[] }> {
|
||||
function buildFeynmanHelpSections(pi: ExtensionAPI): Array<{ title: string; commands: HelpCommand[] }> {
|
||||
const commands = pi.getCommands();
|
||||
const promptCommands = sortCommands(commands.filter((command) => command.source === "prompt")).map((command) => ({
|
||||
usage: `/${command.name}`,
|
||||
description: command.description ?? "Prompt workflow",
|
||||
}));
|
||||
const extensionCommands = sortCommands(commands.filter((command) => command.source === "extension")).map((command) => ({
|
||||
usage: `/${command.name}`,
|
||||
description: command.description ?? "Extension command",
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Core Research Workflows",
|
||||
commands: [
|
||||
{ usage: "/lit <topic>", description: "Survey papers on a topic." },
|
||||
{ usage: "/related <topic>", description: "Map related work and justify the gap." },
|
||||
{ usage: "/review <artifact>", description: "Simulate a peer review for an AI research artifact." },
|
||||
{ usage: "/ablate <artifact>", description: "Design the minimum convincing ablation set." },
|
||||
{ usage: "/rebuttal <artifact>", description: "Draft a rebuttal and revision matrix." },
|
||||
{ usage: "/replicate <paper or claim>", description: "Plan or execute a replication workflow." },
|
||||
{ usage: "/reading <topic>", description: "Build a prioritized reading list." },
|
||||
{ usage: "/memo <topic>", description: "Write a source-grounded research memo." },
|
||||
{ usage: "/compare <topic>", description: "Compare sources and disagreements." },
|
||||
{ usage: "/audit <item>", description: "Audit a paper against its codebase." },
|
||||
{ usage: "/draft <topic>", description: "Write a paper-style draft." },
|
||||
{ usage: "/deepresearch <topic>", description: "Run a source-heavy research pass." },
|
||||
{ usage: "/autoresearch <idea>", description: "Run an end-to-end idea-to-paper workflow." },
|
||||
],
|
||||
title: "Prompt Workflows",
|
||||
commands: promptCommands,
|
||||
},
|
||||
{
|
||||
title: "Project Memory And Tracking",
|
||||
commands: [
|
||||
{ usage: "/init", description: "Bootstrap AGENTS.md and session-log folders." },
|
||||
{ usage: "/log", description: "Write a durable session log into notes/." },
|
||||
{ usage: "/watch <topic>", description: "Create a recurring or deferred research watch." },
|
||||
{ usage: "/jobs", description: "Inspect active background work." },
|
||||
{ usage: "/search", description: "Search prior indexed sessions." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Delegation And Background Work",
|
||||
commands: [
|
||||
{ usage: "/agents", description: "Open the agent and chain manager." },
|
||||
{ usage: "/run <agent> <task>", description: "Run one subagent." },
|
||||
{ usage: "/chain ...", description: "Run a sequential multi-agent chain." },
|
||||
{ usage: "/parallel ...", description: "Run agents in parallel." },
|
||||
{ usage: "/ps", description: "Open the background process panel." },
|
||||
{ usage: "/schedule-prompt", description: "Manage recurring and deferred jobs." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Setup And Utilities",
|
||||
commands: [
|
||||
{ usage: "/alpha-login", description: "Sign in to alphaXiv." },
|
||||
{ usage: "/alpha-status", description: "Check alphaXiv auth." },
|
||||
{ usage: "/alpha-logout", description: "Clear alphaXiv auth." },
|
||||
{ usage: "/preview", description: "Preview generated artifacts." },
|
||||
],
|
||||
title: "Commands",
|
||||
commands: extensionCommands,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function researchTools(pi: ExtensionAPI): void {
|
||||
function installFeynmanHeader(ctx: ExtensionContext): void {
|
||||
let skillSummaryPromise: Promise<CatalogSummary> | undefined;
|
||||
let agentSummaryPromise: Promise<CatalogSummary> | undefined;
|
||||
|
||||
async function installFeynmanHeader(ctx: ExtensionContext): Promise<void> {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
skillSummaryPromise ??= buildSkillCatalogSummary();
|
||||
agentSummaryPromise ??= buildAgentCatalogSummary();
|
||||
const commandSummary = buildCommandCatalogSummary(pi);
|
||||
const skillSummary = await skillSummaryPromise;
|
||||
const agentSummary = await agentSummaryPromise;
|
||||
const toolSummary = buildToolCatalogSummary(pi);
|
||||
|
||||
ctx.ui.setHeader((_tui, theme) => ({
|
||||
render(width: number): string[] {
|
||||
const maxAvailableWidth = Math.max(width - 2, 1);
|
||||
const preferredWidth = Math.min(104, Math.max(56, width - 4));
|
||||
const preferredWidth = Math.min(136, Math.max(72, maxAvailableWidth));
|
||||
const cardWidth = Math.min(maxAvailableWidth, preferredWidth);
|
||||
const innerWidth = cardWidth - 2;
|
||||
const outerPadding = " ".repeat(Math.max(0, Math.floor((width - cardWidth) / 2)));
|
||||
const title = truncateForWidth(` Feynman v${FEYNMAN_VERSION} `, innerWidth);
|
||||
const title = truncateForWidth(` Feynman Research Agent v${FEYNMAN_VERSION} `, innerWidth);
|
||||
const titledBorder = buildTitledBorder(innerWidth, title);
|
||||
const modelLabel = getCurrentModelLabel(ctx);
|
||||
const sessionLabel = ctx.sessionManager.getSessionName()?.trim() || "default session";
|
||||
const sessionLabel = ctx.sessionManager.getSessionName()?.trim() || ctx.sessionManager.getSessionId();
|
||||
const directoryLabel = formatHeaderPath(ctx.cwd);
|
||||
const recentActivity = getRecentActivitySummary(ctx);
|
||||
const shortcuts = [
|
||||
["/lit", "survey papers on a topic"],
|
||||
["/review", "simulate a peer review"],
|
||||
["/draft", "draft a paper-style writeup"],
|
||||
["/deepresearch", "run a source-heavy research pass"],
|
||||
];
|
||||
const lines: string[] = [];
|
||||
|
||||
const push = (line: string): void => {
|
||||
@@ -703,7 +825,15 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
theme.fg("accent", theme.bold(padCell(text, cellWidth)));
|
||||
const styleMutedCell = (text: string, cellWidth: number): string =>
|
||||
theme.fg("muted", padCell(text, cellWidth));
|
||||
const styleSuccessCell = (text: string, cellWidth: number): string =>
|
||||
theme.fg("success", theme.bold(padCell(text, cellWidth)));
|
||||
const styleWarningCell = (text: string, cellWidth: number): string =>
|
||||
theme.fg("warning", theme.bold(padCell(text, cellWidth)));
|
||||
|
||||
push("");
|
||||
for (const logoLine of FEYNMAN_AGENT_LOGO) {
|
||||
push(theme.fg("accent", theme.bold(centerText(logoLine, cardWidth))));
|
||||
}
|
||||
push("");
|
||||
push(
|
||||
theme.fg("borderMuted", `╭${titledBorder.left}`) +
|
||||
@@ -711,7 +841,7 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
theme.fg("borderMuted", `${titledBorder.right}╮`),
|
||||
);
|
||||
|
||||
if (innerWidth < 88) {
|
||||
if (innerWidth < 72) {
|
||||
const activityLines = wrapForWidth(recentActivity, innerWidth, 2);
|
||||
push(renderBoxLine(padCell("", innerWidth)));
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Research session ready", innerWidth)))));
|
||||
@@ -719,19 +849,82 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
push(renderBoxLine(padCell(`session: ${sessionLabel}`, innerWidth)));
|
||||
push(renderBoxLine(padCell(`directory: ${directoryLabel}`, innerWidth)));
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Quick starts", innerWidth)))));
|
||||
for (const [command, description] of shortcuts) {
|
||||
push(renderBoxLine(padCell(formatShortcutLine(command, description, innerWidth), innerWidth)));
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Available tools", innerWidth)))));
|
||||
for (const toolLine of toolSummary.lines.slice(0, 4)) {
|
||||
push(renderBoxLine(padCell(toolLine, innerWidth)));
|
||||
}
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Slash Commands", innerWidth)))));
|
||||
for (const commandLine of commandSummary.lines.slice(0, 4)) {
|
||||
push(renderBoxLine(padCell(commandLine, innerWidth)));
|
||||
}
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("success", theme.bold(padCell("Research Skills", innerWidth)))));
|
||||
for (const skillLine of skillSummary.lines.slice(0, 4)) {
|
||||
push(renderBoxLine(padCell(skillLine, innerWidth)));
|
||||
}
|
||||
if (agentSummary.lines.length > 0) {
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("warning", theme.bold(padCell("Project Agents", innerWidth)))));
|
||||
for (const agentLine of agentSummary.lines.slice(0, 3)) {
|
||||
push(renderBoxLine(padCell(agentLine, innerWidth)));
|
||||
}
|
||||
}
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Recent activity", innerWidth)))));
|
||||
for (const activityLine of activityLines.length > 0 ? activityLines : ["No messages yet in this session."]) {
|
||||
push(renderBoxLine(padCell(activityLine, innerWidth)));
|
||||
}
|
||||
push(renderDivider());
|
||||
push(
|
||||
renderBoxLine(
|
||||
padCell(
|
||||
`${toolSummary.count} tools · ${commandSummary.count} commands · ${skillSummary.count} skills · /help`,
|
||||
innerWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const leftWidth = Math.min(44, Math.max(38, Math.floor(innerWidth * 0.43)));
|
||||
const leftWidth = Math.min(44, Math.max(30, Math.floor(innerWidth * 0.36)));
|
||||
const rightWidth = innerWidth - leftWidth - 3;
|
||||
const activityLines = wrapForWidth(recentActivity, innerWidth, 2);
|
||||
const wrappedToolLines = toolSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 3));
|
||||
const wrappedCommandLines = commandSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
|
||||
const wrappedSkillLines = skillSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
|
||||
const wrappedAgentLines = agentSummary.lines.flatMap((line) => wrapForWidth(line, rightWidth, 4));
|
||||
const wrappedModelLines = wrapForWidth(`model: ${modelLabel}`, leftWidth, 2);
|
||||
const wrappedDirectoryLines = wrapForWidth(`directory: ${directoryLabel}`, leftWidth, 2);
|
||||
const wrappedSessionLines = wrapForWidth(`session: ${sessionLabel}`, leftWidth, 2);
|
||||
const wrappedFooterLines = wrapForWidth(
|
||||
`${toolSummary.count} tools · ${commandSummary.count} commands · ${skillSummary.count} skills · /help`,
|
||||
leftWidth,
|
||||
2,
|
||||
);
|
||||
const leftLines = [
|
||||
...FEYNMAN_MARK_ART.map((line) => centerText(line, leftWidth)),
|
||||
"",
|
||||
centerText("Research shell ready", leftWidth),
|
||||
"",
|
||||
...wrappedModelLines,
|
||||
...wrappedDirectoryLines,
|
||||
...wrappedSessionLines,
|
||||
"",
|
||||
...wrappedFooterLines,
|
||||
];
|
||||
const rightLines = [
|
||||
"Available Tools",
|
||||
...wrappedToolLines,
|
||||
"",
|
||||
"Slash Commands",
|
||||
...wrappedCommandLines,
|
||||
"",
|
||||
"Research Skills",
|
||||
...wrappedSkillLines,
|
||||
...(wrappedAgentLines.length > 0 ? ["", "Project Agents", ...wrappedAgentLines] : []),
|
||||
"",
|
||||
"Recent Activity",
|
||||
...(activityLines.length > 0 ? activityLines : ["No messages yet in this session."]),
|
||||
];
|
||||
const row = (
|
||||
left: string,
|
||||
right: string,
|
||||
@@ -751,15 +944,35 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
};
|
||||
|
||||
push(renderBoxLine(padCell("", innerWidth)));
|
||||
push(row("Research session ready", "Quick starts", { leftAccent: true, rightAccent: true }));
|
||||
push(row(`model: ${modelLabel}`, formatShortcutLine(shortcuts[0][0], shortcuts[0][1], rightWidth)));
|
||||
push(row(`session: ${sessionLabel}`, formatShortcutLine(shortcuts[1][0], shortcuts[1][1], rightWidth)));
|
||||
push(row(`directory: ${directoryLabel}`, formatShortcutLine(shortcuts[2][0], shortcuts[2][1], rightWidth)));
|
||||
push(row("ask naturally; slash commands are optional", formatShortcutLine(shortcuts[3][0], shortcuts[3][1], rightWidth), { leftMuted: true }));
|
||||
push(renderDivider());
|
||||
push(renderBoxLine(theme.fg("accent", theme.bold(padCell("Recent activity", innerWidth)))));
|
||||
for (const activityLine of activityLines.length > 0 ? activityLines : ["No messages yet in this session."]) {
|
||||
push(renderBoxLine(padCell(activityLine, innerWidth)));
|
||||
for (let index = 0; index < Math.max(leftLines.length, rightLines.length); index += 1) {
|
||||
const left = leftLines[index] ?? "";
|
||||
const right = rightLines[index] ?? "";
|
||||
const isLogoLine = index < FEYNMAN_MARK_ART.length;
|
||||
const isRightSectionHeading =
|
||||
right === "Available Tools" || right === "Slash Commands" || right === "Research Skills" || right === "Project Agents" ||
|
||||
right === "Recent Activity";
|
||||
const isResearchHeading = right === "Research Skills";
|
||||
const isAgentHeading = right === "Project Agents";
|
||||
const isFooterLine = left.includes("/help");
|
||||
push(
|
||||
(() => {
|
||||
const leftCell = isLogoLine
|
||||
? styleAccentCell(left, leftWidth)
|
||||
: !isFooterLine && index >= FEYNMAN_MARK_ART.length + 2
|
||||
? styleMutedCell(left, leftWidth)
|
||||
: padCell(left, leftWidth);
|
||||
const rightCell = isResearchHeading
|
||||
? styleSuccessCell(right, rightWidth)
|
||||
: isAgentHeading
|
||||
? styleWarningCell(right, rightWidth)
|
||||
: isRightSectionHeading
|
||||
? styleAccentCell(right, rightWidth)
|
||||
: right.length > 0
|
||||
? styleMutedCell(right, rightWidth)
|
||||
: padCell(right, rightWidth);
|
||||
return renderBoxLine(`${leftCell}${theme.fg("borderMuted", " │ ")}${rightCell}`);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,11 +985,11 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
installFeynmanHeader(ctx);
|
||||
await installFeynmanHeader(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
installFeynmanHeader(ctx);
|
||||
await installFeynmanHeader(ctx);
|
||||
});
|
||||
|
||||
pi.registerCommand("alpha-login", {
|
||||
@@ -818,11 +1031,16 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("help", {
|
||||
description: "Show grouped Feynman commands and prefill the editor with a selected command.",
|
||||
handler: async (_args, ctx) => {
|
||||
const sections = buildFeynmanHelpSections();
|
||||
const sections = buildFeynmanHelpSections(pi);
|
||||
const items = sections.flatMap((section) => [
|
||||
`--- ${section.title} ---`,
|
||||
...section.commands.map((command) => `${command.usage} — ${command.description}`),
|
||||
]);
|
||||
]).filter((item, index, array) => {
|
||||
if (!item.startsWith("---")) {
|
||||
return true;
|
||||
}
|
||||
return array[index + 1] !== undefined && !array[index + 1].startsWith("---");
|
||||
});
|
||||
|
||||
const selected = await ctx.ui.select("Feynman Help", items);
|
||||
if (!selected || selected.startsWith("---")) {
|
||||
@@ -1047,7 +1265,7 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
pi.registerTool({
|
||||
name: "preview_file",
|
||||
label: "Preview File",
|
||||
description: "Open a markdown, LaTeX, PDF, or code artifact in the browser or a PDF viewer for human review.",
|
||||
description: "Open a markdown, LaTeX, PDF, or code artifact in the browser or a PDF viewer for human review. Rendered HTML/PDF previews are temporary and do not replace the source artifact.",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({
|
||||
description: "Path to the file to preview.",
|
||||
@@ -1079,6 +1297,7 @@ export default function researchTools(pi: ExtensionAPI): void {
|
||||
sourcePath: resolvedPath,
|
||||
target,
|
||||
openedPath,
|
||||
temporaryPreview: openedPath !== resolvedPath,
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text", text: formatToolText(result) }],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -112,7 +112,7 @@ if (existsSync(interactiveThemePath)) {
|
||||
" return {",
|
||||
' borderColor: (text) => " ".repeat(text.length),',
|
||||
' bgColor: (text) => theme.bg("userMessageBg", text),',
|
||||
' placeholderText: "Ask Feynman to research anything",',
|
||||
' placeholderText: "Type your message or /help for commands",',
|
||||
' placeholder: (text) => theme.fg("dim", text),',
|
||||
" selectList: getSelectListTheme(),",
|
||||
" };",
|
||||
|
||||
136
src/bootstrap/sync.ts
Normal file
136
src/bootstrap/sync.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
|
||||
import { getBootstrapStatePath } from "../config/paths.js";
|
||||
|
||||
type BootstrapRecord = {
|
||||
lastAppliedSourceHash: string;
|
||||
lastAppliedTargetHash: string;
|
||||
};
|
||||
|
||||
type BootstrapState = {
|
||||
version: 1;
|
||||
files: Record<string, BootstrapRecord>;
|
||||
};
|
||||
|
||||
export type BootstrapSyncResult = {
|
||||
copied: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function readBootstrapState(path: string): BootstrapState {
|
||||
if (!existsSync(path)) {
|
||||
return { version: 1, files: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as BootstrapState;
|
||||
return {
|
||||
version: 1,
|
||||
files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
|
||||
};
|
||||
} catch {
|
||||
return { version: 1, files: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeBootstrapState(path: string, state: BootstrapState): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function listFiles(root: string): string[] {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = resolve(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFiles(path));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function syncManagedFiles(
|
||||
sourceRoot: string,
|
||||
targetRoot: string,
|
||||
state: BootstrapState,
|
||||
result: BootstrapSyncResult,
|
||||
): void {
|
||||
for (const sourcePath of listFiles(sourceRoot)) {
|
||||
const key = relative(sourceRoot, sourcePath);
|
||||
const targetPath = resolve(targetRoot, key);
|
||||
const sourceText = readFileSync(sourcePath, "utf8");
|
||||
const sourceHash = sha256(sourceText);
|
||||
const previous = state.files[key];
|
||||
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
|
||||
if (!existsSync(targetPath)) {
|
||||
writeFileSync(targetPath, sourceText, "utf8");
|
||||
state.files[key] = {
|
||||
lastAppliedSourceHash: sourceHash,
|
||||
lastAppliedTargetHash: sourceHash,
|
||||
};
|
||||
result.copied.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTargetText = readFileSync(targetPath, "utf8");
|
||||
const currentTargetHash = sha256(currentTargetText);
|
||||
|
||||
if (currentTargetHash === sourceHash) {
|
||||
state.files[key] = {
|
||||
lastAppliedSourceHash: sourceHash,
|
||||
lastAppliedTargetHash: currentTargetHash,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!previous) {
|
||||
result.skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTargetHash !== previous.lastAppliedTargetHash) {
|
||||
result.skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(targetPath, sourceText, "utf8");
|
||||
state.files[key] = {
|
||||
lastAppliedSourceHash: sourceHash,
|
||||
lastAppliedTargetHash: sourceHash,
|
||||
};
|
||||
result.updated.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncBundledAssets(appRoot: string, agentDir: string): BootstrapSyncResult {
|
||||
const statePath = getBootstrapStatePath();
|
||||
const state = readBootstrapState(statePath);
|
||||
const result: BootstrapSyncResult = {
|
||||
copied: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
syncManagedFiles(resolve(appRoot, ".pi", "themes"), resolve(agentDir, "themes"), state, result);
|
||||
syncManagedFiles(resolve(appRoot, ".pi", "agents"), resolve(agentDir, "agents"), state, result);
|
||||
|
||||
writeBootstrapState(statePath, state);
|
||||
return result;
|
||||
}
|
||||
445
src/cli.ts
Normal file
445
src/cli.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
getUserName as getAlphaUserName,
|
||||
isLoggedIn as isAlphaLoggedIn,
|
||||
login as loginAlpha,
|
||||
logout as logoutAlpha,
|
||||
} from "@companion-ai/alpha-hub/lib";
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { syncBundledAssets } from "./bootstrap/sync.js";
|
||||
import { editConfig, printConfig, printConfigPath, printConfigValue, setConfigValue } from "./config/commands.js";
|
||||
import { getConfiguredSessionDir, loadFeynmanConfig } from "./config/feynman-config.js";
|
||||
import { ensureFeynmanHome, getFeynmanAgentDir, getFeynmanHome } from "./config/paths.js";
|
||||
import { buildFeynmanSystemPrompt } from "./feynman-prompt.js";
|
||||
import { launchPiChat } from "./pi/launch.js";
|
||||
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
|
||||
import {
|
||||
loginModelProvider,
|
||||
logoutModelProvider,
|
||||
printModelList,
|
||||
printModelProviders,
|
||||
printModelRecommendation,
|
||||
printModelStatus,
|
||||
setDefaultModelSpec,
|
||||
} from "./model/commands.js";
|
||||
import { printSearchProviders, printSearchStatus, setSearchProvider } from "./search/commands.js";
|
||||
import { runDoctor, runStatus } from "./setup/doctor.js";
|
||||
import { setupPreviewDependencies } from "./setup/preview.js";
|
||||
import { runSetup } from "./setup/setup.js";
|
||||
import { printInfo, printPanel, printSection } from "./ui/terminal.js";
|
||||
|
||||
const TOP_LEVEL_COMMANDS = new Set(["alpha", "chat", "config", "doctor", "help", "model", "search", "setup", "status"]);
|
||||
const RESEARCH_WORKFLOW_COMMANDS = new Set([
|
||||
"ablate",
|
||||
"audit",
|
||||
"autoresearch",
|
||||
"compare",
|
||||
"deepresearch",
|
||||
"draft",
|
||||
"jobs",
|
||||
"lit",
|
||||
"log",
|
||||
"memo",
|
||||
"reading",
|
||||
"related",
|
||||
"replicate",
|
||||
"rebuttal",
|
||||
"review",
|
||||
"watch",
|
||||
]);
|
||||
|
||||
function printHelp(): void {
|
||||
printPanel("Feynman", [
|
||||
"Research-first agent shell built on Pi.",
|
||||
"Use `feynman setup` first if this is a new machine.",
|
||||
]);
|
||||
|
||||
printSection("Getting Started");
|
||||
printInfo("feynman");
|
||||
printInfo("feynman setup");
|
||||
printInfo("feynman setup quick");
|
||||
printInfo("feynman doctor");
|
||||
printInfo("feynman model");
|
||||
printInfo("feynman search");
|
||||
|
||||
printSection("Commands");
|
||||
printInfo("feynman chat [prompt] Start chat explicitly, optionally with an initial prompt");
|
||||
printInfo("feynman setup [section] Run setup for model, alpha, web, preview, or all");
|
||||
printInfo("feynman setup quick Configure only missing items");
|
||||
printInfo("feynman doctor Diagnose config, auth, Pi runtime, and preview deps");
|
||||
printInfo("feynman status Show the current setup summary");
|
||||
printInfo("feynman model list Show available models in auth storage");
|
||||
printInfo("feynman model providers Show Pi-supported providers and auth state");
|
||||
printInfo("feynman model recommend Show the recommended research model");
|
||||
printInfo("feynman model login [id] Login to a Pi OAuth model provider");
|
||||
printInfo("feynman model logout [id] Logout from a Pi OAuth model provider");
|
||||
printInfo("feynman model set <spec> Set the default model");
|
||||
printInfo("feynman search status Show web research provider status");
|
||||
printInfo("feynman search set <id> Set web research provider");
|
||||
printInfo("feynman config show Print ~/.feynman/config.json");
|
||||
printInfo("feynman config get <key> Read a config value");
|
||||
printInfo("feynman config set <key> <value>");
|
||||
printInfo("feynman config edit Open config in $EDITOR");
|
||||
printInfo("feynman config path Print the config path");
|
||||
printInfo("feynman alpha login|logout|status");
|
||||
|
||||
printSection("Research Workflows");
|
||||
printInfo("feynman lit <topic> Start the literature-review workflow");
|
||||
printInfo("feynman review <artifact> Start the peer-review workflow");
|
||||
printInfo("feynman audit <item> Start the paper/code audit workflow");
|
||||
printInfo("feynman replicate <target> Start the replication workflow");
|
||||
printInfo("feynman memo <topic> Start the research memo workflow");
|
||||
printInfo("feynman draft <topic> Start the paper-style draft workflow");
|
||||
printInfo("feynman watch <topic> Start the recurring research watch workflow");
|
||||
|
||||
printSection("Legacy Flags");
|
||||
printInfo('--prompt "<text>" Run one prompt and exit');
|
||||
printInfo("--alpha-login Sign in to alphaXiv and exit");
|
||||
printInfo("--alpha-logout Clear alphaXiv auth and exit");
|
||||
printInfo("--alpha-status Show alphaXiv auth status and exit");
|
||||
printInfo("--model provider:model Force a specific model");
|
||||
printInfo("--thinking level off | low | medium | high");
|
||||
printInfo("--cwd /path/to/workdir Working directory for tools");
|
||||
printInfo("--session-dir /path Session storage directory");
|
||||
printInfo("--doctor Alias for `feynman doctor`");
|
||||
printInfo("--setup-preview Alias for `feynman setup preview`");
|
||||
|
||||
printSection("REPL");
|
||||
printInfo("Inside the REPL, slash workflows come from the live prompt-template and extension command set.");
|
||||
printInfo("Use `/help` in chat to browse the commands actually loaded in this session.");
|
||||
}
|
||||
|
||||
async function handleAlphaCommand(action: string | undefined): Promise<void> {
|
||||
if (action === "login") {
|
||||
const result = await loginAlpha();
|
||||
const name =
|
||||
result.userInfo &&
|
||||
typeof result.userInfo === "object" &&
|
||||
"name" in result.userInfo &&
|
||||
typeof result.userInfo.name === "string"
|
||||
? result.userInfo.name
|
||||
: getAlphaUserName();
|
||||
console.log(name ? `alphaXiv login complete: ${name}` : "alphaXiv login complete");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "logout") {
|
||||
logoutAlpha();
|
||||
console.log("alphaXiv auth cleared");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!action || action === "status") {
|
||||
if (isAlphaLoggedIn()) {
|
||||
const name = getAlphaUserName();
|
||||
console.log(name ? `alphaXiv logged in as ${name}` : "alphaXiv logged in");
|
||||
} else {
|
||||
console.log("alphaXiv not logged in");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown alpha command: ${action}`);
|
||||
}
|
||||
|
||||
function handleConfigCommand(subcommand: string | undefined, args: string[]): void {
|
||||
if (!subcommand || subcommand === "show") {
|
||||
printConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "path") {
|
||||
printConfigPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "edit") {
|
||||
editConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "get") {
|
||||
const key = args[0];
|
||||
if (!key) {
|
||||
throw new Error("Usage: feynman config get <key>");
|
||||
}
|
||||
printConfigValue(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "set") {
|
||||
const [key, ...valueParts] = args;
|
||||
if (!key || valueParts.length === 0) {
|
||||
throw new Error("Usage: feynman config set <key> <value>");
|
||||
}
|
||||
setConfigValue(key, valueParts.join(" "));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown config command: ${subcommand}`);
|
||||
}
|
||||
|
||||
async function handleModelCommand(subcommand: string | undefined, args: string[], settingsPath: string, authPath: string): Promise<void> {
|
||||
if (!subcommand || subcommand === "status" || subcommand === "current") {
|
||||
printModelStatus(settingsPath, authPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "list") {
|
||||
printModelList(settingsPath, authPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "providers") {
|
||||
printModelProviders(settingsPath, authPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "recommend") {
|
||||
printModelRecommendation(authPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "login") {
|
||||
await loginModelProvider(authPath, args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "logout") {
|
||||
await logoutModelProvider(authPath, args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "set") {
|
||||
const spec = args[0];
|
||||
if (!spec) {
|
||||
throw new Error("Usage: feynman model set <provider/model>");
|
||||
}
|
||||
setDefaultModelSpec(settingsPath, authPath, spec);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown model command: ${subcommand}`);
|
||||
}
|
||||
|
||||
function handleSearchCommand(subcommand: string | undefined, args: string[]): void {
|
||||
if (!subcommand || subcommand === "status") {
|
||||
printSearchStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "providers" || subcommand === "list") {
|
||||
printSearchProviders();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "set") {
|
||||
const provider = args[0];
|
||||
if (!provider) {
|
||||
throw new Error("Usage: feynman search set <provider> [value]");
|
||||
}
|
||||
setSearchProvider(provider, args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown search command: ${subcommand}`);
|
||||
}
|
||||
|
||||
function loadPackageVersion(appRoot: string): { version?: string } {
|
||||
try {
|
||||
return JSON.parse(readFileSync(resolve(appRoot, "package.json"), "utf8")) as { version?: string };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInitialPrompt(
|
||||
command: string | undefined,
|
||||
rest: string[],
|
||||
oneShotPrompt: string | undefined,
|
||||
): string | undefined {
|
||||
if (oneShotPrompt) {
|
||||
return oneShotPrompt;
|
||||
}
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
if (command === "chat") {
|
||||
return rest.length > 0 ? rest.join(" ") : undefined;
|
||||
}
|
||||
if (RESEARCH_WORKFLOW_COMMANDS.has(command)) {
|
||||
return [`/${command}`, ...rest].join(" ").trim();
|
||||
}
|
||||
if (!TOP_LEVEL_COMMANDS.has(command)) {
|
||||
return [command, ...rest].join(" ");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = resolve(here, "..");
|
||||
const feynmanVersion = loadPackageVersion(appRoot).version;
|
||||
const bundledSettingsPath = resolve(appRoot, ".pi", "settings.json");
|
||||
const feynmanHome = getFeynmanHome();
|
||||
const feynmanAgentDir = getFeynmanAgentDir(feynmanHome);
|
||||
|
||||
ensureFeynmanHome(feynmanHome);
|
||||
syncBundledAssets(appRoot, feynmanAgentDir);
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
cwd: { type: "string" },
|
||||
doctor: { type: "boolean" },
|
||||
help: { type: "boolean" },
|
||||
"alpha-login": { type: "boolean" },
|
||||
"alpha-logout": { type: "boolean" },
|
||||
"alpha-status": { type: "boolean" },
|
||||
model: { type: "string" },
|
||||
"new-session": { type: "boolean" },
|
||||
prompt: { type: "string" },
|
||||
"session-dir": { type: "string" },
|
||||
"setup-preview": { type: "boolean" },
|
||||
thinking: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadFeynmanConfig();
|
||||
const workingDir = resolve(values.cwd ?? process.cwd());
|
||||
const sessionDir = resolve(values["session-dir"] ?? getConfiguredSessionDir(config));
|
||||
const feynmanSettingsPath = resolve(feynmanAgentDir, "settings.json");
|
||||
const feynmanAuthPath = resolve(feynmanAgentDir, "auth.json");
|
||||
const thinkingLevel = normalizeThinkingLevel(values.thinking ?? process.env.FEYNMAN_THINKING) ?? "medium";
|
||||
|
||||
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
|
||||
|
||||
if (values.doctor) {
|
||||
runDoctor({
|
||||
settingsPath: feynmanSettingsPath,
|
||||
authPath: feynmanAuthPath,
|
||||
sessionDir,
|
||||
workingDir,
|
||||
appRoot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["setup-preview"]) {
|
||||
const result = setupPreviewDependencies();
|
||||
console.log(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-login"]) {
|
||||
await handleAlphaCommand("login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-logout"]) {
|
||||
await handleAlphaCommand("logout");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-status"]) {
|
||||
await handleAlphaCommand("status");
|
||||
return;
|
||||
}
|
||||
|
||||
const [command, ...rest] = positionals;
|
||||
if (command === "help") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "setup") {
|
||||
await runSetup({
|
||||
section: rest[0],
|
||||
settingsPath: feynmanSettingsPath,
|
||||
bundledSettingsPath,
|
||||
authPath: feynmanAuthPath,
|
||||
workingDir,
|
||||
sessionDir,
|
||||
appRoot,
|
||||
defaultThinkingLevel: thinkingLevel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "doctor") {
|
||||
runDoctor({
|
||||
settingsPath: feynmanSettingsPath,
|
||||
authPath: feynmanAuthPath,
|
||||
sessionDir,
|
||||
workingDir,
|
||||
appRoot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
runStatus({
|
||||
settingsPath: feynmanSettingsPath,
|
||||
authPath: feynmanAuthPath,
|
||||
sessionDir,
|
||||
workingDir,
|
||||
appRoot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
handleConfigCommand(rest[0], rest.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "model") {
|
||||
await handleModelCommand(rest[0], rest.slice(1), feynmanSettingsPath, feynmanAuthPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "search") {
|
||||
handleSearchCommand(rest[0], rest.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "alpha") {
|
||||
await handleAlphaCommand(rest[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
|
||||
if (explicitModelSpec) {
|
||||
const modelRegistry = new ModelRegistry(AuthStorage.create(feynmanAuthPath));
|
||||
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
|
||||
if (!explicitModel) {
|
||||
throw new Error(`Unknown model: ${explicitModelSpec}`);
|
||||
}
|
||||
}
|
||||
|
||||
await launchPiChat({
|
||||
appRoot,
|
||||
workingDir,
|
||||
sessionDir,
|
||||
feynmanAgentDir,
|
||||
feynmanVersion,
|
||||
thinkingLevel,
|
||||
explicitModelSpec,
|
||||
oneShotPrompt: values.prompt,
|
||||
initialPrompt: resolveInitialPrompt(command, rest, values.prompt),
|
||||
systemPrompt: buildFeynmanSystemPrompt(),
|
||||
});
|
||||
}
|
||||
78
src/config/commands.ts
Normal file
78
src/config/commands.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { FEYNMAN_CONFIG_PATH, loadFeynmanConfig, saveFeynmanConfig } from "./feynman-config.js";
|
||||
|
||||
function coerceConfigValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "null") return null;
|
||||
if (trimmed === "") return "";
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(record: Record<string, unknown>, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, record);
|
||||
}
|
||||
|
||||
function setNestedValue(record: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const segments = path.split(".");
|
||||
let current: Record<string, unknown> = record;
|
||||
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = current[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[segments[segments.length - 1]!] = value;
|
||||
}
|
||||
|
||||
export function printConfig(): void {
|
||||
console.log(JSON.stringify(loadFeynmanConfig(), null, 2));
|
||||
}
|
||||
|
||||
export function printConfigPath(): void {
|
||||
console.log(FEYNMAN_CONFIG_PATH);
|
||||
}
|
||||
|
||||
export function editConfig(): void {
|
||||
if (!existsSync(FEYNMAN_CONFIG_PATH)) {
|
||||
saveFeynmanConfig(loadFeynmanConfig());
|
||||
}
|
||||
|
||||
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
||||
const result = spawnSync(editor, [FEYNMAN_CONFIG_PATH], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to open editor: ${editor}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printConfigValue(key: string): void {
|
||||
const config = loadFeynmanConfig() as Record<string, unknown>;
|
||||
const value = getNestedValue(config, key);
|
||||
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function setConfigValue(key: string, rawValue: string): void {
|
||||
const config = loadFeynmanConfig() as Record<string, unknown>;
|
||||
setNestedValue(config, key, coerceConfigValue(rawValue));
|
||||
saveFeynmanConfig(config as ReturnType<typeof loadFeynmanConfig>);
|
||||
console.log(`Updated ${key}`);
|
||||
}
|
||||
270
src/config/feynman-config.ts
Normal file
270
src/config/feynman-config.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { getDefaultSessionDir, getFeynmanConfigPath } from "./paths.js";
|
||||
|
||||
export type WebSearchProviderId = "auto" | "perplexity" | "gemini-api" | "gemini-browser";
|
||||
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini";
|
||||
|
||||
export type WebSearchConfig = Record<string, unknown> & {
|
||||
provider?: PiWebSearchProvider;
|
||||
perplexityApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
chromeProfile?: string;
|
||||
feynmanWebProvider?: WebSearchProviderId;
|
||||
};
|
||||
|
||||
export type FeynmanConfig = {
|
||||
version: 1;
|
||||
sessionDir?: string;
|
||||
webSearch?: WebSearchConfig;
|
||||
preview?: {
|
||||
lastSetupAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSearchProviderDefinition = {
|
||||
id: WebSearchProviderId;
|
||||
label: string;
|
||||
description: string;
|
||||
runtimeProvider: PiWebSearchProvider;
|
||||
requiresApiKey: boolean;
|
||||
};
|
||||
|
||||
export type WebSearchStatus = {
|
||||
selected: WebSearchProviderDefinition;
|
||||
configPath: string;
|
||||
perplexityConfigured: boolean;
|
||||
geminiApiConfigured: boolean;
|
||||
chromeProfile?: string;
|
||||
browserHint: string;
|
||||
};
|
||||
|
||||
export const FEYNMAN_CONFIG_PATH = getFeynmanConfigPath();
|
||||
export const LEGACY_WEB_SEARCH_CONFIG_PATH = resolve(process.env.HOME ?? "", ".pi", "web-search.json");
|
||||
export const DEFAULT_WEB_SEARCH_PROVIDER: WebSearchProviderId = "gemini-browser";
|
||||
|
||||
export const WEB_SEARCH_PROVIDERS: ReadonlyArray<WebSearchProviderDefinition> = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Prefer Perplexity when configured, otherwise fall back to Gemini.",
|
||||
runtimeProvider: "auto",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "perplexity",
|
||||
label: "Perplexity API",
|
||||
description: "Use Perplexity Sonar directly for web answers and source lists.",
|
||||
runtimeProvider: "perplexity",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-api",
|
||||
label: "Gemini API",
|
||||
description: "Use Gemini directly with an API key.",
|
||||
runtimeProvider: "gemini",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-browser",
|
||||
label: "Gemini Browser",
|
||||
description: "Use your signed-in Chromium browser session through pi-web-access.",
|
||||
runtimeProvider: "gemini",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function readJsonFile<T>(path: string): T | undefined {
|
||||
if (!existsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebSearchConfig(value: unknown): WebSearchConfig | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { ...(value as WebSearchConfig) };
|
||||
}
|
||||
|
||||
function migrateLegacyWebSearchConfig(): WebSearchConfig | undefined {
|
||||
return normalizeWebSearchConfig(readJsonFile<WebSearchConfig>(LEGACY_WEB_SEARCH_CONFIG_PATH));
|
||||
}
|
||||
|
||||
export function loadFeynmanConfig(configPath = FEYNMAN_CONFIG_PATH): FeynmanConfig {
|
||||
const config = readJsonFile<FeynmanConfig>(configPath);
|
||||
if (config && typeof config === "object") {
|
||||
return {
|
||||
version: 1,
|
||||
sessionDir: typeof config.sessionDir === "string" && config.sessionDir.trim() ? config.sessionDir : undefined,
|
||||
webSearch: normalizeWebSearchConfig(config.webSearch),
|
||||
preview: config.preview && typeof config.preview === "object" ? { ...config.preview } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyWebSearch = migrateLegacyWebSearchConfig();
|
||||
return {
|
||||
version: 1,
|
||||
sessionDir: getDefaultSessionDir(),
|
||||
webSearch: legacyWebSearch,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFeynmanConfig(config: FeynmanConfig, configPath = FEYNMAN_CONFIG_PATH): void {
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
...(config.sessionDir ? { sessionDir: config.sessionDir } : {}),
|
||||
...(config.webSearch ? { webSearch: config.webSearch } : {}),
|
||||
...(config.preview ? { preview: config.preview } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredSessionDir(config = loadFeynmanConfig()): string {
|
||||
return typeof config.sessionDir === "string" && config.sessionDir.trim()
|
||||
? config.sessionDir
|
||||
: getDefaultSessionDir();
|
||||
}
|
||||
|
||||
export function loadWebSearchConfig(): WebSearchConfig {
|
||||
return loadFeynmanConfig().webSearch ?? {};
|
||||
}
|
||||
|
||||
export function saveWebSearchConfig(config: WebSearchConfig): void {
|
||||
const current = loadFeynmanConfig();
|
||||
saveFeynmanConfig({
|
||||
...current,
|
||||
webSearch: config,
|
||||
});
|
||||
}
|
||||
|
||||
export function getWebSearchProviderById(id: WebSearchProviderId): WebSearchProviderDefinition {
|
||||
return WEB_SEARCH_PROVIDERS.find((provider) => provider.id === id) ?? WEB_SEARCH_PROVIDERS[0];
|
||||
}
|
||||
|
||||
export function hasPerplexityApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return typeof config.perplexityApiKey === "string" && config.perplexityApiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasGeminiApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return typeof config.geminiApiKey === "string" && config.geminiApiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasConfiguredWebProvider(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return hasPerplexityApiKey(config) || hasGeminiApiKey(config) || getConfiguredWebSearchProvider(config).id === DEFAULT_WEB_SEARCH_PROVIDER;
|
||||
}
|
||||
|
||||
export function getConfiguredWebSearchProvider(
|
||||
config: WebSearchConfig = loadWebSearchConfig(),
|
||||
): WebSearchProviderDefinition {
|
||||
const explicit = config.feynmanWebProvider;
|
||||
if (explicit === "auto" || explicit === "perplexity" || explicit === "gemini-api" || explicit === "gemini-browser") {
|
||||
return getWebSearchProviderById(explicit);
|
||||
}
|
||||
|
||||
if (config.provider === "perplexity") {
|
||||
return getWebSearchProviderById("perplexity");
|
||||
}
|
||||
|
||||
if (config.provider === "gemini") {
|
||||
return hasGeminiApiKey(config)
|
||||
? getWebSearchProviderById("gemini-api")
|
||||
: getWebSearchProviderById("gemini-browser");
|
||||
}
|
||||
|
||||
return getWebSearchProviderById(DEFAULT_WEB_SEARCH_PROVIDER);
|
||||
}
|
||||
|
||||
export function configureWebSearchProvider(
|
||||
current: WebSearchConfig,
|
||||
providerId: WebSearchProviderId,
|
||||
values: { apiKey?: string; chromeProfile?: string } = {},
|
||||
): WebSearchConfig {
|
||||
const next: WebSearchConfig = { ...current };
|
||||
next.feynmanWebProvider = providerId;
|
||||
|
||||
switch (providerId) {
|
||||
case "auto":
|
||||
next.provider = "auto";
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "perplexity":
|
||||
next.provider = "perplexity";
|
||||
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
|
||||
next.perplexityApiKey = values.apiKey.trim();
|
||||
}
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "gemini-api":
|
||||
next.provider = "gemini";
|
||||
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
|
||||
next.geminiApiKey = values.apiKey.trim();
|
||||
}
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "gemini-browser":
|
||||
next.provider = "gemini";
|
||||
delete next.geminiApiKey;
|
||||
if (typeof values.chromeProfile === "string") {
|
||||
const profile = values.chromeProfile.trim();
|
||||
if (profile) {
|
||||
next.chromeProfile = profile;
|
||||
} else {
|
||||
delete next.chromeProfile;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebSearchStatus(config: WebSearchConfig = loadWebSearchConfig()): WebSearchStatus {
|
||||
const selected = getConfiguredWebSearchProvider(config);
|
||||
return {
|
||||
selected,
|
||||
configPath: FEYNMAN_CONFIG_PATH,
|
||||
perplexityConfigured: hasPerplexityApiKey(config),
|
||||
geminiApiConfigured: hasGeminiApiKey(config),
|
||||
chromeProfile: typeof config.chromeProfile === "string" && config.chromeProfile.trim()
|
||||
? config.chromeProfile.trim()
|
||||
: undefined,
|
||||
browserHint: selected.id === "gemini-browser" ? "selected" : "fallback only",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWebSearchDoctorLines(config: WebSearchConfig = loadWebSearchConfig()): string[] {
|
||||
const status = getWebSearchStatus(config);
|
||||
const configured = [];
|
||||
if (status.perplexityConfigured) configured.push("Perplexity API");
|
||||
if (status.geminiApiConfigured) configured.push("Gemini API");
|
||||
if (status.selected.id === "gemini-browser" || status.chromeProfile) configured.push("Gemini Browser");
|
||||
|
||||
return [
|
||||
`web research provider: ${status.selected.label}`,
|
||||
` runtime route: ${status.selected.runtimeProvider}`,
|
||||
` configured credentials: ${configured.length > 0 ? configured.join(", ") : "none"}`,
|
||||
` browser mode: ${status.browserHint}${status.chromeProfile ? ` (profile: ${status.chromeProfile})` : ""}`,
|
||||
` config path: ${status.configPath}`,
|
||||
];
|
||||
}
|
||||
43
src/config/paths.ts
Normal file
43
src/config/paths.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function getFeynmanHome(): string {
|
||||
return resolve(process.env.FEYNMAN_HOME ?? homedir(), ".feynman");
|
||||
}
|
||||
|
||||
export function getFeynmanAgentDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "agent");
|
||||
}
|
||||
|
||||
export function getFeynmanMemoryDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "memory");
|
||||
}
|
||||
|
||||
export function getFeynmanStateDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, ".state");
|
||||
}
|
||||
|
||||
export function getDefaultSessionDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "sessions");
|
||||
}
|
||||
|
||||
export function getFeynmanConfigPath(home = getFeynmanHome()): string {
|
||||
return resolve(home, "config.json");
|
||||
}
|
||||
|
||||
export function getBootstrapStatePath(home = getFeynmanHome()): string {
|
||||
return resolve(getFeynmanStateDir(home), "bootstrap.json");
|
||||
}
|
||||
|
||||
export function ensureFeynmanHome(home = getFeynmanHome()): void {
|
||||
for (const dir of [
|
||||
home,
|
||||
getFeynmanAgentDir(home),
|
||||
getFeynmanMemoryDir(home),
|
||||
getFeynmanStateDir(home),
|
||||
getDefaultSessionDir(home),
|
||||
]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ Operating rules:
|
||||
- Never answer a latest/current question from arXiv or alpha-backed paper search alone.
|
||||
- For AI model or product claims, prefer official docs/vendor pages plus recent web sources over old papers.
|
||||
- Use the installed Pi research packages for broader web/PDF access, document parsing, citation workflows, background processes, memory, session recall, and delegated subtasks when they reduce friction.
|
||||
- Feynman ships project subagents for research work. Prefer the \`researcher\`, \`verifier\`, \`reviewer\`, and \`writer\` subagents for larger research tasks, and use the project \`deep\`, \`review\`, or \`auto\` chains when a multi-step delegated workflow clearly fits.
|
||||
- Feynman ships project subagents for research work. Prefer the \`researcher\`, \`verifier\`, \`reviewer\`, and \`writer\` subagents for larger research tasks when decomposition clearly helps.
|
||||
- Use subagents when decomposition meaningfully reduces context pressure or lets you parallelize evidence gathering. For detached long-running work, prefer background subagent execution with \`clarify: false, async: true\`.
|
||||
- For deep research, act like a lead researcher by default: plan first, use hidden worker batches only when breadth justifies them, synthesize batch results, and finish with a verification/citation pass.
|
||||
- Do not force chain-shaped orchestration onto the user. Multi-agent decomposition is an internal tactic, not the primary UX.
|
||||
- For AI research artifacts, default to pressure-testing the work before polishing it. Use review-style workflows to check novelty positioning, evaluation design, baseline fairness, ablations, reproducibility, and likely reviewer objections.
|
||||
- Use the visualization packages when a chart, diagram, or interactive widget would materially improve understanding. Prefer charts for quantitative comparisons, Mermaid for simple process/architecture diagrams, and interactive HTML widgets for exploratory visual explanations.
|
||||
- Persistent memory is package-backed. Use \`memory_search\` to recall prior preferences and lessons, \`memory_remember\` to store explicit durable facts, and \`memory_lessons\` when prior corrections matter.
|
||||
@@ -32,8 +34,11 @@ Operating rules:
|
||||
- Treat polished scientific communication as part of the job: structure reports cleanly, use Markdown deliberately, and use LaTeX math when equations clarify the argument.
|
||||
- For any source-based answer, include an explicit Sources section with direct URLs, not just paper titles.
|
||||
- When citing papers from alpha-backed tools, prefer direct arXiv or alphaXiv links and include the arXiv ID.
|
||||
- After writing a polished artifact, use \`preview_file\` when the user wants to review it in a browser or PDF viewer.
|
||||
- After writing a polished artifact, use \`preview_file\` only when the user wants review or export. Prefer browser preview by default; use PDF only when explicitly requested.
|
||||
- Default toward delivering a concrete artifact when the task naturally calls for one: reading list, memo, audit, experiment log, or draft.
|
||||
- For user-facing workflows, produce exactly one canonical durable Markdown artifact unless the user explicitly asks for multiple deliverables.
|
||||
- Do not create extra user-facing intermediate markdown files just because the workflow has multiple reasoning stages.
|
||||
- Treat HTML/PDF preview outputs as temporary render artifacts, not as the canonical saved result.
|
||||
- Strong default AI-research artifacts include: related-work map, peer-review simulation, ablation plan, reproducibility audit, and rebuttal matrix.
|
||||
- Default artifact locations:
|
||||
- outputs/ for reviews, reading lists, and summaries
|
||||
|
||||
727
src/index.ts
727
src/index.ts
@@ -1,729 +1,4 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
getUserName as getAlphaUserName,
|
||||
isLoggedIn as isAlphaLoggedIn,
|
||||
login as loginAlpha,
|
||||
logout as logoutAlpha,
|
||||
} from "@companion-ai/alpha-hub/lib";
|
||||
import {
|
||||
ModelRegistry,
|
||||
AuthStorage,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { buildFeynmanSystemPrompt } from "./feynman-prompt.js";
|
||||
|
||||
type ThinkingLevel = "off" | "low" | "medium" | "high";
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Feynman commands:
|
||||
/help Show this help
|
||||
/init Initialize AGENTS.md and session-log folders
|
||||
/alpha-login Sign in to alphaXiv
|
||||
/alpha-logout Clear alphaXiv auth
|
||||
/alpha-status Show alphaXiv auth status
|
||||
/new Start a fresh persisted session
|
||||
/exit Quit the REPL
|
||||
/lit <topic> Expand the literature review prompt template
|
||||
/related <topic> Map related work and justify the research gap
|
||||
/review <artifact> Simulate a peer review for an AI research artifact
|
||||
/ablate <artifact> Design the minimum convincing ablation set
|
||||
/rebuttal <artifact> Draft a rebuttal and revision matrix
|
||||
/replicate <paper> Expand the replication prompt template
|
||||
/reading <topic> Expand the reading list prompt template
|
||||
/memo <topic> Expand the general research memo prompt template
|
||||
/deepresearch <topic> Expand the thorough source-heavy research prompt template
|
||||
/autoresearch <idea> Expand the idea-to-paper autoresearch prompt template
|
||||
/compare <topic> Expand the source comparison prompt template
|
||||
/audit <item> Expand the paper/code audit prompt template
|
||||
/draft <topic> Expand the paper-style writing prompt template
|
||||
/log Write a durable session log
|
||||
/watch <topic> Create a recurring or deferred research watch
|
||||
/jobs Inspect active background work
|
||||
|
||||
Package-powered workflows:
|
||||
/agents Open the subagent and chain manager
|
||||
/run /chain /parallel Delegate research work to subagents
|
||||
/ps Open the background process panel
|
||||
/schedule-prompt Manage deferred and recurring jobs
|
||||
/search Search prior indexed sessions
|
||||
/preview Preview generated markdown or code artifacts
|
||||
|
||||
CLI flags:
|
||||
--prompt "<text>" Run one prompt and exit
|
||||
--alpha-login Sign in to alphaXiv and exit
|
||||
--alpha-logout Clear alphaXiv auth and exit
|
||||
--alpha-status Show alphaXiv auth status and exit
|
||||
--model provider:model Force a specific model
|
||||
--thinking level off | low | medium | high
|
||||
--cwd /path/to/workdir Working directory for tools
|
||||
--session-dir /path Session storage directory
|
||||
--doctor Check Feynman auth, models, preview tools, and paths
|
||||
--setup-preview Install preview dependencies when possible
|
||||
|
||||
Top-level:
|
||||
feynman setup Configure alpha login, web search, and preview deps
|
||||
feynman setup alpha Configure alphaXiv login
|
||||
feynman setup web Configure web search provider
|
||||
feynman setup preview Install preview dependencies`);
|
||||
}
|
||||
|
||||
function parseModelSpec(spec: string, modelRegistry: ModelRegistry) {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.includes(":") ? ":" : trimmed.includes("/") ? "/" : null;
|
||||
if (!separator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [provider, ...rest] = trimmed.split(separator);
|
||||
const id = rest.join(separator);
|
||||
if (!provider || !id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modelRegistry.find(provider, id);
|
||||
}
|
||||
|
||||
function normalizeThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === "off" || normalized === "low" || normalized === "medium" || normalized === "high") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveExecutable(name: string, fallbackPaths: string[] = []): string | undefined {
|
||||
for (const candidate of fallbackPaths) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${name}`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
if (result.status === 0) {
|
||||
const resolved = result.stdout.trim();
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function patchEmbeddedPiBranding(piPackageRoot: string): void {
|
||||
const packageJsonPath = resolve(piPackageRoot, "package.json");
|
||||
const cliPath = resolve(piPackageRoot, "dist", "cli.js");
|
||||
const interactiveModePath = resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.js");
|
||||
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
piConfig?: { name?: string; configDir?: string };
|
||||
};
|
||||
if (pkg.piConfig?.name !== "feynman") {
|
||||
pkg.piConfig = {
|
||||
...pkg.piConfig,
|
||||
name: "feynman",
|
||||
};
|
||||
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, "\t") + "\n", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(cliPath)) {
|
||||
const cliSource = readFileSync(cliPath, "utf8");
|
||||
if (cliSource.includes('process.title = "pi";')) {
|
||||
writeFileSync(cliPath, cliSource.replace('process.title = "pi";', 'process.title = "feynman";'), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(interactiveModePath)) {
|
||||
const interactiveModeSource = readFileSync(interactiveModePath, "utf8");
|
||||
if (interactiveModeSource.includes("`π - ${sessionName} - ${cwdBasename}`")) {
|
||||
writeFileSync(
|
||||
interactiveModePath,
|
||||
interactiveModeSource
|
||||
.replace("`π - ${sessionName} - ${cwdBasename}`", "`feynman - ${sessionName} - ${cwdBasename}`")
|
||||
.replace("`π - ${cwdBasename}`", "`feynman - ${cwdBasename}`"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patchPackageWorkspace(appRoot: string): void {
|
||||
const workspaceRoot = resolve(appRoot, ".pi", "npm", "node_modules");
|
||||
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
|
||||
const sessionSearchIndexerPath = resolve(
|
||||
workspaceRoot,
|
||||
"@kaiserlich-dev",
|
||||
"pi-session-search",
|
||||
"extensions",
|
||||
"indexer.ts",
|
||||
);
|
||||
const piMemoryPath = resolve(workspaceRoot, "@samfp", "pi-memory", "src", "index.ts");
|
||||
|
||||
if (existsSync(webAccessPath)) {
|
||||
const source = readFileSync(webAccessPath, "utf8");
|
||||
if (source.includes('pi.registerCommand("search",')) {
|
||||
writeFileSync(
|
||||
webAccessPath,
|
||||
source.replace('pi.registerCommand("search",', 'pi.registerCommand("web-results",'),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(sessionSearchIndexerPath)) {
|
||||
const source = readFileSync(sessionSearchIndexerPath, "utf8");
|
||||
const original = 'const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");';
|
||||
const replacement =
|
||||
'const sessionsDir = process.env.FEYNMAN_SESSION_DIR ?? process.env.PI_SESSION_DIR ?? path.join(os.homedir(), ".pi", "agent", "sessions");';
|
||||
if (source.includes(original)) {
|
||||
writeFileSync(sessionSearchIndexerPath, source.replace(original, replacement), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(piMemoryPath)) {
|
||||
let source = readFileSync(piMemoryPath, "utf8");
|
||||
const memoryOriginal = 'const MEMORY_DIR = join(homedir(), ".pi", "memory");';
|
||||
const memoryReplacement =
|
||||
'const MEMORY_DIR = process.env.FEYNMAN_MEMORY_DIR ?? process.env.PI_MEMORY_DIR ?? join(homedir(), ".pi", "memory");';
|
||||
if (source.includes(memoryOriginal)) {
|
||||
source = source.replace(memoryOriginal, memoryReplacement);
|
||||
}
|
||||
|
||||
const execOriginal = 'const result = await pi.exec("pi", ["-p", prompt, "--print"], {';
|
||||
const execReplacement = [
|
||||
'const execBinary = process.env.FEYNMAN_NODE_EXECUTABLE || process.env.FEYNMAN_EXECUTABLE || "pi";',
|
||||
' const execArgs = process.env.FEYNMAN_BIN_PATH',
|
||||
' ? [process.env.FEYNMAN_BIN_PATH, "--prompt", prompt]',
|
||||
' : ["-p", prompt, "--print"];',
|
||||
' const result = await pi.exec(execBinary, execArgs, {',
|
||||
].join("\n");
|
||||
if (source.includes(execOriginal)) {
|
||||
source = source.replace(execOriginal, execReplacement);
|
||||
}
|
||||
|
||||
writeFileSync(piMemoryPath, source, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function choosePreferredModel(
|
||||
availableModels: Array<{ provider: string; id: string }>,
|
||||
): { provider: string; id: string } | undefined {
|
||||
const preferences = [
|
||||
{ provider: "anthropic", id: "claude-opus-4-6" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-5" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
{ provider: "openai", id: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
];
|
||||
|
||||
for (const preferred of preferences) {
|
||||
const match = availableModels.find(
|
||||
(model) => model.provider === preferred.provider && model.id === preferred.id,
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
function normalizeFeynmanSettings(
|
||||
settingsPath: string,
|
||||
bundledSettingsPath: string,
|
||||
defaultThinkingLevel: ThinkingLevel,
|
||||
authPath: string,
|
||||
): void {
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
else if (existsSync(bundledSettingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(bundledSettingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.defaultThinkingLevel) {
|
||||
settings.defaultThinkingLevel = defaultThinkingLevel;
|
||||
}
|
||||
if (settings.editorPaddingX === undefined) {
|
||||
settings.editorPaddingX = 1;
|
||||
}
|
||||
settings.theme = "feynman";
|
||||
settings.quietStartup = true;
|
||||
settings.collapseChangelog = true;
|
||||
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const availableModels = modelRegistry.getAvailable().map((model) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
}));
|
||||
|
||||
if ((!settings.defaultProvider || !settings.defaultModel) && availableModels.length > 0) {
|
||||
const preferredModel = choosePreferredModel(availableModels);
|
||||
if (preferredModel) {
|
||||
settings.defaultProvider = preferredModel.provider;
|
||||
settings.defaultModel = preferredModel.id;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getWebSearchConfigPath(): string {
|
||||
return resolve(homedir(), ".pi", "web-search.json");
|
||||
}
|
||||
|
||||
function loadWebSearchConfig(): Record<string, unknown> {
|
||||
return readJson(getWebSearchConfigPath());
|
||||
}
|
||||
|
||||
function saveWebSearchConfig(config: Record<string, unknown>): void {
|
||||
const path = getWebSearchConfigPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function hasConfiguredWebProvider(): boolean {
|
||||
const config = loadWebSearchConfig();
|
||||
return typeof config.perplexityApiKey === "string" && config.perplexityApiKey.trim().length > 0
|
||||
|| typeof config.geminiApiKey === "string" && config.geminiApiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
async function promptText(question: string, defaultValue = ""): Promise<string> {
|
||||
if (!input.isTTY || !output.isTTY) {
|
||||
throw new Error("feynman setup requires an interactive terminal.");
|
||||
}
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
||||
const value = (await rl.question(`${question}${suffix}: `)).trim();
|
||||
return value || defaultValue;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
|
||||
console.log(question);
|
||||
for (const [index, choice] of choices.entries()) {
|
||||
const marker = index === defaultIndex ? "*" : " ";
|
||||
console.log(` ${marker} ${index + 1}. ${choice}`);
|
||||
}
|
||||
const answer = await promptText("Select", String(defaultIndex + 1));
|
||||
const parsed = Number(answer);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) {
|
||||
return defaultIndex;
|
||||
}
|
||||
return parsed - 1;
|
||||
}
|
||||
|
||||
async function setupWebProvider(): Promise<void> {
|
||||
const config = loadWebSearchConfig();
|
||||
const choices = [
|
||||
"Gemini API key",
|
||||
"Perplexity API key",
|
||||
"Browser Gemini (manual sign-in only)",
|
||||
"Skip",
|
||||
];
|
||||
const selection = await promptChoice("Choose a web search provider for Feynman:", choices, hasConfiguredWebProvider() ? 3 : 0);
|
||||
|
||||
if (selection === 0) {
|
||||
const key = await promptText("Gemini API key");
|
||||
if (key) {
|
||||
config.geminiApiKey = key;
|
||||
delete config.perplexityApiKey;
|
||||
saveWebSearchConfig(config);
|
||||
console.log("Saved Gemini API key to ~/.pi/web-search.json");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection === 1) {
|
||||
const key = await promptText("Perplexity API key");
|
||||
if (key) {
|
||||
config.perplexityApiKey = key;
|
||||
delete config.geminiApiKey;
|
||||
saveWebSearchConfig(config);
|
||||
console.log("Saved Perplexity API key to ~/.pi/web-search.json");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection === 2) {
|
||||
console.log("Sign into gemini.google.com in Chrome, Chromium, Brave, or Edge, then restart Feynman.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSetup(
|
||||
section: string | undefined,
|
||||
settingsPath: string,
|
||||
bundledSettingsPath: string,
|
||||
authPath: string,
|
||||
workingDir: string,
|
||||
sessionDir: string,
|
||||
): Promise<void> {
|
||||
if (section === "alpha" || !section) {
|
||||
if (!isAlphaLoggedIn()) {
|
||||
await loginAlpha();
|
||||
console.log("alphaXiv login complete");
|
||||
} else {
|
||||
console.log("alphaXiv login already configured");
|
||||
}
|
||||
if (section === "alpha") return;
|
||||
}
|
||||
|
||||
if (section === "web" || !section) {
|
||||
await setupWebProvider();
|
||||
if (section === "web") return;
|
||||
}
|
||||
|
||||
if (section === "preview" || !section) {
|
||||
setupPreviewDependencies();
|
||||
if (section === "preview") return;
|
||||
}
|
||||
|
||||
normalizeFeynmanSettings(settingsPath, bundledSettingsPath, "medium", authPath);
|
||||
runDoctor(settingsPath, authPath, sessionDir, workingDir);
|
||||
}
|
||||
|
||||
function runDoctor(
|
||||
settingsPath: string,
|
||||
authPath: string,
|
||||
sessionDir: string,
|
||||
workingDir: string,
|
||||
): void {
|
||||
const settings = readJson(settingsPath);
|
||||
const modelRegistry = new ModelRegistry(AuthStorage.create(authPath));
|
||||
const availableModels = modelRegistry.getAvailable();
|
||||
const pandocPath = resolveExecutable("pandoc", [
|
||||
"/opt/homebrew/bin/pandoc",
|
||||
"/usr/local/bin/pandoc",
|
||||
]);
|
||||
const browserPath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ??
|
||||
resolveExecutable("google-chrome", [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]);
|
||||
|
||||
console.log("Feynman doctor");
|
||||
console.log("");
|
||||
console.log(`working dir: ${workingDir}`);
|
||||
console.log(`session dir: ${sessionDir}`);
|
||||
console.log("");
|
||||
console.log(`alphaXiv auth: ${isAlphaLoggedIn() ? "ok" : "missing"}`);
|
||||
if (isAlphaLoggedIn()) {
|
||||
const name = getAlphaUserName();
|
||||
if (name) {
|
||||
console.log(` user: ${name}`);
|
||||
}
|
||||
}
|
||||
console.log(`models available: ${availableModels.length}`);
|
||||
if (availableModels.length > 0) {
|
||||
const sample = availableModels
|
||||
.slice(0, 6)
|
||||
.map((model) => `${model.provider}/${model.id}`)
|
||||
.join(", ");
|
||||
console.log(` sample: ${sample}`);
|
||||
}
|
||||
console.log(
|
||||
`default model: ${typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string"
|
||||
? `${settings.defaultProvider}/${settings.defaultModel}`
|
||||
: "not set"}`,
|
||||
);
|
||||
console.log(`pandoc: ${pandocPath ?? "missing"}`);
|
||||
console.log(`browser preview runtime: ${browserPath ?? "missing"}`);
|
||||
console.log(`web research provider: ${hasConfiguredWebProvider() ? "configured" : "missing"}`);
|
||||
console.log(`quiet startup: ${settings.quietStartup === true ? "enabled" : "disabled"}`);
|
||||
console.log(`theme: ${typeof settings.theme === "string" ? settings.theme : "not set"}`);
|
||||
console.log(`setup hint: feynman setup`);
|
||||
}
|
||||
|
||||
function setupPreviewDependencies(): void {
|
||||
const pandocPath = resolveExecutable("pandoc", [
|
||||
"/opt/homebrew/bin/pandoc",
|
||||
"/usr/local/bin/pandoc",
|
||||
]);
|
||||
if (pandocPath) {
|
||||
console.log(`pandoc already installed at ${pandocPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const brewPath = resolveExecutable("brew", [
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
]);
|
||||
if (process.platform === "darwin" && brewPath) {
|
||||
const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to install pandoc via Homebrew.");
|
||||
}
|
||||
console.log("Preview dependency installed: pandoc");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Automatic preview setup is only supported on macOS with Homebrew right now.");
|
||||
}
|
||||
|
||||
function syncDirectory(sourceDir: string, targetDir: string): void {
|
||||
if (!existsSync(sourceDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = resolve(sourceDir, entry.name);
|
||||
const targetPath = resolve(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
syncDirectory(sourcePath, targetPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
writeFileSync(targetPath, readFileSync(sourcePath, "utf8"), "utf8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncFeynmanTheme(appRoot: string, agentDir: string): void {
|
||||
const sourceThemePath = resolve(appRoot, ".pi", "themes", "feynman.json");
|
||||
const targetThemeDir = resolve(agentDir, "themes");
|
||||
const targetThemePath = resolve(targetThemeDir, "feynman.json");
|
||||
|
||||
if (!existsSync(sourceThemePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(targetThemeDir, { recursive: true });
|
||||
writeFileSync(targetThemePath, readFileSync(sourceThemePath, "utf8"), "utf8");
|
||||
}
|
||||
|
||||
function syncFeynmanAgents(appRoot: string, agentDir: string): void {
|
||||
syncDirectory(resolve(appRoot, ".pi", "agents"), resolve(agentDir, "agents"));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = resolve(here, "..");
|
||||
const piPackageRoot = resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent");
|
||||
const piCliPath = resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
|
||||
const feynmanAgentDir = resolve(homedir(), ".feynman", "agent");
|
||||
const bundledSettingsPath = resolve(appRoot, ".pi", "settings.json");
|
||||
patchEmbeddedPiBranding(piPackageRoot);
|
||||
patchPackageWorkspace(appRoot);
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
cwd: { type: "string" },
|
||||
doctor: { type: "boolean" },
|
||||
help: { type: "boolean" },
|
||||
"alpha-login": { type: "boolean" },
|
||||
"alpha-logout": { type: "boolean" },
|
||||
"alpha-status": { type: "boolean" },
|
||||
model: { type: "string" },
|
||||
"new-session": { type: "boolean" },
|
||||
prompt: { type: "string" },
|
||||
"session-dir": { type: "string" },
|
||||
"setup-preview": { type: "boolean" },
|
||||
thinking: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const workingDir = resolve(values.cwd ?? process.cwd());
|
||||
const sessionDir = resolve(values["session-dir"] ?? resolve(homedir(), ".feynman", "sessions"));
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
mkdirSync(feynmanAgentDir, { recursive: true });
|
||||
syncFeynmanTheme(appRoot, feynmanAgentDir);
|
||||
syncFeynmanAgents(appRoot, feynmanAgentDir);
|
||||
const feynmanSettingsPath = resolve(feynmanAgentDir, "settings.json");
|
||||
const feynmanAuthPath = resolve(feynmanAgentDir, "auth.json");
|
||||
const thinkingLevel = normalizeThinkingLevel(values.thinking ?? process.env.FEYNMAN_THINKING) ?? "medium";
|
||||
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
|
||||
|
||||
if (positionals[0] === "setup") {
|
||||
await runSetup(positionals[1], feynmanSettingsPath, bundledSettingsPath, feynmanAuthPath, workingDir, sessionDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.doctor) {
|
||||
runDoctor(feynmanSettingsPath, feynmanAuthPath, sessionDir, workingDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["setup-preview"]) {
|
||||
setupPreviewDependencies();
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-login"]) {
|
||||
const result = await loginAlpha();
|
||||
normalizeFeynmanSettings(feynmanSettingsPath, bundledSettingsPath, thinkingLevel, feynmanAuthPath);
|
||||
const name =
|
||||
(result.userInfo &&
|
||||
typeof result.userInfo === "object" &&
|
||||
"name" in result.userInfo &&
|
||||
typeof result.userInfo.name === "string")
|
||||
? result.userInfo.name
|
||||
: getAlphaUserName();
|
||||
console.log(name ? `alphaXiv login complete: ${name}` : "alphaXiv login complete");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-logout"]) {
|
||||
logoutAlpha();
|
||||
console.log("alphaXiv auth cleared");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values["alpha-status"]) {
|
||||
if (isAlphaLoggedIn()) {
|
||||
const name = getAlphaUserName();
|
||||
console.log(name ? `alphaXiv logged in as ${name}` : "alphaXiv logged in");
|
||||
} else {
|
||||
console.log("alphaXiv not logged in");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
|
||||
if (explicitModelSpec) {
|
||||
const modelRegistry = new ModelRegistry(AuthStorage.create(feynmanAuthPath));
|
||||
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
|
||||
if (!explicitModel) {
|
||||
throw new Error(`Unknown model: ${explicitModelSpec}`);
|
||||
}
|
||||
}
|
||||
const oneShotPrompt = values.prompt;
|
||||
const initialPrompt = oneShotPrompt ?? (positionals.length > 0 ? positionals.join(" ") : undefined);
|
||||
const systemPrompt = buildFeynmanSystemPrompt();
|
||||
|
||||
const piArgs = [
|
||||
"--session-dir",
|
||||
sessionDir,
|
||||
"--extension",
|
||||
resolve(appRoot, "extensions", "research-tools.ts"),
|
||||
"--skill",
|
||||
resolve(appRoot, "skills"),
|
||||
"--prompt-template",
|
||||
resolve(appRoot, "prompts"),
|
||||
"--system-prompt",
|
||||
systemPrompt,
|
||||
];
|
||||
|
||||
if (explicitModelSpec) {
|
||||
piArgs.push("--model", explicitModelSpec);
|
||||
}
|
||||
if (thinkingLevel) {
|
||||
piArgs.push("--thinking", thinkingLevel);
|
||||
}
|
||||
if (oneShotPrompt) {
|
||||
piArgs.push("-p", oneShotPrompt);
|
||||
}
|
||||
else if (initialPrompt) {
|
||||
piArgs.push(initialPrompt);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [piCliPath, ...piArgs], {
|
||||
cwd: workingDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: feynmanAgentDir,
|
||||
FEYNMAN_CODING_AGENT_DIR: feynmanAgentDir,
|
||||
FEYNMAN_PI_NPM_ROOT: resolve(appRoot, ".pi", "npm", "node_modules"),
|
||||
FEYNMAN_SESSION_DIR: sessionDir,
|
||||
PI_SESSION_DIR: sessionDir,
|
||||
FEYNMAN_MEMORY_DIR: resolve(dirname(feynmanAgentDir), "memory"),
|
||||
FEYNMAN_NODE_EXECUTABLE: process.execPath,
|
||||
FEYNMAN_BIN_PATH: resolve(appRoot, "bin", "feynman.js"),
|
||||
PANDOC_PATH:
|
||||
process.env.PANDOC_PATH ??
|
||||
resolveExecutable("pandoc", [
|
||||
"/opt/homebrew/bin/pandoc",
|
||||
"/usr/local/bin/pandoc",
|
||||
]),
|
||||
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
|
||||
MERMAID_CLI_PATH:
|
||||
process.env.MERMAID_CLI_PATH ??
|
||||
resolveExecutable("mmdc", [
|
||||
"/opt/homebrew/bin/mmdc",
|
||||
"/usr/local/bin/mmdc",
|
||||
]),
|
||||
PUPPETEER_EXECUTABLE_PATH:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ??
|
||||
resolveExecutable("google-chrome", [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 0;
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
import { main } from "./cli.js";
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
|
||||
282
src/model/catalog.ts
Normal file
282
src/model/catalog.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
type ModelRecord = {
|
||||
provider: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ProviderStatus = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportedModels: number;
|
||||
availableModels: number;
|
||||
configured: boolean;
|
||||
current: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
|
||||
export type ModelStatusSnapshot = {
|
||||
current?: string;
|
||||
currentValid: boolean;
|
||||
recommended?: string;
|
||||
recommendationReason?: string;
|
||||
availableModels: string[];
|
||||
providers: ProviderStatus[];
|
||||
guidance: string[];
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
openrouter: "OpenRouter",
|
||||
google: "Google",
|
||||
"google-gemini-cli": "Google Gemini CLI",
|
||||
zai: "Z.AI / GLM",
|
||||
minimax: "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"vercel-ai-gateway": "Vercel AI Gateway",
|
||||
opencode: "OpenCode",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
xai: "xAI",
|
||||
groq: "Groq",
|
||||
mistral: "Mistral",
|
||||
cerebras: "Cerebras",
|
||||
huggingface: "Hugging Face",
|
||||
"amazon-bedrock": "Amazon Bedrock",
|
||||
"azure-openai-responses": "Azure OpenAI Responses",
|
||||
};
|
||||
|
||||
const RESEARCH_MODEL_PREFERENCES = [
|
||||
{
|
||||
spec: "anthropic/claude-opus-4-6",
|
||||
reason: "strong long-context reasoning for source-heavy research work",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-opus-4-5",
|
||||
reason: "strong long-context reasoning for source-heavy research work",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-sonnet-4-6",
|
||||
reason: "balanced reasoning and speed for iterative research sessions",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-sonnet-4-5",
|
||||
reason: "balanced reasoning and speed for iterative research sessions",
|
||||
},
|
||||
{
|
||||
spec: "openai/gpt-5.4",
|
||||
reason: "strong general reasoning and drafting quality for research tasks",
|
||||
},
|
||||
{
|
||||
spec: "openai/gpt-5",
|
||||
reason: "strong general reasoning and drafting quality for research tasks",
|
||||
},
|
||||
{
|
||||
spec: "openai-codex/gpt-5.4",
|
||||
reason: "strong research + coding balance when Pi exposes Codex directly",
|
||||
},
|
||||
{
|
||||
spec: "google/gemini-3-pro-preview",
|
||||
reason: "good fallback for broad web-and-doc research work",
|
||||
},
|
||||
{
|
||||
spec: "google/gemini-2.5-pro",
|
||||
reason: "good fallback for broad web-and-doc research work",
|
||||
},
|
||||
{
|
||||
spec: "openrouter/openai/gpt-5.1-codex",
|
||||
reason: "good routed fallback when only OpenRouter is configured",
|
||||
},
|
||||
{
|
||||
spec: "zai/glm-5",
|
||||
reason: "good fallback when GLM is the available research model",
|
||||
},
|
||||
{
|
||||
spec: "kimi-coding/kimi-k2-thinking",
|
||||
reason: "good fallback when Kimi is the available research model",
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_SORT_ORDER = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"google",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"github-copilot",
|
||||
"vercel-ai-gateway",
|
||||
];
|
||||
|
||||
function formatProviderLabel(provider: string): string {
|
||||
return PROVIDER_LABELS[provider] ?? provider;
|
||||
}
|
||||
|
||||
function modelSpec(model: ModelRecord): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
function compareByResearchPreference(left: ModelRecord, right: ModelRecord): number {
|
||||
const leftSpec = modelSpec(left);
|
||||
const rightSpec = modelSpec(right);
|
||||
const leftIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === leftSpec);
|
||||
const rightIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === rightSpec);
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
if (leftIndex === -1) return 1;
|
||||
if (rightIndex === -1) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
|
||||
const leftProviderIndex = PROVIDER_SORT_ORDER.indexOf(left.provider);
|
||||
const rightProviderIndex = PROVIDER_SORT_ORDER.indexOf(right.provider);
|
||||
if (leftProviderIndex !== -1 || rightProviderIndex !== -1) {
|
||||
if (leftProviderIndex === -1) return 1;
|
||||
if (rightProviderIndex === -1) return -1;
|
||||
return leftProviderIndex - rightProviderIndex;
|
||||
}
|
||||
|
||||
return modelSpec(left).localeCompare(modelSpec(right));
|
||||
}
|
||||
|
||||
function sortProviders(left: ProviderStatus, right: ProviderStatus): number {
|
||||
if (left.configured !== right.configured) {
|
||||
return left.configured ? -1 : 1;
|
||||
}
|
||||
if (left.current !== right.current) {
|
||||
return left.current ? -1 : 1;
|
||||
}
|
||||
if (left.recommended !== right.recommended) {
|
||||
return left.recommended ? -1 : 1;
|
||||
}
|
||||
const leftIndex = PROVIDER_SORT_ORDER.indexOf(left.id);
|
||||
const rightIndex = PROVIDER_SORT_ORDER.indexOf(right.id);
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
if (leftIndex === -1) return 1;
|
||||
if (rightIndex === -1) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function createModelRegistry(authPath: string): ModelRegistry {
|
||||
return new ModelRegistry(AuthStorage.create(authPath));
|
||||
}
|
||||
|
||||
export function getAvailableModelRecords(authPath: string): ModelRecord[] {
|
||||
return createModelRegistry(authPath)
|
||||
.getAvailable()
|
||||
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
|
||||
}
|
||||
|
||||
export function getSupportedModelRecords(authPath: string): ModelRecord[] {
|
||||
return createModelRegistry(authPath)
|
||||
.getAll()
|
||||
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
|
||||
}
|
||||
|
||||
export function chooseRecommendedModel(authPath: string): { spec: string; reason: string } | undefined {
|
||||
const available = getAvailableModelRecords(authPath).sort(compareByResearchPreference);
|
||||
if (available.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedPreference = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(available[0]!));
|
||||
if (matchedPreference) {
|
||||
return matchedPreference;
|
||||
}
|
||||
|
||||
return {
|
||||
spec: modelSpec(available[0]!),
|
||||
reason: "best currently authenticated fallback for research work",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelStatusSnapshotFromRecords(
|
||||
supported: ModelRecord[],
|
||||
available: ModelRecord[],
|
||||
current: string | undefined,
|
||||
): ModelStatusSnapshot {
|
||||
const availableSpecs = available
|
||||
.slice()
|
||||
.sort(compareByResearchPreference)
|
||||
.map((model) => modelSpec(model));
|
||||
const recommended = available.length > 0
|
||||
? (() => {
|
||||
const preferred = available.slice().sort(compareByResearchPreference)[0]!;
|
||||
const matched = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(preferred));
|
||||
return {
|
||||
spec: modelSpec(preferred),
|
||||
reason: matched?.reason ?? "best currently authenticated fallback for research work",
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const currentValid = current ? availableSpecs.includes(current) : false;
|
||||
const providerMap = new Map<string, ProviderStatus>();
|
||||
|
||||
for (const model of supported) {
|
||||
const provider = providerMap.get(model.provider) ?? {
|
||||
id: model.provider,
|
||||
label: formatProviderLabel(model.provider),
|
||||
supportedModels: 0,
|
||||
availableModels: 0,
|
||||
configured: false,
|
||||
current: false,
|
||||
recommended: false,
|
||||
};
|
||||
provider.supportedModels += 1;
|
||||
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
|
||||
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
|
||||
providerMap.set(model.provider, provider);
|
||||
}
|
||||
|
||||
for (const model of available) {
|
||||
const provider = providerMap.get(model.provider) ?? {
|
||||
id: model.provider,
|
||||
label: formatProviderLabel(model.provider),
|
||||
supportedModels: 0,
|
||||
availableModels: 0,
|
||||
configured: false,
|
||||
current: false,
|
||||
recommended: false,
|
||||
};
|
||||
provider.availableModels += 1;
|
||||
provider.configured = true;
|
||||
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
|
||||
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
|
||||
providerMap.set(model.provider, provider);
|
||||
}
|
||||
|
||||
const guidance: string[] = [];
|
||||
if (available.length === 0) {
|
||||
guidance.push("No authenticated Pi models are available yet.");
|
||||
guidance.push("Run `feynman model login <provider>` or add provider credentials that Pi can see.");
|
||||
guidance.push("After auth is in place, rerun `feynman model list` or `feynman setup model`.");
|
||||
} else if (!current) {
|
||||
guidance.push(`No default research model is set. Recommended: ${recommended?.spec}.`);
|
||||
guidance.push("Run `feynman model set <provider/model>` or `feynman setup model`.");
|
||||
} else if (!currentValid) {
|
||||
guidance.push(`Configured default model is unavailable: ${current}.`);
|
||||
if (recommended) {
|
||||
guidance.push(`Switch to the current research recommendation: ${recommended.spec}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
currentValid,
|
||||
recommended: recommended?.spec,
|
||||
recommendationReason: recommended?.reason,
|
||||
availableModels: availableSpecs,
|
||||
providers: Array.from(providerMap.values()).sort(sortProviders),
|
||||
guidance,
|
||||
};
|
||||
}
|
||||
273
src/model/commands.ts
Normal file
273
src/model/commands.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { readJson } from "../pi/settings.js";
|
||||
import { promptChoice, promptText } from "../setup/prompts.js";
|
||||
import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js";
|
||||
import {
|
||||
buildModelStatusSnapshotFromRecords,
|
||||
chooseRecommendedModel,
|
||||
getAvailableModelRecords,
|
||||
getSupportedModelRecords,
|
||||
type ModelStatusSnapshot,
|
||||
} from "./catalog.js";
|
||||
|
||||
function formatProviderSummaryLine(status: ModelStatusSnapshot["providers"][number]): string {
|
||||
const state = status.configured ? `${status.availableModels} authenticated` : "not authenticated";
|
||||
const flags = [
|
||||
status.current ? "current" : undefined,
|
||||
status.recommended ? "recommended" : undefined,
|
||||
].filter(Boolean);
|
||||
return `${status.label}: ${state}, ${status.supportedModels} supported${flags.length > 0 ? ` (${flags.join(", ")})` : ""}`;
|
||||
}
|
||||
|
||||
function collectModelStatus(settingsPath: string, authPath: string): ModelStatusSnapshot {
|
||||
return buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(authPath),
|
||||
getAvailableModelRecords(authPath),
|
||||
getCurrentModelSpec(settingsPath),
|
||||
);
|
||||
}
|
||||
|
||||
type OAuthProviderInfo = {
|
||||
id: string;
|
||||
name?: string;
|
||||
usesCallbackServer?: boolean;
|
||||
};
|
||||
|
||||
function getOAuthProviders(authPath: string): OAuthProviderInfo[] {
|
||||
return AuthStorage.create(authPath).getOAuthProviders() as OAuthProviderInfo[];
|
||||
}
|
||||
|
||||
function resolveOAuthProvider(authPath: string, input: string): OAuthProviderInfo | undefined {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return undefined;
|
||||
}
|
||||
return getOAuthProviders(authPath).find((provider) => provider.id.toLowerCase() === normalizedInput);
|
||||
}
|
||||
|
||||
async function selectOAuthProvider(authPath: string, action: "login" | "logout"): Promise<OAuthProviderInfo | undefined> {
|
||||
const providers = getOAuthProviders(authPath);
|
||||
if (providers.length === 0) {
|
||||
printWarning("No Pi OAuth model providers are available.");
|
||||
return undefined;
|
||||
}
|
||||
if (providers.length === 1) {
|
||||
return providers[0];
|
||||
}
|
||||
|
||||
const choices = providers.map((provider) => `${provider.id} — ${provider.name ?? provider.id}`);
|
||||
choices.push("Cancel");
|
||||
const selection = await promptChoice(`Choose an OAuth provider to ${action}:`, choices, 0);
|
||||
if (selection >= providers.length) {
|
||||
return undefined;
|
||||
}
|
||||
return providers[selection];
|
||||
}
|
||||
|
||||
function resolveAvailableModelSpec(authPath: string, input: string): string | undefined {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const available = getAvailableModelRecords(authPath);
|
||||
const fullSpecMatch = available.find((model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedInput);
|
||||
if (fullSpecMatch) {
|
||||
return `${fullSpecMatch.provider}/${fullSpecMatch.id}`;
|
||||
}
|
||||
|
||||
const exactIdMatches = available.filter((model) => model.id.toLowerCase() === normalizedInput);
|
||||
if (exactIdMatches.length === 1) {
|
||||
return `${exactIdMatches[0]!.provider}/${exactIdMatches[0]!.id}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCurrentModelSpec(settingsPath: string): string | undefined {
|
||||
const settings = readJson(settingsPath);
|
||||
if (typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string") {
|
||||
return `${settings.defaultProvider}/${settings.defaultModel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function printModelStatus(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
|
||||
printInfo(`Current default model: ${status.current ?? "not set"}`);
|
||||
printInfo(`Current default valid: ${status.currentValid ? "yes" : "no"}`);
|
||||
printInfo(`Authenticated models: ${status.availableModels.length}`);
|
||||
printInfo(`Providers with auth: ${status.providers.filter((provider) => provider.configured).length}`);
|
||||
printInfo(`Research recommendation: ${status.recommended ?? "none available"}`);
|
||||
if (status.recommendationReason) {
|
||||
printInfo(`Recommendation reason: ${status.recommendationReason}`);
|
||||
}
|
||||
|
||||
if (status.providers.length > 0) {
|
||||
printSection("Providers");
|
||||
for (const provider of status.providers) {
|
||||
printInfo(formatProviderSummaryLine(provider));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.guidance.length > 0) {
|
||||
printSection("Next Steps");
|
||||
for (const line of status.guidance) {
|
||||
printWarning(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelProviders(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
for (const provider of status.providers) {
|
||||
printInfo(formatProviderSummaryLine(provider));
|
||||
}
|
||||
const oauthProviders = getOAuthProviders(authPath);
|
||||
if (oauthProviders.length > 0) {
|
||||
printSection("OAuth Login");
|
||||
for (const provider of oauthProviders) {
|
||||
printInfo(`${provider.id} — ${provider.name ?? provider.id}`);
|
||||
}
|
||||
}
|
||||
if (status.providers.length === 0) {
|
||||
printWarning("No Pi model providers are visible in the current runtime.");
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelList(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
if (status.availableModels.length === 0) {
|
||||
printWarning("No authenticated Pi models are currently available.");
|
||||
for (const line of status.guidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let lastProvider: string | undefined;
|
||||
for (const spec of status.availableModels) {
|
||||
const [provider] = spec.split("/", 1);
|
||||
if (provider !== lastProvider) {
|
||||
lastProvider = provider;
|
||||
printSection(provider);
|
||||
}
|
||||
const markers = [
|
||||
spec === status.current ? "current" : undefined,
|
||||
spec === status.recommended ? "recommended" : undefined,
|
||||
].filter(Boolean);
|
||||
printInfo(`${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelRecommendation(authPath: string): void {
|
||||
const recommendation = chooseRecommendedModel(authPath);
|
||||
if (!recommendation) {
|
||||
printWarning("No authenticated Pi models are available to recommend.");
|
||||
printInfo("Run `feynman model login <provider>` or add provider credentials that Pi can see, then rerun this command.");
|
||||
return;
|
||||
}
|
||||
|
||||
printSuccess(`Recommended model: ${recommendation.spec}`);
|
||||
printInfo(recommendation.reason);
|
||||
}
|
||||
|
||||
export async function loginModelProvider(authPath: string, providerId?: string): Promise<void> {
|
||||
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "login");
|
||||
if (!provider) {
|
||||
if (providerId) {
|
||||
throw new Error(`Unknown OAuth model provider: ${providerId}`);
|
||||
}
|
||||
printInfo("Login cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const abortController = new AbortController();
|
||||
|
||||
await authStorage.login(provider.id, {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
printSection(`Login: ${provider.name ?? provider.id}`);
|
||||
printInfo(`Open this URL: ${info.url}`);
|
||||
if (info.instructions) {
|
||||
printInfo(info.instructions);
|
||||
}
|
||||
},
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
return promptText(prompt.message, prompt.placeholder ?? "");
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
printInfo(message);
|
||||
},
|
||||
onManualCodeInput: async () => {
|
||||
return promptText("Paste redirect URL or auth code");
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
printSuccess(`Model provider login complete: ${provider.id}`);
|
||||
}
|
||||
|
||||
export async function logoutModelProvider(authPath: string, providerId?: string): Promise<void> {
|
||||
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "logout");
|
||||
if (!provider) {
|
||||
if (providerId) {
|
||||
throw new Error(`Unknown OAuth model provider: ${providerId}`);
|
||||
}
|
||||
printInfo("Logout cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
AuthStorage.create(authPath).logout(provider.id);
|
||||
printSuccess(`Model provider logout complete: ${provider.id}`);
|
||||
}
|
||||
|
||||
export function setDefaultModelSpec(settingsPath: string, authPath: string, spec: string): void {
|
||||
const resolvedSpec = resolveAvailableModelSpec(authPath, spec);
|
||||
if (!resolvedSpec) {
|
||||
throw new Error(`Model not available in Pi auth storage: ${spec}. Run \`feynman model list\` first.`);
|
||||
}
|
||||
|
||||
const [provider, ...rest] = resolvedSpec.split("/");
|
||||
const modelId = rest.join("/");
|
||||
const settings = readJson(settingsPath);
|
||||
settings.defaultProvider = provider;
|
||||
settings.defaultModel = modelId;
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Default model set to ${resolvedSpec}`);
|
||||
}
|
||||
|
||||
export async function runModelSetup(settingsPath: string, authPath: string): Promise<void> {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
|
||||
if (status.availableModels.length === 0) {
|
||||
printWarning("No Pi models are currently authenticated for Feynman.");
|
||||
for (const line of status.guidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
printInfo("Tip: run `feynman model login <provider>` if your provider supports Pi OAuth login.");
|
||||
return;
|
||||
}
|
||||
|
||||
const choices = status.availableModels.map((spec) => {
|
||||
const markers = [
|
||||
spec === status.recommended ? "recommended" : undefined,
|
||||
spec === status.current ? "current" : undefined,
|
||||
].filter(Boolean);
|
||||
return `${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`;
|
||||
});
|
||||
choices.push(`Keep current (${status.current ?? "unset"})`);
|
||||
|
||||
const defaultIndex = status.current ? Math.max(0, status.availableModels.indexOf(status.current)) : 0;
|
||||
const selection = await promptChoice("Select your default research model:", choices, defaultIndex >= 0 ? defaultIndex : 0);
|
||||
|
||||
if (selection >= status.availableModels.length) {
|
||||
printInfo("Skipped (keeping current model)");
|
||||
return;
|
||||
}
|
||||
|
||||
setDefaultModelSpec(settingsPath, authPath, status.availableModels[selection]!);
|
||||
}
|
||||
32
src/pi/launch.ts
Normal file
32
src/pi/launch.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths } from "./runtime.js";
|
||||
|
||||
export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
|
||||
const { piCliPath, promisePolyfillPath } = resolvePiPaths(options.appRoot);
|
||||
if (!existsSync(piCliPath)) {
|
||||
throw new Error(`Pi CLI not found: ${piCliPath}`);
|
||||
}
|
||||
if (!existsSync(promisePolyfillPath)) {
|
||||
throw new Error(`Promise polyfill not found: ${promisePolyfillPath}`);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ["--import", promisePolyfillPath, piCliPath, ...buildPiArgs(options)], {
|
||||
cwd: options.workingDir,
|
||||
stdio: "inherit",
|
||||
env: buildPiEnv(options),
|
||||
});
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 0;
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
99
src/pi/runtime.ts
Normal file
99
src/pi/runtime.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BROWSER_FALLBACK_PATHS,
|
||||
MERMAID_FALLBACK_PATHS,
|
||||
PANDOC_FALLBACK_PATHS,
|
||||
resolveExecutable,
|
||||
} from "../system/executables.js";
|
||||
|
||||
export type PiRuntimeOptions = {
|
||||
appRoot: string;
|
||||
workingDir: string;
|
||||
sessionDir: string;
|
||||
feynmanAgentDir: string;
|
||||
feynmanVersion?: string;
|
||||
thinkingLevel?: string;
|
||||
explicitModelSpec?: string;
|
||||
oneShotPrompt?: string;
|
||||
initialPrompt?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
|
||||
export function resolvePiPaths(appRoot: string) {
|
||||
return {
|
||||
piPackageRoot: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent"),
|
||||
piCliPath: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"),
|
||||
promisePolyfillPath: resolve(appRoot, "dist", "system", "promise-polyfill.js"),
|
||||
researchToolsPath: resolve(appRoot, "extensions", "research-tools.ts"),
|
||||
skillsPath: resolve(appRoot, "skills"),
|
||||
promptTemplatePath: resolve(appRoot, "prompts"),
|
||||
piWorkspaceNodeModulesPath: resolve(appRoot, ".pi", "npm", "node_modules"),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePiInstallation(appRoot: string): string[] {
|
||||
const paths = resolvePiPaths(appRoot);
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!existsSync(paths.piCliPath)) missing.push(paths.piCliPath);
|
||||
if (!existsSync(paths.promisePolyfillPath)) missing.push(paths.promisePolyfillPath);
|
||||
if (!existsSync(paths.researchToolsPath)) missing.push(paths.researchToolsPath);
|
||||
if (!existsSync(paths.skillsPath)) missing.push(paths.skillsPath);
|
||||
if (!existsSync(paths.promptTemplatePath)) missing.push(paths.promptTemplatePath);
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function buildPiArgs(options: PiRuntimeOptions): string[] {
|
||||
const paths = resolvePiPaths(options.appRoot);
|
||||
const args = [
|
||||
"--session-dir",
|
||||
options.sessionDir,
|
||||
"--extension",
|
||||
paths.researchToolsPath,
|
||||
"--skill",
|
||||
paths.skillsPath,
|
||||
"--prompt-template",
|
||||
paths.promptTemplatePath,
|
||||
"--system-prompt",
|
||||
options.systemPrompt,
|
||||
];
|
||||
|
||||
if (options.explicitModelSpec) {
|
||||
args.push("--model", options.explicitModelSpec);
|
||||
}
|
||||
if (options.thinkingLevel) {
|
||||
args.push("--thinking", options.thinkingLevel);
|
||||
}
|
||||
if (options.oneShotPrompt) {
|
||||
args.push("-p", options.oneShotPrompt);
|
||||
} else if (options.initialPrompt) {
|
||||
args.push(options.initialPrompt);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv {
|
||||
const paths = resolvePiPaths(options.appRoot);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: options.feynmanAgentDir,
|
||||
FEYNMAN_CODING_AGENT_DIR: options.feynmanAgentDir,
|
||||
FEYNMAN_VERSION: options.feynmanVersion,
|
||||
FEYNMAN_PI_NPM_ROOT: paths.piWorkspaceNodeModulesPath,
|
||||
FEYNMAN_SESSION_DIR: options.sessionDir,
|
||||
PI_SESSION_DIR: options.sessionDir,
|
||||
FEYNMAN_MEMORY_DIR: resolve(dirname(options.feynmanAgentDir), "memory"),
|
||||
FEYNMAN_NODE_EXECUTABLE: process.execPath,
|
||||
FEYNMAN_BIN_PATH: resolve(options.appRoot, "bin", "feynman.js"),
|
||||
PANDOC_PATH: process.env.PANDOC_PATH ?? resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS),
|
||||
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
|
||||
MERMAID_CLI_PATH: process.env.MERMAID_CLI_PATH ?? resolveExecutable("mmdc", MERMAID_FALLBACK_PATHS),
|
||||
PUPPETEER_EXECUTABLE_PATH:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS),
|
||||
};
|
||||
}
|
||||
121
src/pi/settings.ts
Normal file
121
src/pi/settings.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export type ThinkingLevel = "off" | "low" | "medium" | "high";
|
||||
|
||||
export function parseModelSpec(spec: string, modelRegistry: ModelRegistry) {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.includes(":") ? ":" : trimmed.includes("/") ? "/" : null;
|
||||
if (!separator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [provider, ...rest] = trimmed.split(separator);
|
||||
const id = rest.join(separator);
|
||||
if (!provider || !id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modelRegistry.find(provider, id);
|
||||
}
|
||||
|
||||
export function normalizeThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === "off" || normalized === "low" || normalized === "medium" || normalized === "high") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function choosePreferredModel(
|
||||
availableModels: Array<{ provider: string; id: string }>,
|
||||
): { provider: string; id: string } | undefined {
|
||||
const preferences = [
|
||||
{ provider: "anthropic", id: "claude-opus-4-6" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-5" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
{ provider: "openai", id: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
];
|
||||
|
||||
for (const preferred of preferences) {
|
||||
const match = availableModels.find(
|
||||
(model) => model.provider === preferred.provider && model.id === preferred.id,
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFeynmanSettings(
|
||||
settingsPath: string,
|
||||
bundledSettingsPath: string,
|
||||
defaultThinkingLevel: ThinkingLevel,
|
||||
authPath: string,
|
||||
): void {
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
} else if (existsSync(bundledSettingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(bundledSettingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.defaultThinkingLevel) {
|
||||
settings.defaultThinkingLevel = defaultThinkingLevel;
|
||||
}
|
||||
if (settings.editorPaddingX === undefined) {
|
||||
settings.editorPaddingX = 1;
|
||||
}
|
||||
settings.theme = "feynman";
|
||||
settings.quietStartup = true;
|
||||
settings.collapseChangelog = true;
|
||||
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const availableModels = modelRegistry.getAvailable().map((model) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
}));
|
||||
|
||||
if ((!settings.defaultProvider || !settings.defaultModel) && availableModels.length > 0) {
|
||||
const preferredModel = choosePreferredModel(availableModels);
|
||||
if (preferredModel) {
|
||||
settings.defaultProvider = preferredModel.provider;
|
||||
settings.defaultModel = preferredModel.id;
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
49
src/search/commands.ts
Normal file
49
src/search/commands.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
WEB_SEARCH_PROVIDERS,
|
||||
configureWebSearchProvider,
|
||||
getWebSearchStatus,
|
||||
loadFeynmanConfig,
|
||||
saveFeynmanConfig,
|
||||
type WebSearchProviderId,
|
||||
} from "../config/feynman-config.js";
|
||||
import { printInfo, printSuccess } from "../ui/terminal.js";
|
||||
|
||||
export function printSearchStatus(): void {
|
||||
const status = getWebSearchStatus(loadFeynmanConfig().webSearch ?? {});
|
||||
printInfo(`Provider: ${status.selected.label}`);
|
||||
printInfo(`Runtime route: ${status.selected.runtimeProvider}`);
|
||||
printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`);
|
||||
printInfo(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`);
|
||||
printInfo(`Browser mode: ${status.browserHint}${status.chromeProfile ? ` (${status.chromeProfile})` : ""}`);
|
||||
}
|
||||
|
||||
export function printSearchProviders(): void {
|
||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
||||
const marker = provider.id === DEFAULT_WEB_SEARCH_PROVIDER ? " (default)" : "";
|
||||
printInfo(`${provider.id} — ${provider.label}${marker}: ${provider.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSearchProvider(providerId: string, value?: string): void {
|
||||
if (!WEB_SEARCH_PROVIDERS.some((provider) => provider.id === providerId)) {
|
||||
throw new Error(`Unknown search provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const config = loadFeynmanConfig();
|
||||
const nextWebSearch = configureWebSearchProvider(
|
||||
config.webSearch ?? {},
|
||||
providerId as WebSearchProviderId,
|
||||
providerId === "gemini-browser"
|
||||
? { chromeProfile: value }
|
||||
: providerId === "perplexity" || providerId === "gemini-api"
|
||||
? { apiKey: value }
|
||||
: {},
|
||||
);
|
||||
|
||||
saveFeynmanConfig({
|
||||
...config,
|
||||
webSearch: nextWebSearch,
|
||||
});
|
||||
printSuccess(`Search provider set to ${providerId}`);
|
||||
}
|
||||
180
src/setup/doctor.ts
Normal file
180
src/setup/doctor.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
import { getUserName as getAlphaUserName, isLoggedIn as isAlphaLoggedIn } from "@companion-ai/alpha-hub/lib";
|
||||
|
||||
import {
|
||||
FEYNMAN_CONFIG_PATH,
|
||||
formatWebSearchDoctorLines,
|
||||
getWebSearchStatus,
|
||||
loadFeynmanConfig,
|
||||
} from "../config/feynman-config.js";
|
||||
import { BROWSER_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
|
||||
import { readJson } from "../pi/settings.js";
|
||||
import { validatePiInstallation } from "../pi/runtime.js";
|
||||
import { printInfo, printPanel, printSection } from "../ui/terminal.js";
|
||||
import { getCurrentModelSpec } from "../model/commands.js";
|
||||
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
|
||||
|
||||
export type DoctorOptions = {
|
||||
settingsPath: string;
|
||||
authPath: string;
|
||||
sessionDir: string;
|
||||
workingDir: string;
|
||||
appRoot: string;
|
||||
};
|
||||
|
||||
export type FeynmanStatusSnapshot = {
|
||||
model?: string;
|
||||
modelValid: boolean;
|
||||
recommendedModel?: string;
|
||||
recommendedModelReason?: string;
|
||||
authenticatedModelCount: number;
|
||||
authenticatedProviderCount: number;
|
||||
modelGuidance: string[];
|
||||
alphaLoggedIn: boolean;
|
||||
alphaUser?: string;
|
||||
webProviderLabel: string;
|
||||
webConfigured: boolean;
|
||||
previewConfigured: boolean;
|
||||
sessionDir: string;
|
||||
configPath: string;
|
||||
pandocReady: boolean;
|
||||
browserReady: boolean;
|
||||
piReady: boolean;
|
||||
missingPiBits: string[];
|
||||
};
|
||||
|
||||
export function collectStatusSnapshot(options: DoctorOptions): FeynmanStatusSnapshot {
|
||||
const config = loadFeynmanConfig();
|
||||
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
|
||||
const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS);
|
||||
const missingPiBits = validatePiInstallation(options.appRoot);
|
||||
const webStatus = getWebSearchStatus(config.webSearch ?? {});
|
||||
const modelStatus = buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(options.authPath),
|
||||
getAvailableModelRecords(options.authPath),
|
||||
getCurrentModelSpec(options.settingsPath),
|
||||
);
|
||||
|
||||
return {
|
||||
model: modelStatus.current,
|
||||
modelValid: modelStatus.currentValid,
|
||||
recommendedModel: modelStatus.recommended,
|
||||
recommendedModelReason: modelStatus.recommendationReason,
|
||||
authenticatedModelCount: modelStatus.availableModels.length,
|
||||
authenticatedProviderCount: modelStatus.providers.filter((provider) => provider.configured).length,
|
||||
modelGuidance: modelStatus.guidance,
|
||||
alphaLoggedIn: isAlphaLoggedIn(),
|
||||
alphaUser: isAlphaLoggedIn() ? getAlphaUserName() ?? undefined : undefined,
|
||||
webProviderLabel: webStatus.selected.label,
|
||||
webConfigured: webStatus.perplexityConfigured || webStatus.geminiApiConfigured || webStatus.selected.id === "gemini-browser",
|
||||
previewConfigured: Boolean(config.preview?.lastSetupAt),
|
||||
sessionDir: options.sessionDir,
|
||||
configPath: FEYNMAN_CONFIG_PATH,
|
||||
pandocReady: Boolean(pandocPath),
|
||||
browserReady: Boolean(browserPath),
|
||||
piReady: missingPiBits.length === 0,
|
||||
missingPiBits,
|
||||
};
|
||||
}
|
||||
|
||||
export function runStatus(options: DoctorOptions): void {
|
||||
const snapshot = collectStatusSnapshot(options);
|
||||
printPanel("Feynman Status", [
|
||||
"Current setup summary for the research shell.",
|
||||
]);
|
||||
printSection("Core");
|
||||
printInfo(`Model: ${snapshot.model ?? "not configured"}`);
|
||||
printInfo(`Model valid: ${snapshot.modelValid ? "yes" : "no"}`);
|
||||
printInfo(`Authenticated models: ${snapshot.authenticatedModelCount}`);
|
||||
printInfo(`Authenticated providers: ${snapshot.authenticatedProviderCount}`);
|
||||
printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`);
|
||||
printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`);
|
||||
printInfo(`Web research: ${snapshot.webConfigured ? snapshot.webProviderLabel : "not configured"}`);
|
||||
printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
|
||||
|
||||
printSection("Paths");
|
||||
printInfo(`Config: ${snapshot.configPath}`);
|
||||
printInfo(`Sessions: ${snapshot.sessionDir}`);
|
||||
|
||||
printSection("Runtime");
|
||||
printInfo(`Pi runtime: ${snapshot.piReady ? "ready" : "missing files"}`);
|
||||
printInfo(`Pandoc: ${snapshot.pandocReady ? "ready" : "missing"}`);
|
||||
printInfo(`Browser preview: ${snapshot.browserReady ? "ready" : "missing"}`);
|
||||
if (snapshot.missingPiBits.length > 0) {
|
||||
for (const entry of snapshot.missingPiBits) {
|
||||
printInfo(` missing: ${entry}`);
|
||||
}
|
||||
}
|
||||
if (snapshot.modelGuidance.length > 0) {
|
||||
printSection("Next Steps");
|
||||
for (const line of snapshot.modelGuidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runDoctor(options: DoctorOptions): void {
|
||||
const settings = readJson(options.settingsPath);
|
||||
const config = loadFeynmanConfig();
|
||||
const modelRegistry = new ModelRegistry(AuthStorage.create(options.authPath));
|
||||
const availableModels = modelRegistry.getAvailable();
|
||||
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
|
||||
const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS);
|
||||
const missingPiBits = validatePiInstallation(options.appRoot);
|
||||
|
||||
printPanel("Feynman Doctor", [
|
||||
"Checks config, auth, runtime wiring, and preview dependencies.",
|
||||
]);
|
||||
console.log(`working dir: ${options.workingDir}`);
|
||||
console.log(`session dir: ${options.sessionDir}`);
|
||||
console.log(`config path: ${FEYNMAN_CONFIG_PATH}`);
|
||||
console.log("");
|
||||
console.log(`alphaXiv auth: ${isAlphaLoggedIn() ? "ok" : "missing"}`);
|
||||
if (isAlphaLoggedIn()) {
|
||||
const name = getAlphaUserName();
|
||||
if (name) {
|
||||
console.log(` user: ${name}`);
|
||||
}
|
||||
}
|
||||
console.log(`models available: ${availableModels.length}`);
|
||||
if (availableModels.length > 0) {
|
||||
const sample = availableModels
|
||||
.slice(0, 6)
|
||||
.map((model) => `${model.provider}/${model.id}`)
|
||||
.join(", ");
|
||||
console.log(` sample: ${sample}`);
|
||||
}
|
||||
console.log(
|
||||
`default model: ${typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string"
|
||||
? `${settings.defaultProvider}/${settings.defaultModel}`
|
||||
: "not set"}`,
|
||||
);
|
||||
const modelStatus = collectStatusSnapshot(options);
|
||||
console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`);
|
||||
console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`);
|
||||
console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`);
|
||||
console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`);
|
||||
if (modelStatus.recommendedModelReason) {
|
||||
console.log(` why: ${modelStatus.recommendedModelReason}`);
|
||||
}
|
||||
console.log(`pandoc: ${pandocPath ?? "missing"}`);
|
||||
console.log(`browser preview runtime: ${browserPath ?? "missing"}`);
|
||||
console.log(`configured session dir: ${config.sessionDir ?? "not set"}`);
|
||||
for (const line of formatWebSearchDoctorLines(config.webSearch ?? {})) {
|
||||
console.log(line);
|
||||
}
|
||||
console.log(`quiet startup: ${settings.quietStartup === true ? "enabled" : "disabled"}`);
|
||||
console.log(`theme: ${typeof settings.theme === "string" ? settings.theme : "not set"}`);
|
||||
if (missingPiBits.length > 0) {
|
||||
console.log("pi runtime: missing files");
|
||||
for (const entry of missingPiBits) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
} else {
|
||||
console.log("pi runtime: ok");
|
||||
}
|
||||
for (const line of modelStatus.modelGuidance) {
|
||||
console.log(`next step: ${line}`);
|
||||
}
|
||||
console.log("setup hint: feynman setup");
|
||||
}
|
||||
29
src/setup/preview.ts
Normal file
29
src/setup/preview.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { BREW_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
|
||||
|
||||
export type PreviewSetupResult =
|
||||
| { status: "ready"; message: string }
|
||||
| { status: "installed"; message: string }
|
||||
| { status: "manual"; message: string };
|
||||
|
||||
export function setupPreviewDependencies(): PreviewSetupResult {
|
||||
const pandocPath = resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS);
|
||||
if (pandocPath) {
|
||||
return { status: "ready", message: `pandoc already installed at ${pandocPath}` };
|
||||
}
|
||||
|
||||
const brewPath = resolveExecutable("brew", BREW_FALLBACK_PATHS);
|
||||
if (process.platform === "darwin" && brewPath) {
|
||||
const result = spawnSync(brewPath, ["install", "pandoc"], { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to install pandoc via Homebrew.");
|
||||
}
|
||||
return { status: "installed", message: "Preview dependency installed: pandoc" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "manual",
|
||||
message: "pandoc is required for preview support. Install it manually and rerun `feynman --doctor`.",
|
||||
};
|
||||
}
|
||||
30
src/setup/prompts.ts
Normal file
30
src/setup/prompts.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
export async function promptText(question: string, defaultValue = ""): Promise<string> {
|
||||
if (!input.isTTY || !output.isTTY) {
|
||||
throw new Error("feynman setup requires an interactive terminal.");
|
||||
}
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
||||
const value = (await rl.question(`${question}${suffix}: `)).trim();
|
||||
return value || defaultValue;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptChoice(question: string, choices: string[], defaultIndex = 0): Promise<number> {
|
||||
console.log(question);
|
||||
for (const [index, choice] of choices.entries()) {
|
||||
const marker = index === defaultIndex ? "*" : " ";
|
||||
console.log(` ${marker} ${index + 1}. ${choice}`);
|
||||
}
|
||||
const answer = await promptText("Select", String(defaultIndex + 1));
|
||||
const parsed = Number(answer);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > choices.length) {
|
||||
return defaultIndex;
|
||||
}
|
||||
return parsed - 1;
|
||||
}
|
||||
316
src/setup/setup.ts
Normal file
316
src/setup/setup.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { isLoggedIn as isAlphaLoggedIn, login as loginAlpha } from "@companion-ai/alpha-hub/lib";
|
||||
|
||||
import {
|
||||
DEFAULT_WEB_SEARCH_PROVIDER,
|
||||
FEYNMAN_CONFIG_PATH,
|
||||
WEB_SEARCH_PROVIDERS,
|
||||
configureWebSearchProvider,
|
||||
getConfiguredWebSearchProvider,
|
||||
getWebSearchStatus,
|
||||
hasConfiguredWebProvider,
|
||||
loadFeynmanConfig,
|
||||
saveFeynmanConfig,
|
||||
} from "../config/feynman-config.js";
|
||||
import { getFeynmanHome } from "../config/paths.js";
|
||||
import { normalizeFeynmanSettings } from "../pi/settings.js";
|
||||
import type { ThinkingLevel } from "../pi/settings.js";
|
||||
import { getCurrentModelSpec, runModelSetup } from "../model/commands.js";
|
||||
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
|
||||
import { promptChoice, promptText } from "./prompts.js";
|
||||
import { setupPreviewDependencies } from "./preview.js";
|
||||
import { runDoctor } from "./doctor.js";
|
||||
import { printInfo, printPanel, printSection, printSuccess } from "../ui/terminal.js";
|
||||
|
||||
type SetupOptions = {
|
||||
section: string | undefined;
|
||||
settingsPath: string;
|
||||
bundledSettingsPath: string;
|
||||
authPath: string;
|
||||
workingDir: string;
|
||||
sessionDir: string;
|
||||
appRoot: string;
|
||||
defaultThinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
async function setupWebProvider(): Promise<void> {
|
||||
const config = loadFeynmanConfig();
|
||||
const current = getConfiguredWebSearchProvider(config.webSearch ?? {});
|
||||
const preferredSelectionId = config.webSearch?.feynmanWebProvider ?? DEFAULT_WEB_SEARCH_PROVIDER;
|
||||
const choices = [
|
||||
...WEB_SEARCH_PROVIDERS.map((provider) => `${provider.label} — ${provider.description}`),
|
||||
"Skip",
|
||||
];
|
||||
const defaultIndex = WEB_SEARCH_PROVIDERS.findIndex((provider) => provider.id === preferredSelectionId);
|
||||
const selection = await promptChoice(
|
||||
"Choose a web search provider for Feynman:",
|
||||
choices,
|
||||
defaultIndex >= 0 ? defaultIndex : 0,
|
||||
);
|
||||
|
||||
if (selection === WEB_SEARCH_PROVIDERS.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = WEB_SEARCH_PROVIDERS[selection] ?? WEB_SEARCH_PROVIDERS[0];
|
||||
let nextWebConfig = { ...(config.webSearch ?? {}) };
|
||||
|
||||
if (selected.id === "perplexity") {
|
||||
const key = await promptText(
|
||||
"Perplexity API key",
|
||||
typeof nextWebConfig.perplexityApiKey === "string" ? nextWebConfig.perplexityApiKey : "",
|
||||
);
|
||||
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { apiKey: key });
|
||||
} else if (selected.id === "gemini-api") {
|
||||
const key = await promptText(
|
||||
"Gemini API key",
|
||||
typeof nextWebConfig.geminiApiKey === "string" ? nextWebConfig.geminiApiKey : "",
|
||||
);
|
||||
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { apiKey: key });
|
||||
} else if (selected.id === "gemini-browser") {
|
||||
const profile = await promptText(
|
||||
"Chrome profile (optional)",
|
||||
typeof nextWebConfig.chromeProfile === "string" ? nextWebConfig.chromeProfile : "",
|
||||
);
|
||||
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id, { chromeProfile: profile });
|
||||
} else {
|
||||
nextWebConfig = configureWebSearchProvider(nextWebConfig, selected.id);
|
||||
}
|
||||
|
||||
saveFeynmanConfig({
|
||||
...config,
|
||||
webSearch: nextWebConfig,
|
||||
});
|
||||
printSuccess(`Saved web search provider: ${selected.label}`);
|
||||
if (selected.id === "gemini-browser") {
|
||||
printInfo("Gemini Browser relies on a signed-in Chromium profile through pi-web-access.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPreviewConfigured() {
|
||||
return Boolean(loadFeynmanConfig().preview?.lastSetupAt);
|
||||
}
|
||||
|
||||
function isInteractiveTerminal(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
function printNonInteractiveSetupGuidance(): void {
|
||||
printPanel("Feynman Setup", [
|
||||
"Non-interactive terminal detected.",
|
||||
]);
|
||||
printInfo("Use the explicit commands instead of the interactive setup wizard:");
|
||||
printInfo(" feynman status");
|
||||
printInfo(" feynman model providers");
|
||||
printInfo(" feynman model login <provider>");
|
||||
printInfo(" feynman model list");
|
||||
printInfo(" feynman model recommend");
|
||||
printInfo(" feynman model set <provider/model>");
|
||||
printInfo(" feynman search providers");
|
||||
printInfo(" feynman search set <provider> [value]");
|
||||
printInfo(" feynman alpha login");
|
||||
printInfo(" feynman doctor");
|
||||
printInfo(" feynman # Pi's /login flow still works inside chat if you prefer it");
|
||||
}
|
||||
|
||||
async function runPreviewSetup(): Promise<void> {
|
||||
const result = setupPreviewDependencies();
|
||||
printSuccess(result.message);
|
||||
saveFeynmanConfig({
|
||||
...loadFeynmanConfig(),
|
||||
preview: {
|
||||
lastSetupAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function printConfigurationLocation(appRoot: string): void {
|
||||
printSection("Configuration Location");
|
||||
printInfo(`Config file: ${FEYNMAN_CONFIG_PATH}`);
|
||||
printInfo(`Data folder: ${getFeynmanHome()}`);
|
||||
printInfo(`Install dir: ${appRoot}`);
|
||||
printInfo("You can edit config.json directly or use `feynman config` commands.");
|
||||
}
|
||||
|
||||
function printSetupSummary(settingsPath: string, authPath: string): void {
|
||||
const config = loadFeynmanConfig();
|
||||
const webStatus = getWebSearchStatus(config.webSearch ?? {});
|
||||
const modelStatus = buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(authPath),
|
||||
getAvailableModelRecords(authPath),
|
||||
getCurrentModelSpec(settingsPath),
|
||||
);
|
||||
printSection("Setup Summary");
|
||||
printInfo(`Model: ${getCurrentModelSpec(settingsPath) ?? "not set"}`);
|
||||
printInfo(`Model valid: ${modelStatus.currentValid ? "yes" : "no"}`);
|
||||
printInfo(`Recommended model: ${modelStatus.recommended ?? "not available"}`);
|
||||
printInfo(`alphaXiv: ${isAlphaLoggedIn() ? "configured" : "missing"}`);
|
||||
printInfo(`Web research: ${hasConfiguredWebProvider(config.webSearch ?? {}) ? webStatus.selected.label : "not configured"}`);
|
||||
printInfo(`Preview: ${isPreviewConfigured() ? "configured" : "not configured"}`);
|
||||
for (const line of modelStatus.guidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSetupSection(section: "model" | "alpha" | "web" | "preview", options: SetupOptions): Promise<void> {
|
||||
if (section === "model") {
|
||||
await runModelSetup(options.settingsPath, options.authPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === "alpha") {
|
||||
if (!isAlphaLoggedIn()) {
|
||||
await loginAlpha();
|
||||
printSuccess("alphaXiv login complete");
|
||||
} else {
|
||||
printInfo("alphaXiv login already configured");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === "web") {
|
||||
await setupWebProvider();
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === "preview") {
|
||||
await runPreviewSetup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function runFullSetup(options: SetupOptions): Promise<void> {
|
||||
printConfigurationLocation(options.appRoot);
|
||||
await runSetupSection("model", options);
|
||||
await runSetupSection("alpha", options);
|
||||
await runSetupSection("web", options);
|
||||
await runSetupSection("preview", options);
|
||||
normalizeFeynmanSettings(
|
||||
options.settingsPath,
|
||||
options.bundledSettingsPath,
|
||||
options.defaultThinkingLevel ?? "medium",
|
||||
options.authPath,
|
||||
);
|
||||
runDoctor({
|
||||
settingsPath: options.settingsPath,
|
||||
authPath: options.authPath,
|
||||
sessionDir: options.sessionDir,
|
||||
workingDir: options.workingDir,
|
||||
appRoot: options.appRoot,
|
||||
});
|
||||
printSetupSummary(options.settingsPath, options.authPath);
|
||||
}
|
||||
|
||||
async function runQuickSetup(options: SetupOptions): Promise<void> {
|
||||
printSection("Quick Setup");
|
||||
let changed = false;
|
||||
const modelStatus = buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(options.authPath),
|
||||
getAvailableModelRecords(options.authPath),
|
||||
getCurrentModelSpec(options.settingsPath),
|
||||
);
|
||||
|
||||
if (!modelStatus.current || !modelStatus.currentValid) {
|
||||
await runSetupSection("model", options);
|
||||
changed = true;
|
||||
}
|
||||
if (!isAlphaLoggedIn()) {
|
||||
await runSetupSection("alpha", options);
|
||||
changed = true;
|
||||
}
|
||||
if (!hasConfiguredWebProvider(loadFeynmanConfig().webSearch ?? {})) {
|
||||
await runSetupSection("web", options);
|
||||
changed = true;
|
||||
}
|
||||
if (!isPreviewConfigured()) {
|
||||
await runSetupSection("preview", options);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
printSuccess("Everything already looks configured.");
|
||||
printInfo("Run `feynman setup` and choose Full Setup if you want to reconfigure everything.");
|
||||
return;
|
||||
}
|
||||
|
||||
normalizeFeynmanSettings(
|
||||
options.settingsPath,
|
||||
options.bundledSettingsPath,
|
||||
options.defaultThinkingLevel ?? "medium",
|
||||
options.authPath,
|
||||
);
|
||||
printSetupSummary(options.settingsPath, options.authPath);
|
||||
}
|
||||
|
||||
function hasExistingSetup(settingsPath: string, authPath: string): boolean {
|
||||
const config = loadFeynmanConfig();
|
||||
const modelStatus = buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(authPath),
|
||||
getAvailableModelRecords(authPath),
|
||||
getCurrentModelSpec(settingsPath),
|
||||
);
|
||||
return Boolean(
|
||||
modelStatus.current ||
|
||||
modelStatus.availableModels.length > 0 ||
|
||||
isAlphaLoggedIn() ||
|
||||
hasConfiguredWebProvider(config.webSearch ?? {}) ||
|
||||
config.preview?.lastSetupAt,
|
||||
);
|
||||
}
|
||||
|
||||
async function runDefaultInteractiveSetup(options: SetupOptions): Promise<void> {
|
||||
const existing = hasExistingSetup(options.settingsPath, options.authPath);
|
||||
printPanel("Feynman Setup Wizard", [
|
||||
"Guided setup for the research-first Pi agent.",
|
||||
"Press Ctrl+C at any time to exit.",
|
||||
]);
|
||||
|
||||
if (existing) {
|
||||
printSection("Full Setup");
|
||||
printInfo("Existing configuration detected. Rerunning the full guided setup.");
|
||||
printInfo("Use `feynman setup quick` if you only want to fill missing items.");
|
||||
} else {
|
||||
printInfo("We'll walk you through:");
|
||||
printInfo(" 1. Model Selection");
|
||||
printInfo(" 2. alphaXiv Login");
|
||||
printInfo(" 3. Web Research Provider");
|
||||
printInfo(" 4. Preview Dependencies");
|
||||
}
|
||||
printInfo("Press Enter to begin, or Ctrl+C to exit.");
|
||||
await promptText("Press Enter to start");
|
||||
await runFullSetup(options);
|
||||
}
|
||||
|
||||
export async function runSetup(options: SetupOptions): Promise<void> {
|
||||
if (!isInteractiveTerminal()) {
|
||||
printNonInteractiveSetupGuidance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.section) {
|
||||
await runDefaultInteractiveSetup(options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.section === "model") {
|
||||
await runSetupSection("model", options);
|
||||
return;
|
||||
}
|
||||
if (options.section === "alpha") {
|
||||
await runSetupSection("alpha", options);
|
||||
return;
|
||||
}
|
||||
if (options.section === "web") {
|
||||
await runSetupSection("web", options);
|
||||
return;
|
||||
}
|
||||
if (options.section === "preview") {
|
||||
await runSetupSection("preview", options);
|
||||
return;
|
||||
}
|
||||
if (options.section === "quick") {
|
||||
await runQuickSetup(options);
|
||||
return;
|
||||
}
|
||||
|
||||
await runFullSetup(options);
|
||||
}
|
||||
46
src/system/executables.ts
Normal file
46
src/system/executables.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export const PANDOC_FALLBACK_PATHS = [
|
||||
"/opt/homebrew/bin/pandoc",
|
||||
"/usr/local/bin/pandoc",
|
||||
];
|
||||
|
||||
export const BREW_FALLBACK_PATHS = [
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
];
|
||||
|
||||
export const BROWSER_FALLBACK_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
];
|
||||
|
||||
export const MERMAID_FALLBACK_PATHS = [
|
||||
"/opt/homebrew/bin/mmdc",
|
||||
"/usr/local/bin/mmdc",
|
||||
];
|
||||
|
||||
export function resolveExecutable(name: string, fallbackPaths: string[] = []): string | undefined {
|
||||
for (const candidate of fallbackPaths) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${name}`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
if (result.status === 0) {
|
||||
const resolved = result.stdout.trim();
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
26
src/system/promise-polyfill.ts
Normal file
26
src/system/promise-polyfill.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
type PromiseWithResolvers<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface PromiseConstructor {
|
||||
withResolvers?<T>(): PromiseWithResolvers<T>;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Promise.withResolvers !== "function") {
|
||||
Promise.withResolvers = function withResolvers<T>(): PromiseWithResolvers<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
63
src/ui/terminal.ts
Normal file
63
src/ui/terminal.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
|
||||
function rgb(red: number, green: number, blue: number): string {
|
||||
return `\x1b[38;2;${red};${green};${blue}m`;
|
||||
}
|
||||
|
||||
// Match the outer CLI to the bundled Feynman Pi theme instead of generic magenta panels.
|
||||
const INK = rgb(211, 198, 170);
|
||||
const STONE = rgb(157, 169, 160);
|
||||
const ASH = rgb(133, 146, 137);
|
||||
const DARK_ASH = rgb(92, 106, 114);
|
||||
const SAGE = rgb(167, 192, 128);
|
||||
const TEAL = rgb(127, 187, 179);
|
||||
const ROSE = rgb(230, 126, 128);
|
||||
|
||||
function paint(text: string, ...codes: string[]): string {
|
||||
return `${codes.join("")}${text}${RESET}`;
|
||||
}
|
||||
|
||||
export function printInfo(text: string): void {
|
||||
console.log(paint(` ${text}`, ASH));
|
||||
}
|
||||
|
||||
export function printSuccess(text: string): void {
|
||||
console.log(paint(`✓ ${text}`, SAGE, BOLD));
|
||||
}
|
||||
|
||||
export function printWarning(text: string): void {
|
||||
console.log(paint(`⚠ ${text}`, STONE, BOLD));
|
||||
}
|
||||
|
||||
export function printError(text: string): void {
|
||||
console.log(paint(`✗ ${text}`, ROSE, BOLD));
|
||||
}
|
||||
|
||||
export function printSection(title: string): void {
|
||||
console.log("");
|
||||
console.log(paint(`◆ ${title}`, TEAL, BOLD));
|
||||
}
|
||||
|
||||
export function printPanel(title: string, subtitleLines: string[] = []): void {
|
||||
const inner = 53;
|
||||
const border = "─".repeat(inner + 2);
|
||||
const renderLine = (text: string, color: string, bold = false): string => {
|
||||
const content = text.length > inner ? `${text.slice(0, inner - 3)}...` : text;
|
||||
const codes = bold ? `${color}${BOLD}` : color;
|
||||
return `${DARK_ASH}${BOLD}│${RESET} ${codes}${content.padEnd(inner)}${RESET} ${DARK_ASH}${BOLD}│${RESET}`;
|
||||
};
|
||||
|
||||
console.log("");
|
||||
console.log(paint(`┌${border}┐`, DARK_ASH, BOLD));
|
||||
console.log(renderLine(title, TEAL, true));
|
||||
if (subtitleLines.length > 0) {
|
||||
console.log(paint(`├${border}┤`, DARK_ASH, BOLD));
|
||||
for (const line of subtitleLines) {
|
||||
console.log(renderLine(line, INK));
|
||||
}
|
||||
}
|
||||
console.log(paint(`└${border}┘`, DARK_ASH, BOLD));
|
||||
console.log("");
|
||||
}
|
||||
19
src/web-search.ts
Normal file
19
src/web-search.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
FEYNMAN_CONFIG_PATH as WEB_SEARCH_CONFIG_PATH,
|
||||
WEB_SEARCH_PROVIDERS,
|
||||
configureWebSearchProvider,
|
||||
formatWebSearchDoctorLines,
|
||||
getConfiguredWebSearchProvider,
|
||||
getWebSearchProviderById,
|
||||
getWebSearchStatus,
|
||||
hasConfiguredWebProvider,
|
||||
hasGeminiApiKey,
|
||||
hasPerplexityApiKey,
|
||||
loadWebSearchConfig,
|
||||
saveWebSearchConfig,
|
||||
type PiWebSearchProvider,
|
||||
type WebSearchConfig,
|
||||
type WebSearchProviderDefinition,
|
||||
type WebSearchProviderId,
|
||||
type WebSearchStatus,
|
||||
} from "./config/feynman-config.js";
|
||||
51
tests/bootstrap-sync.test.ts
Normal file
51
tests/bootstrap-sync.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { syncBundledAssets } from "../src/bootstrap/sync.js";
|
||||
|
||||
function createAppRoot(): string {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), "feynman-app-"));
|
||||
mkdirSync(join(appRoot, ".pi", "themes"), { recursive: true });
|
||||
mkdirSync(join(appRoot, ".pi", "agents"), { recursive: true });
|
||||
writeFileSync(join(appRoot, ".pi", "themes", "feynman.json"), '{"theme":"v1"}\n', "utf8");
|
||||
writeFileSync(join(appRoot, ".pi", "agents", "researcher.md"), "# v1\n", "utf8");
|
||||
return appRoot;
|
||||
}
|
||||
|
||||
test("syncBundledAssets copies missing bundled files", () => {
|
||||
const appRoot = createAppRoot();
|
||||
const home = mkdtempSync(join(tmpdir(), "feynman-home-"));
|
||||
process.env.FEYNMAN_HOME = home;
|
||||
const agentDir = join(home, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
const result = syncBundledAssets(appRoot, agentDir);
|
||||
|
||||
assert.deepEqual(result.copied.sort(), ["feynman.json", "researcher.md"]);
|
||||
assert.equal(readFileSync(join(agentDir, "themes", "feynman.json"), "utf8"), '{"theme":"v1"}\n');
|
||||
assert.equal(readFileSync(join(agentDir, "agents", "researcher.md"), "utf8"), "# v1\n");
|
||||
});
|
||||
|
||||
test("syncBundledAssets preserves user-modified files and updates managed files", () => {
|
||||
const appRoot = createAppRoot();
|
||||
const home = mkdtempSync(join(tmpdir(), "feynman-home-"));
|
||||
process.env.FEYNMAN_HOME = home;
|
||||
const agentDir = join(home, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
syncBundledAssets(appRoot, agentDir);
|
||||
|
||||
writeFileSync(join(appRoot, ".pi", "themes", "feynman.json"), '{"theme":"v2"}\n', "utf8");
|
||||
writeFileSync(join(appRoot, ".pi", "agents", "researcher.md"), "# v2\n", "utf8");
|
||||
writeFileSync(join(agentDir, "agents", "researcher.md"), "# user-custom\n", "utf8");
|
||||
|
||||
const result = syncBundledAssets(appRoot, agentDir);
|
||||
|
||||
assert.deepEqual(result.updated, ["feynman.json"]);
|
||||
assert.deepEqual(result.skipped, ["researcher.md"]);
|
||||
assert.equal(readFileSync(join(agentDir, "themes", "feynman.json"), "utf8"), '{"theme":"v2"}\n');
|
||||
assert.equal(readFileSync(join(agentDir, "agents", "researcher.md"), "utf8"), "# user-custom\n");
|
||||
});
|
||||
60
tests/feynman-config.test.ts
Normal file
60
tests/feynman-config.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
configureWebSearchProvider,
|
||||
getConfiguredWebSearchProvider,
|
||||
loadFeynmanConfig,
|
||||
saveFeynmanConfig,
|
||||
} from "../src/config/feynman-config.js";
|
||||
|
||||
test("loadFeynmanConfig falls back to legacy web-search config", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feynman-config-"));
|
||||
const configPath = join(root, "config.json");
|
||||
const legacyDir = join(process.env.HOME ?? root, ".pi");
|
||||
const legacyPath = join(legacyDir, "web-search.json");
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
feynmanWebProvider: "perplexity",
|
||||
perplexityApiKey: "legacy-key",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = loadFeynmanConfig(configPath);
|
||||
assert.equal(config.version, 1);
|
||||
assert.equal(config.webSearch?.feynmanWebProvider, "perplexity");
|
||||
assert.equal(config.webSearch?.perplexityApiKey, "legacy-key");
|
||||
});
|
||||
|
||||
test("saveFeynmanConfig persists sessionDir and webSearch", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "feynman-config-"));
|
||||
const configPath = join(root, "config.json");
|
||||
const webSearch = configureWebSearchProvider({}, "gemini-browser", { chromeProfile: "Profile 2" });
|
||||
|
||||
saveFeynmanConfig(
|
||||
{
|
||||
version: 1,
|
||||
sessionDir: "/tmp/feynman-sessions",
|
||||
webSearch,
|
||||
},
|
||||
configPath,
|
||||
);
|
||||
|
||||
const config = loadFeynmanConfig(configPath);
|
||||
assert.equal(config.sessionDir, "/tmp/feynman-sessions");
|
||||
assert.equal(config.webSearch?.feynmanWebProvider, "gemini-browser");
|
||||
assert.equal(config.webSearch?.chromeProfile, "Profile 2");
|
||||
});
|
||||
|
||||
test("default web provider falls back to Pi web via gemini-browser", () => {
|
||||
const provider = getConfiguredWebSearchProvider({});
|
||||
|
||||
assert.equal(provider.id, "gemini-browser");
|
||||
assert.equal(provider.runtimeProvider, "gemini");
|
||||
});
|
||||
67
tests/model-harness.test.ts
Normal file
67
tests/model-harness.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveInitialPrompt } from "../src/cli.js";
|
||||
import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js";
|
||||
import { setDefaultModelSpec } from "../src/model/commands.js";
|
||||
|
||||
function createAuthPath(contents: Record<string, unknown>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "feynman-auth-"));
|
||||
const authPath = join(root, "auth.json");
|
||||
writeFileSync(authPath, JSON.stringify(contents, null, 2) + "\n", "utf8");
|
||||
return authPath;
|
||||
}
|
||||
|
||||
test("chooseRecommendedModel prefers the strongest authenticated research model", () => {
|
||||
const authPath = createAuthPath({
|
||||
openai: { type: "api_key", key: "openai-test-key" },
|
||||
anthropic: { type: "api_key", key: "anthropic-test-key" },
|
||||
});
|
||||
|
||||
const recommendation = chooseRecommendedModel(authPath);
|
||||
|
||||
assert.equal(recommendation?.spec, "anthropic/claude-opus-4-6");
|
||||
});
|
||||
|
||||
test("setDefaultModelSpec accepts a unique bare model id from authenticated models", () => {
|
||||
const authPath = createAuthPath({
|
||||
openai: { type: "api_key", key: "openai-test-key" },
|
||||
});
|
||||
const settingsPath = join(mkdtempSync(join(tmpdir(), "feynman-settings-")), "settings.json");
|
||||
|
||||
setDefaultModelSpec(settingsPath, authPath, "gpt-5.4");
|
||||
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
defaultProvider?: string;
|
||||
defaultModel?: string;
|
||||
};
|
||||
assert.equal(settings.defaultProvider, "openai");
|
||||
assert.equal(settings.defaultModel, "gpt-5.4");
|
||||
});
|
||||
|
||||
test("buildModelStatusSnapshotFromRecords flags an invalid current model and suggests a replacement", () => {
|
||||
const snapshot = buildModelStatusSnapshotFromRecords(
|
||||
[
|
||||
{ provider: "anthropic", id: "claude-opus-4-6" },
|
||||
{ provider: "openai", id: "gpt-5.4" },
|
||||
],
|
||||
[{ provider: "openai", id: "gpt-5.4" }],
|
||||
"anthropic/claude-opus-4-6",
|
||||
);
|
||||
|
||||
assert.equal(snapshot.currentValid, false);
|
||||
assert.equal(snapshot.recommended, "openai/gpt-5.4");
|
||||
assert.ok(snapshot.guidance.some((line) => line.includes("Configured default model is unavailable")));
|
||||
});
|
||||
|
||||
test("resolveInitialPrompt maps top-level research commands to Pi slash workflows", () => {
|
||||
assert.equal(resolveInitialPrompt("lit", ["tool-using", "agents"], undefined), "/lit tool-using agents");
|
||||
assert.equal(resolveInitialPrompt("watch", ["openai"], undefined), "/watch openai");
|
||||
assert.equal(resolveInitialPrompt("jobs", [], undefined), "/jobs");
|
||||
assert.equal(resolveInitialPrompt("chat", ["hello"], undefined), "hello");
|
||||
assert.equal(resolveInitialPrompt("unknown", ["topic"], undefined), "unknown topic");
|
||||
});
|
||||
|
||||
58
tests/pi-runtime.test.ts
Normal file
58
tests/pi-runtime.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildPiArgs, buildPiEnv, resolvePiPaths } from "../src/pi/runtime.js";
|
||||
|
||||
test("buildPiArgs includes configured runtime paths and prompt", () => {
|
||||
const args = buildPiArgs({
|
||||
appRoot: "/repo/feynman",
|
||||
workingDir: "/workspace",
|
||||
sessionDir: "/sessions",
|
||||
feynmanAgentDir: "/home/.feynman/agent",
|
||||
systemPrompt: "system",
|
||||
initialPrompt: "hello",
|
||||
explicitModelSpec: "openai:gpt-5.4",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
|
||||
assert.deepEqual(args, [
|
||||
"--session-dir",
|
||||
"/sessions",
|
||||
"--extension",
|
||||
"/repo/feynman/extensions/research-tools.ts",
|
||||
"--skill",
|
||||
"/repo/feynman/skills",
|
||||
"--prompt-template",
|
||||
"/repo/feynman/prompts",
|
||||
"--system-prompt",
|
||||
"system",
|
||||
"--model",
|
||||
"openai:gpt-5.4",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"hello",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildPiEnv wires Feynman paths into the Pi environment", () => {
|
||||
const env = buildPiEnv({
|
||||
appRoot: "/repo/feynman",
|
||||
workingDir: "/workspace",
|
||||
sessionDir: "/sessions",
|
||||
feynmanAgentDir: "/home/.feynman/agent",
|
||||
systemPrompt: "system",
|
||||
feynmanVersion: "0.1.5",
|
||||
});
|
||||
|
||||
assert.equal(env.PI_CODING_AGENT_DIR, "/home/.feynman/agent");
|
||||
assert.equal(env.FEYNMAN_SESSION_DIR, "/sessions");
|
||||
assert.equal(env.FEYNMAN_BIN_PATH, "/repo/feynman/bin/feynman.js");
|
||||
assert.equal(env.FEYNMAN_PI_NPM_ROOT, "/repo/feynman/.pi/npm/node_modules");
|
||||
assert.equal(env.FEYNMAN_MEMORY_DIR, "/home/.feynman/memory");
|
||||
});
|
||||
|
||||
test("resolvePiPaths includes the Promise.withResolvers polyfill path", () => {
|
||||
const paths = resolvePiPaths("/repo/feynman");
|
||||
|
||||
assert.equal(paths.promisePolyfillPath, "/repo/feynman/dist/system/promise-polyfill.js");
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"extensions/**/*.ts"
|
||||
"extensions/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user