mirror of
https://github.com/getcompanion-ai/alpha-hub.git
synced 2026-04-15 05:02:06 +00:00
Add status command, strip raw blobs from search, harden login flow
- Add `alpha status` command to check auth state - Search results drop raw text blobs by default (opt-in via includeRaw) - Clean and normalize search result fields - Export parsePaperSearchResults for external consumers - Login callback server handles EADDRINUSE, styled HTML pages - Better browser open error handling on all platforms - Search prints mode description before results Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f6469556f0
commit
081d32d50c
8 changed files with 134 additions and 29 deletions
|
|
@ -10,6 +10,7 @@ Research agents hallucinate paper details and forget what they learn in a sessio
|
|||
```bash
|
||||
npm install -g @companion-ai/alpha-hub
|
||||
alpha login # sign in with alphaXiv
|
||||
alpha status # show whether alphaXiv auth is present
|
||||
alpha search "attention mechanism" # search papers
|
||||
alpha get 1706.03762 # fetch paper report
|
||||
```
|
||||
|
|
@ -52,6 +53,7 @@ alpha ask 1706.03762 "What datasets were used for evaluation?"
|
|||
| `alpha annotate <id> --clear` | Remove a note |
|
||||
| `alpha annotate --list` | List all notes |
|
||||
| `alpha login` | Sign in with alphaXiv |
|
||||
| `alpha status` | Show alphaXiv authentication status |
|
||||
| `alpha logout` | Sign out |
|
||||
|
||||
All commands accept `--json` for machine-readable output.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ npm install -g @companion-ai/alpha-hub
|
|||
|
||||
```bash
|
||||
alpha login
|
||||
alpha status
|
||||
alpha search "attention mechanism"
|
||||
alpha get 1706.03762
|
||||
alpha ask 1706.03762 "What datasets were used for evaluation?"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import chalk from 'chalk';
|
||||
import { login, isLoggedIn, logout } from '../lib/auth.js';
|
||||
import { getUserName, login, isLoggedIn, logout } from '../lib/auth.js';
|
||||
|
||||
export function registerLoginCommand(program) {
|
||||
program
|
||||
|
|
@ -29,3 +29,19 @@ export function registerLogoutCommand(program) {
|
|||
console.log(chalk.green('Logged out'));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerStatusCommand(program) {
|
||||
program
|
||||
.command('status')
|
||||
.description('Show alphaXiv authentication status')
|
||||
.action(() => {
|
||||
if (!isLoggedIn()) {
|
||||
process.stderr.write(chalk.dim('Not logged in to alphaXiv.\n'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const name = getUserName();
|
||||
console.log(chalk.green(name ? `Logged in to alphaXiv as ${name}` : 'Logged in to alphaXiv'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
import chalk from 'chalk';
|
||||
import { searchByEmbedding, searchByKeyword, agenticSearch, disconnect } from '../lib/alphaxiv.js';
|
||||
import { output, error } from '../lib/output.js';
|
||||
import { output, error, info } from '../lib/output.js';
|
||||
|
||||
function formatResults(data) {
|
||||
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
console.log(text);
|
||||
}
|
||||
|
||||
function describeMode(mode) {
|
||||
switch (mode) {
|
||||
case 'keyword':
|
||||
return 'keyword full-text';
|
||||
case 'agentic':
|
||||
return 'agentic';
|
||||
case 'both':
|
||||
return 'semantic + keyword';
|
||||
case 'all':
|
||||
return 'semantic + keyword + agentic';
|
||||
default:
|
||||
return 'semantic';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSearchCommand(program) {
|
||||
program
|
||||
.command('search <query>')
|
||||
|
|
@ -15,6 +30,9 @@ export function registerSearchCommand(program) {
|
|||
.action(async (query, cmdOpts) => {
|
||||
const opts = { ...program.opts(), ...cmdOpts };
|
||||
try {
|
||||
if (!opts.json) {
|
||||
info(chalk.dim(`Searching alphaXiv (${describeMode(opts.mode)})...`));
|
||||
}
|
||||
let results;
|
||||
if (opts.mode === 'keyword') {
|
||||
results = await searchByKeyword(query);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { registerGetCommand } from './commands/get.js';
|
|||
import { registerAskCommand } from './commands/ask.js';
|
||||
import { registerAnnotateCommand } from './commands/annotate.js';
|
||||
import { registerCodeCommand } from './commands/code.js';
|
||||
import { registerLoginCommand, registerLogoutCommand } from './commands/login.js';
|
||||
import { registerLoginCommand, registerLogoutCommand, registerStatusCommand } from './commands/login.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
||||
|
|
@ -34,6 +34,7 @@ ${chalk.bold.underline('Usage')}
|
|||
${chalk.bold.underline('Commands')}
|
||||
|
||||
${chalk.bold('login')} Log in to alphaXiv (opens browser)
|
||||
${chalk.bold('status')} Show alphaXiv authentication status
|
||||
${chalk.bold('logout')} Log out
|
||||
${chalk.bold('search')} <query> Search papers (semantic, keyword, both, agentic, or all)
|
||||
${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation
|
||||
|
|
@ -63,6 +64,7 @@ program
|
|||
});
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerStatusCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
registerSearchCommand(program);
|
||||
registerGetCommand(program);
|
||||
|
|
|
|||
|
|
@ -81,10 +81,56 @@ function generatePKCE() {
|
|||
}
|
||||
|
||||
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}"`);
|
||||
try {
|
||||
const plat = platform();
|
||||
if (plat === 'darwin') execSync(`open "${url}"`);
|
||||
else if (plat === 'linux') execSync(`xdg-open "${url}"`);
|
||||
else if (plat === 'win32') execSync(`start "" "${url}"`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const SUCCESS_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>alphaXiv</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #e5e5e5; }
|
||||
.card { text-align: center; padding: 2rem; }
|
||||
h2 { color: #10b981; margin-bottom: 0.5rem; }
|
||||
p { color: #737373; }
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="card"><h2>Logged in to alphaXiv</h2><p>You can close this tab</p></div>
|
||||
<script>setTimeout(function(){window.close()},2000)</script>
|
||||
</body></html>`;
|
||||
|
||||
const ERROR_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>alphaXiv</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #e5e5e5; }
|
||||
.card { text-align: center; padding: 2rem; }
|
||||
h2 { color: #ef4444; margin-bottom: 0.5rem; }
|
||||
p { color: #737373; }
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="card"><h2>Login failed</h2><p>You can close this tab and try again</p></div></body></html>`;
|
||||
|
||||
function startCallbackServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close the process using it and try again.`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForCallback(server) {
|
||||
|
|
@ -108,7 +154,7 @@ function waitForCallback(server) {
|
|||
|
||||
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>');
|
||||
res.end(ERROR_HTML);
|
||||
clearTimeout(timeout);
|
||||
server.close();
|
||||
reject(new Error(`OAuth error: ${error}`));
|
||||
|
|
@ -117,7 +163,7 @@ function waitForCallback(server) {
|
|||
|
||||
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>');
|
||||
res.end(SUCCESS_HTML);
|
||||
clearTimeout(timeout);
|
||||
server.close();
|
||||
resolve(code);
|
||||
|
|
@ -194,8 +240,7 @@ export async function login() {
|
|||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.set('state', state);
|
||||
|
||||
const server = createServer();
|
||||
server.listen(CALLBACK_PORT);
|
||||
const server = await startCallbackServer();
|
||||
|
||||
process.stderr.write('Opening browser for alphaXiv login...\n');
|
||||
openBrowser(authUrl.toString());
|
||||
|
|
|
|||
9
cli/src/lib/index.d.ts
vendored
9
cli/src/lib/index.d.ts
vendored
|
|
@ -13,9 +13,18 @@ export declare function searchByEmbedding(query: string): Promise<unknown>;
|
|||
export declare function searchByKeyword(query: string): Promise<unknown>;
|
||||
export declare function agenticSearch(query: string): Promise<unknown>;
|
||||
|
||||
export declare function parsePaperSearchResults(
|
||||
text: unknown,
|
||||
options?: { includeRaw?: boolean },
|
||||
): {
|
||||
results: unknown[];
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export declare function searchPapers(
|
||||
query: string,
|
||||
mode?: "semantic" | "keyword" | "both" | "agentic" | "all" | string,
|
||||
options?: { includeRaw?: boolean },
|
||||
): Promise<unknown>;
|
||||
|
||||
export declare function getPaper(
|
||||
|
|
|
|||
|
|
@ -45,9 +45,21 @@ function parsePublishedAt(fragment) {
|
|||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function parsePaperListText(text) {
|
||||
function cleanSearchField(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/\s*\n\s*/g, ' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function parsePaperSearchResults(text, options = {}) {
|
||||
const includeRaw = options.includeRaw === true;
|
||||
if (typeof text !== 'string') {
|
||||
return { raw: text, results: [] };
|
||||
return { results: [] };
|
||||
}
|
||||
|
||||
const blocks = text
|
||||
|
|
@ -69,28 +81,28 @@ function parsePaperListText(text) {
|
|||
|
||||
return {
|
||||
rank: index + 1,
|
||||
title: headerMatch ? headerMatch[1].trim() : header,
|
||||
title: cleanSearchField(headerMatch ? headerMatch[1] : header),
|
||||
visits: headerMatch ? parseMetricNumber(headerMatch[2], 'Visits') : null,
|
||||
likes: headerMatch ? parseMetricNumber(headerMatch[2], 'Likes') : null,
|
||||
publishedAt: headerMatch ? parsePublishedAt(headerMatch[2]) : null,
|
||||
organizations: fieldValue('- Organizations:'),
|
||||
authors: fieldValue('- Authors:'),
|
||||
abstract: fieldValue('- Abstract:'),
|
||||
arxivId,
|
||||
organizations: cleanSearchField(fieldValue('- Organizations:')),
|
||||
authors: cleanSearchField(fieldValue('- Authors:')),
|
||||
abstract: cleanSearchField(fieldValue('- Abstract:')),
|
||||
arxivId: cleanSearchField(arxivId),
|
||||
arxivUrl: arxivId ? `https://arxiv.org/abs/${arxivId}` : null,
|
||||
alphaXivUrl: arxivId ? `https://www.alphaxiv.org/overview/${arxivId}` : null,
|
||||
raw: block,
|
||||
...(includeRaw ? { raw: block } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return { raw: text, results };
|
||||
return includeRaw ? { raw: text, results } : { results };
|
||||
}
|
||||
|
||||
function normalizeSearchPayload(query, mode, payload) {
|
||||
function normalizeSearchPayload(query, mode, payload, options = {}) {
|
||||
if (mode === 'all' || mode === 'both') {
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
normalized[key] = parsePaperListText(value);
|
||||
normalized[key] = parsePaperSearchResults(value, options);
|
||||
}
|
||||
return {
|
||||
query,
|
||||
|
|
@ -99,7 +111,7 @@ function normalizeSearchPayload(query, mode, payload) {
|
|||
};
|
||||
}
|
||||
|
||||
const parsed = parsePaperListText(payload);
|
||||
const parsed = parsePaperSearchResults(payload, options);
|
||||
return {
|
||||
query,
|
||||
mode,
|
||||
|
|
@ -107,18 +119,18 @@ function normalizeSearchPayload(query, mode, payload) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function searchPapers(query, mode = 'semantic') {
|
||||
if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query));
|
||||
if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query));
|
||||
export async function searchPapers(query, mode = 'semantic', options = {}) {
|
||||
if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query), options);
|
||||
if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query), options);
|
||||
if (mode === 'both') {
|
||||
const [semantic, keyword] = await Promise.all([
|
||||
searchByEmbedding(query),
|
||||
searchByKeyword(query),
|
||||
]);
|
||||
return normalizeSearchPayload(query, mode, { semantic, keyword });
|
||||
return normalizeSearchPayload(query, mode, { semantic, keyword }, options);
|
||||
}
|
||||
if (mode === 'all') return normalizeSearchPayload(query, mode, await searchAll(query));
|
||||
return normalizeSearchPayload(query, mode, await searchByEmbedding(query));
|
||||
if (mode === 'all') return normalizeSearchPayload(query, mode, await searchAll(query), options);
|
||||
return normalizeSearchPayload(query, mode, await searchByEmbedding(query), options);
|
||||
}
|
||||
|
||||
export async function getPaper(identifier, options = {}) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue