This repository has no description
0

Configure Feed

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

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