This repository has no description
0

Configure Feed

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

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