This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
40 kB 1149 lines
1import { Hono } from "hono"; 2import { cors } from "hono/cors"; 3import { createWorker } from "@atmo-dev/contrail/worker"; 4import { Contrail } from "@atmo-dev/contrail"; 5import { config } from "./contrail.config.js"; 6import { lexicons } from "../lexicons/generated/index.js"; 7import { LANDING_PAGE } from "./landing-page.js"; 8import { StrataContainer } from "./container.js"; 9 10// ─── Env ──────────────────────────────────────────────────────────── 11 12interface Env { 13 DB: D1Database; 14 VAULT_BUCKET: R2Bucket; 15 CARRY_BUCKET: R2Bucket; 16 STRATA_CONTAINER: DurableObjectNamespace; 17 PDS_APP_PASSWORD: string; 18 CARRY_GITHUB_TOKEN: string; 19 OBSIDIAN_AUTH_TOKEN: string; 20 OBSIDIAN_ENCRYPTION_KEY: string; 21 OBSIDIAN_ENCRYPTION_SALT: string; 22 LETTA_API_KEY: string; 23 LETTA_AGENT_ID: string; 24} 25 26// ─── Contrail setup ───────────────────────────────────────────────── 27 28const contrailWorker = createWorker(config, { lexicons }); 29const contrail = new Contrail(config); 30let contrailReady = false; 31 32async function ensureContrailReady(db: D1Database): Promise<void> { 33 if (contrailReady) return; 34 await contrail.init(db); 35 contrailReady = true; 36} 37 38// ─── Island detection (connected components) ──────────────────────── 39// 40// Load all connections, build an undirected graph, find connected 41// components via BFS. Each component is an "island" — a cluster of 42// linked URLs that form a constellation in the knowledge graph. 43 44interface Island { 45 id: string; 46 vertices: string[]; 47 edges: Array<{ 48 uri: string; 49 did: string; 50 source: string; 51 target: string; 52 connectionType: string; 53 note: string; 54 handle?: string | null; 55 }>; 56} 57 58async function detectIslands(db: D1Database): Promise<Island[]> { 59 await ensureContrailReady(db); 60 61 // Load all connections 62 const rows = await db 63 .prepare( 64 `SELECT r.record, r.did, r.rkey, i.handle 65 FROM records_connection r 66 LEFT JOIN identities i ON r.did = i.did 67 ORDER BY r.time_us DESC`, 68 ) 69 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 70 71 // Build adjacency list 72 const adj = new Map<string, Set<string>>(); 73 const edges: Island["edges"] = []; 74 75 for (const row of rows.results || []) { 76 try { 77 const value = JSON.parse(row.record); 78 const source = value.source as string; 79 const target = value.target as string; 80 if (!source || !target) continue; 81 82 if (!adj.has(source)) adj.set(source, new Set()); 83 if (!adj.has(target)) adj.set(target, new Set()); 84 adj.get(source)!.add(target); 85 adj.get(target)!.add(source); 86 87 edges.push({ 88 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 89 did: row.did, 90 source, 91 target, 92 connectionType: value.connectionType || "relates", 93 note: value.note || "", 94 handle: row.handle, 95 }); 96 } catch {} 97 } 98 99 // BFS to find connected components 100 const visited = new Set<string>(); 101 const islands: Island[] = []; 102 103 for (const node of adj.keys()) { 104 if (visited.has(node)) continue; 105 106 const component: string[] = []; 107 const queue = [node]; 108 while (queue.length > 0) { 109 const current = queue.shift()!; 110 if (visited.has(current)) continue; 111 visited.add(current); 112 component.push(current); 113 114 for (const neighbor of adj.get(current) || []) { 115 if (!visited.has(neighbor)) queue.push(neighbor); 116 } 117 } 118 119 // Only include edges where both endpoints are in this component 120 const componentSet = new Set(component); 121 const componentEdges = edges.filter( 122 e => componentSet.has(e.source) && componentSet.has(e.target), 123 ); 124 125 // Stable ID: truncated SHA-256 of the lexicographic minimum vertex 126 const lexmin = component.sort()[0]; 127 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 128 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 129 130 islands.push({ 131 id, 132 vertices: component, 133 edges: componentEdges, 134 }); 135 } 136 137 // Sort by size descending 138 islands.sort((a, b) => b.vertices.length - a.vertices.length); 139 return islands; 140} 141 142// ─── Resolve vertex metadata (batched) ────────────────────────────── 143// 144// Bulk queries instead of N+1 per vertex. Loads all cards and citations 145// matching the given URIs in a few chunked queries. 146 147async function resolveVertexMeta( 148 db: D1Database, 149 uris: string[], 150): Promise<Map<string, { title: string; description: string; type: string }>> { 151 const meta = new Map<string, { title: string; description: string; type: string }>(); 152 for (const uri of uris) { 153 meta.set(uri, { title: uri, description: "", type: "unknown" }); 154 } 155 156 const httpUris = uris.filter(u => u.startsWith("http")); 157 const atUris = uris.filter(u => u.startsWith("at://")); 158 const CHUNK = 50; 159 160 // Build all queries upfront, then batch-execute in one D1 round trip 161 const stmts: D1PreparedStatement[] = []; 162 163 // HTTP URI queries — cards 164 const httpChunks: string[][] = []; 165 for (let i = 0; i < httpUris.length; i += CHUNK) { 166 const chunk = httpUris.slice(i, i + CHUNK); 167 httpChunks.push(chunk); 168 const placeholders = chunk.map(() => "?").join(","); 169 stmts.push( 170 db.prepare( 171 `SELECT record FROM records_card WHERE json_extract(record, '$.content.url') IN (${placeholders})` 172 ).bind(...chunk) 173 ); 174 } 175 176 // HTTP URI queries — citations 177 for (let i = 0; i < httpUris.length; i += CHUNK) { 178 const chunk = httpUris.slice(i, i + CHUNK); 179 const placeholders = chunk.map(() => "?").join(","); 180 stmts.push( 181 db.prepare( 182 `SELECT record FROM records_citation WHERE json_extract(record, '$.url') IN (${placeholders})` 183 ).bind(...chunk) 184 ); 185 } 186 187 // AT URI queries — cards 188 const atChunks: string[][] = []; 189 for (let i = 0; i < atUris.length; i += CHUNK) { 190 const chunk = atUris.slice(i, i + CHUNK); 191 atChunks.push(chunk); 192 const placeholders = chunk.map(() => "?").join(","); 193 stmts.push( 194 db.prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`).bind(...chunk) 195 ); 196 } 197 198 // AT URI queries — collections 199 for (let i = 0; i < atUris.length; i += CHUNK) { 200 const chunk = atUris.slice(i, i + CHUNK); 201 const placeholders = chunk.map(() => "?").join(","); 202 stmts.push( 203 db.prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`).bind(...chunk) 204 ); 205 } 206 207 // Execute all queries in one batch 208 const results = stmts.length > 0 ? await db.batch(stmts) : []; 209 let resultIdx = 0; 210 211 // Process card results (HTTP) 212 for (const chunk of httpChunks) { 213 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 214 for (const row of rows) { 215 try { 216 const card = JSON.parse(row.record); 217 const url = card?.content?.url; 218 if (!url || !meta.has(url)) continue; 219 const m = card.content.metadata; 220 if (m?.title || m?.description) { 221 meta.set(url, { title: m.title || url, description: m.description || "", type: card.type === "URL" ? "url" : "note" }); 222 } 223 } catch {} 224 } 225 } 226 227 // Process citation results (HTTP) — overrides card data 228 for (let i = 0; i < httpChunks.length; i++) { 229 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 230 for (const row of rows) { 231 try { 232 const citation = JSON.parse(row.record); 233 const url = citation.url; 234 if (!url || !meta.has(url)) continue; 235 if (citation.title) { 236 const existing = meta.get(url)!; 237 meta.set(url, { title: citation.title, description: citation.takeaway || existing.description, type: "citation" }); 238 } 239 } catch {} 240 } 241 } 242 243 // Process card results (AT) 244 for (const chunk of atChunks) { 245 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 246 for (const row of rows) { 247 try { 248 const card = JSON.parse(row.record); 249 const m = card?.content?.metadata; 250 const url = card?.content?.url; 251 if (meta.has(row.uri)) { 252 meta.set(row.uri, { title: m?.title || url || row.uri, description: m?.description || "", type: "card" }); 253 } 254 } catch {} 255 } 256 } 257 258 // Process collection results (AT) 259 for (let i = 0; i < atChunks.length; i++) { 260 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 261 for (const row of rows) { 262 try { 263 const coll = JSON.parse(row.record); 264 if (meta.has(row.uri)) { 265 meta.set(row.uri, { title: coll.name || row.uri, description: coll.description || "", type: "collection" }); 266 } 267 } catch {} 268 } 269 } 270 271 return meta; 272} 273 274// ─── Derive island from lexmin ──────────────────────────────────── 275// 276// Given a lexmin vertex (canonical component reference), derive the 277// connected component by traversing network.cosmik.connection records. 278 279async function deriveIsland( 280 db: D1Database, 281 seed: string, 282): Promise<Island | null> { 283 await ensureContrailReady(db); 284 285 // BFS in application code — recursive CTE OOMs in D1 due to cross join 286 const visited = new Set<string>(); 287 const queue = [seed]; 288 const allEdges: Array<{ 289 uri: string; did: string; rkey: string; 290 source: string; target: string; connectionType: string; 291 note: string; handle: string | null; 292 }> = []; 293 294 while (queue.length > 0) { 295 // Drain the current queue into a batch 296 const batch = queue.splice(0, 50); 297 const toQuery = batch.filter(v => !visited.has(v)); 298 if (toQuery.length === 0) continue; 299 300 // Mark visited 301 for (const v of toQuery) visited.add(v); 302 303 // Query connections where any of these vertices are source or target 304 const placeholders = toQuery.map(() => "?").join(","); 305 const rows = await db 306 .prepare( 307 `SELECT r.record, r.did, r.rkey, i.handle 308 FROM records_connection r 309 LEFT JOIN identities i ON r.did = i.did 310 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 311 OR json_extract(r.record, '$.target') IN (${placeholders})`, 312 ) 313 .bind(...toQuery, ...toQuery) 314 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 315 316 for (const row of rows.results || []) { 317 try { 318 const value = JSON.parse(row.record); 319 const source = value.source as string; 320 const target = value.target as string; 321 if (!source || !target) continue; 322 323 // Collect edge 324 const edgeKey = `${source}|${target}|${row.rkey}`; 325 allEdges.push({ 326 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 327 did: row.did, 328 rkey: row.rkey, 329 source, 330 target, 331 connectionType: value.connectionType || "relates", 332 note: value.note || "", 333 handle: row.handle, 334 }); 335 336 // Enqueue unvisited neighbors 337 if (!visited.has(source)) queue.push(source); 338 if (!visited.has(target)) queue.push(target); 339 } catch {} 340 } 341 } 342 343 if (visited.size === 0) return null; 344 345 const vertices = [...visited]; 346 347 // Filter edges to only those with both endpoints in the component 348 const vertexSet = new Set(vertices); 349 const edges: Island["edges"] = []; 350 const seenEdges = new Set<string>(); 351 for (const e of allEdges) { 352 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 353 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 354 if (seenEdges.has(edgeKey)) continue; 355 seenEdges.add(edgeKey); 356 edges.push({ 357 uri: e.uri, 358 did: e.did, 359 source: e.source, 360 target: e.target, 361 connectionType: e.connectionType, 362 note: e.note, 363 handle: e.handle, 364 }); 365 } 366 367 // Compute stable ID from lexmin 368 const sortedVertices = [...vertices].sort(); 369 const actualLexmin = sortedVertices[0]; 370 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actualLexmin)); 371 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 372 373 return { id, vertices, edges }; 374} 375 376// ─── List strata islands from records_strata ────────────────────── 377 378// ─── Hono app ─────────────────────────────────────────────────────── 379 380let app: Hono<{ Bindings: Env }> | null = null; 381 382function buildApp(env: Env): Hono<{ Bindings: Env }> { 383 const app = new Hono<{ Bindings: Env }>(); 384 const db = env.DB; 385 386 app.use("*", cors()); 387 388 // Landing page — strata records feed 389 app.get("/", async (c) => { 390 // Load only the fields we need — records are 100KB+ with embedded connections 391 // No ensureContrailReady needed — just D1 392 let islandsJson = "[]"; 393 try { 394 const rows = await db 395 .prepare("SELECT uri, json_extract(record, '$.source.uri') as lexmin, json_extract(record, '$.analysis.title') as title FROM records_strata ORDER BY time_us DESC LIMIT 10") 396 .all<{ uri: string; lexmin: string | null; title: string | null }>(); 397 398 const islands: any[] = []; 399 for (const row of rows.results || []) { 400 if (!row.lexmin) continue; 401 const id = row.uri.split("/").pop() || row.uri; 402 islands.push({ id, lexmin: row.lexmin, title: row.title || null, recordUri: row.uri }); 403 } 404 islandsJson = JSON.stringify(islands); 405 } catch (e) { 406 console.error("Failed to load islands:", e); 407 } 408 409 const page = LANDING_PAGE.replace( 410 "</head>", 411 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 412 ); 413 return c.html(page); 414 }); 415 416 // ── Islands API ────────────────────────────────────────────────── 417 418 app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 419 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 420 const summary = c.req.query("summary") === "true"; 421 // No ensureContrailReady — just D1 queries 422 423 const rows = await db 424 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 425 .bind(limit) 426 .all<{ uri: string; record: string }>(); 427 428 const islands: any[] = []; 429 for (const row of rows.results || []) { 430 try { 431 const rec = JSON.parse(row.record); 432 const lexmin = rec.source?.uri; 433 if (!lexmin) continue; 434 435 // ID is the record rkey — unique per strata record even if same graph 436 const id = row.uri.split("/").pop() || row.uri; 437 438 // Build graph directly from embedded connections — no D1 lookups needed 439 const connections: any[] = rec.connections || []; 440 const vertexSet = new Set<string>(); 441 const edges: any[] = []; 442 443 for (const conn of connections) { 444 if (typeof conn === "object" && conn.source && conn.target) { 445 vertexSet.add(conn.source); 446 vertexSet.add(conn.target); 447 edges.push(conn); 448 } 449 } 450 451 const vertices = [...vertexSet].sort(); 452 const analysis = rec.analysis || {}; 453 454 // Summary mode: minimal payload for explore page canvas 455 // Skip vertexMeta (122KB) and trim edges to just source/target 456 const vertexMeta = summary ? {} : Object.fromEntries(await resolveVertexMeta(db, vertices)); 457 const trimmedEdges = summary 458 ? edges.map(e => ({ source: e.source, target: e.target, connectionType: e.connectionType })) 459 : edges; 460 461 islands.push({ 462 id, lexmin, vertices, edges: trimmedEdges, vertexMeta, 463 title: analysis.title || null, 464 strata: analysis.synthesis ? { 465 prose: analysis.synthesis || "", title: analysis.title || "", 466 themes: analysis.themes || [], 467 relationships: [], 468 highlights: analysis.highlights || [], 469 tensions: analysis.tensions || [], 470 open_questions: analysis.openQuestions || [], 471 synthesis: analysis.synthesis || "", 472 } : null, 473 recordUri: row.uri, 474 }); 475 } catch {} 476 } 477 478 return c.json({ islands }); 479 }); 480 481 // ── Get single island by ID or AT URI ──────────────────────────── 482 483 app.get("/xrpc/org.latha.strata.getIsland", async (c) => { 484 const id = c.req.query("id"); 485 if (!id) { 486 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 487 } 488 489 await ensureContrailReady(db); 490 491 // If it's an AT URI, look up the strata record and derive from its lexmin 492 if (id.startsWith("at://")) { 493 const row = await db 494 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 495 .bind(id) 496 .first<{ uri: string; record: string }>(); 497 498 if (!row) { 499 return c.json({ error: "RecordNotFound" }, 404); 500 } 501 502 const rec = JSON.parse(row.record); 503 const lexmin = rec.source?.uri; 504 if (!lexmin) { 505 return c.json({ error: "InvalidRecord" }, 400); 506 } 507 508 const island = await deriveIsland(db, lexmin); 509 if (!island) { 510 return c.json({ error: "IslandNotFound" }, 404); 511 } 512 513 const meta = await resolveVertexMeta(db, island.vertices); 514 const analysis = rec.analysis || {}; 515 516 return c.json({ 517 island: { 518 ...island, 519 lexmin: island.vertices.slice().sort()[0], 520 vertexMeta: Object.fromEntries(meta), 521 summary: analysis.title || null, 522 strata: { 523 prose: analysis.synthesis || "", 524 title: analysis.title || "", 525 themes: analysis.themes || [], 526 relationships: [], 527 highlights: analysis.highlights || [], 528 tensions: analysis.tensions || [], 529 open_questions: analysis.openQuestions || [], 530 synthesis: analysis.synthesis || "", 531 }, 532 }, 533 recordUri: row.uri, 534 }); 535 } 536 537 // ID is the record rkey — look up directly by URI 538 const recordUri = `at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.strata/${id}`; 539 const row = await db 540 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 541 .bind(recordUri) 542 .first<{ uri: string; record: string }>(); 543 544 if (row) { 545 try { 546 const rec = JSON.parse(row.record); 547 const lexmin = rec.source?.uri; 548 549 // Build graph from embedded connections 550 const connections: any[] = rec.connections || []; 551 const vertexSet = new Set<string>(); 552 const edges: any[] = []; 553 for (const conn of connections) { 554 if (typeof conn === "object" && conn.source && conn.target) { 555 vertexSet.add(conn.source); 556 vertexSet.add(conn.target); 557 edges.push(conn); 558 } 559 } 560 const vertices = [...vertexSet].sort(); 561 const meta = await resolveVertexMeta(db, vertices); 562 const analysis = rec.analysis || {}; 563 564 return c.json({ 565 island: { 566 id, 567 vertices, 568 edges, 569 lexmin: lexmin || vertices[0], 570 vertexMeta: Object.fromEntries(meta), 571 summary: analysis.title || null, 572 strata: { 573 prose: analysis.synthesis || "", 574 title: analysis.title || "", 575 themes: analysis.themes || [], 576 relationships: [], 577 highlights: analysis.highlights || [], 578 tensions: analysis.tensions || [], 579 open_questions: analysis.openQuestions || [], 580 synthesis: analysis.synthesis || "", 581 }, 582 recordUri: row.uri, 583 }, 584 }); 585 } catch {} 586 } 587 588 // No strata record found — try deriving island by treating the ID as a lexmin seed 589 const island = await deriveIsland(db, id); 590 if (!island) { 591 return c.json({ error: "IslandNotFound" }, 404); 592 } 593 594 const meta = await resolveVertexMeta(db, island.vertices); 595 return c.json({ 596 island: { 597 ...island, 598 lexmin: island.vertices.slice().sort()[0], 599 vertexMeta: Object.fromEntries(meta), 600 summary: null, 601 strata: null, 602 }, 603 recordUri: null, 604 }); 605 }); 606 607 // ── List strata records (AT URI-based) ────────────────────────── 608 609 app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 610 const rows = await db 611 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 612 .all<{ uri: string; record: string }>(); 613 614 const records = (rows.results || []).map(r => { 615 try { 616 const rec = JSON.parse(r.record); 617 return { 618 uri: r.uri, 619 source: rec.source?.uri || "", 620 title: rec.analysis?.title || "", 621 themes: rec.analysis?.themes || [], 622 createdAt: rec.createdAt || "", 623 }; 624 } catch { 625 return null; 626 } 627 }).filter(Boolean); 628 629 return c.json({ records }); 630 }); 631 632 // ── Strata record endpoint (AT URI-based) ────────────────────── 633 634 app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 635 const uri = c.req.query("uri"); 636 if (!uri) { 637 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 638 } 639 640 // Fetch the strata record from D1 (indexed by Contrail) 641 const row = await db 642 .prepare("SELECT record FROM records_strata WHERE uri = ?") 643 .bind(uri) 644 .first<{ record: string }>(); 645 646 if (!row) { 647 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 648 } 649 650 let strataRecord: any; 651 try { 652 strataRecord = JSON.parse(row.record); 653 } catch { 654 return c.json({ error: "InvalidRecord" }, 500); 655 } 656 657 // Derive island from lexmin (source.uri) 658 const lexmin = strataRecord.source?.uri; 659 if (!lexmin) { 660 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 661 } 662 663 const island = await deriveIsland(db, lexmin); 664 if (!island) { 665 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 666 } 667 668 // Resolve vertex metadata 669 const meta = await resolveVertexMeta(db, island.vertices); 670 671 // Build the response in the same format as the island cache 672 const analysis = strataRecord.analysis || {}; 673 const strataData = { 674 prose: analysis.synthesis || "", 675 title: analysis.title || "", 676 themes: analysis.themes || [], 677 relationships: [], 678 highlights: analysis.highlights || [], 679 tensions: analysis.tensions || [], 680 open_questions: analysis.openQuestions || [], 681 synthesis: analysis.synthesis || "", 682 }; 683 684 return c.json({ 685 island: { 686 ...island, 687 vertexMeta: Object.fromEntries(meta), 688 summary: analysis.title || null, 689 strata: strataData, 690 }, 691 record: strataRecord, 692 recordUri: uri, 693 }); 694 }); 695 696 // ── Container-backed xrpc methods ──────────────────────────────── 697 698 // Helper: read carry data from D1 for analysis 699 async function getCarryData(db: D1Database, did?: string): Promise<{ 700 entities: Array<{ atUri: string; name: string; entityType: string; description?: string; url?: string }>; 701 citations: Array<{ atUri: string; url: string; title: string; takeaway: string; confidence?: string }>; 702 reasonings: Array<{ atUri: string; claim: string; evidence: string; reasoningType?: string; url?: string }>; 703 }> { 704 const entities: Array<{ atUri: string; name: string; entityType: string; description?: string; url?: string }> = []; 705 const citations: Array<{ atUri: string; url: string; title: string; takeaway: string; confidence?: string }> = []; 706 const reasonings: Array<{ atUri: string; claim: string; evidence: string; reasoningType?: string; url?: string }> = []; 707 708 try { 709 // Read entities 710 const entityRows = await db 711 .prepare("SELECT uri, record FROM records_entity ORDER BY time_us DESC LIMIT 100") 712 .all<{ uri: string; record: string }>(); 713 for (const row of entityRows.results || []) { 714 try { 715 const r = JSON.parse(row.record); 716 entities.push({ 717 atUri: row.uri, 718 name: r.name || "", 719 entityType: r.entityType || "", 720 description: r.description || "", 721 url: r.url || "", 722 }); 723 } catch {} 724 } 725 726 // Read citations 727 const citeRows = await db 728 .prepare("SELECT uri, record FROM records_citation ORDER BY time_us DESC LIMIT 100") 729 .all<{ uri: string; record: string }>(); 730 for (const row of citeRows.results || []) { 731 try { 732 const r = JSON.parse(row.record); 733 citations.push({ 734 atUri: row.uri, 735 url: r.url || "", 736 title: r.title || "", 737 takeaway: r.takeaway || "", 738 confidence: r.confidence || "", 739 }); 740 } catch {} 741 } 742 743 // Read reasonings 744 const reasonRows = await db 745 .prepare("SELECT uri, record FROM records_reasoning ORDER BY time_us DESC LIMIT 100") 746 .all<{ uri: string; record: string }>(); 747 for (const row of reasonRows.results || []) { 748 try { 749 const r = JSON.parse(row.record); 750 reasonings.push({ 751 atUri: row.uri, 752 claim: r.claim || "", 753 evidence: r.evidence || "", 754 reasoningType: r.reasoningType || "", 755 url: r.url || "", 756 }); 757 } catch {} 758 } 759 } catch (e: any) { 760 console.error("getCarryData error:", e.message); 761 } 762 763 return { entities, citations, reasonings }; 764 } 765 766 // Helper: call the strata container 767 async function callContainer(path: string, method: string, body?: any): Promise<any> { 768 const id = env.STRATA_CONTAINER.idFromName("strata"); 769 const stub = env.STRATA_CONTAINER.get(id); 770 const url = new URL(path, "http://container"); 771 const init: RequestInit = { method }; 772 if (body) { 773 init.body = JSON.stringify(body); 774 init.headers = { "Content-Type": "application/json" }; 775 } 776 const resp = await stub.fetch(new Request(url.toString(), init)); 777 const text = await resp.text(); 778 try { 779 return JSON.parse(text); 780 } catch { 781 throw new Error(`Container returned non-JSON: ${text.slice(0, 500)}`); 782 } 783 } 784 785 // Sync container data (vault + carry) 786 app.post("/xrpc/org.latha.strata.syncContainer", async (c) => { 787 try { 788 const result = await callContainer("/sync", "POST"); 789 return c.json(result); 790 } catch (e: any) { 791 return c.json({ error: "ContainerError", message: e.message }, 500); 792 } 793 }); 794 795 // Container health + data status 796 app.get("/xrpc/org.latha.strata.containerHealth", async (c) => { 797 try { 798 const result = await callContainer("/health", "GET"); 799 return c.json(result); 800 } catch (e: any) { 801 return c.json({ error: "ContainerError", message: e.message }, 500); 802 } 803 }); 804 805 // Derive islands — POST, delegates to container 806 // With seed: single island. Without: all islands. 807 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => { 808 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 809 const seed = body.seed; 810 811 // Load all connections from D1 for the container to traverse 812 await ensureContrailReady(db); 813 const rows = await db 814 .prepare( 815 `SELECT r.record, r.did, r.rkey, i.handle 816 FROM records_connection r 817 LEFT JOIN identities i ON r.did = i.did`, 818 ) 819 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 820 821 const connections = (rows.results || []).map(r => { 822 try { 823 const value = JSON.parse(r.record); 824 return { 825 source: value.source, 826 target: value.target, 827 connectionType: value.connectionType || "relates", 828 note: value.note || "", 829 did: r.did, 830 rkey: r.rkey, 831 }; 832 } catch { 833 return null; 834 } 835 }).filter(Boolean); 836 837 try { 838 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 839 return c.json(result); 840 } catch (e: any) { 841 return c.json({ error: "ContainerError", message: e.message }, 500); 842 } 843 }); 844 845 // Run strata analysis — delegates to container 846 app.post("/xrpc/org.latha.strata.analyze", async (c) => { 847 const body = await c.req.json().catch(() => ({})); 848 if (!body.island) { 849 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 850 } 851 852 try { 853 // Read carry data from D1 for the user's DID 854 const carryData = await getCarryData(db, body.did); 855 856 // Pass carry data to container alongside island 857 const payload = { ...body, carryData }; 858 const result = await callContainer("/analyze", "POST", payload); 859 860 // Create new connection records on PDS 861 const newConnections: Array<{ source: string; target: string; connectionType: string; note: string }> = result.newConnections || []; 862 const createdUris: string[] = []; 863 864 if (newConnections.length > 0) { 865 const appPassword = env.PDS_APP_PASSWORD; 866 if (appPassword && body.did) { 867 const pdsHost = "amanita.us-east.host.bsky.network"; 868 const authResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, { 869 method: "POST", 870 headers: { "Content-Type": "application/json" }, 871 body: JSON.stringify({ identifier: body.did, password: appPassword }), 872 }); 873 if (authResp.ok) { 874 const { accessJwt } = await authResp.json() as { accessJwt: string }; 875 876 for (const conn of newConnections) { 877 const rkey = crypto.randomUUID().replace(/-/g, "").slice(0, 13); // TID format 878 const record = { 879 "$type": "network.cosmik.connection", 880 source: conn.source, 881 target: conn.target, 882 connectionType: conn.connectionType, 883 note: conn.note, 884 createdAt: new Date().toISOString(), 885 }; 886 887 const createResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, { 888 method: "POST", 889 headers: { 890 "Content-Type": "application/json", 891 "Authorization": `Bearer ${accessJwt}`, 892 }, 893 body: JSON.stringify({ 894 repo: body.did, 895 collection: "network.cosmik.connection", 896 rkey, 897 record, 898 }), 899 }); 900 901 if (createResp.ok) { 902 const createResult = await createResp.json() as { uri: string }; 903 createdUris.push(createResult.uri); 904 } 905 } 906 } 907 } 908 } 909 910 // Add new connection URIs to highlights 911 result.highlights = [...(result.highlights || []), ...createdUris]; 912 result.createdConnections = createdUris; 913 914 return c.json(result); 915 } catch (e: any) { 916 return c.json({ error: "ContainerError", message: e.message }, 500); 917 } 918 }); 919 920 // ── Update strata record (write back to PDS) ──────────────────── 921 app.put("/xrpc/org.latha.strata.updateRecord", async (c) => { 922 const body = await c.req.json().catch(() => ({})) as { 923 uri?: string; 924 analysis?: Record<string, any>; 925 }; 926 if (!body.uri || !body.analysis) { 927 return c.json({ error: "MissingRequiredField", message: "uri and analysis are required" }, 400); 928 } 929 930 const appPassword = env.PDS_APP_PASSWORD; 931 if (!appPassword) { 932 return c.json({ error: "NotConfigured", message: "PDS_APP_PASSWORD not set" }, 500); 933 } 934 935 // Parse the AT URI to extract repo DID, collection, rkey 936 const parts = body.uri.replace("at://", "").split("/"); 937 const repoDid = parts[0]; 938 const collection = parts[1]; 939 const rkey = parts[2]; 940 if (!repoDid || !collection || !rkey) { 941 return c.json({ error: "InvalidUri", message: "Expected at://did:.../org.latha.strata/rkey" }, 400); 942 } 943 944 // Read existing record from D1 945 const row = await db 946 .prepare("SELECT record FROM records_strata WHERE uri = ?") 947 .bind(body.uri) 948 .first<{ record: string }>(); 949 if (!row) { 950 return c.json({ error: "RecordNotFound" }, 404); 951 } 952 953 // Merge updated analysis into existing record 954 const record = JSON.parse(row.record); 955 record.analysis = { ...record.analysis, ...body.analysis }; 956 // Remove stale fields that are no longer in the lexicon 957 delete record.analysis.connections; 958 delete record.connections; 959 record.updatedAt = new Date().toISOString(); 960 961 // Write back to PDS via com.atproto.repo.putRecord 962 const pdsHost = "amanita.us-east.host.bsky.network"; 963 const authResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, { 964 method: "POST", 965 headers: { "Content-Type": "application/json" }, 966 body: JSON.stringify({ identifier: repoDid, password: appPassword }), 967 }); 968 if (!authResp.ok) { 969 const err = await authResp.text(); 970 return c.json({ error: "AuthFailed", message: err }, 500); 971 } 972 const { accessJwt } = await authResp.json() as { accessJwt: string }; 973 974 const putResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, { 975 method: "POST", 976 headers: { 977 "Content-Type": "application/json", 978 "Authorization": `Bearer ${accessJwt}`, 979 }, 980 body: JSON.stringify({ 981 repo: repoDid, 982 collection, 983 rkey, 984 record, 985 }), 986 }); 987 if (!putResp.ok) { 988 const err = await putResp.text(); 989 return c.json({ error: "PutRecordFailed", message: err }, 500); 990 } 991 992 // Update D1 cache 993 await db 994 .prepare("UPDATE records_strata SET record = ? WHERE uri = ?") 995 .bind(JSON.stringify(record), body.uri) 996 .run(); 997 998 return c.json({ ok: true, uri: body.uri }); 999 }); 1000 1001 // ── R2 sync endpoints ─────────────────────────────────────────── 1002 1003 app.put("/api/sync/vault/:did/*", async (c) => { 1004 const did = c.req.param("did"); 1005 const path = c.req.param("path"); 1006 if (!did || !path) { 1007 return c.json({ error: "Missing path" }, 400); 1008 } 1009 1010 const key = `${did}/${path}`; 1011 const body = await c.req.raw.arrayBuffer(); 1012 await env.VAULT_BUCKET.put(key, body, { 1013 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 1014 }); 1015 1016 return c.json({ ok: true, key }); 1017 }); 1018 1019 app.put("/api/sync/carry/:did/*", async (c) => { 1020 const did = c.req.param("did"); 1021 const path = c.req.param("path"); 1022 if (!did || !path) { 1023 return c.json({ error: "Missing path" }, 400); 1024 } 1025 1026 const key = `${did}/${path}`; 1027 const body = await c.req.raw.arrayBuffer(); 1028 await env.CARRY_BUCKET.put(key, body, { 1029 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 1030 }); 1031 1032 return c.json({ ok: true, key }); 1033 }); 1034 1035 // ── Graph neighborhood query ──────────────────────────────────── 1036 1037 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 1038 const uri = c.req.query("uri"); 1039 if (!uri) { 1040 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 1041 } 1042 1043 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 1044 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 1045 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 1046 1047 // Reuse island detection for the subgraph 1048 const islands = await detectIslands(db); 1049 const island = islands.find(i => i.vertices.includes(uri)); 1050 1051 if (!island) { 1052 return c.json({ connections: [], resources: [], depth, uri }); 1053 } 1054 1055 // Convert to graph format 1056 const resources = island.vertices.map(v => ({ 1057 uri: v, 1058 title: v, 1059 type: "unknown", 1060 })); 1061 1062 return c.json({ 1063 connections: island.edges, 1064 resources, 1065 depth, 1066 uri, 1067 }); 1068 }); 1069 1070 // ── OAuth client metadata ────────────────────────────────────── 1071 1072 app.get("/oauth-client-metadata.json", (c) => { 1073 const host = new URL(c.req.url).host; 1074 return c.json({ 1075 client_id: `https://${host}/oauth-client-metadata.json`, 1076 client_name: "Stigmergic", 1077 client_uri: `https://${host}`, 1078 redirect_uris: [`https://${host}/`], 1079 scope: "atproto transition:generic", 1080 grant_types: ["authorization_code", "refresh_token"], 1081 response_types: ["code"], 1082 token_endpoint_auth_method: "none", 1083 application_type: "web", 1084 dpop_bound_access_tokens: true, 1085 }); 1086 }); 1087 1088 // ── SPA fallback: serve landing page for client-side routes ────── 1089 for (const path of ["/island", "/strata", "/connect"]) { 1090 app.get(path, async (c) => { 1091 let islandsJson = "[]"; 1092 try { 1093 await ensureContrailReady(db); 1094 const rows = await db 1095 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 1096 .all<{ uri: string; record: string }>(); 1097 const islands: any[] = []; 1098 for (const row of rows.results || []) { 1099 try { 1100 const rec = JSON.parse(row.record); 1101 const lexmin = rec.source?.uri; 1102 if (!lexmin) continue; 1103 const id = row.uri.split("/").pop() || row.uri; 1104 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri }); 1105 } catch {} 1106 } 1107 islandsJson = JSON.stringify(islands); 1108 } catch {} 1109 1110 const page = LANDING_PAGE.replace( 1111 "</head>", 1112 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 1113 ); 1114 return c.html(page); 1115 }); 1116 } 1117 1118 // ── All other routes pass through to contrail ────────────────── 1119 1120 app.all("*", async (c) => { 1121 const response = await contrailWorker.fetch( 1122 c.req.raw, 1123 c.env as unknown as Record<string, unknown>, 1124 ); 1125 return response; 1126 }); 1127 1128 return app; 1129} 1130 1131// ─── Export ───────────────────────────────────────────────────────── 1132 1133export { StrataContainer }; 1134 1135export default { 1136 fetch(request: Request, env: Env): Response | Promise<Response> { 1137 app ??= buildApp(env); 1138 return app.fetch(request, env); 1139 }, 1140 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 1141 // Run Contrail indexing 1142 await contrailWorker.scheduled( 1143 event, 1144 env as unknown as Record<string, unknown>, 1145 ctx, 1146 ); 1147 }, 1148}; 1149