mirror of
https://github.com/getcompanion-ai/alpha-hub.git
synced 2026-04-15 03:00:43 +00:00
Alpha Hub: Context Hub for research papers
CLI + MCP server for searching papers and building persistent knowledge. Powered by alphaXiv for semantic search, paper reports, and PDF Q&A. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9a708a1ab9
20 changed files with 2212 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Alpha Hub
|
||||||
|
|
||||||
|
Research agents hallucinate paper details and forget what they learn in a session. Alpha Hub gives them semantic paper search, AI-generated reports, and persistent annotations — so they get smarter with every task. Search and content powered by [alphaXiv](https://alphaxiv.org).
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://nodejs.org)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @alpha-hub/cli
|
||||||
|
alpha login # sign in with alphaXiv
|
||||||
|
alpha search "attention mechanism" # search papers
|
||||||
|
alpha get 1706.03762 # fetch paper report
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Alpha is designed for your coding agent to use (not for you to use!). You can prompt your agent to use it (e.g., "Use the CLI command `alpha` to search for papers on LoRA. Run `alpha` to see how it works.")
|
||||||
|
|
||||||
|
**Most of the time, it's simple — search, read, use:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alpha search "transformer attention" # find relevant papers
|
||||||
|
alpha get 1706.03762 # fetch AI-generated paper report
|
||||||
|
# Agent reads the report, understands the paper. Done.
|
||||||
|
```
|
||||||
|
|
||||||
|
**When the agent discovers something useful**, it can annotate locally for next time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alpha annotate 1706.03762 "Superseded by Flash Attention for efficiency"
|
||||||
|
|
||||||
|
# Next session, the annotation appears automatically on alpha get.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Need to go deeper?** Ask questions about any paper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alpha ask 1706.03762 "What datasets were used for evaluation?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `alpha search <query>` | Search papers (semantic, keyword, or agentic) |
|
||||||
|
| `alpha get <id\|url>` | Fetch paper report + local annotation |
|
||||||
|
| `alpha ask <id\|url> <question>` | Ask a question about a paper |
|
||||||
|
| `alpha annotate <id> <note>` | Attach a note to a paper |
|
||||||
|
| `alpha annotate <id> --clear` | Remove a note |
|
||||||
|
| `alpha annotate --list` | List all notes |
|
||||||
|
| `alpha login` | Sign in with alphaXiv |
|
||||||
|
| `alpha logout` | Sign out |
|
||||||
|
|
||||||
|
All commands accept `--json` for machine-readable output.
|
||||||
|
|
||||||
|
## Self-Improving Agents
|
||||||
|
|
||||||
|
Alpha Hub is designed for a loop where agents get better over time.
|
||||||
|
|
||||||
|
**Annotations** are local notes that agents attach to papers. They persist across sessions and appear automatically on future fetches — so agents learn from past experience.
|
||||||
|
|
||||||
|
```
|
||||||
|
Without Alpha Hub With Alpha Hub
|
||||||
|
───────────────── ──────────────
|
||||||
|
Search the web for papers Semantic search via alphaXiv
|
||||||
|
Read raw PDFs AI-generated paper reports
|
||||||
|
Miss context and gotchas Agent notes what it learns
|
||||||
|
Knowledge forgotten ↗ Even smarter next session
|
||||||
|
↻ Repeat next session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Semantic Search
|
||||||
|
|
||||||
|
Three search modes — semantic (embedding similarity), keyword (exact terms), and agentic (multi-turn retrieval) — so agents find the right papers regardless of how they phrase the query.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alpha search "methods for reducing hallucination in LLMs" # semantic
|
||||||
|
alpha search "LoRA" --mode keyword # keyword
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paper Q&A
|
||||||
|
|
||||||
|
Ask questions about any paper without reading the full PDF. The answer is grounded in the paper's actual content.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alpha ask 2106.09685 "What is the rank used for the low-rank matrices?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Annotations
|
||||||
|
|
||||||
|
Local notes that agents attach to papers — they persist across sessions and appear automatically on future fetches. See the annotation as a gap the agent discovered and recorded so it doesn't repeat the same mistake.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
2
cli/bin/alpha
Executable file
2
cli/bin/alpha
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import '../src/index.js';
|
||||||
2
cli/bin/alpha-mcp
Executable file
2
cli/bin/alpha-mcp
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import '../src/mcp/server.js';
|
||||||
1170
cli/package-lock.json
generated
Normal file
1170
cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
cli/package.json
Normal file
32
cli/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "@alpha-hub/cli",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "CLI for Alpha Hub - search papers and share community annotations",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"alpha": "./bin/alpha",
|
||||||
|
"alpha-mcp": "./bin/alpha-mcp"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/",
|
||||||
|
"src/"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"research",
|
||||||
|
"papers",
|
||||||
|
"annotations",
|
||||||
|
"alphaxiv",
|
||||||
|
"mcp",
|
||||||
|
"cli"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"commander": "^12.0.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
cli/src/commands/annotate.js
Normal file
65
cli/src/commands/annotate.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
|
||||||
|
import { normalizePaperId } from '../lib/papers.js';
|
||||||
|
import { output, error } from '../lib/output.js';
|
||||||
|
|
||||||
|
function formatList(annotations) {
|
||||||
|
if (annotations.length === 0) {
|
||||||
|
console.log(chalk.dim('No annotations.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
annotations.forEach(a => {
|
||||||
|
console.log(`${chalk.bold(a.id)} ${chalk.dim(`(${a.updatedAt})`)}`);
|
||||||
|
console.log(` ${a.note}`);
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnnotateCommand(program) {
|
||||||
|
program
|
||||||
|
.command('annotate [paper-id] [note]')
|
||||||
|
.description('Read, write, or list local annotations')
|
||||||
|
.option('--clear', 'Remove annotation for this paper')
|
||||||
|
.option('--list', 'List all annotations')
|
||||||
|
.action(async (paperId, note, cmdOpts) => {
|
||||||
|
const opts = { ...program.opts(), ...cmdOpts };
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
const all = listAnnotations();
|
||||||
|
output(all, formatList, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paperId) {
|
||||||
|
error('Provide a paper ID, or use --list', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizePaperId(paperId);
|
||||||
|
|
||||||
|
if (opts.clear) {
|
||||||
|
const removed = clearAnnotation(id);
|
||||||
|
if (removed) {
|
||||||
|
console.log(chalk.green(`Cleared annotation for ${id}`));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.dim(`No annotation for ${id}`));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
const saved = writeAnnotation(id, note);
|
||||||
|
output(saved, () => console.log(chalk.green(`Annotation saved for ${id}`)), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readAnnotation(id);
|
||||||
|
if (existing) {
|
||||||
|
output(existing, () => {
|
||||||
|
console.log(`${chalk.bold(existing.id)} ${chalk.dim(`(${existing.updatedAt})`)}`);
|
||||||
|
console.log(` ${existing.note}`);
|
||||||
|
}, opts);
|
||||||
|
} else {
|
||||||
|
console.log(chalk.dim(`No annotation for ${id}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
24
cli/src/commands/ask.js
Normal file
24
cli/src/commands/ask.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { answerPdfQuery, disconnect } from '../lib/alphaxiv.js';
|
||||||
|
import { output, error } from '../lib/output.js';
|
||||||
|
import { toArxivUrl } from '../lib/papers.js';
|
||||||
|
|
||||||
|
function formatAnswer(data) {
|
||||||
|
console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAskCommand(program) {
|
||||||
|
program
|
||||||
|
.command('ask <url> <question>')
|
||||||
|
.description('Ask a question about a paper (arXiv/alphaXiv URL or ID)')
|
||||||
|
.action(async (url, question, cmdOpts) => {
|
||||||
|
const opts = { ...program.opts(), ...cmdOpts };
|
||||||
|
try {
|
||||||
|
const answer = await answerPdfQuery(toArxivUrl(url), question);
|
||||||
|
output(answer, formatAnswer, opts);
|
||||||
|
} catch (err) {
|
||||||
|
error(err.message, opts);
|
||||||
|
} finally {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
39
cli/src/commands/get.js
Normal file
39
cli/src/commands/get.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { getPaperContent, disconnect } from '../lib/alphaxiv.js';
|
||||||
|
import { readAnnotation } from '../lib/annotations.js';
|
||||||
|
import { output, error } from '../lib/output.js';
|
||||||
|
import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
|
||||||
|
|
||||||
|
function formatPaper({ content, annotation }) {
|
||||||
|
console.log(typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
||||||
|
|
||||||
|
if (annotation) {
|
||||||
|
console.log();
|
||||||
|
console.log(chalk.dim('---'));
|
||||||
|
console.log(chalk.dim(`[Note — ${annotation.updatedAt}]`));
|
||||||
|
console.log(annotation.note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGetCommand(program) {
|
||||||
|
program
|
||||||
|
.command('get <url>')
|
||||||
|
.description('Get paper content + local annotation (arXiv/alphaXiv URL or ID)')
|
||||||
|
.option('--full-text', 'Get raw extracted text instead of AI report')
|
||||||
|
.action(async (url, cmdOpts) => {
|
||||||
|
const opts = { ...program.opts(), ...cmdOpts };
|
||||||
|
try {
|
||||||
|
const paperId = normalizePaperId(url);
|
||||||
|
const arxivUrl = toArxivUrl(url);
|
||||||
|
|
||||||
|
const content = await getPaperContent(arxivUrl, { fullText: !!opts.fullText });
|
||||||
|
const annotation = readAnnotation(paperId);
|
||||||
|
|
||||||
|
output({ content, annotation }, formatPaper, opts);
|
||||||
|
} catch (err) {
|
||||||
|
error(err.message, opts);
|
||||||
|
} finally {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
31
cli/src/commands/login.js
Normal file
31
cli/src/commands/login.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { login, isLoggedIn, logout } from '../lib/auth.js';
|
||||||
|
|
||||||
|
export function registerLoginCommand(program) {
|
||||||
|
program
|
||||||
|
.command('login')
|
||||||
|
.description('Log in to alphaXiv (opens browser)')
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
process.stderr.write(chalk.dim('Already logged in. Use `alpha logout` to sign out first.\n'));
|
||||||
|
}
|
||||||
|
const { userInfo } = await login();
|
||||||
|
const name = userInfo?.name || userInfo?.email || 'unknown';
|
||||||
|
console.log(chalk.green(`Logged in to alphaXiv as ${name}`));
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`${chalk.red('Login failed:')} ${err.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLogoutCommand(program) {
|
||||||
|
program
|
||||||
|
.command('logout')
|
||||||
|
.description('Log out of alphaXiv')
|
||||||
|
.action(() => {
|
||||||
|
logout();
|
||||||
|
console.log(chalk.green('Logged out'));
|
||||||
|
});
|
||||||
|
}
|
||||||
37
cli/src/commands/search.js
Normal file
37
cli/src/commands/search.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { searchByEmbedding, searchByKeyword, disconnect } from '../lib/alphaxiv.js';
|
||||||
|
import { output, error } from '../lib/output.js';
|
||||||
|
|
||||||
|
function formatResults(data) {
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
console.log(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSearchCommand(program) {
|
||||||
|
program
|
||||||
|
.command('search <query>')
|
||||||
|
.description('Search papers via alphaXiv (semantic + keyword)')
|
||||||
|
.option('-m, --mode <mode>', 'Search mode: semantic, keyword, both', 'semantic')
|
||||||
|
.action(async (query, cmdOpts) => {
|
||||||
|
const opts = { ...program.opts(), ...cmdOpts };
|
||||||
|
try {
|
||||||
|
let results;
|
||||||
|
if (opts.mode === 'keyword') {
|
||||||
|
results = await searchByKeyword(query);
|
||||||
|
} else if (opts.mode === 'both') {
|
||||||
|
const [semantic, keyword] = await Promise.all([
|
||||||
|
searchByEmbedding(query),
|
||||||
|
searchByKeyword(query),
|
||||||
|
]);
|
||||||
|
results = { semantic, keyword };
|
||||||
|
} else {
|
||||||
|
results = await searchByEmbedding(query);
|
||||||
|
}
|
||||||
|
output(results, formatResults, opts);
|
||||||
|
} catch (err) {
|
||||||
|
error(err.message, opts);
|
||||||
|
} finally {
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
67
cli/src/index.js
Normal file
67
cli/src/index.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { registerSearchCommand } from './commands/search.js';
|
||||||
|
import { registerGetCommand } from './commands/get.js';
|
||||||
|
import { registerAskCommand } from './commands/ask.js';
|
||||||
|
import { registerAnnotateCommand } from './commands/annotate.js';
|
||||||
|
import { registerLoginCommand, registerLogoutCommand } from './commands/login.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
${chalk.bold('alpha')} — Alpha Hub CLI v${pkg.version}
|
||||||
|
Search papers and annotate what you learn. Powered by alphaXiv.
|
||||||
|
|
||||||
|
${chalk.bold.underline('Usage')}
|
||||||
|
|
||||||
|
${chalk.dim('$')} alpha search "transformer attention mechanisms" ${chalk.dim('# semantic search')}
|
||||||
|
${chalk.dim('$')} alpha search "LoRA" --mode keyword ${chalk.dim('# keyword search')}
|
||||||
|
${chalk.dim('$')} alpha get 1706.03762 ${chalk.dim('# paper content + annotation')}
|
||||||
|
${chalk.dim('$')} alpha get https://arxiv.org/abs/2106.09685 ${chalk.dim('# by URL')}
|
||||||
|
${chalk.dim('$')} alpha ask 1706.03762 "How does attention work?" ${chalk.dim('# ask about a paper')}
|
||||||
|
${chalk.dim('$')} alpha annotate 1706.03762 "key insight" ${chalk.dim('# save a note')}
|
||||||
|
${chalk.dim('$')} alpha annotate --list ${chalk.dim('# see all notes')}
|
||||||
|
|
||||||
|
${chalk.bold.underline('Commands')}
|
||||||
|
|
||||||
|
${chalk.bold('login')} Log in to alphaXiv (opens browser)
|
||||||
|
${chalk.bold('logout')} Log out
|
||||||
|
${chalk.bold('search')} <query> Search papers (semantic, keyword, or agentic)
|
||||||
|
${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation
|
||||||
|
${chalk.bold('ask')} <url|arxiv-id> <question> Ask a question about a paper
|
||||||
|
${chalk.bold('annotate')} [paper-id] [note] Save a note — appears on future fetches
|
||||||
|
${chalk.bold('annotate')} <paper-id> --clear Remove a note
|
||||||
|
${chalk.bold('annotate')} --list List all notes
|
||||||
|
|
||||||
|
${chalk.bold.underline('Flags')}
|
||||||
|
|
||||||
|
--json JSON output (for agents and piping)
|
||||||
|
-m, --mode <mode> Search mode: semantic, keyword, both (default: semantic)
|
||||||
|
--full-text Get raw text instead of AI report (for get)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('alpha')
|
||||||
|
.description('Alpha Hub - search papers and annotate what you learn')
|
||||||
|
.version(pkg.version, '-V, --cli-version')
|
||||||
|
.option('--json', 'Output as JSON (machine-readable)')
|
||||||
|
.action(() => {
|
||||||
|
printUsage();
|
||||||
|
});
|
||||||
|
|
||||||
|
registerLoginCommand(program);
|
||||||
|
registerLogoutCommand(program);
|
||||||
|
registerSearchCommand(program);
|
||||||
|
registerGetCommand(program);
|
||||||
|
registerAskCommand(program);
|
||||||
|
registerAnnotateCommand(program);
|
||||||
|
|
||||||
|
program.parse();
|
||||||
110
cli/src/lib/alphaxiv.js
Normal file
110
cli/src/lib/alphaxiv.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { getValidToken, refreshAccessToken } from './auth.js';
|
||||||
|
|
||||||
|
const ALPHAXIV_MCP_URL = 'https://api.alphaxiv.org/mcp/v1';
|
||||||
|
|
||||||
|
let _client = null;
|
||||||
|
let _connected = false;
|
||||||
|
|
||||||
|
async function getClient() {
|
||||||
|
if (_client && _connected) return _client;
|
||||||
|
|
||||||
|
const token = await getValidToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Not logged in. Run `alpha login` first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_client = new Client({ name: 'alpha', version: '0.1.0' });
|
||||||
|
|
||||||
|
_client.onerror = (err) => {
|
||||||
|
process.stderr.write(`[alpha] alphaXiv MCP error: ${err.message || err}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(ALPHAXIV_MCP_URL), {
|
||||||
|
requestInit: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await _client.connect(transport);
|
||||||
|
_connected = true;
|
||||||
|
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTool(name, args) {
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await getClient();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message?.includes('401') || err.message?.includes('Unauthorized')) {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
if (newToken) {
|
||||||
|
_client = null;
|
||||||
|
_connected = false;
|
||||||
|
client = await getClient();
|
||||||
|
} else {
|
||||||
|
throw new Error('Session expired. Run `alpha login` to re-authenticate.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.callTool({ name, arguments: args });
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
const text = result.content?.[0]?.text || 'Unknown error';
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = result.content?.[0]?.text;
|
||||||
|
if (!text) return result.content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchByEmbedding(query) {
|
||||||
|
return await callTool('embedding_similarity_search', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchByKeyword(query) {
|
||||||
|
return await callTool('full_text_papers_search', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agenticSearch(query) {
|
||||||
|
return await callTool('agentic_paper_retrieval', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaperContent(url, { fullText = false } = {}) {
|
||||||
|
const args = { url };
|
||||||
|
if (fullText) args.fullText = true;
|
||||||
|
return await callTool('get_paper_content', args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function answerPdfQuery(url, query) {
|
||||||
|
return await callTool('answer_pdf_queries', { url, query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGithubRepo(githubUrl, path = '/') {
|
||||||
|
return await callTool('read_files_from_github_repository', { githubUrl, path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnect() {
|
||||||
|
if (_client) {
|
||||||
|
_client.onerror = () => {};
|
||||||
|
try {
|
||||||
|
await _client.close();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
_client = null;
|
||||||
|
_connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
cli/src/lib/annotations.js
Normal file
57
cli/src/lib/annotations.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
function getAnnotationsDir() {
|
||||||
|
const dir = join(homedir(), '.ahub', 'annotations');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(id) {
|
||||||
|
return id.replace(/\//g, '--') + '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAnnotation(id, note) {
|
||||||
|
const filePath = join(getAnnotationsDir(), safeFilename(id));
|
||||||
|
const data = {
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAnnotation(id) {
|
||||||
|
try {
|
||||||
|
const filePath = join(getAnnotationsDir(), safeFilename(id));
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAnnotation(id) {
|
||||||
|
try {
|
||||||
|
const filePath = join(getAnnotationsDir(), safeFilename(id));
|
||||||
|
unlinkSync(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAnnotations() {
|
||||||
|
const dir = getAnnotationsDir();
|
||||||
|
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||||
|
const annotations = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
||||||
|
if (data.id && data.note) annotations.push(data);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return annotations;
|
||||||
|
}
|
||||||
245
cli/src/lib/auth.js
Normal file
245
cli/src/lib/auth.js
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { platform } from 'node:os';
|
||||||
|
|
||||||
|
const CLERK_ISSUER = 'https://clerk.alphaxiv.org';
|
||||||
|
const AUTH_ENDPOINT = `${CLERK_ISSUER}/oauth/authorize`;
|
||||||
|
const TOKEN_ENDPOINT = `${CLERK_ISSUER}/oauth/token`;
|
||||||
|
const REGISTER_ENDPOINT = `${CLERK_ISSUER}/oauth/register`;
|
||||||
|
const CALLBACK_PORT = 9876;
|
||||||
|
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
|
||||||
|
const USERINFO_ENDPOINT = `${CLERK_ISSUER}/oauth/userinfo`;
|
||||||
|
const SCOPES = 'profile email offline_access';
|
||||||
|
|
||||||
|
function getAuthPath() {
|
||||||
|
const dir = join(homedir(), '.ahub');
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return join(dir, 'auth.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAuth() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(getAuthPath(), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAuth(data) {
|
||||||
|
writeFileSync(getAuthPath(), JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
const auth = loadAuth();
|
||||||
|
if (!auth?.access_token) return null;
|
||||||
|
return auth.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserId() {
|
||||||
|
const auth = loadAuth();
|
||||||
|
return auth?.user_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserName() {
|
||||||
|
const auth = loadAuth();
|
||||||
|
return auth?.user_name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserInfo(accessToken) {
|
||||||
|
const res = await fetch(USERINFO_ENDPOINT, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerClient() {
|
||||||
|
const res = await fetch(REGISTER_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: 'Alpha Hub CLI',
|
||||||
|
redirect_uris: [REDIRECT_URI],
|
||||||
|
grant_types: ['authorization_code'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Client registration failed: ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePKCE() {
|
||||||
|
const verifier = randomBytes(32).toString('base64url');
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBrowser(url) {
|
||||||
|
const plat = platform();
|
||||||
|
if (plat === 'darwin') execSync(`open "${url}"`);
|
||||||
|
else if (plat === 'linux') execSync(`xdg-open "${url}"`);
|
||||||
|
else if (plat === 'win32') execSync(`start "${url}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForCallback(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
server.close();
|
||||||
|
reject(new Error('Login timed out after 120 seconds'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
server.on('request', (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
|
||||||
|
|
||||||
|
if (url.pathname !== '/callback') {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end('<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>');
|
||||||
|
clearTimeout(timeout);
|
||||||
|
server.close();
|
||||||
|
reject(new Error(`OAuth error: ${error}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end('<html><body><h2>Logged in to Alpha Hub</h2><p>You can close this tab.</p></body></html>');
|
||||||
|
clearTimeout(timeout);
|
||||||
|
server.close();
|
||||||
|
resolve(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(code, clientId, codeVerifier) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
client_id: clientId,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken() {
|
||||||
|
const auth = loadAuth();
|
||||||
|
if (!auth?.refresh_token || !auth?.client_id) return null;
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: auth.refresh_token,
|
||||||
|
client_id: auth.client_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const tokens = await res.json();
|
||||||
|
saveAuth({
|
||||||
|
...auth,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token || auth.refresh_token,
|
||||||
|
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : auth.expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login() {
|
||||||
|
const registration = await registerClient();
|
||||||
|
const clientId = registration.client_id;
|
||||||
|
const { verifier, challenge } = generatePKCE();
|
||||||
|
|
||||||
|
const state = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
const authUrl = new URL(AUTH_ENDPOINT);
|
||||||
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
|
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('scope', SCOPES);
|
||||||
|
authUrl.searchParams.set('code_challenge', challenge);
|
||||||
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
server.listen(CALLBACK_PORT);
|
||||||
|
|
||||||
|
process.stderr.write('Opening browser for alphaXiv login...\n');
|
||||||
|
openBrowser(authUrl.toString());
|
||||||
|
process.stderr.write(`If browser didn't open, visit:\n${authUrl.toString()}\n\n`);
|
||||||
|
process.stderr.write('Waiting for login...\n');
|
||||||
|
|
||||||
|
const code = await waitForCallback(server);
|
||||||
|
|
||||||
|
const tokens = await exchangeCode(code, clientId, verifier);
|
||||||
|
|
||||||
|
const userInfo = await fetchUserInfo(tokens.access_token);
|
||||||
|
|
||||||
|
saveAuth({
|
||||||
|
client_id: clientId,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
|
||||||
|
user_id: userInfo?.sub || null,
|
||||||
|
user_name: userInfo?.name || userInfo?.preferred_username || null,
|
||||||
|
user_email: userInfo?.email || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tokens, userInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getValidToken() {
|
||||||
|
let token = getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
const auth = loadAuth();
|
||||||
|
if (auth?.expires_at && Date.now() > auth.expires_at - 60000) {
|
||||||
|
token = await refreshAccessToken();
|
||||||
|
}
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggedIn() {
|
||||||
|
return !!getAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
try {
|
||||||
|
writeFileSync(getAuthPath(), '{}', 'utf8');
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
20
cli/src/lib/output.js
Normal file
20
cli/src/lib/output.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
export function output(data, humanFormatter, opts) {
|
||||||
|
if (opts?.json) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
} else {
|
||||||
|
humanFormatter(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function info(msg) {
|
||||||
|
process.stderr.write(msg + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(msg, opts) {
|
||||||
|
if (opts?.json) {
|
||||||
|
console.log(JSON.stringify({ error: msg }));
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`Error: ${msg}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
25
cli/src/lib/papers.js
Normal file
25
cli/src/lib/papers.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
export function normalizePaperId(input) {
|
||||||
|
const patterns = [
|
||||||
|
/arxiv\.org\/abs\/(\d+\.\d+)/,
|
||||||
|
/arxiv\.org\/pdf\/(\d+\.\d+)/,
|
||||||
|
/alphaxiv\.org\/(?:abs|overview)\/(\d+\.\d+)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = input.match(pattern);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+\.\d+$/.test(input)) return input;
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toArxivUrl(input) {
|
||||||
|
const id = normalizePaperId(input);
|
||||||
|
if (/^\d+\.\d+$/.test(id)) {
|
||||||
|
return `https://arxiv.org/abs/${id}`;
|
||||||
|
}
|
||||||
|
if (input.startsWith('http')) return input;
|
||||||
|
return `https://arxiv.org/abs/${input}`;
|
||||||
|
}
|
||||||
84
cli/src/mcp/server.js
Normal file
84
cli/src/mcp/server.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { handleSearch, handleGet, handleAsk, handleAnnotate, handleCode } from './tools.js';
|
||||||
|
|
||||||
|
const _stderr = process.stderr;
|
||||||
|
console.log = (...args) => _stderr.write(args.join(' ') + '\n');
|
||||||
|
console.warn = (...args) => _stderr.write('[warn] ' + args.join(' ') + '\n');
|
||||||
|
console.info = (...args) => _stderr.write('[info] ' + args.join(' ') + '\n');
|
||||||
|
console.debug = (...args) => _stderr.write('[debug] ' + args.join(' ') + '\n');
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: 'alpha',
|
||||||
|
version: pkg.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'alpha_search',
|
||||||
|
'Search research papers via alphaXiv. Supports semantic (embedding), keyword, and agentic search modes.',
|
||||||
|
{
|
||||||
|
query: z.string().describe('Search query — use 2-3 sentences for semantic mode, keywords for keyword mode'),
|
||||||
|
mode: z.enum(['semantic', 'keyword', 'agentic']).optional().describe('Search mode (default: semantic)'),
|
||||||
|
},
|
||||||
|
async (args) => handleSearch(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'alpha_get',
|
||||||
|
'Get paper content and local annotation. Accepts arXiv URL, alphaXiv URL, or arXiv ID.',
|
||||||
|
{
|
||||||
|
url: z.string().describe('arXiv/alphaXiv URL or arXiv ID (e.g. "2106.09685")'),
|
||||||
|
full_text: z.boolean().optional().describe('Get raw text instead of AI-generated report (default: false)'),
|
||||||
|
},
|
||||||
|
async (args) => handleGet(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'alpha_ask',
|
||||||
|
'Ask a question about a specific paper. Uses AI to analyze the PDF and answer.',
|
||||||
|
{
|
||||||
|
url: z.string().describe('arXiv/alphaXiv URL or arXiv ID'),
|
||||||
|
question: z.string().describe('Question about the paper'),
|
||||||
|
},
|
||||||
|
async (args) => handleAsk(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'alpha_annotate',
|
||||||
|
'Read, write, clear, or list local annotations on papers. Annotations persist across sessions and appear on future fetches.',
|
||||||
|
{
|
||||||
|
id: z.string().optional().describe('Paper ID (arXiv ID or URL). Required unless using list mode.'),
|
||||||
|
note: z.string().optional().describe('Annotation text to save. Omit to read existing.'),
|
||||||
|
clear: z.boolean().optional().describe('Remove annotation for this paper'),
|
||||||
|
list: z.boolean().optional().describe('List all annotations'),
|
||||||
|
},
|
||||||
|
async (args) => handleAnnotate(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'alpha_code',
|
||||||
|
"Read files from a paper's GitHub repository. Use path '/' for repo overview.",
|
||||||
|
{
|
||||||
|
github_url: z.string().describe('GitHub repository URL'),
|
||||||
|
path: z.string().optional().describe("File or directory path (default: '/')"),
|
||||||
|
},
|
||||||
|
async (args) => handleCode(args),
|
||||||
|
);
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
_stderr.write(`[alpha-mcp] Uncaught exception: ${err.message}\n`);
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
_stderr.write(`[alpha-mcp] Unhandled rejection: ${reason}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
_stderr.write(`[alpha-mcp] Server started (v${pkg.version})\n`);
|
||||||
94
cli/src/mcp/tools.js
Normal file
94
cli/src/mcp/tools.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
searchByEmbedding,
|
||||||
|
searchByKeyword,
|
||||||
|
agenticSearch,
|
||||||
|
getPaperContent,
|
||||||
|
answerPdfQuery,
|
||||||
|
readGithubRepo,
|
||||||
|
} from '../lib/alphaxiv.js';
|
||||||
|
import { writeAnnotation, readAnnotation, clearAnnotation, listAnnotations } from '../lib/annotations.js';
|
||||||
|
import { normalizePaperId, toArxivUrl } from '../lib/papers.js';
|
||||||
|
|
||||||
|
function textResult(data) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResult(message) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSearch({ query, mode = 'semantic' }) {
|
||||||
|
try {
|
||||||
|
if (mode === 'keyword') return textResult(await searchByKeyword(query));
|
||||||
|
if (mode === 'agentic') return textResult(await agenticSearch(query));
|
||||||
|
return textResult(await searchByEmbedding(query));
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(`Search failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGet({ url, full_text = false }) {
|
||||||
|
try {
|
||||||
|
const paperId = normalizePaperId(url);
|
||||||
|
const arxivUrl = toArxivUrl(url);
|
||||||
|
|
||||||
|
const content = await getPaperContent(arxivUrl, { fullText: full_text });
|
||||||
|
const annotation = readAnnotation(paperId);
|
||||||
|
|
||||||
|
return textResult({ content, annotation });
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(`Failed to fetch paper: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAsk({ url, question }) {
|
||||||
|
try {
|
||||||
|
const answer = await answerPdfQuery(toArxivUrl(url), question);
|
||||||
|
return textResult(answer);
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(`Ask failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAnnotate({ id, note, clear = false, list = false }) {
|
||||||
|
try {
|
||||||
|
if (list) {
|
||||||
|
const all = listAnnotations();
|
||||||
|
return textResult({ annotations: all, total: all.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) return errorResult('Provide a paper ID or use list mode.');
|
||||||
|
|
||||||
|
const paperId = normalizePaperId(id);
|
||||||
|
|
||||||
|
if (clear) {
|
||||||
|
const removed = clearAnnotation(paperId);
|
||||||
|
return textResult({ status: removed ? 'cleared' : 'not_found', id: paperId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
const saved = writeAnnotation(paperId, note);
|
||||||
|
return textResult({ status: 'saved', annotation: saved });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readAnnotation(paperId);
|
||||||
|
if (existing) return textResult({ annotation: existing });
|
||||||
|
return textResult({ status: 'no_annotation', id: paperId });
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(`Annotation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCode({ github_url, path = '/' }) {
|
||||||
|
try {
|
||||||
|
const result = await readGithubRepo(github_url, path);
|
||||||
|
return textResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult(`Code read failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
package.json
Normal file
6
package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "alpha-hub",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue