import Fastify from 'fastify' import cors from '@fastify/cors' import { readFileSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { getContext, closeContext } from './browser.js' import { extractContent } from './extractor.js' import { pageToRSS } from './feeds.js' import { chat } from './llm.js' import { resolveManifest, resolveDatasetManifest, getKnownSpaces } from './curlycue.js' import { callSpace, callSpaceFromPrompt, previewSpaceCall } from './spacecall.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const AGENTS_MD = readFileSync(resolve(__dirname, '../AGENTS.md'), 'utf8') const UI_HTML = readFileSync(resolve(__dirname, './ui.html'), 'utf8') const INJECT_SRC = resolve(__dirname, './inject.browser.js') const PORT = parseInt(process.env.BEACON_PORT ?? '3700') const PUBLIC_BASE = process.env.BEACON_PUBLIC_URL || `http://localhost:${PORT}` const app = Fastify({ logger: { level: 'warn' } }) await app.register(cors) // ── Manifest ──────────────────────────────────────────────────────────────── app.get('/', async (req, reply) => { const accept = req.headers.accept ?? '' if (accept.includes('text/html')) { reply.type('text/html').send(UI_HTML) } else { reply.type('text/markdown').send(AGENTS_MD) } }) app.get('/ui', async (_, reply) => { reply.type('text/html').send(UI_HTML) }) // /agents.md alias — standard path for the curlyCue spec app.get('/agents.md', async (_, reply) => { reply.type('text/markdown').send(AGENTS_MD) }) app.get('/health', async () => ({ status: 'ok', ts: Date.now(), port: PORT })) // ── Space caller — call any Gradio Space through the beacon ───────────────── // POST /space/call { space: "owner/name", endpoint: "predict", inputs: {...} } // File inputs that are URLs are uploaded automatically. // Returns { outputs, output_urls, space, endpoint, event_id } app.post('/space/call', async (req, reply) => { const { space, endpoint, inputs, timeout } = req.body ?? {} if (!space) return reply.code(400).send({ error: 'space required (e.g. "microsoft/TRELLIS.2")' }) try { const result = await callSpace({ space, endpoint, inputs, timeout }) return result } catch (err) { reply.code(502).send({ error: err.message }) } }) // Natural language → Space call // POST /space/ask { space: "owner/name", prompt: "make a 3d model from this image: https://..." } // LLM reads the Space schema and fills in endpoint + inputs automatically app.post('/space/ask', async (req, reply) => { const { space, prompt, timeout } = req.body ?? {} if (!space) return reply.code(400).send({ error: 'space required' }) if (!prompt) return reply.code(400).send({ error: 'prompt required' }) try { const result = await callSpaceFromPrompt({ space, prompt, timeout }) return result } catch (err) { reply.code(502).send({ error: err.message }) } }) // List all known spaces — powers the sidebar cards in /ui app.get('/space/list', async () => getKnownSpaces()) // Dry-run: LLM resolves parameters but does NOT call the Space // Returns { curl, structured, resolved } for the UI preview panel app.post('/space/preview', async (req, reply) => { const { space, prompt } = req.body ?? {} if (!space) return reply.code(400).send({ error: 'space required' }) if (!prompt) return reply.code(400).send({ error: 'prompt required' }) try { const result = await previewSpaceCall({ space, prompt }) return result } catch (err) { reply.code(502).send({ error: err.message }) } }) // ── curlyCue — AGENTS.md for any HF Space or dataset ──────────────────────── // curl https://acecalisto3-beacon.hf.space/spaces/owner/name/agents.md app.get('/spaces/:owner/:name/agents.md', async (req, reply) => { const { owner, name } = req.params const result = await resolveManifest(owner, name) if (!result) return reply.code(404).send(`# AGENTS.md not found\n\n${owner}/${name} has no discoverable manifest.`) reply.type('text/markdown').header('X-CurlyCue-Source', result.source).send(result.content) }) // curl https://acecalisto3-beacon.hf.space/datasets/owner/name/agents.md app.get('/datasets/:owner/:name/agents.md', async (req, reply) => { const { owner, name } = req.params try { const content = await resolveDatasetManifest(owner, name) reply.type('text/markdown').header('X-CurlyCue-Source', 'hf-dataset').send(content) } catch (err) { reply.code(502).send({ error: err.message }) } }) // ── Core fetch helpers ─────────────────────────────────────────────────────── async function withPage(url, fn, reply) { if (!url) return reply.code(400).send({ error: 'url param required' }) const ctx = await getContext() const page = await ctx.newPage() try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }) return await fn(page) } catch (err) { reply.code(502).send({ error: err.message }) } finally { await page.close() } } // ── Endpoints ──────────────────────────────────────────────────────────────── // Clean readable text — the workhorse app.get('/raw', async (req, reply) => { const result = await withPage(req.query.url, page => extractContent(page, 'text'), reply) if (result) reply.type('text/plain').send(result) }) // Structured article JSON app.get('/json', async (req, reply) => { const result = await withPage(req.query.url, page => extractContent(page, 'json'), reply) if (result) reply.send(result) }) // RSS feed from any URL app.get('/feed', async (req, reply) => { const { url } = req.query const result = await withPage(url, page => pageToRSS(page, url), reply) if (result) reply.type('application/rss+xml').send(result) }) // Screenshot app.get('/screenshot', async (req, reply) => { const result = await withPage( req.query.url, page => page.screenshot({ fullPage: false, type: 'png' }), reply ) if (result) reply.type('image/png').send(result) }) // Inject script — served as JS so any page can load it // Bookmarklet: javascript:(function(){var s=document.createElement('script');s.src='BASE/inject.js';document.head.appendChild(s)})() app.get('/inject.js', async (_, reply) => { const script = readFileSync(INJECT_SRC, 'utf8').replaceAll('__BEACON_BASE__', PUBLIC_BASE) reply.type('application/javascript').send(script) }) // Chat — natural language in, LLM reply out // Body: { message: string, context?: string (URL) } // The current page is fetched through the beacon and injected as context app.post('/chat', async (req, reply) => { const { message, context: contextUrl } = req.body ?? {} if (!message) return reply.code(400).send({ error: 'message required' }) let pageContent = '' if (contextUrl) { try { const ctx = await getContext() const page = await ctx.newPage() try { await page.goto(contextUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }) const raw = await extractContent(page, 'json') pageContent = `Title: ${raw.title}\nURL: ${raw.url}\n\n${(raw.content || raw.text || '').slice(0, 5000)}` } finally { await page.close() } } catch {} } const messages = [ { role: 'system', content: `You are a browsing agent with authenticated access to the web. You are embedded in the user's browser via the beacon console interface. Respond concisely and directly — this is a DevTools console, not a chat UI. ${pageContent ? 'Current page content is provided below.' : 'No page context provided.'}`, }, ...(pageContent ? [{ role: 'user', content: `Page content:\n${pageContent}`, }, { role: 'assistant', content: 'I have the page content. What would you like to know?', }] : []), { role: 'user', content: message }, ] try { const reply_text = await chat(messages) return { reply: reply_text } } catch (err) { return reply.code(502).send({ error: err.message }) } }) // Save session snapshot for a named site (call after you've logged in via CDP) app.post('/session/:name', async (req, reply) => { const { name } = req.params const ctx = await getContext() try { const path = resolve(__dirname, `../sessions/snapshots/${name}.json`) await ctx.storageState({ path }) return { saved: path } } catch (err) { reply.code(500).send({ error: err.message }) } }) // ── Start ──────────────────────────────────────────────────────────────────── process.on('SIGINT', async () => { await closeContext() process.exit(0) }) await app.listen({ port: PORT, host: '0.0.0.0' }) console.log(` ╔══════════════════════════════════════════════════════╗ ║ beacon :${PORT} ║ ╠══════════════════════════════════════════════════════╣ ║ GET /ui → beacon dashboard ║ ║ GET /space/list → known spaces (JSON) ║ ║ POST /space/preview → resolve params, no call ║ ║ POST /space/ask → NL → Space call ║ ║ POST /space/call → direct Space call ║ ╠══════════════════════════════════════════════════════╣ ║ GET /raw?url=... → clean text ║ ║ GET /json?url=... → article JSON ║ ║ GET /feed?url=... → RSS feed ║ ║ GET /screenshot?url=... → PNG ║ ║ GET /inject.js → browser console bridge ║ ║ POST /chat → LLM chat w/ page context ║ ║ POST /session/:name → save auth state ║ ╠══════════════════════════════════════════════════════╣ ║ Console bookmarklet: ║ ║ javascript:(function(){var s=document.createElement ║ ║ ('script');s.src='${PUBLIC_BASE}/inject.js'; ║ ║ document.head.appendChild(s)})() ║ ╚══════════════════════════════════════════════════════╝ `)