Overhaul Feynman harness: streamline agents, prompts, and extensions

Remove legacy chains, skills, and config modules. Add citation agent,
SYSTEM.md, modular research-tools extension, and web-access layer.
Add ralph-wiggum to Pi package stack for long-running loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Advait Paliwal
2026-03-23 14:59:30 -07:00
parent d23e679331
commit 406d50b3ff
60 changed files with 2994 additions and 3191 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
import {
annotatePaper,
askPaper,
clearPaperAnnotation,
disconnect,
getPaper,
getUserName as getAlphaUserName,
isLoggedIn as isAlphaLoggedIn,
listPaperAnnotations,
login as loginAlpha,
logout as logoutAlpha,
readPaperCode,
searchPapers,
} from "@companion-ai/alpha-hub/lib";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { formatToolText } from "./shared.js";
export function registerAlphaCommands(pi: ExtensionAPI): void {
pi.registerCommand("alpha-login", {
description: "Sign in to alphaXiv from inside Feynman.",
handler: async (_args, ctx) => {
if (isAlphaLoggedIn()) {
const name = getAlphaUserName();
ctx.ui.notify(name ? `alphaXiv already connected as ${name}` : "alphaXiv already connected", "info");
return;
}
await loginAlpha();
const name = getAlphaUserName();
ctx.ui.notify(name ? `alphaXiv connected as ${name}` : "alphaXiv login complete", "info");
},
});
pi.registerCommand("alpha-logout", {
description: "Clear alphaXiv auth from inside Feynman.",
handler: async (_args, ctx) => {
logoutAlpha();
ctx.ui.notify("alphaXiv auth cleared", "info");
},
});
pi.registerCommand("alpha-status", {
description: "Show alphaXiv authentication status.",
handler: async (_args, ctx) => {
if (!isAlphaLoggedIn()) {
ctx.ui.notify("alphaXiv not connected", "warning");
return;
}
const name = getAlphaUserName();
ctx.ui.notify(name ? `alphaXiv connected as ${name}` : "alphaXiv connected", "info");
},
});
}
export function registerAlphaTools(pi: ExtensionAPI): void {
pi.registerTool({
name: "alpha_search",
label: "Alpha Search",
description: "Search papers through alphaXiv using semantic, keyword, both, agentic, or all retrieval modes.",
parameters: Type.Object({
query: Type.String({ description: "Paper search query." }),
mode: Type.Optional(
Type.String({
description: "Search mode: semantic, keyword, both, agentic, or all.",
}),
),
}),
async execute(_toolCallId, params) {
try {
const result = await searchPapers(params.query, params.mode?.trim() || "all");
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
} finally {
await disconnect();
}
},
});
pi.registerTool({
name: "alpha_get_paper",
label: "Alpha Get Paper",
description: "Fetch a paper report or full text, plus any local annotation, using alphaXiv.",
parameters: Type.Object({
paper: Type.String({
description: "arXiv ID, arXiv URL, or alphaXiv URL.",
}),
fullText: Type.Optional(
Type.Boolean({
description: "Return raw full text instead of the AI report.",
}),
),
}),
async execute(_toolCallId, params) {
try {
const result = await getPaper(params.paper, { fullText: params.fullText });
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
} finally {
await disconnect();
}
},
});
pi.registerTool({
name: "alpha_ask_paper",
label: "Alpha Ask Paper",
description: "Ask a targeted question about a paper using alphaXiv's PDF analysis.",
parameters: Type.Object({
paper: Type.String({
description: "arXiv ID, arXiv URL, or alphaXiv URL.",
}),
question: Type.String({
description: "Question to ask about the paper.",
}),
}),
async execute(_toolCallId, params) {
try {
const result = await askPaper(params.paper, params.question);
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
} finally {
await disconnect();
}
},
});
pi.registerTool({
name: "alpha_annotate_paper",
label: "Alpha Annotate Paper",
description: "Write or clear a persistent local annotation for a paper.",
parameters: Type.Object({
paper: Type.String({
description: "Paper ID to annotate.",
}),
note: Type.Optional(
Type.String({
description: "Annotation text. Omit when clear=true.",
}),
),
clear: Type.Optional(
Type.Boolean({
description: "Clear the existing annotation instead of writing one.",
}),
),
}),
async execute(_toolCallId, params) {
const result = params.clear
? await clearPaperAnnotation(params.paper)
: params.note
? await annotatePaper(params.paper, params.note)
: (() => {
throw new Error("Provide either note or clear=true.");
})();
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
},
});
pi.registerTool({
name: "alpha_list_annotations",
label: "Alpha List Annotations",
description: "List all persistent local paper annotations.",
parameters: Type.Object({}),
async execute() {
const result = await listPaperAnnotations();
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
},
});
pi.registerTool({
name: "alpha_read_code",
label: "Alpha Read Code",
description: "Read files from a paper's GitHub repository through alphaXiv.",
parameters: Type.Object({
githubUrl: Type.String({
description: "GitHub repository URL for the paper implementation.",
}),
path: Type.Optional(
Type.String({
description: "Repository path to inspect. Use / for the repo overview.",
}),
),
}),
async execute(_toolCallId, params) {
try {
const result = await readPaperCode(params.githubUrl, params.path?.trim() || "/");
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
} finally {
await disconnect();
}
},
});
}

