import { execFile, spawn } from "node:child_process"; 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 { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; 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, ExtensionContext, SlashCommandInfo } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const FEYNMAN_VERSION = (() => { try { const pkg = require("../package.json") as { version?: string }; return pkg.version ?? "dev"; } catch { return "dev"; } })(); 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); } function getFeynmanHome(): string { const agentDir = process.env.FEYNMAN_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? resolvePath(homedir(), ".feynman", "agent"); return dirname(agentDir); } 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}`; } 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; 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), }; } 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`; } async function openWithDefaultApp(targetPath: string): Promise { 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); }); } async function renderHtmlPreview(filePath: string): Promise { 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 = `${basename(filePath)}
${stdout}
`; const tempDir = await mkdtemp(join(tmpdir(), "feynman-preview-")); const htmlPath = join(tempDir, `${basename(filePath)}.html`); await writeFile(htmlPath, html, "utf8"); return htmlPath; } async function renderPdfPreview(filePath: string): Promise { 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; } function formatHeaderPath(path: string): string { const home = homedir(); return path.startsWith(home) ? `~${path.slice(home.length)}` : path; } function truncateForWidth(text: string, width: number): string { if (width <= 0) { return ""; } if (text.length <= width) { return text; } if (width <= 3) { return ".".repeat(width); } return `${text.slice(0, width - 3)}...`; } function padCell(text: string, width: number): string { const truncated = truncateForWidth(text, width); 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 []; } const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) { return []; } const words = normalized.split(" "); const lines: string[] = []; let current = ""; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (candidate.length <= width) { current = candidate; continue; } if (current) { lines.push(current); if (lines.length === maxLines) { lines[maxLines - 1] = truncateForWidth(lines[maxLines - 1], width); return lines; } } current = word.length <= width ? word : truncateForWidth(word, width); } if (current && lines.length < maxLines) { lines.push(current); } return lines; } 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.provider}/${entry.modelId}`; } } return "model not set"; } 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 text = extractMessageText(entry.message).replace(/\s+/g, " ").trim(); if (!text) { continue; } const role = entry.message.role === "assistant" ? "agent" : entry.message.role === "user" ? "you" : entry.message.role; return `${role}: ${text}`; } return "No messages yet in this session."; } function buildTitledBorder(width: number, title: string): { left: string; right: string } { const gap = Math.max(0, width - title.length); const left = Math.floor(gap / 2); return { left: "─".repeat(left), right: "─".repeat(gap - left), }; } type CatalogSummary = { count: number; lines: string[]; }; function sortCommands(commands: SlashCommandInfo[]): SlashCommandInfo[] { return [...commands].sort((a, b) => a.name.localeCompare(b.name)); } function buildCommandCatalogSummary(pi: ExtensionAPI): CatalogSummary { const commands = pi.getCommands(); const promptCommands = sortCommands(commands.filter((command) => command.source === "prompt")).map((command) => `/${command.name}`); const extensionCommands = sortCommands(commands.filter((command) => command.source === "extension")).map((command) => `/${command.name}` ); const lines: string[] = []; if (promptCommands.length > 0) { lines.push(`prompts: ${promptCommands.join(", ")}`); } if (extensionCommands.length > 0) { lines.push(`commands: ${extensionCommands.join(", ")}`); } return { count: promptCommands.length + extensionCommands.length, lines, }; } function buildToolCatalogSummary(pi: ExtensionAPI): CatalogSummary { const available = new Set(pi.getAllTools().map((tool) => tool.name)); const tools = FEYNMAN_RESEARCH_TOOLS.filter((tool) => available.has(tool)); const lines = [ `alpha_search, alpha_get_paper, alpha_ask_paper`, `alpha_annotate_paper, alpha_list_annotations, alpha_read_code`, `session_search, preview_file`, ].filter((line) => line.split(", ").some((tool) => available.has(tool))); return { count: tools.length, lines, }; } async function buildSkillCatalogSummary(): Promise { const categories = new Map(); let count = 0; async function walk(dir: string, segments: string[] = []): Promise { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const nextPath = resolvePath(dir, entry.name); if (entry.isDirectory()) { await walk(nextPath, [...segments, entry.name]); continue; } if (!entry.isFile() || entry.name !== "SKILL.md") { continue; } const category = segments[0] ?? "general"; const skillName = segments[segments.length - 1] ?? "skill"; const bucket = categories.get(category) ?? []; bucket.push(skillName); categories.set(category, bucket); count += 1; } } try { await walk(resolvePath(APP_ROOT, "skills")); } catch { return { count: 0, lines: [] }; } const lines = [...categories.entries()] .sort(([a], [b]) => a.localeCompare(b)) .slice(0, 8) .map(([category, names]) => `${category}: ${names.join(", ")}`); return { count, lines }; } async function buildAgentCatalogSummary(): Promise { const names: string[] = []; try { const entries = await readdir(resolvePath(APP_ROOT, ".pi", "agents"), { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".md")) { continue; } const base = entry.name.replace(/\.chain\.md$/i, "").replace(/\.md$/i, ""); names.push(base); } } catch { return { count: 0, lines: [] }; } names.sort((a, b) => a.localeCompare(b)); return { count: names.length, lines: names.length > 0 ? wrapForWidth(names.join(", "), 80, 4) : [], }; } function formatShortcutLine(command: string, description: string, width: number): string { const commandWidth = Math.min(18, Math.max(13, Math.floor(width * 0.3))); return truncateForWidth(`${padCell(command, commandWidth)} ${description}`, width); } async function pathExists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } 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: `; } 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 `; } type HelpCommand = { usage: string; description: string; }; 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: "Prompt Workflows", commands: promptCommands, }, { title: "Commands", commands: extensionCommands, }, ]; } export default function researchTools(pi: ExtensionAPI): void { let skillSummaryPromise: Promise | undefined; let agentSummaryPromise: Promise | undefined; async function installFeynmanHeader(ctx: ExtensionContext): Promise { if (!ctx.hasUI) { return; } skillSummaryPromise ??= buildSkillCatalogSummary(); agentSummaryPromise ??= buildAgentCatalogSummary(); const commandSummary = buildCommandCatalogSummary(pi); const skillSummary = await skillSummaryPromise; const agentSummary = await agentSummaryPromise; const toolSummary = buildToolCatalogSummary(pi); ctx.ui.setHeader((_tui, theme) => ({ render(width: number): string[] { const maxAvailableWidth = Math.max(width - 2, 1); const preferredWidth = Math.min(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 Research Agent v${FEYNMAN_VERSION} `, innerWidth); const titledBorder = buildTitledBorder(innerWidth, title); const modelLabel = getCurrentModelLabel(ctx); const sessionLabel = ctx.sessionManager.getSessionName()?.trim() || ctx.sessionManager.getSessionId(); const directoryLabel = formatHeaderPath(ctx.cwd); const recentActivity = getRecentActivitySummary(ctx); const lines: string[] = []; const push = (line: string): void => { lines.push(`${outerPadding}${line}`); }; const renderBoxLine = (content: string): string => `${theme.fg("borderMuted", "│")}${content}${theme.fg("borderMuted", "│")}`; const renderDivider = (): string => `${theme.fg("borderMuted", "├")}${theme.fg("borderMuted", "─".repeat(innerWidth))}${theme.fg("borderMuted", "┤")}`; const styleAccentCell = (text: string, cellWidth: number): string => 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}`) + theme.fg("accent", theme.bold(title)) + theme.fg("borderMuted", `${titledBorder.right}╮`), ); 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))))); push(renderBoxLine(padCell(`model: ${modelLabel}`, innerWidth))); push(renderBoxLine(padCell(`session: ${sessionLabel}`, innerWidth))); push(renderBoxLine(padCell(`directory: ${directoryLabel}`, innerWidth))); push(renderDivider()); 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(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, options?: { leftAccent?: boolean; rightAccent?: boolean; leftMuted?: boolean; rightMuted?: boolean }, ): string => { const leftCell = options?.leftAccent ? styleAccentCell(left, leftWidth) : options?.leftMuted ? styleMutedCell(left, leftWidth) : padCell(left, leftWidth); const rightCell = options?.rightAccent ? styleAccentCell(right, rightWidth) : options?.rightMuted ? styleMutedCell(right, rightWidth) : padCell(right, rightWidth); return renderBoxLine(`${leftCell}${theme.fg("borderMuted", " │ ")}${rightCell}`); }; push(renderBoxLine(padCell("", 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}`); })(), ); } } push(theme.fg("borderMuted", `╰${"─".repeat(innerWidth)}╯`)); push(""); return lines; }, invalidate() {}, })); } pi.on("session_start", async (_event, ctx) => { await installFeynmanHeader(ctx); }); pi.on("session_switch", async (_event, ctx) => { await installFeynmanHeader(ctx); }); 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"); }, }); pi.registerCommand("help", { description: "Show grouped Feynman commands and prefill the editor with a selected command.", handler: async (_args, ctx) => { 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("---")) { return; } const usage = selected.split(" — ")[0]; ctx.ui.setEditorText(usage); ctx.ui.notify(`Prefilled ${usage}`, "info"); }, }); 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"); }, }); 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, }; }, }); 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(); } }, }); 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 = extname(resolvedPath).toLowerCase() === ".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, }; }, }); }