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 893 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 // Load only the fields we need — records are 100KB+ with embedded connections 383 let islandsJson = "[]"; 384 try { 385 await ensureContrailReady(db); 386 const rows = await db 387 .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") 388 .all<{ uri: string; lexmin: string | null; title: string | null }>(); 389 390 const islands: any[] = []; 391 for (const row of rows.results || []) { 392 if (!row.lexmin) continue; 393 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(row.lexmin)); 394 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 395 islands.push({ id, lexmin: row.lexmin, title: row.title || null, recordUri: row.uri }); 396 } 397 islandsJson = JSON.stringify(islands); 398 } catch (e) { 399 console.error("Failed to load islands:", e); 400 } 401 402 const page = LANDING_PAGE.replace( 403 "</head>", 404 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 405 ); 406 return c.html(page); 407 }); 408 409 // ── Islands API ────────────────────────────────────────────────── 410 411 app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 412 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 413 await ensureContrailReady(db); 414 415 const rows = await db 416 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 417 .bind(limit) 418 .all<{ uri: string; record: string }>(); 419 420 const islands: any[] = []; 421 for (const row of rows.results || []) { 422 try { 423 const rec = JSON.parse(row.record); 424 const lexmin = rec.source?.uri; 425 if (!lexmin) continue; 426 427 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 428 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 429 430 // Build graph directly from embedded connections — no D1 lookups needed 431 const connections: any[] = rec.connections || []; 432 const vertexSet = new Set<string>(); 433 const edges: any[] = []; 434 435 for (const conn of connections) { 436 if (typeof conn === "object" && conn.source && conn.target) { 437 vertexSet.add(conn.source); 438 vertexSet.add(conn.target); 439 edges.push(conn); 440 } 441 } 442 443 const vertices = [...vertexSet].sort(); 444 const vertexMeta = Object.fromEntries(await resolveVertexMeta(db, vertices)); 445 446 const analysis = rec.analysis || {}; 447 islands.push({ 448 id, lexmin, vertices, edges, vertexMeta, 449 title: analysis.title || null, 450 strata: analysis.synthesis ? { 451 prose: analysis.synthesis || "", title: analysis.title || "", 452 themes: analysis.themes || [], 453 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 454 tensions: analysis.tensions || [], 455 open_questions: analysis.openQuestions || [], 456 synthesis: analysis.synthesis || "", 457 } : null, 458 recordUri: row.uri, 459 }); 460 } catch {} 461 } 462 463 return c.json({ islands }); 464 }); 465 466 // ── Get single island by ID or AT URI ──────────────────────────── 467 468 app.get("/xrpc/org.latha.strata.getIsland", async (c) => { 469 const id = c.req.query("id"); 470 if (!id) { 471 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 472 } 473 474 await ensureContrailReady(db); 475 476 // If it's an AT URI, look up the strata record and derive from its lexmin 477 if (id.startsWith("at://")) { 478 const row = await db 479 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 480 .bind(id) 481 .first<{ uri: string; record: string }>(); 482 483 if (!row) { 484 return c.json({ error: "RecordNotFound" }, 404); 485 } 486 487 const rec = JSON.parse(row.record); 488 const lexmin = rec.source?.uri; 489 if (!lexmin) { 490 return c.json({ error: "InvalidRecord" }, 400); 491 } 492 493 const island = await deriveIsland(db, lexmin); 494 if (!island) { 495 return c.json({ error: "IslandNotFound" }, 404); 496 } 497 498 const meta = await resolveVertexMeta(db, island.vertices); 499 const analysis = rec.analysis || {}; 500 501 return c.json({ 502 island: { 503 ...island, 504 lexmin: island.vertices.slice().sort()[0], 505 vertexMeta: Object.fromEntries(meta), 506 summary: analysis.title || null, 507 strata: { 508 prose: analysis.synthesis || "", 509 title: analysis.title || "", 510 themes: analysis.themes || [], 511 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 512 tensions: analysis.tensions || [], 513 open_questions: analysis.openQuestions || [], 514 synthesis: analysis.synthesis || "", 515 }, 516 }, 517 recordUri: row.uri, 518 }); 519 } 520 521 // Stable ID — derive from lexmin by reverse-lookup 522 // Find the strata record whose lexmin hashes to this ID 523 const strataRows = await db 524 .prepare("SELECT uri, record FROM records_strata") 525 .all<{ uri: string; record: string }>(); 526 527 for (const row of strataRows.results || []) { 528 try { 529 const rec = JSON.parse(row.record); 530 const lexmin = rec.source?.uri; 531 if (!lexmin) continue; 532 533 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 534 const candidateId = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 535 536 if (candidateId === id) { 537 const island = await deriveIsland(db, lexmin); 538 if (!island) continue; 539 540 const meta = await resolveVertexMeta(db, island.vertices); 541 const analysis = rec.analysis || {}; 542 543 return c.json({ 544 island: { 545 ...island, 546 lexmin: island.vertices.slice().sort()[0], 547 vertexMeta: Object.fromEntries(meta), 548 summary: analysis.title || null, 549 strata: { 550 prose: analysis.synthesis || "", 551 title: analysis.title || "", 552 themes: analysis.themes || [], 553 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 554 tensions: analysis.tensions || [], 555 open_questions: analysis.openQuestions || [], 556 synthesis: analysis.synthesis || "", 557 }, 558 }, 559 recordUri: row.uri, 560 }); 561 } 562 } catch {} 563 } 564 565 // No strata record — try deriving island by treating the ID as a lexmin seed 566 // (for islands that haven't been analyzed yet) 567 const island = await deriveIsland(db, id); 568 if (!island) { 569 return c.json({ error: "IslandNotFound" }, 404); 570 } 571 572 const meta = await resolveVertexMeta(db, island.vertices); 573 return c.json({ 574 island: { 575 ...island, 576 lexmin: island.vertices.slice().sort()[0], 577 vertexMeta: Object.fromEntries(meta), 578 summary: null, 579 strata: null, 580 }, 581 recordUri: null, 582 }); 583 }); 584 585 // ── List strata records (AT URI-based) ────────────────────────── 586 587 app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 588 const rows = await db 589 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 590 .all<{ uri: string; record: string }>(); 591 592 const records = (rows.results || []).map(r => { 593 try { 594 const rec = JSON.parse(r.record); 595 return { 596 uri: r.uri, 597 source: rec.source?.uri || "", 598 title: rec.analysis?.title || "", 599 themes: rec.analysis?.themes || [], 600 createdAt: rec.createdAt || "", 601 }; 602 } catch { 603 return null; 604 } 605 }).filter(Boolean); 606 607 return c.json({ records }); 608 }); 609 610 // ── Strata record endpoint (AT URI-based) ────────────────────── 611 612 app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 613 const uri = c.req.query("uri"); 614 if (!uri) { 615 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 616 } 617 618 // Fetch the strata record from D1 (indexed by Contrail) 619 const row = await db 620 .prepare("SELECT record FROM records_strata WHERE uri = ?") 621 .bind(uri) 622 .first<{ record: string }>(); 623 624 if (!row) { 625 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 626 } 627 628 let strataRecord: any; 629 try { 630 strataRecord = JSON.parse(row.record); 631 } catch { 632 return c.json({ error: "InvalidRecord" }, 500); 633 } 634 635 // Derive island from lexmin (source.uri) 636 const lexmin = strataRecord.source?.uri; 637 if (!lexmin) { 638 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 639 } 640 641 const island = await deriveIsland(db, lexmin); 642 if (!island) { 643 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 644 } 645 646 // Resolve vertex metadata 647 const meta = await resolveVertexMeta(db, island.vertices); 648 649 // Build the response in the same format as the island cache 650 const analysis = strataRecord.analysis || {}; 651 const strataData = { 652 prose: analysis.synthesis || "", 653 title: analysis.title || "", 654 themes: analysis.themes || [], 655 relationships: (analysis.connections || []).map((c: any) => c.description || ""), 656 tensions: analysis.tensions || [], 657 open_questions: analysis.openQuestions || [], 658 synthesis: analysis.synthesis || "", 659 }; 660 661 return c.json({ 662 island: { 663 ...island, 664 vertexMeta: Object.fromEntries(meta), 665 summary: analysis.title || null, 666 strata: strataData, 667 }, 668 record: strataRecord, 669 recordUri: uri, 670 }); 671 }); 672 673 // ── Container-backed xrpc methods ──────────────────────────────── 674 675 // Helper: call the strata container 676 async function callContainer(path: string, method: string, body?: any): Promise<any> { 677 const id = env.STRATA_CONTAINER.idFromName("strata"); 678 const stub = env.STRATA_CONTAINER.get(id); 679 const url = new URL(path, "http://container"); 680 const init: RequestInit = { method }; 681 if (body) { 682 init.body = JSON.stringify(body); 683 init.headers = { "Content-Type": "application/json" }; 684 } 685 const resp = await stub.fetch(new Request(url.toString(), init)); 686 return resp.json(); 687 } 688 689 // Derive islands — POST, delegates to container 690 // With seed: single island. Without: all islands. 691 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => { 692 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 693 const seed = body.seed; 694 695 // Load all connections from D1 for the container to traverse 696 await ensureContrailReady(db); 697 const rows = await db 698 .prepare( 699 `SELECT r.record, r.did, r.rkey, i.handle 700 FROM records_connection r 701 LEFT JOIN identities i ON r.did = i.did`, 702 ) 703 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 704 705 const connections = (rows.results || []).map(r => { 706 try { 707 const value = JSON.parse(r.record); 708 return { 709 source: value.source, 710 target: value.target, 711 connectionType: value.connectionType || "relates", 712 note: value.note || "", 713 did: r.did, 714 rkey: r.rkey, 715 }; 716 } catch { 717 return null; 718 } 719 }).filter(Boolean); 720 721 try { 722 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 723 return c.json(result); 724 } catch (e: any) { 725 return c.json({ error: "ContainerError", message: e.message }, 500); 726 } 727 }); 728 729 // Run strata analysis — delegates to container 730 app.post("/xrpc/org.latha.strata.analyze", async (c) => { 731 const body = await c.req.json().catch(() => ({})); 732 if (!body.island) { 733 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 734 } 735 736 try { 737 const result = await callContainer("/analyze", "POST", body); 738 return c.json(result); 739 } catch (e: any) { 740 return c.json({ error: "ContainerError", message: e.message }, 500); 741 } 742 }); 743 744 // ── R2 sync endpoints ─────────────────────────────────────────── 745 746 app.put("/api/sync/vault/:did/*", async (c) => { 747 const did = c.req.param("did"); 748 const path = c.req.param("path"); 749 if (!did || !path) { 750 return c.json({ error: "Missing path" }, 400); 751 } 752 753 const key = `${did}/${path}`; 754 const body = await c.req.raw.arrayBuffer(); 755 await env.VAULT_BUCKET.put(key, body, { 756 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 757 }); 758 759 return c.json({ ok: true, key }); 760 }); 761 762 app.put("/api/sync/carry/:did/*", async (c) => { 763 const did = c.req.param("did"); 764 const path = c.req.param("path"); 765 if (!did || !path) { 766 return c.json({ error: "Missing path" }, 400); 767 } 768 769 const key = `${did}/${path}`; 770 const body = await c.req.raw.arrayBuffer(); 771 await env.CARRY_BUCKET.put(key, body, { 772 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 773 }); 774 775 return c.json({ ok: true, key }); 776 }); 777 778 // ── Graph neighborhood query ──────────────────────────────────── 779 780 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 781 const uri = c.req.query("uri"); 782 if (!uri) { 783 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 784 } 785 786 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 787 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 788 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 789 790 // Reuse island detection for the subgraph 791 const islands = await detectIslands(db); 792 const island = islands.find(i => i.vertices.includes(uri)); 793 794 if (!island) { 795 return c.json({ connections: [], resources: [], depth, uri }); 796 } 797 798 // Convert to graph format 799 const resources = island.vertices.map(v => ({ 800 uri: v, 801 title: v, 802 type: "unknown", 803 })); 804 805 return c.json({ 806 connections: island.edges, 807 resources, 808 depth, 809 uri, 810 }); 811 }); 812 813 // ── OAuth client metadata ────────────────────────────────────── 814 815 app.get("/oauth-client-metadata.json", (c) => { 816 const host = new URL(c.req.url).host; 817 return c.json({ 818 client_id: `https://${host}/oauth-client-metadata.json`, 819 client_name: "Stigmergic", 820 client_uri: `https://${host}`, 821 redirect_uris: [`https://${host}/`], 822 scope: "atproto transition:generic", 823 grant_types: ["authorization_code", "refresh_token"], 824 response_types: ["code"], 825 token_endpoint_auth_method: "none", 826 application_type: "web", 827 dpop_bound_access_tokens: true, 828 }); 829 }); 830 831 // ── SPA fallback: serve landing page for client-side routes ────── 832 for (const path of ["/island", "/strata", "/connect"]) { 833 app.get(path, async (c) => { 834 let islandsJson = "[]"; 835 try { 836 await ensureContrailReady(db); 837 const rows = await db 838 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 839 .all<{ uri: string; record: string }>(); 840 const islands: any[] = []; 841 for (const row of rows.results || []) { 842 try { 843 const rec = JSON.parse(row.record); 844 const lexmin = rec.source?.uri; 845 if (!lexmin) continue; 846 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 847 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 848 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri }); 849 } catch {} 850 } 851 islandsJson = JSON.stringify(islands); 852 } catch {} 853 854 const page = LANDING_PAGE.replace( 855 "</head>", 856 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 857 ); 858 return c.html(page); 859 }); 860 } 861 862 // ── All other routes pass through to contrail ────────────────── 863 864 app.all("*", async (c) => { 865 const response = await contrailWorker.fetch( 866 c.req.raw, 867 c.env as unknown as Record<string, unknown>, 868 ); 869 return response; 870 }); 871 872 return app; 873} 874 875// ─── Export ───────────────────────────────────────────────────────── 876 877export { StrataContainer }; 878 879export default { 880 fetch(request: Request, env: Env): Response | Promise<Response> { 881 app ??= buildApp(env); 882 return app.fetch(request, env); 883 }, 884 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 885 // Run Contrail indexing 886 await contrailWorker.scheduled( 887 event, 888 env as unknown as Record<string, unknown>, 889 ctx, 890 ); 891 }, 892}; 893