View File

@@ -0,0 +1,328 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve as resolvePath } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
APP_ROOT,
FEYNMAN_AGENT_LOGO,
FEYNMAN_VERSION,
} from "./shared.js";
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(text: string): number {
return text.replace(ANSI_RE, "").length;
}
function formatHeaderPath(path: string): string {
const home = homedir();
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
}
function truncateVisible(text: string, maxVisible: number): string {
const raw = text.replace(ANSI_RE, "");
if (raw.length <= maxVisible) return text;
if (maxVisible <= 3) return ".".repeat(maxVisible);
return `${raw.slice(0, maxVisible - 3)}...`;
}
function wrapWords(text: string, maxW: number): string[] {
const words = text.split(" ");
const lines: string[] = [];
let cur = "";
for (let word of words) {
if (word.length > maxW) {
if (cur) { lines.push(cur); cur = ""; }
word = maxW > 3 ? `${word.slice(0, maxW - 1)}` : word.slice(0, maxW);
}
const test = cur ? `${cur} ${word}` : word;
if (cur && test.length > maxW) {
lines.push(cur);
cur = word;
} else {
cur = test;
}
}
if (cur) lines.push(cur);
return lines.length ? lines : [""];
}
function padRight(text: string, width: number): string {
const gap = Math.max(0, width - visibleLength(text));
return `${text}${" ".repeat(gap)}`;
}
function centerText(text: string, width: number): string {
if (text.length >= width) return text.slice(0, width);
const left = Math.floor((width - text.length) / 2);
const right = width - text.length - left;
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
}
function getCurrentModelLabel(ctx: ExtensionContext): string {
if (ctx.model) return `${ctx.model.provider}/${ctx.model.id}`;
const branch = ctx.sessionManager.getBranch();
for (let index = branch.length - 1; index >= 0; index -= 1) {
const entry = branch[index]!;
if (entry.type === "model_change") return `${(entry as any).provider}/${(entry as any).modelId}`;
}
return "not set";
}
function extractMessageText(message: unknown): string {
if (!message || typeof message !== "object") return "";
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((item) => {
if (!item || typeof item !== "object") return "";
const record = item as { type?: string; text?: unknown; name?: unknown };
if (record.type === "text" && typeof record.text === "string") return record.text;
if (record.type === "toolCall") return `[${typeof record.name === "string" ? record.name : "tool"}]`;
return "";
})
.filter(Boolean)
.join(" ");
}
function getRecentActivitySummary(ctx: ExtensionContext): string {
const branch = ctx.sessionManager.getBranch();
for (let index = branch.length - 1; index >= 0; index -= 1) {
const entry = branch[index]!;
if (entry.type !== "message") continue;
const msg = entry as any;
const text = extractMessageText(msg.message).replace(/\s+/g, " ").trim();
if (!text) continue;
const role = msg.message.role === "assistant" ? "agent" : msg.message.role === "user" ? "you" : msg.message.role;
return `${role}: ${text}`;
}
return "";
}
async function buildAgentCatalogSummary(): Promise<{ agents: string[]; chains: string[] }> {
const agents: string[] = [];
const chains: 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;
if (entry.name.endsWith(".chain.md")) {
chains.push(entry.name.replace(/\.chain\.md$/i, ""));
} else {
agents.push(entry.name.replace(/\.md$/i, ""));
}
}
} catch {
return { agents: [], chains: [] };
}
agents.sort();
chains.sort();
return { agents, chains };
}
type WorkflowInfo = { name: string; description: string };
function getResearchWorkflows(pi: ExtensionAPI): WorkflowInfo[] {
return pi.getCommands()
.filter((cmd) => cmd.source === "prompt")
.map((cmd) => ({ name: `/${cmd.name}`, description: cmd.description ?? "" }))
.sort((a, b) => a.name.localeCompare(b.name));
}
function shortDescription(desc: string): string {
const lower = desc.toLowerCase();
for (const prefix of ["run a ", "run an ", "set up a ", "build a ", "build the ", "turn ", "design the ", "produce a ", "compare ", "simulate ", "inspect ", "write a ", "plan or execute a ", "prepare a "]) {
if (lower.startsWith(prefix)) return desc.slice(prefix.length);
}
return desc;
}
export function installFeynmanHeader(
pi: ExtensionAPI,
ctx: ExtensionContext,
cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> },
): void | Promise<void> {
if (!ctx.hasUI) return;
cache.agentSummaryPromise ??= buildAgentCatalogSummary();
return cache.agentSummaryPromise.then((agentData) => {
const workflows = getResearchWorkflows(pi);
const toolCount = pi.getAllTools().length;
const commandCount = pi.getCommands().length;
const agentCount = agentData.agents.length + agentData.chains.length;
ctx.ui.setHeader((_tui, theme) => ({
render(width: number): string[] {
const maxW = Math.max(width - 2, 1);
const cardW = Math.min(maxW, 120);
const innerW = cardW - 2;
const contentW = innerW - 2;
const outerPad = " ".repeat(Math.max(0, Math.floor((width - cardW) / 2)));
const lines: string[] = [];
const push = (line: string) => { lines.push(`${outerPad}${line}`); };
const border = (ch: string) => theme.fg("borderMuted", ch);
const row = (content: string): string =>
`${border("│")} ${padRight(content, contentW)} ${border("│")}`;
const emptyRow = (): string =>
`${border("│")}${" ".repeat(innerW)}${border("│")}`;
const sep = (): string =>
`${border("├")}${border("─".repeat(innerW))}${border("┤")}`;
const useWideLayout = contentW >= 70;
const leftW = useWideLayout ? Math.min(38, Math.floor(contentW * 0.35)) : 0;
const divColW = useWideLayout ? 3 : 0;
const rightW = useWideLayout ? contentW - leftW - divColW : contentW;
const twoCol = (left: string, right: string): string => {
if (!useWideLayout) return row(left || right);
return row(
`${padRight(left, leftW)}${border(" │ ")}${padRight(right, rightW)}`,
);
};
const modelLabel = getCurrentModelLabel(ctx);
const sessionId = ctx.sessionManager.getSessionName()?.trim() || ctx.sessionManager.getSessionId();
const dirLabel = formatHeaderPath(ctx.cwd);
const activity = getRecentActivitySummary(ctx);
push("");
if (cardW >= 70) {
for (const logoLine of FEYNMAN_AGENT_LOGO) {
push(theme.fg("accent", theme.bold(centerText(truncateVisible(logoLine, cardW), cardW))));
}
push("");
}
const versionTag = ` v${FEYNMAN_VERSION} `;
const gap = Math.max(0, innerW - versionTag.length);
const gapL = Math.floor(gap / 2);
push(
border(`${"─".repeat(gapL)}`) +
theme.fg("dim", versionTag) +
border(`${"─".repeat(gap - gapL)}`),
);
if (useWideLayout) {
const cmdNameW = 16;
const descW = Math.max(10, rightW - cmdNameW - 2);
const leftValueW = Math.max(1, leftW - 11);
const indent = " ".repeat(11);
const leftLines: string[] = [""];
const pushLabeled = (label: string, value: string, color: "text" | "dim") => {
const wrapped = wrapWords(value, leftValueW);
leftLines.push(`${theme.fg("dim", label.padEnd(10))} ${theme.fg(color, wrapped[0]!)}`);
for (let i = 1; i < wrapped.length; i++) {
leftLines.push(`${indent}${theme.fg(color, wrapped[i]!)}`);
}
};
pushLabeled("model", modelLabel, "text");
pushLabeled("directory", dirLabel, "text");
pushLabeled("session", sessionId, "dim");
leftLines.push("");
leftLines.push(theme.fg("dim", `${toolCount} tools · ${agentCount} agents`));
const pushList = (heading: string, items: string[]) => {
if (items.length === 0) return;
leftLines.push("");
leftLines.push(theme.fg("accent", theme.bold(heading)));
for (const line of wrapWords(items.join(", "), leftW)) {
leftLines.push(theme.fg("dim", line));
}
};
pushList("Agents", agentData.agents);
pushList("Chains", agentData.chains);
if (activity) {
leftLines.push("");
leftLines.push(theme.fg("accent", theme.bold("Last Activity")));
for (const line of wrapWords(activity, leftW)) {
leftLines.push(theme.fg("dim", line));
}
}
const rightLines: string[] = [
"",
theme.fg("accent", theme.bold("Research Workflows")),
];
for (const wf of workflows) {
if (wf.name === "/jobs" || wf.name === "/log") continue;
const desc = shortDescription(wf.description);
const descWords = desc.split(" ");
let line = "";
let first = true;
for (const word of descWords) {
const test = line ? `${line} ${word}` : word;
if (line && test.length > descW) {
rightLines.push(
first
? `${theme.fg("accent", wf.name.padEnd(cmdNameW))}${theme.fg("dim", line)}`
: `${" ".repeat(cmdNameW)}${theme.fg("dim", line)}`,
);
first = false;
line = word;
} else {
line = test;
}
}
if (line || first) {
rightLines.push(
first
? `${theme.fg("accent", wf.name.padEnd(cmdNameW))}${theme.fg("dim", line)}`
: `${" ".repeat(cmdNameW)}${theme.fg("dim", line)}`,
);
}
}
const maxRows = Math.max(leftLines.length, rightLines.length);
for (let i = 0; i < maxRows; i++) {
push(twoCol(leftLines[i] ?? "", rightLines[i] ?? ""));
}
} else {
const narrowValW = Math.max(1, contentW - 11);
push(emptyRow());
push(row(`${theme.fg("dim", "model".padEnd(10))} ${theme.fg("text", truncateVisible(modelLabel, narrowValW))}`));
push(row(`${theme.fg("dim", "directory".padEnd(10))} ${theme.fg("text", truncateVisible(dirLabel, narrowValW))}`));
push(row(`${theme.fg("dim", "session".padEnd(10))} ${theme.fg("dim", truncateVisible(sessionId, narrowValW))}`));
push(row(theme.fg("dim", truncateVisible(`${toolCount} tools · ${agentCount} agents · ${commandCount} commands`, contentW))));
push(emptyRow());
push(sep());
push(row(theme.fg("accent", theme.bold("Research Workflows"))));
const narrowDescW = Math.max(1, contentW - 17);
for (const wf of workflows) {
if (wf.name === "/jobs" || wf.name === "/log") continue;
const desc = shortDescription(wf.description);
push(row(`${theme.fg("accent", wf.name.padEnd(16))} ${theme.fg("dim", truncateVisible(desc, narrowDescW))}`));
}
if (agentData.agents.length > 0 || agentData.chains.length > 0) {
push(sep());
push(row(theme.fg("accent", theme.bold("Agents & Chains"))));
if (agentData.agents.length > 0) {
push(row(theme.fg("dim", truncateVisible(`agents ${agentData.agents.join(", ")}`, contentW))));
}
if (agentData.chains.length > 0) {
push(row(theme.fg("dim", truncateVisible(`chains ${agentData.chains.join(", ")}`, contentW))));
}
}
}
push(border(`${"─".repeat(innerW)}`));
push("");
return lines;
},
invalidate() {},
}));
});
}

