This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
30 kB 902 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} 18 19// ─── Contrail setup ───────────────────────────────────────────────── 20 21const contrailWorker = createWorker(config, { lexicons }); 22const contrail = new Contrail(config); 23let contrailReady = false; 24 25async function ensureContrailReady(db: D1Database): Promise<void> { 26 if (contrailReady) return; 27 await contrail.init(db); 28 contrailReady = true; 29} 30 31// ─── Island detection (connected components) ──────────────────────── 32// 33// Load all connections, build an undirected graph, find connected 34// components via BFS. Each component is an "island" — a cluster of 35// linked URLs that form a constellation in the knowledge graph. 36 37interface Island { 38 id: string; 39 vertices: string[]; 40 edges: Array<{ 41 uri: string; 42 did: string; 43 source: string; 44 target: string; 45 connectionType: string; 46 note: string; 47 handle?: string | null; 48 }>; 49} 50 51async function detectIslands(db: D1Database): Promise<Island[]> { 52 await ensureContrailReady(db); 53 54 // Load all connections 55 const rows = await db 56 .prepare( 57 `SELECT r.record, r.did, r.rkey, i.handle 58 FROM records_connection r 59 LEFT JOIN identities i ON r.did = i.did 60 ORDER BY r.time_us DESC`, 61 ) 62 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 63 64 // Build adjacency list 65 const adj = new Map<string, Set<string>>(); 66 const edges: Island["edges"] = []; 67 68 for (const row of rows.results || []) { 69 try { 70 const value = JSON.parse(row.record); 71 const source = value.source as string; 72 const target = value.target as string; 73 if (!source || !target) continue; 74 75 if (!adj.has(source)) adj.set(source, new Set()); 76 if (!adj.has(target)) adj.set(target, new Set()); 77 adj.get(source)!.add(target); 78 adj.get(target)!.add(source); 79 80 edges.push({ 81 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 82 did: row.did, 83 source, 84 target, 85 connectionType: value.connectionType || "relates", 86 note: value.note || "", 87 handle: row.handle, 88 }); 89 } catch {} 90 } 91 92 // BFS to find connected components 93 const visited = new Set<string>(); 94 const islands: Island[] = []; 95 96 for (const node of adj.keys()) { 97 if (visited.has(node)) continue; 98 99 const component: string[] = []; 100 const queue = [node]; 101 while (queue.length > 0) { 102 const current = queue.shift()!; 103 if (visited.has(current)) continue; 104 visited.add(current); 105 component.push(current); 106 107 for (const neighbor of adj.get(current) || []) { 108 if (!visited.has(neighbor)) queue.push(neighbor); 109 } 110 } 111 112 // Only include edges where both endpoints are in this component 113 const componentSet = new Set(component); 114 const componentEdges = edges.filter( 115 e => componentSet.has(e.source) && componentSet.has(e.target), 116 ); 117 118 // Stable ID: truncated SHA-256 of the lexicographic minimum vertex 119 const lexmin = component.sort()[0]; 120 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 121 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 122 123 islands.push({ 124 id, 125 vertices: component, 126 edges: componentEdges, 127 }); 128 } 129 130 // Sort by size descending 131 islands.sort((a, b) => b.vertices.length - a.vertices.length); 132 return islands; 133} 134 135// ─── Resolve vertex metadata (batched) ────────────────────────────── 136// 137// Bulk queries instead of N+1 per vertex. Loads all cards and citations 138// matching the given URIs in a few chunked queries. 139 140async function resolveVertexMeta( 141 db: D1Database, 142 uris: string[], 143): Promise<Map<string, { title: string; description: string; type: string }>> { 144 const meta = new Map<string, { title: string; description: string; type: string }>(); 145 for (const uri of uris) { 146 meta.set(uri, { title: uri, description: "", type: "unknown" }); 147 } 148 149 const httpUris = uris.filter(u => u.startsWith("http")); 150 const atUris = uris.filter(u => u.startsWith("at://")); 151 const CHUNK = 50; 152 153 // ── Resolve HTTP URIs (existing logic) ── 154 155 // Batch card lookup 156 for (let i = 0; i < httpUris.length; i += CHUNK) { 157 const chunk = httpUris.slice(i, i + CHUNK); 158 const placeholders = chunk.map(() => "?").join(","); 159 const rows = await db 160 .prepare( 161 `SELECT record FROM records_card 162 WHERE json_extract(record, '$.content.url') IN (${placeholders})`, 163 ) 164 .bind(...chunk) 165 .all<{ record: string }>(); 166 167 for (const row of rows.results || []) { 168 try { 169 const card = JSON.parse(row.record); 170 const url = card?.content?.url; 171 if (!url || !meta.has(url)) continue; 172 const m = card.content.metadata; 173 if (m?.title || m?.description) { 174 meta.set(url, { 175 title: m.title || url, 176 description: m.description || "", 177 type: card.type === "URL" ? "url" : "note", 178 }); 179 } 180 } catch {} 181 } 182 } 183 184 // Batch citation lookup (overrides card data if found) 185 for (let i = 0; i < httpUris.length; i += CHUNK) { 186 const chunk = httpUris.slice(i, i + CHUNK); 187 const placeholders = chunk.map(() => "?").join(","); 188 const rows = await db 189 .prepare( 190 `SELECT record FROM records_citation 191 WHERE json_extract(record, '$.url') IN (${placeholders})`, 192 ) 193 .bind(...chunk) 194 .all<{ record: string }>(); 195 196 for (const row of rows.results || []) { 197 try { 198 const citation = JSON.parse(row.record); 199 const url = citation.url; 200 if (!url || !meta.has(url)) continue; 201 if (citation.title) { 202 const existing = meta.get(url)!; 203 meta.set(url, { 204 title: citation.title, 205 description: citation.takeaway || existing.description, 206 type: "citation", 207 }); 208 } 209 } catch {} 210 } 211 } 212 213 // ── Resolve AT URIs ── 214 215 // Batch card lookup by AT URI 216 for (let i = 0; i < atUris.length; i += CHUNK) { 217 const chunk = atUris.slice(i, i + CHUNK); 218 const placeholders = chunk.map(() => "?").join(","); 219 const rows = await db 220 .prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`) 221 .bind(...chunk) 222 .all<{ uri: string; record: string }>(); 223 224 for (const row of rows.results || []) { 225 try { 226 const card = JSON.parse(row.record); 227 const m = card?.content?.metadata; 228 const url = card?.content?.url; 229 if (meta.has(row.uri)) { 230 meta.set(row.uri, { 231 title: m?.title || url || row.uri, 232 description: m?.description || "", 233 type: "card", 234 }); 235 } 236 } catch {} 237 } 238 } 239 240 // Batch collection lookup by AT URI 241 for (let i = 0; i < atUris.length; i += CHUNK) { 242 const chunk = atUris.slice(i, i + CHUNK); 243 const placeholders = chunk.map(() => "?").join(","); 244 const rows = await db 245 .prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`) 246 .bind(...chunk) 247 .all<{ uri: string; record: string }>(); 248 249 for (const row of rows.results || []) { 250 try { 251 const coll = JSON.parse(row.record); 252 if (meta.has(row.uri)) { 253 meta.set(row.uri, { 254 title: coll.name || row.uri, 255 description: coll.description || "", 256 type: "collection", 257 }); 258 } 259 } catch {} 260 } 261 } 262 263 return meta; 264} 265 266// ─── Derive island from lexmin ──────────────────────────────────── 267// 268// Given a lexmin vertex (canonical component reference), derive the 269// connected component by traversing network.cosmik.connection records. 270 271async function deriveIsland( 272 db: D1Database, 273 seed: string, 274): Promise<Island | null> { 275 await ensureContrailReady(db); 276 277 // BFS in application code — recursive CTE OOMs in D1 due to cross join 278 const visited = new Set<string>(); 279 const queue = [seed]; 280 const allEdges: Array<{ 281 uri: string; did: string; rkey: string; 282 source: string; target: string; connectionType: string; 283 note: string; handle: string | null; 284 }> = []; 285 286 while (queue.length > 0) { 287 // Drain the current queue into a batch 288 const batch = queue.splice(0, 50); 289 const toQuery = batch.filter(v => !visited.has(v)); 290 if (toQuery.length === 0) continue; 291 292 // Mark visited 293 for (const v of toQuery) visited.add(v); 294 295 // Query connections where any of these vertices are source or target 296 const placeholders = toQuery.map(() => "?").join(","); 297 const rows = await db 298 .prepare( 299 `SELECT r.record, r.did, r.rkey, i.handle 300 FROM records_connection r 301 LEFT JOIN identities i ON r.did = i.did 302 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 303 OR json_extract(r.record, '$.target') IN (${placeholders})`, 304 ) 305 .bind(...toQuery, ...toQuery) 306 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 307 308 for (const row of rows.results || []) { 309 try { 310 const value = JSON.parse(row.record); 311 const source = value.source as string; 312 const target = value.target as string; 313 if (!source || !target) continue; 314 315 // Collect edge 316 const edgeKey = `${source}|${target}|${row.rkey}`; 317 allEdges.push({ 318 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 319 did: row.did, 320 rkey: row.rkey, 321 source, 322 target, 323 connectionType: value.connectionType || "relates", 324 note: value.note || "", 325 handle: row.handle, 326 }); 327 328 // Enqueue unvisited neighbors 329 if (!visited.has(source)) queue.push(source); 330 if (!visited.has(target)) queue.push(target); 331 } catch {} 332 } 333 } 334 335 if (visited.size === 0) return null; 336 337 const vertices = [...visited]; 338 339 // Filter edges to only those with both endpoints in the component 340 const vertexSet = new Set(vertices); 341 const edges: Island["edges"] = []; 342 const seenEdges = new Set<string>(); 343 for (const e of allEdges) { 344 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 345 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 346 if (seenEdges.has(edgeKey)) continue; 347 seenEdges.add(edgeKey); 348 edges.push({ 349 uri: e.uri, 350 did: e.did, 351 source: e.source, 352 target: e.target, 353 connectionType: e.connectionType, 354 note: e.note, 355 handle: e.handle, 356 }); 357 } 358 359 // Compute stable ID from lexmin 360 const sortedVertices = [...vertices].sort(); 361 const actualLexmin = sortedVertices[0]; 362 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actualLexmin)); 363 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 364 365 return { id, vertices, edges }; 366} 367 368// ─── List strata islands from records_strata ────────────────────── 369 370// ─── Hono app ─────────────────────────────────────────────────────── 371 372let app: Hono<{ Bindings: Env }> | null = null; 373 374function buildApp(env: Env): Hono<{ Bindings: Env }> { 375 const app = new Hono<{ Bindings: Env }>(); 376 const db = env.DB; 377 378 app.use("*", cors()); 379 380 // Landing page — strata records feed 381 app.get("/", async (c) => { 382 // Just load summaries for the landing page — client-side will fetch full data 383 let islandsJson = "[]"; 384 try { 385 await ensureContrailReady(db); 386 const rows = await db 387 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 10") 388 .all<{ uri: string; record: string }>(); 389 390 const islands: any[] = []; 391 for (const row of rows.results || []) { 392 try { 393 const rec = JSON.parse(row.record); 394 const lexmin = rec.source?.uri; 395 if (!lexmin) continue; 396 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 397 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 398 islands.push({ 399 id, 400 lexmin, 401 title: rec.analysis?.title || null, 402 recordUri: row.uri, 403 }); 404 } catch {} 405 } 406 islandsJson = JSON.stringify(islands); 407 } catch (e) { 408 console.error("Failed to load islands:", e); 409 } 410 411 const page = LANDING_PAGE.replace( 412 "</head>", 413 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 414 ); 415 return c.html(page); 416 }); 417 418 // ── Islands API ────────────────────────────────────────────────── 419 420 app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 421 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 422 await ensureContrailReady(db); 423 424 const rows = await db 425 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 426 .bind(limit) 427 .all<{ uri: string; record: string }>(); 428 429 const islands: any[] = []; 430 for (const row of rows.results || []) { 431 try { 432 const rec = JSON.parse(row.record); 433 const lexmin = rec.source?.uri; 434 if (!lexmin) continue; 435 436 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 437 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 438 439 // Build graph directly from embedded connections — no D1 lookups needed 440 const connections: any[] = rec.connections || []; 441 const vertexSet = new Set<string>(); 442 const edges: any[] = []; 443 444 for (const conn of connections) { 445 if (typeof conn === "object" && conn.source && conn.target) { 446 vertexSet.add(conn.source); 447 vertexSet.add(conn.target); 448 edges.push(conn); 449 } 450 } 451 452 const vertices = [...vertexSet].sort(); 453 const vertexMeta = Object.fromEntries(await resolveVertexMeta(db, vertices)); 454 455 const analysis = rec.analysis || {}; 456 islands.push({ 457 id, lexmin, vertices, edges, vertexMeta, 458 title: analysis.title || null, 459 strata: analysis.synthesis ? { 460 prose: analysis.synthesis || "", title: analysis.title || "", 461 themes: analysis.themes || [], 462 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 463 tensions: analysis.tensions || [], 464 open_questions: analysis.openQuestions || [], 465 synthesis: analysis.synthesis || "", 466 } : null, 467 recordUri: row.uri, 468 }); 469 } catch {} 470 } 471 472 return c.json({ islands }); 473 }); 474 475 // ── Get single island by ID or AT URI ──────────────────────────── 476 477 app.get("/xrpc/org.latha.strata.getIsland", async (c) => { 478 const id = c.req.query("id"); 479 if (!id) { 480 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 481 } 482 483 await ensureContrailReady(db); 484 485 // If it's an AT URI, look up the strata record and derive from its lexmin 486 if (id.startsWith("at://")) { 487 const row = await db 488 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 489 .bind(id) 490 .first<{ uri: string; record: string }>(); 491 492 if (!row) { 493 return c.json({ error: "RecordNotFound" }, 404); 494 } 495 496 const rec = JSON.parse(row.record); 497 const lexmin = rec.source?.uri; 498 if (!lexmin) { 499 return c.json({ error: "InvalidRecord" }, 400); 500 } 501 502 const island = await deriveIsland(db, lexmin); 503 if (!island) { 504 return c.json({ error: "IslandNotFound" }, 404); 505 } 506 507 const meta = await resolveVertexMeta(db, island.vertices); 508 const analysis = rec.analysis || {}; 509 510 return c.json({ 511 island: { 512 ...island, 513 lexmin: island.vertices.slice().sort()[0], 514 vertexMeta: Object.fromEntries(meta), 515 summary: analysis.title || null, 516 strata: { 517 prose: analysis.synthesis || "", 518 title: analysis.title || "", 519 themes: analysis.themes || [], 520 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 521 tensions: analysis.tensions || [], 522 open_questions: analysis.openQuestions || [], 523 synthesis: analysis.synthesis || "", 524 }, 525 }, 526 recordUri: row.uri, 527 }); 528 } 529 530 // Stable ID — derive from lexmin by reverse-lookup 531 // Find the strata record whose lexmin hashes to this ID 532 const strataRows = await db 533 .prepare("SELECT uri, record FROM records_strata") 534 .all<{ uri: string; record: string }>(); 535 536 for (const row of strataRows.results || []) { 537 try { 538 const rec = JSON.parse(row.record); 539 const lexmin = rec.source?.uri; 540 if (!lexmin) continue; 541 542 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 543 const candidateId = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 544 545 if (candidateId === id) { 546 const island = await deriveIsland(db, lexmin); 547 if (!island) continue; 548 549 const meta = await resolveVertexMeta(db, island.vertices); 550 const analysis = rec.analysis || {}; 551 552 return c.json({ 553 island: { 554 ...island, 555 lexmin: island.vertices.slice().sort()[0], 556 vertexMeta: Object.fromEntries(meta), 557 summary: analysis.title || null, 558 strata: { 559 prose: analysis.synthesis || "", 560 title: analysis.title || "", 561 themes: analysis.themes || [], 562 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 563 tensions: analysis.tensions || [], 564 open_questions: analysis.openQuestions || [], 565 synthesis: analysis.synthesis || "", 566 }, 567 }, 568 recordUri: row.uri, 569 }); 570 } 571 } catch {} 572 } 573 574 // No strata record — try deriving island by treating the ID as a lexmin seed 575 // (for islands that haven't been analyzed yet) 576 const island = await deriveIsland(db, id); 577 if (!island) { 578 return c.json({ error: "IslandNotFound" }, 404); 579 } 580 581 const meta = await resolveVertexMeta(db, island.vertices); 582 return c.json({ 583 island: { 584 ...island, 585 lexmin: island.vertices.slice().sort()[0], 586 vertexMeta: Object.fromEntries(meta), 587 summary: null, 588 strata: null, 589 }, 590 recordUri: null, 591 }); 592 }); 593 594 // ── List strata records (AT URI-based) ────────────────────────── 595 596 app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 597 const rows = await db 598 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 599 .all<{ uri: string; record: string }>(); 600 601 const records = (rows.results || []).map(r => { 602 try { 603 const rec = JSON.parse(r.record); 604 return { 605 uri: r.uri, 606 source: rec.source?.uri || "", 607 title: rec.analysis?.title || "", 608 themes: rec.analysis?.themes || [], 609 createdAt: rec.createdAt || "", 610 }; 611 } catch { 612 return null; 613 } 614 }).filter(Boolean); 615 616 return c.json({ records }); 617 }); 618 619 // ── Strata record endpoint (AT URI-based) ────────────────────── 620 621 app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 622 const uri = c.req.query("uri"); 623 if (!uri) { 624 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 625 } 626 627 // Fetch the strata record from D1 (indexed by Contrail) 628 const row = await db 629 .prepare("SELECT record FROM records_strata WHERE uri = ?") 630 .bind(uri) 631 .first<{ record: string }>(); 632 633 if (!row) { 634 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 635 } 636 637 let strataRecord: any; 638 try { 639 strataRecord = JSON.parse(row.record); 640 } catch { 641 return c.json({ error: "InvalidRecord" }, 500); 642 } 643 644 // Derive island from lexmin (source.uri) 645 const lexmin = strataRecord.source?.uri; 646 if (!lexmin) { 647 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 648 } 649 650 const island = await deriveIsland(db, lexmin); 651 if (!island) { 652 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 653 } 654 655 // Resolve vertex metadata 656 const meta = await resolveVertexMeta(db, island.vertices); 657 658 // Build the response in the same format as the island cache 659 const analysis = strataRecord.analysis || {}; 660 const strataData = { 661 prose: analysis.synthesis || "", 662 title: analysis.title || "", 663 themes: analysis.themes || [], 664 relationships: (analysis.connections || []).map((c: any) => c.description || ""), 665 tensions: analysis.tensions || [], 666 open_questions: analysis.openQuestions || [], 667 synthesis: analysis.synthesis || "", 668 }; 669 670 return c.json({ 671 island: { 672 ...island, 673 vertexMeta: Object.fromEntries(meta), 674 summary: analysis.title || null, 675 strata: strataData, 676 }, 677 record: strataRecord, 678 recordUri: uri, 679 }); 680 }); 681 682 // ── Container-backed xrpc methods ──────────────────────────────── 683 684 // Helper: call the strata container 685 async function callContainer(path: string, method: string, body?: any): Promise<any> { 686 const id = env.STRATA_CONTAINER.idFromName("strata"); 687 const stub = env.STRATA_CONTAINER.get(id); 688 const url = new URL(path, "http://container"); 689 const init: RequestInit = { method }; 690 if (body) { 691 init.body = JSON.stringify(body); 692 init.headers = { "Content-Type": "application/json" }; 693 } 694 const resp = await stub.fetch(new Request(url.toString(), init)); 695 return resp.json(); 696 } 697 698 // Derive islands — POST, delegates to container 699 // With seed: single island. Without: all islands. 700 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => { 701 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 702 const seed = body.seed; 703 704 // Load all connections from D1 for the container to traverse 705 await ensureContrailReady(db); 706 const rows = await db 707 .prepare( 708 `SELECT r.record, r.did, r.rkey, i.handle 709 FROM records_connection r 710 LEFT JOIN identities i ON r.did = i.did`, 711 ) 712 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 713 714 const connections = (rows.results || []).map(r => { 715 try { 716 const value = JSON.parse(r.record); 717 return { 718 source: value.source, 719 target: value.target, 720 connectionType: value.connectionType || "relates", 721 note: value.note || "", 722 did: r.did, 723 rkey: r.rkey, 724 }; 725 } catch { 726 return null; 727 } 728 }).filter(Boolean); 729 730 try { 731 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 732 return c.json(result); 733 } catch (e: any) { 734 return c.json({ error: "ContainerError", message: e.message }, 500); 735 } 736 }); 737 738 // Run strata analysis — delegates to container 739 app.post("/xrpc/org.latha.strata.analyze", async (c) => { 740 const body = await c.req.json().catch(() => ({})); 741 if (!body.island) { 742 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 743 } 744 745 try { 746 const result = await callContainer("/analyze", "POST", body); 747 return c.json(result); 748 } catch (e: any) { 749 return c.json({ error: "ContainerError", message: e.message }, 500); 750 } 751 }); 752 753 // ── R2 sync endpoints ─────────────────────────────────────────── 754 755 app.put("/api/sync/vault/:did/*", async (c) => { 756 const did = c.req.param("did"); 757 const path = c.req.param("path"); 758 if (!did || !path) { 759 return c.json({ error: "Missing path" }, 400); 760 } 761 762 const key = `${did}/${path}`; 763 const body = await c.req.raw.arrayBuffer(); 764 await env.VAULT_BUCKET.put(key, body, { 765 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 766 }); 767 768 return c.json({ ok: true, key }); 769 }); 770 771 app.put("/api/sync/carry/:did/*", async (c) => { 772 const did = c.req.param("did"); 773 const path = c.req.param("path"); 774 if (!did || !path) { 775 return c.json({ error: "Missing path" }, 400); 776 } 777 778 const key = `${did}/${path}`; 779 const body = await c.req.raw.arrayBuffer(); 780 await env.CARRY_BUCKET.put(key, body, { 781 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 782 }); 783 784 return c.json({ ok: true, key }); 785 }); 786 787 // ── Graph neighborhood query ──────────────────────────────────── 788 789 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 790 const uri = c.req.query("uri"); 791 if (!uri) { 792 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 793 } 794 795 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 796 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 797 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 798 799 // Reuse island detection for the subgraph 800 const islands = await detectIslands(db); 801 const island = islands.find(i => i.vertices.includes(uri)); 802 803 if (!island) { 804 return c.json({ connections: [], resources: [], depth, uri }); 805 } 806 807 // Convert to graph format 808 const resources = island.vertices.map(v => ({ 809 uri: v, 810 title: v, 811 type: "unknown", 812 })); 813 814 return c.json({ 815 connections: island.edges, 816 resources, 817 depth, 818 uri, 819 }); 820 }); 821 822 // ── OAuth client metadata ────────────────────────────────────── 823 824 app.get("/oauth-client-metadata.json", (c) => { 825 const host = new URL(c.req.url).host; 826 return c.json({ 827 client_id: `https://${host}/oauth-client-metadata.json`, 828 client_name: "Stigmergic", 829 client_uri: `https://${host}`, 830 redirect_uris: [`https://${host}/`], 831 scope: "atproto transition:generic", 832 grant_types: ["authorization_code", "refresh_token"], 833 response_types: ["code"], 834 token_endpoint_auth_method: "none", 835 application_type: "web", 836 dpop_bound_access_tokens: true, 837 }); 838 }); 839 840 // ── SPA fallback: serve landing page for client-side routes ────── 841 for (const path of ["/island", "/strata", "/connect"]) { 842 app.get(path, async (c) => { 843 let islandsJson = "[]"; 844 try { 845 await ensureContrailReady(db); 846 const rows = await db 847 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 848 .all<{ uri: string; record: string }>(); 849 const islands: any[] = []; 850 for (const row of rows.results || []) { 851 try { 852 const rec = JSON.parse(row.record); 853 const lexmin = rec.source?.uri; 854 if (!lexmin) continue; 855 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 856 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 857 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri }); 858 } catch {} 859 } 860 islandsJson = JSON.stringify(islands); 861 } catch {} 862 863 const page = LANDING_PAGE.replace( 864 "</head>", 865 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 866 ); 867 return c.html(page); 868 }); 869 } 870 871 // ── All other routes pass through to contrail ────────────────── 872 873 app.all("*", async (c) => { 874 const response = await contrailWorker.fetch( 875 c.req.raw, 876 c.env as unknown as Record<string, unknown>, 877 ); 878 return response; 879 }); 880 881 return app; 882} 883 884// ─── Export ───────────────────────────────────────────────────────── 885 886export { StrataContainer }; 887 888export default { 889 fetch(request: Request, env: Env): Response | Promise<Response> { 890 app ??= buildApp(env); 891 return app.fetch(request, env); 892 }, 893 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 894 // Run Contrail indexing 895 await contrailWorker.scheduled( 896 event, 897 env as unknown as Record<string, unknown>, 898 ctx, 899 ); 900 }, 901}; 902