import { askPaper, annotatePaper, clearPaperAnnotation, disconnect, getPaper, isLoggedIn, listPaperAnnotations, readPaperCode, searchPapers, } from "@companion-ai/alpha-hub/lib"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { extractPaperSections } from "./alpha-sections.js"; import { searchArxiv } from "./science-database-arxiv.js"; import { searchOpenAlex } from "./science-database-openalex.js"; function formatText(value: unknown): string { if (typeof value === "string") return value; return JSON.stringify(value, null, 2); } // alphaXiv's API has changed without notice before; when a call fails, point // the model at the other paper sources instead of leaving it on a broken tool. const ARXIV_ID = /(?:^|arxiv\.org\/(?:abs|pdf)\/|alphaxiv\.org\/(?:abs|overview)\/)(\d{4}\.\d{4,5}(?:v\d+)?|[a-z-]+(?:\.[A-Z]{2})?\/\d{7}(?:v\d+)?)/i; // When alphaXiv does not answer (expired login, outage), answer from another // paper source and say so first, instead of failing the research step. async function withPaperFallback( run: () => Promise, fallback?: { source: string; run: () => Promise> }, ): Promise> { try { return await run(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (fallback) { try { return { fallbackNote: `alphaXiv did not answer (${message}); these results are from ${fallback.source}.`, ...(await fallback.run()) }; } catch {} } throw new Error( `${message}\nalphaXiv did not answer this call. Use feynman_science_database_search (source "arxiv" or "semanticscholar") or fetch_content on https://arxiv.org/abs/ instead.`, ); } } // Pi converts Type.Array inputs before validating them, which would turn null into ["null"]. // Preserve the JSON Schema array contract without the Type.Array conversion marker. const paperSectionsSchema = Type.Unsafe({ type: "array", items: Type.String({ description: "Multiple sections to extract from content. Supported values: abstract, introduction, methodology, experiments, results, discussion, limitations, conclusion.", }), }); export function registerAlphaTools(pi: ExtensionAPI, signedIn = isLoggedIn()): void { // The alphaXiv MCP client keeps a connection open; close it so print and // JSON runs can exit once the session ends. pi.on("session_shutdown", async () => { await disconnect(); }); // Search, paper, Q&A, and code tools need an alphaXiv account. Without one, // every call failed, so leave them out and let the model use the other // paper sources. Annotations are local and stay available. const registerAccountTool: ExtensionAPI["registerTool"] = signedIn ? (tool) => pi.registerTool(tool) : () => {}; registerAccountTool({ name: "alpha_search", label: "Alpha Search", description: "Search research papers through alphaXiv. Modes: semantic (default, use 2-3 sentence queries), keyword (exact terms), agentic (broad multi-turn retrieval), both, or all.", parameters: Type.Object({ query: Type.String({ description: "Search query." }), mode: Type.Optional( Type.String({ description: "Search mode: semantic, keyword, both, agentic, or all." }), ), }), async execute(_toolCallId, params) { const result = await withPaperFallback(() => searchPapers(params.query, params.mode?.trim() || "semantic"), { source: "OpenAlex semantic search", run: () => searchOpenAlex({ source: "openalex", query: `semantic: ${params.query}`, limit: 10 }), }); return { content: [{ type: "text", text: formatText(result) }], details: result }; }, }); registerAccountTool({ name: "alpha_get_paper", label: "Alpha Get Paper", description: "Fetch a paper's AI-generated report (or raw full text) plus any local annotation. Optional section filters return only requested sections when detectable.", 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 AI report." })), section: Type.Optional( Type.String({ description: "Single section to extract from content: abstract, introduction, methodology, experiments, results, discussion, limitations, or conclusion.", }), ), sections: Type.Optional(paperSectionsSchema), }), async execute(_toolCallId, params) { const arxivId = ARXIV_ID.exec(params.paper.trim())?.[1]; const result = await withPaperFallback(() => getPaper(params.paper, { fullText: params.fullText }), arxivId ? { source: "arXiv metadata and abstract (no full text or sections)", run: () => searchArxiv({ query: arxivId }), } : undefined); if ("fallbackNote" in result) { return { content: [{ type: "text", text: formatText(result) }], details: result }; } const extracted = extractPaperSections(result.content, params.section, params.sections); const filteredResult = extracted.requested.length ? { ...result, content: Object.keys(extracted.selected).length > 0 ? extracted.selected : result.content, requestedSections: extracted.requested, missingSections: extracted.missing, } : result; return { content: [{ type: "text", text: formatText(filteredResult) }], details: filteredResult }; }, }); registerAccountTool({ name: "alpha_ask_paper", label: "Alpha Ask Paper", description: "Ask a targeted question about an arXiv or alphaXiv paper. Uses AI to analyze the PDF and answer. DOI-only papers are not supported; read those with fetch_content on the open-access PDF or Europe PMC full text.", parameters: Type.Object({ paper: Type.String({ description: "arXiv ID, arXiv URL, or alphaXiv URL." }), question: Type.String({ description: "Question about the paper." }), }), async execute(_toolCallId, params) { const arxivId = ARXIV_ID.exec(params.paper.trim())?.[1]; const result = await withPaperFallback(() => askPaper(params.paper, params.question), arxivId ? { source: "arXiv metadata and abstract; the question was not answered, so answer it from the abstract or read the full text with fetch_content on the arXiv PDF", run: () => searchArxiv({ query: arxivId }), } : undefined); return { content: [{ type: "text", text: formatText(result) }], details: result }; }, }); 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 (arXiv ID or URL)." }), note: Type.Optional(Type.String({ description: "Annotation text. Omit when clear=true." })), clear: Type.Optional(Type.Boolean({ description: "Clear the existing annotation." })), }), 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: formatText(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: formatText(result) }], details: result }; }, }); registerAccountTool({ name: "alpha_read_code", label: "Alpha Read Code", description: "Read files from a paper's GitHub repository. Use '/' for repo overview.", parameters: Type.Object({ githubUrl: Type.String({ description: "GitHub repository URL." }), path: Type.Optional(Type.String({ description: "File or directory path. Default: '/'" })), }), async execute(_toolCallId, params) { const result = await readPaperCode(params.githubUrl, params.path?.trim() || "/"); return { content: [{ type: "text", text: formatText(result) }], details: result }; }, }); }