View File

@@ -0,0 +1,70 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
type HelpCommand = { usage: string; description: string };
type HelpSection = { title: string; commands: HelpCommand[] };
function buildHelpSections(): HelpSection[] {
return [
{
title: "Research Workflows",
commands: [
{ usage: "/deepresearch <topic>", description: "Source-heavy investigation with parallel researchers." },
{ usage: "/lit <topic>", description: "Literature review using paper search." },
{ usage: "/review <artifact>", description: "Simulated peer review with objections and revision plan." },
{ usage: "/audit <item>", description: "Audit a paper against its public codebase." },
{ usage: "/replicate <paper>", description: "Replication workflow for a paper or claim." },
{ usage: "/draft <topic>", description: "Paper-style draft from research findings." },
{ usage: "/compare <topic>", description: "Compare sources with agreements and disagreements." },
{ usage: "/autoresearch <target>", description: "Autonomous experiment optimization loop." },
{ usage: "/watch <topic>", description: "Recurring research watch on a topic." },
],
},
{
title: "Agents & Delegation",
commands: [
{ usage: "/agents", description: "Open the agent and chain manager." },
{ usage: "/run <agent> <task>", description: "Run a single subagent." },
{ usage: "/chain agent1 -> agent2", description: "Run agents in sequence." },
{ usage: "/parallel agent1 -> agent2", description: "Run agents in parallel." },
],
},
{
title: "Project & Session",
commands: [
{ usage: "/init", description: "Bootstrap AGENTS.md and session-log folders." },
{ usage: "/log", description: "Write a session log to notes/." },
{ usage: "/jobs", description: "Inspect active background work." },
{ usage: "/search", description: "Search prior sessions." },
{ usage: "/preview", description: "Preview a generated artifact." },
],
},
{
title: "Setup",
commands: [
{ usage: "/alpha-login", description: "Sign in to alphaXiv." },
{ usage: "/alpha-status", description: "Check alphaXiv auth." },
{ usage: "/alpha-logout", description: "Clear alphaXiv auth." },
],
},
];
}
export function registerHelpCommand(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 = buildHelpSections();
const items = sections.flatMap((section) => [
`--- ${section.title} ---`,
...section.commands.map((cmd) => `${cmd.usage}${cmd.description}`),
]);
const selected = await ctx.ui.select("Feynman Help", items);
if (!selected || selected.startsWith("---")) return;
const usage = selected.split(" — ")[0];
ctx.ui.setEditorText(usage);
ctx.ui.notify(`Prefilled ${usage}`, "info");
},
});
}

