This repository has no description
0

Configure Feed

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

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