This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Letta API enrichment for paper summaries

- enrichWithLetta() calls Letta REST API for LLM-generated summaries
- /api/enrich endpoint enriches records with placeholder/empty summaries
- Also enriches records missing takeaway field
- Produces structured JSON: summary, domains, takeaway
- LETTA_API_KEY and LETTA_AGENT_ID as Cloudflare secrets
- All 8 existing records enriched with real takeaways

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

author
Nandi
co-author
Letta Code
date (May 17, 2026, 9:17 PM -0700) commit f6f89804 parent 003b56b7
+170 -2
+5
src/auth.ts
··· 143 143 } 144 144 145 145 export async function loadPrivateKey(): Promise<string> { 146 + // In Cloudflare Workers, the key is passed directly via RESEARCHER_KEY_PEM secret 147 + if (process.env.RESEARCHER_KEY_PEM) { 148 + return process.env.RESEARCHER_KEY_PEM; 149 + } 150 + // Fallback: read from filesystem (CLI/local use) 146 151 const keyPath = process.env.RESEARCHER_KEY_PATH || "/home/nandi/vault/.credentials/researcher-pds-latha-org.pem"; 147 152 return readFile(keyPath, "utf-8"); 148 153 }
+86 -1
src/paper-summarizer.ts
··· 3 3 * 4 4 * Two modes: 5 5 * 1. Worker mode: heuristic extraction from embed + OpenGraph enrichment 6 - * 2. CLI mode: uses letta-code-sdk for rich OXA-backed summaries 6 + * 2. Letta enrichment: calls Letta API for rich LLM-generated summaries 7 7 */ 8 8 9 9 import { mystTextToOxaRecord } from "@nandithebull/myst-oxa"; ··· 174 174 year: metadata.year || null, 175 175 }; 176 176 } 177 + 178 + // --- Letta-powered enrichment --- 179 + 180 + const LETTA_API_BASE = "https://api.letta.com/v1"; 181 + 182 + const ENRICHMENT_PROMPT = `You are a research paper summarizer. Given a paper's URL, title, abstract, and existing metadata, produce a concise, insightful summary. 183 + 184 + Return ONLY a JSON object with these fields (no markdown, no code fences): 185 + - "summary": 2-3 sentence summary in your own words. Focus on what the paper does and why it matters. Not a restatement of the abstract. 186 + - "domains": array of 1-3 research domain strings (e.g. ["machine learning", "optimization"]) 187 + - "takeaway": one sentence — the single most important thing a reader should remember 188 + 189 + Paper: {title} 190 + URL: {paperUrl} 191 + Abstract: {abstract} 192 + Existing domains: {domains} 193 + Venue: {venue}`; 194 + 195 + export interface LettaEnrichment { 196 + summary: string; 197 + domains: string[]; 198 + takeaway: string; 199 + } 200 + 201 + export async function enrichWithLetta( 202 + paper: { title: string; paperUrl: string; abstract: string; domains: string[]; venue: string }, 203 + apiKey: string, 204 + agentId: string, 205 + ): Promise<LettaEnrichment> { 206 + const prompt = ENRICHMENT_PROMPT 207 + .replace("{title}", paper.title) 208 + .replace("{paperUrl}", paper.paperUrl) 209 + .replace("{abstract}", paper.abstract || "(no abstract available)") 210 + .replace("{domains}", paper.domains.join(", ") || "unknown") 211 + .replace("{venue}", paper.venue || "unknown"); 212 + 213 + const res = await fetch(`${LETTA_API_BASE}/agents/${agentId}/messages`, { 214 + method: "POST", 215 + headers: { 216 + "Authorization": `Bearer ${apiKey}`, 217 + "Content-Type": "application/json", 218 + "Accept": "application/json", 219 + }, 220 + body: JSON.stringify({ 221 + messages: [{ role: "user", content: prompt }], 222 + }), 223 + }); 224 + 225 + if (!res.ok) { 226 + const body = await res.text(); 227 + throw new Error(`Letta API error (${res.status}): ${body}`); 228 + } 229 + 230 + const data = await res.json() as { 231 + messages: Array<{ message_type: string; content?: string }>; 232 + }; 233 + 234 + // Find the assistant message 235 + const assistantMsg = data.messages.find( 236 + (m) => m.message_type === "assistant_message" && m.content, 237 + ); 238 + 239 + if (!assistantMsg?.content) { 240 + throw new Error("No assistant message in Letta response"); 241 + } 242 + 243 + // Parse JSON from the response — handle possible markdown code fences 244 + let content = assistantMsg.content; 245 + const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); 246 + if (jsonMatch) content = jsonMatch[1].trim(); 247 + 248 + let parsed: LettaEnrichment; 249 + try { 250 + parsed = JSON.parse(content); 251 + } catch { 252 + // If JSON parse fails, use the raw content as summary 253 + parsed = { summary: content, domains: paper.domains, takeaway: "" }; 254 + } 255 + 256 + return { 257 + summary: parsed.summary || content, 258 + domains: parsed.domains || paper.domains, 259 + takeaway: parsed.takeaway || "", 260 + }; 261 + }
+79 -1
src/worker.ts
··· 12 12 import { lexicons } from "../lexicons/generated/index.js"; 13 13 import { config } from "./contrail.config.js"; 14 14 import { pollNewPapers } from "./feed-watcher.js"; 15 - import { buildSummary } from "./paper-summarizer.js"; 15 + import { buildSummary, enrichWithLetta } from "./paper-summarizer.js"; 16 16 import { writeSummaryToPds } from "./pds-writer.js"; 17 17 import { LANDING_PAGE } from "./landing-page.js"; 18 18 import { SUMMARY_PAGE } from "./summary-page.js"; ··· 20 20 interface Env { 21 21 DB: D1Database; 22 22 FEED_CURSOR?: string; 23 + RESEARCHER_KEY_PEM?: string; 24 + LETTA_API_KEY?: string; 25 + LETTA_AGENT_ID?: string; 23 26 } 24 27 25 28 const contrailWorker = createWorker(config, { lexicons }); ··· 230 233 // table might not exist yet 231 234 } 232 235 return c.json({ ok: true, summaries: count }); 236 + }); 237 + 238 + // Enrich existing summaries using Letta API 239 + app.post("/api/enrich", async (c) => { 240 + const apiKey = process.env.LETTA_API_KEY; 241 + const agentId = process.env.LETTA_AGENT_ID; 242 + if (!apiKey || !agentId) { 243 + return c.json({ error: "Missing LETTA_API_KEY or LETTA_AGENT_ID" }, 500); 244 + } 245 + 246 + await ensureContrailReady(db); 247 + 248 + // Get records that need enrichment (empty/placeholder summaries, no takeaway, or Untitled) 249 + const rows = await db 250 + .prepare( 251 + `SELECT rkey, record FROM records_summary 252 + WHERE json_extract(record, '$.summary') = '' 253 + OR json_extract(record, '$.summary') LIKE 'Published in%' 254 + OR json_extract(record, '$.title') = 'Untitled' 255 + OR json_extract(record, '$.takeaway') IS NULL 256 + OR json_extract(record, '$.takeaway') = '' 257 + ORDER BY time_us DESC LIMIT 20` 258 + ) 259 + .all<{ rkey: string; record: string }>(); 260 + 261 + if (!rows.results?.length) { 262 + return c.json({ enriched: 0, message: "No records need enrichment" }); 263 + } 264 + 265 + let enriched = 0; 266 + let errors = 0; 267 + 268 + for (const row of rows.results) { 269 + try { 270 + const record = JSON.parse(row.record) as { 271 + title: string; 272 + paperUrl: string; 273 + abstract: string; 274 + domains: string[]; 275 + venue: string; 276 + summary: string; 277 + takeaway?: string; 278 + }; 279 + 280 + const result = await enrichWithLetta( 281 + { 282 + title: record.title, 283 + paperUrl: record.paperUrl, 284 + abstract: record.abstract, 285 + domains: record.domains || [], 286 + venue: record.venue || "", 287 + }, 288 + apiKey, 289 + agentId, 290 + ); 291 + 292 + // Update the record in D1 293 + record.summary = result.summary; 294 + record.domains = result.domains; 295 + if (result.takeaway) record.takeaway = result.takeaway; 296 + 297 + await db 298 + .prepare("UPDATE records_summary SET record = ? WHERE rkey = ?") 299 + .bind(JSON.stringify(record), row.rkey) 300 + .run(); 301 + 302 + enriched++; 303 + console.log(`Enriched: ${record.title?.substring(0, 50)}...`); 304 + } catch (err: any) { 305 + console.error(`Enrichment failed for ${row.rkey}: ${err?.message ?? err}`); 306 + errors++; 307 + } 308 + } 309 + 310 + return c.json({ enriched, errors, total: rows.results.length }); 233 311 }); 234 312 235 313 // Crawl endpoint: register a DID and trigger immediate ingest