View File

@@ -0,0 +1,233 @@
import { execFile, spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
function isMarkdownPath(path: string): boolean {
return [".md", ".markdown", ".txt"].includes(extname(path).toLowerCase());
}
function isLatexPath(path: string): boolean {
return extname(path).toLowerCase() === ".tex";
}
function wrapCodeAsMarkdown(source: string, filePath: string): string {
const language = extname(filePath).replace(/^\./, "") || "text";
return `# ${basename(filePath)}\n\n\`\`\`${language}\n${source}\n\`\`\`\n`;
}
export async function openWithDefaultApp(targetPath: string): Promise<void> {
const target = pathToFileURL(targetPath).href;
if (process.platform === "darwin") {
await execFileAsync("open", [target]);
return;
}
if (process.platform === "win32") {
await execFileAsync("cmd", ["/c", "start", "", target]);
return;
}
await execFileAsync("xdg-open", [target]);
}
async function runCommandWithInput(
command: string,
args: string[],
input: string,
): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer | string) => {
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
child.once("error", reject);
child.once("close", (code) => {
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
const stderr = Buffer.concat(stderrChunks).toString("utf8");
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(`${command} failed with exit code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
});
child.stdin.end(input);
});
}
export async function renderHtmlPreview(filePath: string): Promise<string> {
const source = await readFile(filePath, "utf8");
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
const inputFormat = isLatexPath(filePath)
? "latex"
: "markdown+lists_without_preceding_blankline+tex_math_dollars+autolink_bare_uris-raw_html";
const markdown = isLatexPath(filePath) || isMarkdownPath(filePath) ? source : wrapCodeAsMarkdown(source, filePath);
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", `--resource-path=${dirname(filePath)}`];
const { stdout } = await runCommandWithInput(pandocCommand, args, markdown);
const html = `<!doctype html><html><head><meta charset="utf-8" /><base href="${pathToFileURL(dirname(filePath) + "/").href}" /><title>${basename(filePath)}</title><style>
:root{
--bg:#faf7f2;
--paper:#fffdf9;
--border:#d7cec1;
--text:#1f1c18;
--muted:#6c645a;
--code:#f3eee6;
--link:#0f6d8c;
--quote:#8b7f70;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#161311;
--paper:#1d1916;
--border:#3b342d;
--text:#ebe3d6;
--muted:#b4ab9f;
--code:#221d19;
--link:#8ac6d6;
--quote:#a89d8f;
}
}
body{
font-family:Charter,"Iowan Old Style","Palatino Linotype","Book Antiqua",Palatino,Georgia,serif;
margin:0;
background:var(--bg);
color:var(--text);
line-height:1.7;
}
main{
max-width:900px;
margin:2rem auto 4rem;
padding:2.5rem 3rem;
background:var(--paper);
border:1px solid var(--border);
border-radius:18px;
box-shadow:0 12px 40px rgba(0,0,0,.06);
}
h1,h2,h3,h4,h5,h6{
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
line-height:1.2;
margin-top:1.5em;
}
h1{font-size:2.2rem;border-bottom:1px solid var(--border);padding-bottom:.35rem;}
h2{font-size:1.6rem;border-bottom:1px solid var(--border);padding-bottom:.25rem;}
p,ul,ol,blockquote,table{margin:1rem 0;}
pre,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
pre{
background:var(--code);
border:1px solid var(--border);
border-radius:12px;
padding:1rem 1.1rem;
overflow:auto;
}
code{
background:var(--code);
padding:.12rem .28rem;
border-radius:6px;
}
a{color:var(--link);text-decoration:none}
a:hover{text-decoration:underline}
img{max-width:100%}
blockquote{
border-left:4px solid var(--border);
padding-left:1rem;
color:var(--quote);
}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid var(--border);padding:.55rem .7rem;text-align:left}
</style></head><body><main>${stdout}</main></body></html>`;
const tempDir = await mkdtemp(join(tmpdir(), "feynman-preview-"));
const htmlPath = join(tempDir, `${basename(filePath)}.html`);
await writeFile(htmlPath, html, "utf8");
return htmlPath;
}
export async function renderPdfPreview(filePath: string): Promise<string> {
const source = await readFile(filePath, "utf8");
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
const inputFormat = isLatexPath(filePath)
? "latex"
: "markdown+lists_without_preceding_blankline+tex_math_dollars+autolink_bare_uris-raw_html";
const markdown = isLatexPath(filePath) || isMarkdownPath(filePath) ? source : wrapCodeAsMarkdown(source, filePath);
const tempDir = await mkdtemp(join(tmpdir(), "feynman-preview-"));
const pdfPath = join(tempDir, `${basename(filePath)}.pdf`);
const args = [
"-f",
inputFormat,
"-o",
pdfPath,
`--pdf-engine=${pdfEngine}`,
`--resource-path=${dirname(filePath)}`,
];
await runCommandWithInput(pandocCommand, args, markdown);
return pdfPath;
}
export async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
export function buildProjectAgentsTemplate(): string {
return `# Feynman Project Guide
This file is read automatically at startup. It is the durable project memory for Feynman.
## Project Overview
- State the research question, target artifact, target venue, and key datasets or benchmarks here.
## AI Research Context
- Problem statement:
- Core hypothesis:
- Closest prior work:
- Required baselines:
- Required ablations:
- Primary metrics:
- Datasets / benchmarks:
## Ground Rules
- Do not modify raw data in \`Data/Raw/\` or equivalent raw-data folders.
- Read first, act second: inspect project structure and existing notes before making changes.
- Prefer durable artifacts in \`notes/\`, \`outputs/\`, \`experiments/\`, and \`papers/\`.
- Keep strong claims source-grounded. Include direct URLs in final writeups.
## Current Status
- Replace this section with the latest project status, known issues, and next steps.
## Session Logging
- Use \`/log\` at the end of meaningful sessions to write a durable session note into \`notes/session-logs/\`.
## Review Readiness
- Known reviewer concerns:
- Missing experiments:
- Missing writing or framing work:
`;
}
export function buildSessionLogsReadme(): string {
return `# Session Logs
Use \`/log\` to write one durable note per meaningful Feynman session.
Recommended contents:
- what was done
- strongest findings
- artifacts written
- unresolved questions
- next steps
`;
}

View File

@@ -0,0 +1,115 @@
import { mkdir, stat, writeFile } from "node:fs/promises";
import { dirname, resolve as resolvePath } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { renderHtmlPreview, renderPdfPreview, openWithDefaultApp, pathExists, buildProjectAgentsTemplate, buildSessionLogsReadme } from "./preview.js";
import { formatToolText } from "./shared.js";
import { searchSessionTranscripts } from "./session-search.js";
export function registerInitCommand(pi: ExtensionAPI): void {
pi.registerCommand("init", {
description: "Initialize AGENTS.md and session-log folders for a research project.",
handler: async (_args, ctx) => {
const agentsPath = resolvePath(ctx.cwd, "AGENTS.md");
const notesDir = resolvePath(ctx.cwd, "notes");
const sessionLogsDir = resolvePath(notesDir, "session-logs");
const sessionLogsReadmePath = resolvePath(sessionLogsDir, "README.md");
const created: string[] = [];
const skipped: string[] = [];
await mkdir(notesDir, { recursive: true });
await mkdir(sessionLogsDir, { recursive: true });
if (!(await pathExists(agentsPath))) {
await writeFile(agentsPath, buildProjectAgentsTemplate(), "utf8");
created.push("AGENTS.md");
} else {
skipped.push("AGENTS.md");
}
if (!(await pathExists(sessionLogsReadmePath))) {
await writeFile(sessionLogsReadmePath, buildSessionLogsReadme(), "utf8");
created.push("notes/session-logs/README.md");
} else {
skipped.push("notes/session-logs/README.md");
}
const createdSummary = created.length > 0 ? `created: ${created.join(", ")}` : "created: nothing";
const skippedSummary = skipped.length > 0 ? `; kept existing: ${skipped.join(", ")}` : "";
ctx.ui.notify(`${createdSummary}${skippedSummary}`, "info");
},
});
}
export function registerSessionSearchTool(pi: ExtensionAPI): void {
pi.registerTool({
name: "session_search",
label: "Session Search",
description: "Search prior Feynman session transcripts to recover what was done, said, or written before.",
parameters: Type.Object({
query: Type.String({
description: "Search query to look for in past sessions.",
}),
limit: Type.Optional(
Type.Number({
description: "Maximum number of sessions to return. Defaults to 3.",
}),
),
}),
async execute(_toolCallId, params) {
const result = await searchSessionTranscripts(params.query, Math.max(1, Math.min(params.limit ?? 3, 8)));
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
},
});
}
export function registerPreviewTool(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. 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.",
}),
target: Type.Optional(
Type.String({
description: "Preview target: browser or pdf. Defaults to browser.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const target = (params.target?.trim().toLowerCase() || "browser");
if (target !== "browser" && target !== "pdf") {
throw new Error("target must be browser or pdf");
}
const resolvedPath = resolvePath(ctx.cwd, params.path);
const openedPath =
resolvePath(resolvedPath).toLowerCase().endsWith(".pdf") && target === "pdf"
? resolvedPath
: target === "pdf"
? await renderPdfPreview(resolvedPath)
: await renderHtmlPreview(resolvedPath);
await mkdir(dirname(openedPath), { recursive: true }).catch(() => {});
await openWithDefaultApp(openedPath);
const result = {
sourcePath: resolvedPath,
target,
openedPath,
temporaryPreview: openedPath !== resolvedPath,
};
return {
content: [{ type: "text", text: formatToolText(result) }],
details: result,
};
},
});
}

View File

@@ -0,0 +1,223 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import { getFeynmanHome } from "./shared.js";
function extractMessageText(message: unknown): string {
if (!message || typeof message !== "object") {
return "";
}
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((item) => {
if (!item || typeof item !== "object") {
return "";
}
const record = item as { type?: string; text?: unknown; arguments?: unknown; name?: unknown };
if (record.type === "text" && typeof record.text === "string") {
return record.text;
}
if (record.type === "toolCall") {
const name = typeof record.name === "string" ? record.name : "tool";
const args =
typeof record.arguments === "string"
? record.arguments
: record.arguments
? JSON.stringify(record.arguments)
: "";
return `[tool:${name}] ${args}`;
}
return "";
})
.filter(Boolean)
.join("\n");
}
function buildExcerpt(text: string, query: string, radius = 180): string {
const normalizedText = text.replace(/\s+/g, " ").trim();
if (!normalizedText) {
return "";
}
const lower = normalizedText.toLowerCase();
const q = query.toLowerCase();
const index = lower.indexOf(q);
if (index === -1) {
return normalizedText.slice(0, radius * 2) + (normalizedText.length > radius * 2 ? "..." : "");
}
const start = Math.max(0, index - radius);
const end = Math.min(normalizedText.length, index + q.length + radius);
const prefix = start > 0 ? "..." : "";
const suffix = end < normalizedText.length ? "..." : "";
return `${prefix}${normalizedText.slice(start, end)}${suffix}`;
}
export async function searchSessionTranscripts(query: string, limit: number): Promise<{
query: string;
results: Array<{
sessionId: string;
sessionFile: string;
startedAt?: string;
cwd?: string;
matchCount: number;
topMatches: Array<{ role: string; timestamp?: string; excerpt: string }>;
}>;
}> {
const packageRoot = process.env.FEYNMAN_PI_NPM_ROOT;
if (packageRoot) {
try {
const indexerPath = pathToFileURL(
join(packageRoot, "@kaiserlich-dev", "pi-session-search", "extensions", "indexer.ts"),
).href;
const indexer = await import(indexerPath) as {
updateIndex?: (onProgress?: (msg: string) => void) => Promise<number>;
search?: (query: string, limit?: number) => Array<{
sessionPath: string;
project: string;
timestamp: string;
snippet: string;
rank: number;
title: string | null;
}>;
getSessionSnippets?: (sessionPath: string, query: string, limit?: number) => string[];
};
await indexer.updateIndex?.();
const results = indexer.search?.(query, limit) ?? [];
if (results.length > 0) {
return {
query,
results: results.map((result) => ({
sessionId: basename(result.sessionPath),
sessionFile: result.sessionPath,
startedAt: result.timestamp,
cwd: result.project,
matchCount: 1,
topMatches: (indexer.getSessionSnippets?.(result.sessionPath, query, 4) ?? [result.snippet])
.filter(Boolean)
.map((excerpt) => ({
role: "match",
excerpt,
})),
})),
};
}
} catch {
// Fall back to direct JSONL scanning below.
}
}
const sessionDir = join(getFeynmanHome(), "sessions");
const terms = query
.toLowerCase()
.split(/\s+/)
.map((term) => term.trim())
.filter((term) => term.length >= 2);
const needle = query.toLowerCase();
let files: string[] = [];
try {
files = (await readdir(sessionDir))
.filter((entry) => entry.endsWith(".jsonl"))
.map((entry) => join(sessionDir, entry));
} catch {
return { query, results: [] };
}
const sessions = [];
for (const file of files) {
const raw = await readFile(file, "utf8").catch(() => "");
if (!raw) {
continue;
}
let sessionId = basename(file);
let startedAt: string | undefined;
let cwd: string | undefined;
const matches: Array<{ role: string; timestamp?: string; excerpt: string }> = [];
for (const line of raw.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const record = JSON.parse(line) as {
type?: string;
id?: string;
timestamp?: string;
cwd?: string;
message?: { role?: string; content?: unknown };
};
if (record.type === "session") {
sessionId = record.id ?? sessionId;
startedAt = record.timestamp;
cwd = record.cwd;
continue;
}
if (record.type !== "message" || !record.message) {
continue;
}
const text = extractMessageText(record.message);
if (!text) {
continue;
}
const lower = text.toLowerCase();
const matched = lower.includes(needle) || terms.some((term) => lower.includes(term));
if (!matched) {
continue;
}
matches.push({
role: record.message.role ?? "unknown",
timestamp: record.timestamp,
excerpt: buildExcerpt(text, query),
});
} catch {
continue;
}
}
if (matches.length === 0) {
continue;
}
let mtime = 0;
try {
mtime = (await stat(file)).mtimeMs;
} catch {
mtime = 0;
}
sessions.push({
sessionId,
sessionFile: file,
startedAt,
cwd,
matchCount: matches.length,
topMatches: matches.slice(0, 4),
mtime,
});
}
sessions.sort((a, b) => {
if (b.matchCount !== a.matchCount) {
return b.matchCount - a.matchCount;
}
return b.mtime - a.mtime;
});
return {
query,
results: sessions.slice(0, limit).map(({ mtime: _mtime, ...session }) => session),
};
}

View File

@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
export const APP_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const FEYNMAN_VERSION = (() => {
try {
const pkg = JSON.parse(readFileSync(resolvePath(APP_ROOT, "package.json"), "utf8")) as { version?: string };
return pkg.version ?? "dev";
} catch {
return "dev";
}
})();
export const FEYNMAN_AGENT_LOGO = [
"███████╗███████╗██╗ ██╗███╗ ██╗███╗ ███╗ █████╗ ███╗ ██╗",
"██╔════╝██╔════╝╚██╗ ██╔╝████╗ ██║████╗ ████║██╔══██╗████╗ ██║",
"█████╗ █████╗ ╚████╔╝ ██╔██╗ ██║██╔████╔██║███████║██╔██╗ ██║",
"██╔══╝ ██╔══╝ ╚██╔╝ ██║╚██╗██║██║╚██╔╝██║██╔══██║██║╚██╗██║",
"██║ ███████╗ ██║ ██║ ╚████║██║ ╚═╝ ██║██║ ██║██║ ╚████║",
"╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝",
];
export 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",
];
export function formatToolText(result: unknown): string {
return typeof result === "string" ? result : JSON.stringify(result, null, 2);
}
export function getFeynmanHome(): string {
const agentDir = process.env.FEYNMAN_CODING_AGENT_DIR ??
process.env.PI_CODING_AGENT_DIR ??
resolvePath(homedir(), ".feynman", "agent");
return dirname(agentDir);
}