This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
49 kB 1389 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 CARRY_GITHUB_TOKEN: string; 19 OBSIDIAN_AUTH_TOKEN: string; 20 OBSIDIAN_ENCRYPTION_KEY: string; 21 OBSIDIAN_ENCRYPTION_SALT: string; 22} 23 24// ─── Contrail setup ───────────────────────────────────────────────── 25 26const contrailWorker = createWorker(config, { lexicons }); 27const contrail = new Contrail(config); 28let contrailReady = false; 29 30async function ensureContrailReady(db: D1Database): Promise<void> { 31 if (contrailReady) return; 32 await contrail.init(db); 33 contrailReady = true; 34} 35 36// ─── Island detection (connected components) ──────────────────────── 37// 38// Load all connections, build an undirected graph, find connected 39// components via BFS. Each component is an "island" — a cluster of 40// linked URLs that form a constellation in the knowledge graph. 41 42interface Island { 43 id: string; 44 centroid: string; 45 vertices: string[]; 46 edges: Array<{ 47 uri: string; 48 did: string; 49 source: string; 50 target: string; 51 connectionType: string; 52 note: string; 53 handle?: string | null; 54 }>; 55} 56 57/** Compute centroid: vertex with highest closeness centrality (smallest avg BFS distance). */ 58function computeCentroid(adj: Map<string, Set<string>>, nodes: Set<string>): string { 59 if (nodes.size <= 1) return [...nodes][0] ?? ''; 60 61 let bestNode = ''; 62 let bestAvg = Infinity; 63 64 for (const start of nodes) { 65 const dist = new Map<string, number>(); 66 dist.set(start, 0); 67 const queue = [start]; 68 while (queue.length > 0) { 69 const current = queue.shift()!; 70 for (const neighbor of adj.get(current) || []) { 71 if (!dist.has(neighbor) && nodes.has(neighbor)) { 72 dist.set(neighbor, dist.get(current)! + 1); 73 queue.push(neighbor); 74 } 75 } 76 } 77 78 let totalDist = 0; 79 let reachable = 0; 80 for (const [node, d] of dist) { 81 if (nodes.has(node)) { 82 totalDist += d; 83 reachable++; 84 } 85 } 86 87 const avg = reachable > 0 ? totalDist / reachable : Infinity; 88 if (avg < bestAvg) { 89 bestAvg = avg; 90 bestNode = start; 91 } 92 } 93 94 return bestNode; 95} 96 97/** Hash a URL to a 12-char hex island ID (matches CLI's islandId()). */ 98async function islandHash(url: string): Promise<string> { 99 const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)); 100 return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 12); 101} 102 103async function detectIslands(db: D1Database): Promise<Island[]> { 104 await ensureContrailReady(db); 105 106 // Load all connections 107 const rows = await db 108 .prepare( 109 `SELECT r.record, r.did, r.rkey, i.handle 110 FROM records_connection r 111 LEFT JOIN identities i ON r.did = i.did 112 ORDER BY r.time_us DESC`, 113 ) 114 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 115 116 // Build adjacency list 117 const adj = new Map<string, Set<string>>(); 118 const edges: Island["edges"] = []; 119 120 for (const row of rows.results || []) { 121 try { 122 const value = JSON.parse(row.record); 123 const source = value.source as string; 124 const target = value.target as string; 125 if (!source || !target) continue; 126 127 if (!adj.has(source)) adj.set(source, new Set()); 128 if (!adj.has(target)) adj.set(target, new Set()); 129 adj.get(source)!.add(target); 130 adj.get(target)!.add(source); 131 132 edges.push({ 133 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 134 did: row.did, 135 source, 136 target, 137 connectionType: value.connectionType || "relates", 138 note: value.note || "", 139 handle: row.handle, 140 }); 141 } catch {} 142 } 143 144 // BFS to find connected components 145 const visited = new Set<string>(); 146 const islands: Island[] = []; 147 148 for (const node of adj.keys()) { 149 if (visited.has(node)) continue; 150 151 const component: string[] = []; 152 const queue = [node]; 153 while (queue.length > 0) { 154 const current = queue.shift()!; 155 if (visited.has(current)) continue; 156 visited.add(current); 157 component.push(current); 158 159 for (const neighbor of adj.get(current) || []) { 160 if (!visited.has(neighbor)) queue.push(neighbor); 161 } 162 } 163 164 // Only include edges where both endpoints are in this component 165 const componentSet = new Set(component); 166 const componentEdges = edges.filter( 167 e => componentSet.has(e.source) && componentSet.has(e.target), 168 ); 169 170 // Stable ID: truncated SHA-256 of the centroid vertex (highest closeness centrality) 171 const centroid = computeCentroid(adj, componentSet); 172 const id = await islandHash(centroid); 173 174 islands.push({ 175 id, 176 centroid, 177 vertices: component, 178 edges: componentEdges, 179 }); 180 } 181 182 // Sort by size descending 183 islands.sort((a, b) => b.vertices.length - a.vertices.length); 184 return islands; 185} 186 187// ─── Resolve vertex metadata (batched) ────────────────────────────── 188// 189// Bulk queries instead of N+1 per vertex. Loads all cards and citations 190// matching the given URIs in a few chunked queries. 191 192async function resolveVertexMeta( 193 db: D1Database, 194 uris: string[], 195): Promise<Map<string, { title: string; description: string; type: string }>> { 196 const meta = new Map<string, { title: string; description: string; type: string }>(); 197 for (const uri of uris) { 198 meta.set(uri, { title: uri, description: "", type: "unknown" }); 199 } 200 201 const httpUris = uris.filter(u => u.startsWith("http")); 202 const atUris = uris.filter(u => u.startsWith("at://")); 203 const CHUNK = 50; 204 205 // Build all queries upfront, then batch-execute in one D1 round trip 206 const stmts: D1PreparedStatement[] = []; 207 208 // HTTP URI queries — cards 209 const httpChunks: string[][] = []; 210 for (let i = 0; i < httpUris.length; i += CHUNK) { 211 const chunk = httpUris.slice(i, i + CHUNK); 212 httpChunks.push(chunk); 213 const placeholders = chunk.map(() => "?").join(","); 214 stmts.push( 215 db.prepare( 216 `SELECT record FROM records_card WHERE json_extract(record, '$.content.url') IN (${placeholders})` 217 ).bind(...chunk) 218 ); 219 } 220 221 // HTTP URI queries — citations 222 for (let i = 0; i < httpUris.length; i += CHUNK) { 223 const chunk = httpUris.slice(i, i + CHUNK); 224 const placeholders = chunk.map(() => "?").join(","); 225 stmts.push( 226 db.prepare( 227 `SELECT record FROM records_citation WHERE json_extract(record, '$.url') IN (${placeholders})` 228 ).bind(...chunk) 229 ); 230 } 231 232 // AT URI queries — cards 233 const atChunks: string[][] = []; 234 for (let i = 0; i < atUris.length; i += CHUNK) { 235 const chunk = atUris.slice(i, i + CHUNK); 236 atChunks.push(chunk); 237 const placeholders = chunk.map(() => "?").join(","); 238 stmts.push( 239 db.prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`).bind(...chunk) 240 ); 241 } 242 243 // AT URI queries — collections 244 for (let i = 0; i < atUris.length; i += CHUNK) { 245 const chunk = atUris.slice(i, i + CHUNK); 246 const placeholders = chunk.map(() => "?").join(","); 247 stmts.push( 248 db.prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`).bind(...chunk) 249 ); 250 } 251 252 // Execute all queries in one batch 253 const results = stmts.length > 0 ? await db.batch(stmts) : []; 254 let resultIdx = 0; 255 256 // Process card results (HTTP) 257 for (const chunk of httpChunks) { 258 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 259 for (const row of rows) { 260 try { 261 const card = JSON.parse(row.record); 262 const url = card?.content?.url; 263 if (!url || !meta.has(url)) continue; 264 const m = card.content.metadata; 265 if (m?.title || m?.description) { 266 meta.set(url, { title: m.title || url, description: m.description || "", type: card.type === "URL" ? "url" : "note" }); 267 } 268 } catch {} 269 } 270 } 271 272 // Process citation results (HTTP) — overrides card data 273 for (let i = 0; i < httpChunks.length; i++) { 274 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 275 for (const row of rows) { 276 try { 277 const citation = JSON.parse(row.record); 278 const url = citation.url; 279 if (!url || !meta.has(url)) continue; 280 if (citation.title) { 281 const existing = meta.get(url)!; 282 meta.set(url, { title: citation.title, description: citation.takeaway || existing.description, type: "citation" }); 283 } 284 } catch {} 285 } 286 } 287 288 // Process card results (AT) 289 for (const chunk of atChunks) { 290 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 291 for (const row of rows) { 292 try { 293 const card = JSON.parse(row.record); 294 const m = card?.content?.metadata; 295 const url = card?.content?.url; 296 if (meta.has(row.uri)) { 297 meta.set(row.uri, { title: m?.title || url || row.uri, description: m?.description || "", type: "card" }); 298 } 299 } catch {} 300 } 301 } 302 303 // Process collection results (AT) 304 for (let i = 0; i < atChunks.length; i++) { 305 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 306 for (const row of rows) { 307 try { 308 const coll = JSON.parse(row.record); 309 if (meta.has(row.uri)) { 310 meta.set(row.uri, { title: coll.name || row.uri, description: coll.description || "", type: "collection" }); 311 } 312 } catch {} 313 } 314 } 315 316 return meta; 317} 318 319// ─── Derive island from centroid ──────────────────────────────────── 320// 321// Given a centroid vertex (canonical component reference), derive the 322// connected component by traversing network.cosmik.connection records. 323 324async function deriveIsland( 325 db: D1Database, 326 seed: string, 327): Promise<Island | null> { 328 await ensureContrailReady(db); 329 330 // BFS in application code — recursive CTE OOMs in D1 due to cross join 331 const visited = new Set<string>(); 332 const queue = [seed]; 333 const allEdges: Array<{ 334 uri: string; did: string; rkey: string; 335 source: string; target: string; connectionType: string; 336 note: string; handle: string | null; 337 }> = []; 338 339 while (queue.length > 0) { 340 // Drain the current queue into a batch 341 const batch = queue.splice(0, 50); 342 const toQuery = batch.filter(v => !visited.has(v)); 343 if (toQuery.length === 0) continue; 344 345 // Mark visited 346 for (const v of toQuery) visited.add(v); 347 348 // Query connections where any of these vertices are source or target 349 const placeholders = toQuery.map(() => "?").join(","); 350 const rows = await db 351 .prepare( 352 `SELECT r.record, r.did, r.rkey, i.handle 353 FROM records_connection r 354 LEFT JOIN identities i ON r.did = i.did 355 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 356 OR json_extract(r.record, '$.target') IN (${placeholders})`, 357 ) 358 .bind(...toQuery, ...toQuery) 359 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 360 361 for (const row of rows.results || []) { 362 try { 363 const value = JSON.parse(row.record); 364 const source = value.source as string; 365 const target = value.target as string; 366 if (!source || !target) continue; 367 368 // Collect edge 369 const edgeKey = `${source}|${target}|${row.rkey}`; 370 allEdges.push({ 371 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 372 did: row.did, 373 rkey: row.rkey, 374 source, 375 target, 376 connectionType: value.connectionType || "relates", 377 note: value.note || "", 378 handle: row.handle, 379 }); 380 381 // Enqueue unvisited neighbors 382 if (!visited.has(source)) queue.push(source); 383 if (!visited.has(target)) queue.push(target); 384 } catch {} 385 } 386 } 387 388 if (visited.size === 0) return null; 389 390 const vertices = [...visited]; 391 392 // Filter edges to only those with both endpoints in the component 393 const vertexSet = new Set(vertices); 394 const edges: Island["edges"] = []; 395 const seenEdges = new Set<string>(); 396 for (const e of allEdges) { 397 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 398 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 399 if (seenEdges.has(edgeKey)) continue; 400 seenEdges.add(edgeKey); 401 edges.push({ 402 uri: e.uri, 403 did: e.did, 404 source: e.source, 405 target: e.target, 406 connectionType: e.connectionType, 407 note: e.note, 408 handle: e.handle, 409 }); 410 } 411 412 // Compute stable ID from centroid (highest closeness centrality) 413 const adj = new Map<string, Set<string>>(); 414 for (const e of edges) { 415 if (!adj.has(e.source)) adj.set(e.source, new Set()); 416 if (!adj.has(e.target)) adj.set(e.target, new Set()); 417 adj.get(e.source)!.add(e.target); 418 adj.get(e.target)!.add(e.source); 419 } 420 const centroid = computeCentroid(adj, vertexSet); 421 const id = await islandHash(centroid); 422 423 return { id, centroid, vertices, edges }; 424} 425 426// ─── List strata islands from records_strata ────────────────────── 427 428// ─── Resolve edge URIs to full edge objects ────────────────────── 429 430/** Given a list of connections (either full objects or just {uri}), resolve URIs to full edges from D1. */ 431async function resolveEdgeUris(db: D1Database, connections: any[]): Promise<any[]> { 432 const resolved: any[] = []; 433 const uriIndices: Map<string, number[]> = new Map(); // uri → indices in connections that need it 434 435 for (let i = 0; i < connections.length; i++) { 436 const conn = connections[i]; 437 // Already has source/target — use as-is 438 if (typeof conn === "object" && conn.source && conn.target) { 439 resolved.push(conn); 440 continue; 441 } 442 // Just a URI reference — needs lookup 443 const uri = conn.uri; 444 if (!uri) continue; 445 resolved.push(null); // placeholder 446 if (!uriIndices.has(uri)) uriIndices.set(uri, []); 447 uriIndices.get(uri)!.push(i); 448 } 449 450 if (uriIndices.size === 0) return resolved.filter(Boolean); 451 452 // Batch-fetch connection records from D1 453 const uris = [...uriIndices.keys()]; 454 const placeholders = uris.map(() => "?").join(","); 455 const rows = await db 456 .prepare( 457 `SELECT r.uri, r.record, r.did, r.rkey, i.handle 458 FROM records_connection r 459 LEFT JOIN identities i ON r.did = i.did 460 WHERE r.uri IN (${placeholders})`, 461 ) 462 .bind(...uris) 463 .all<{ uri: string; record: string; did: string; rkey: string; handle: string | null }>(); 464 465 for (const row of rows.results || []) { 466 try { 467 const value = JSON.parse(row.record); 468 const edge = { 469 uri: row.uri, 470 did: row.did, 471 source: value.source, 472 target: value.target, 473 connectionType: value.connectionType || "relates", 474 note: value.note || "", 475 handle: row.handle, 476 }; 477 for (const idx of uriIndices.get(row.uri) || []) { 478 resolved[idx] = edge; 479 } 480 } catch {} 481 } 482 483 return resolved.filter(Boolean); 484} 485 486// ─── Hono app ─────────────────────────────────────────────────────── 487 488let app: Hono<{ Bindings: Env }> | null = null; 489 490function buildApp(env: Env): Hono<{ Bindings: Env }> { 491 const app = new Hono<{ Bindings: Env }>(); 492 const db = env.DB; 493 494 app.use("*", cors()); 495 496 // Landing page — strata records feed 497 app.get("/", async (c) => { 498 // Load only the fields we need — records are 100KB+ with embedded connections 499 // No ensureContrailReady needed — just D1 500 let islandsJson = "[]"; 501 try { 502 const rows = await db 503 .prepare("SELECT uri, json_extract(record, '$.source.uri') as centroid, json_extract(record, '$.analysis.title') as title FROM records_strata ORDER BY time_us DESC LIMIT 10") 504 .all<{ uri: string; centroid: string | null; title: string | null }>(); 505 506 const islands: any[] = []; 507 for (const row of rows.results || []) { 508 if (!row.centroid) continue; 509 const id = row.uri.split("/").pop() || row.uri; 510 islands.push({ id, centroid: row.centroid, title: row.title || null, recordUri: row.uri }); 511 } 512 islandsJson = JSON.stringify(islands); 513 } catch (e) { 514 console.error("Failed to load islands:", e); 515 } 516 517 const page = LANDING_PAGE.replace( 518 "</head>", 519 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 520 ); 521 return c.html(page); 522 }); 523 524 // ── Islands API ────────────────────────────────────────────────── 525 526 app.get("/xrpc/org.latha.island.getIslands", async (c) => { 527 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 528 const summary = c.req.query("summary") === "true"; 529 // No ensureContrailReady — just D1 queries 530 531 const rows = await db 532 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 533 .bind(limit) 534 .all<{ uri: string; record: string }>(); 535 536 const islands: any[] = []; 537 for (const row of rows.results || []) { 538 try { 539 const rec = JSON.parse(row.record); 540 const centroid = rec.source?.uri; 541 if (!centroid) continue; 542 543 // ID is the record rkey — unique per strata record even if same graph 544 const id = row.uri.split("/").pop() || row.uri; 545 546 // Build graph from connections — resolve URIs if edges are just {uri} references 547 const connections: any[] = rec.connections || []; 548 const resolvedEdges = await resolveEdgeUris(db, connections); 549 const vertexSet = new Set<string>(); 550 const edges: any[] = []; 551 552 for (const conn of resolvedEdges) { 553 if (conn.source && conn.target) { 554 vertexSet.add(conn.source); 555 vertexSet.add(conn.target); 556 edges.push(conn); 557 } 558 } 559 560 const vertices = [...vertexSet].sort(); 561 const analysis = rec.analysis || {}; 562 563 // Summary mode: minimal payload for explore page canvas 564 // Skip vertexMeta (122KB) and trim edges to just source/target 565 const vertexMeta = summary ? {} : Object.fromEntries(await resolveVertexMeta(db, vertices)); 566 const trimmedEdges = summary 567 ? edges.map(e => ({ source: e.source, target: e.target, connectionType: e.connectionType })) 568 : edges; 569 570 islands.push({ 571 id, centroid, vertices, edges: trimmedEdges, vertexMeta, 572 title: analysis.title || null, 573 strata: analysis.synthesis ? { 574 prose: analysis.synthesis || "", title: analysis.title || "", 575 themes: analysis.themes || [], 576 relationships: [], 577 highlights: analysis.highlights || [], 578 tensions: analysis.tensions || [], 579 open_questions: analysis.openQuestions || [], 580 synthesis: analysis.synthesis || "", 581 } : null, 582 recordUri: row.uri, 583 }); 584 } catch {} 585 } 586 587 return c.json({ islands }); 588 }); 589 590 // ── Get single island by ID or AT URI ──────────────────────────── 591 592 app.get("/xrpc/org.latha.island.getIsland", async (c) => { 593 const id = c.req.query("id"); 594 if (!id) { 595 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 596 } 597 598 await ensureContrailReady(db); 599 600 // If it's an AT URI, look up the strata record and derive from its centroid 601 if (id.startsWith("at://")) { 602 const row = await db 603 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 604 .bind(id) 605 .first<{ uri: string; record: string }>(); 606 607 if (!row) { 608 return c.json({ error: "RecordNotFound" }, 404); 609 } 610 611 const rec = JSON.parse(row.record); 612 const centroid = rec.source?.uri; 613 if (!centroid) { 614 return c.json({ error: "InvalidRecord" }, 400); 615 } 616 617 const island = await deriveIsland(db, centroid); 618 if (!island) { 619 return c.json({ error: "IslandNotFound" }, 404); 620 } 621 622 const meta = await resolveVertexMeta(db, island.vertices); 623 const analysis = rec.analysis || {}; 624 625 return c.json({ 626 island: { 627 ...island, 628 centroid: island.centroid, 629 vertexMeta: Object.fromEntries(meta), 630 summary: analysis.title || null, 631 strata: { 632 prose: analysis.synthesis || "", 633 title: analysis.title || "", 634 themes: analysis.themes || [], 635 relationships: [], 636 highlights: analysis.highlights || [], 637 tensions: analysis.tensions || [], 638 open_questions: analysis.openQuestions || [], 639 synthesis: analysis.synthesis || "", 640 }, 641 }, 642 recordUri: row.uri, 643 }); 644 } 645 646 // ID is the record rkey — look up directly by URI 647 const recordUri = `at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.island/${id}`; 648 const row = await db 649 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 650 .bind(recordUri) 651 .first<{ uri: string; record: string }>(); 652 653 if (row) { 654 try { 655 const rec = JSON.parse(row.record); 656 const centroid = rec.source?.uri; 657 658 // Build graph from connections — resolve URIs if edges are just {uri} references 659 const connections: any[] = rec.connections || []; 660 const resolvedEdges = await resolveEdgeUris(db, connections); 661 const vertexSet = new Set<string>(); 662 const edges: any[] = []; 663 for (const conn of resolvedEdges) { 664 if (conn.source && conn.target) { 665 vertexSet.add(conn.source); 666 vertexSet.add(conn.target); 667 edges.push(conn); 668 } 669 } 670 const vertices = [...vertexSet].sort(); 671 const meta = await resolveVertexMeta(db, vertices); 672 const analysis = rec.analysis || {}; 673 674 return c.json({ 675 island: { 676 id, 677 vertices, 678 edges, 679 centroid: centroid || vertices[0], 680 vertexMeta: Object.fromEntries(meta), 681 summary: analysis.title || null, 682 strata: { 683 prose: analysis.synthesis || "", 684 title: analysis.title || "", 685 themes: analysis.themes || [], 686 relationships: [], 687 highlights: analysis.highlights || [], 688 tensions: analysis.tensions || [], 689 open_questions: analysis.openQuestions || [], 690 synthesis: analysis.synthesis || "", 691 }, 692 recordUri: row.uri, 693 }, 694 }); 695 } catch {} 696 } 697 698 // No strata record found — try deriving island by treating the ID as a centroid seed 699 // If the ID is a 12-char hex string (centroid hash), scan strata records for a match 700 if (/^[0-9a-f]{12}$/.test(id)) { 701 const rows = await db 702 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 500") 703 .all<{ uri: string; record: string }>(); 704 for (const row of rows.results || []) { 705 try { 706 const rec = JSON.parse(row.record); 707 const centroid = rec.source?.uri; 708 if (!centroid) continue; 709 const hash = await islandHash(centroid); 710 if (hash === id) { 711 // Found the record — redirect to the AT URI path 712 const connections: any[] = rec.connections || []; 713 const resolvedEdges = await resolveEdgeUris(db, connections); 714 const vertexSet = new Set<string>(); 715 const edges: any[] = []; 716 for (const conn of resolvedEdges) { 717 if (conn.source && conn.target) { 718 vertexSet.add(conn.source); 719 vertexSet.add(conn.target); 720 edges.push(conn); 721 } 722 } 723 const vertices = [...vertexSet].sort(); 724 const meta = await resolveVertexMeta(db, vertices); 725 const analysis = rec.analysis || {}; 726 return c.json({ 727 island: { 728 id, 729 vertices, 730 edges, 731 centroid, 732 vertexMeta: Object.fromEntries(meta), 733 summary: analysis.title || null, 734 strata: { 735 prose: analysis.synthesis || "", 736 title: analysis.title || "", 737 themes: analysis.themes || [], 738 relationships: [], 739 highlights: analysis.highlights || [], 740 tensions: analysis.tensions || [], 741 open_questions: analysis.openQuestions || [], 742 synthesis: analysis.synthesis || "", 743 }, 744 }, 745 recordUri: row.uri, 746 }); 747 } 748 } catch {} 749 } 750 } 751 752 // Fall back: treat ID as a vertex URL for BFS derivation 753 const island = await deriveIsland(db, id); 754 if (!island) { 755 return c.json({ error: "IslandNotFound" }, 404); 756 } 757 758 const meta = await resolveVertexMeta(db, island.vertices); 759 return c.json({ 760 island: { 761 ...island, 762 centroid: island.centroid, 763 vertexMeta: Object.fromEntries(meta), 764 summary: null, 765 strata: null, 766 }, 767 recordUri: null, 768 }); 769 }); 770 771 // ── List strata records (AT URI-based) ────────────────────────── 772 773 app.get("/xrpc/org.latha.island.listRecords", async (c) => { 774 const rows = await db 775 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 776 .all<{ uri: string; record: string }>(); 777 778 const records = (rows.results || []).map(r => { 779 try { 780 const rec = JSON.parse(r.record); 781 return { 782 uri: r.uri, 783 source: rec.source?.uri || "", 784 title: rec.analysis?.title || "", 785 themes: rec.analysis?.themes || [], 786 createdAt: rec.createdAt || "", 787 }; 788 } catch { 789 return null; 790 } 791 }).filter(Boolean); 792 793 return c.json({ records }); 794 }); 795 796 // ── Strata record endpoint (AT URI-based) ────────────────────── 797 798 app.get("/xrpc/org.latha.island.getRecord", async (c) => { 799 const uri = c.req.query("uri"); 800 if (!uri) { 801 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 802 } 803 804 // Fetch the strata record from D1 (indexed by Contrail) 805 const row = await db 806 .prepare("SELECT record FROM records_strata WHERE uri = ?") 807 .bind(uri) 808 .first<{ record: string }>(); 809 810 if (!row) { 811 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 812 } 813 814 let strataRecord: any; 815 try { 816 strataRecord = JSON.parse(row.record); 817 } catch { 818 return c.json({ error: "InvalidRecord" }, 500); 819 } 820 821 // Derive island from centroid (source.uri) 822 const centroid = strataRecord.source?.uri; 823 if (!centroid) { 824 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 825 } 826 827 const island = await deriveIsland(db, centroid); 828 if (!island) { 829 return c.json({ error: "IslandNotFound", message: "Could not derive island from centroid" }, 404); 830 } 831 832 // Resolve vertex metadata 833 const meta = await resolveVertexMeta(db, island.vertices); 834 835 // Build the response in the same format as the island cache 836 const analysis = strataRecord.analysis || {}; 837 const strataData = { 838 prose: analysis.synthesis || "", 839 title: analysis.title || "", 840 themes: analysis.themes || [], 841 relationships: [], 842 highlights: analysis.highlights || [], 843 tensions: analysis.tensions || [], 844 open_questions: analysis.openQuestions || [], 845 synthesis: analysis.synthesis || "", 846 }; 847 848 return c.json({ 849 island: { 850 ...island, 851 vertexMeta: Object.fromEntries(meta), 852 summary: analysis.title || null, 853 strata: strataData, 854 }, 855 record: strataRecord, 856 recordUri: uri, 857 }); 858 }); 859 860 // ── Container-backed xrpc methods ──────────────────────────────── 861 862 // Helper: read carry data from D1 for analysis 863 async function getCarryData(db: D1Database, did?: string): Promise<{ 864 entities: Array<{ atUri: string; name: string; entityType: string; description?: string; url?: string }>; 865 citations: Array<{ atUri: string; url: string; title: string; takeaway: string; confidence?: string }>; 866 reasonings: Array<{ atUri: string; claim: string; evidence: string; reasoningType?: string; url?: string }>; 867 connections: Array<{ atUri: string; source: string; target: string; relation: string; sourceUrl: string; targetUrl: string; context: string }>; 868 }> { 869 const entities: Array<{ atUri: string; name: string; entityType: string; description?: string; url?: string }> = []; 870 const citations: Array<{ atUri: string; url: string; title: string; takeaway: string; confidence?: string }> = []; 871 const reasonings: Array<{ atUri: string; claim: string; evidence: string; reasoningType?: string; url?: string }> = []; 872 const connections: Array<{ atUri: string; source: string; target: string; relation: string; sourceUrl: string; targetUrl: string; context: string }> = []; 873 874 try { 875 // Read entities 876 const entityRows = await db 877 .prepare("SELECT uri, record FROM records_entity ORDER BY time_us DESC LIMIT 100") 878 .all<{ uri: string; record: string }>(); 879 for (const row of entityRows.results || []) { 880 try { 881 const r = JSON.parse(row.record); 882 entities.push({ 883 atUri: row.uri, 884 name: r.name || "", 885 entityType: r.entityType || "", 886 description: r.description || "", 887 url: r.url || "", 888 }); 889 } catch {} 890 } 891 892 // Read citations 893 const citeRows = await db 894 .prepare("SELECT uri, record FROM records_citation ORDER BY time_us DESC LIMIT 100") 895 .all<{ uri: string; record: string }>(); 896 for (const row of citeRows.results || []) { 897 try { 898 const r = JSON.parse(row.record); 899 citations.push({ 900 atUri: row.uri, 901 url: r.url || "", 902 title: r.title || "", 903 takeaway: r.takeaway || "", 904 confidence: r.confidence || "", 905 }); 906 } catch {} 907 } 908 909 // Read reasonings 910 const reasonRows = await db 911 .prepare("SELECT uri, record FROM records_reasoning ORDER BY time_us DESC LIMIT 100") 912 .all<{ uri: string; record: string }>(); 913 for (const row of reasonRows.results || []) { 914 try { 915 const r = JSON.parse(row.record); 916 reasonings.push({ 917 atUri: row.uri, 918 claim: r.claim || "", 919 evidence: r.evidence || "", 920 reasoningType: r.reasoningType || "", 921 url: r.url || "", 922 }); 923 } catch {} 924 } 925 } catch (e: any) { 926 console.error("getCarryData error:", e.message); 927 } 928 929 // Read connections from D1 930 try { 931 const connRows = await db 932 .prepare("SELECT uri, record FROM records_connection ORDER BY time_us DESC LIMIT 100") 933 .all<{ uri: string; record: string }>(); 934 for (const row of connRows.results || []) { 935 try { 936 const r = JSON.parse(row.record); 937 connections.push({ 938 atUri: row.uri, 939 source: r.source || "", 940 target: r.target || "", 941 relation: r.relation || "", 942 sourceUrl: r.sourceUrl || "", 943 targetUrl: r.targetUrl || "", 944 context: r.context || "", 945 }); 946 } catch {} 947 } 948 } catch (e: any) { 949 console.error("getCarryData connections error:", e.message); 950 } 951 952 return { entities, citations, reasonings, connections }; 953 } 954 955 // Helper: call the strata container 956 async function callContainer(path: string, method: string, body?: any): Promise<any> { 957 const id = env.STRATA_CONTAINER.idFromName("strata"); 958 const stub = env.STRATA_CONTAINER.get(id); 959 const url = new URL(path, "http://container"); 960 const init: RequestInit = { method }; 961 if (body) { 962 init.body = JSON.stringify(body); 963 init.headers = { "Content-Type": "application/json" }; 964 } 965 const resp = await stub.fetch(new Request(url.toString(), init)); 966 const text = await resp.text(); 967 try { 968 return JSON.parse(text); 969 } catch { 970 throw new Error(`Container returned non-JSON: ${text.slice(0, 500)}`); 971 } 972 } 973 974 // Sync container data (vault + carry) 975 app.post("/xrpc/org.latha.island.syncContainer", async (c) => { 976 try { 977 const result = await callContainer("/sync", "POST"); 978 return c.json(result); 979 } catch (e: any) { 980 return c.json({ error: "ContainerError", message: e.message }, 500); 981 } 982 }); 983 984 // Notify appview of a newly published record — crawl the DID's PDS and upsert into D1 985 app.post("/xrpc/org.latha.island.notifyOfUpdate", async (c) => { 986 const body = await c.req.json().catch(() => ({})) as { did?: string; uri?: string }; 987 const did = body.did; 988 if (!did) return c.json({ error: "InvalidRequest", message: "did is required" }, 400); 989 990 await ensureContrailReady(db); 991 992 // Look up the DID's PDS 993 const identity = await db 994 .prepare("SELECT pds FROM identities WHERE did = ?") 995 .bind(did) 996 .first<{ pds: string }>(); 997 998 const pds = identity?.pds; 999 if (!pds) return c.json({ error: "IdentityNotFound", message: `DID ${did} not in identities table` }, 404); 1000 1001 // Fetch org.latha.island records from the DID's PDS 1002 const url = `${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=org.latha.island&limit=50`; 1003 const resp = await fetch(url); 1004 if (!resp.ok) return c.json({ error: "PdsError", message: `PDS returned ${resp.status}` }, 502); 1005 1006 const data = await resp.json() as { records: Array<{ uri: string; cid: string; value: Record<string, unknown> }> }; 1007 const records = data.records || []; 1008 if (records.length === 0) return c.json({ ingested: 0 }); 1009 1010 // Upsert each record into records_strata 1011 const now = Date.now() * 1000; // time_us 1012 const indexedAt = Date.now(); 1013 const stmt = db.prepare( 1014 `INSERT INTO records_strata (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, time_us = excluded.time_us, indexed_at = excluded.indexed_at` 1015 ); 1016 1017 let ingested = 0; 1018 const batch: D1PreparedStatement[] = []; 1019 for (const r of records) { 1020 const rkey = r.uri.split("/").pop() || ""; 1021 batch.push(stmt.bind(r.uri, did, rkey, r.cid || "", JSON.stringify(r.value), now + ingested, indexedAt)); 1022 ingested++; 1023 } 1024 1025 await db.batch(batch); 1026 return c.json({ ingested }); 1027 }); 1028 1029 // Container health + data status 1030 app.get("/xrpc/org.latha.island.containerHealth", async (c) => { 1031 try { 1032 const result = await callContainer("/health", "GET"); 1033 return c.json(result); 1034 } catch (e: any) { 1035 return c.json({ error: "ContainerError", message: e.message }, 500); 1036 } 1037 }); 1038 1039 // Derive islands — POST, delegates to container 1040 // With seed: single island. Without: all islands. 1041 app.post("/xrpc/org.latha.island.deriveIsland", async (c) => { 1042 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 1043 const seed = body.seed; 1044 1045 // Load all connections from D1 for the container to traverse 1046 await ensureContrailReady(db); 1047 const rows = await db 1048 .prepare( 1049 `SELECT r.record, r.did, r.rkey, i.handle 1050 FROM records_connection r 1051 LEFT JOIN identities i ON r.did = i.did`, 1052 ) 1053 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 1054 1055 const connections = (rows.results || []).map(r => { 1056 try { 1057 const value = JSON.parse(r.record); 1058 return { 1059 source: value.source, 1060 target: value.target, 1061 connectionType: value.connectionType || "relates", 1062 note: value.note || "", 1063 did: r.did, 1064 rkey: r.rkey, 1065 }; 1066 } catch { 1067 return null; 1068 } 1069 }).filter(Boolean); 1070 1071 try { 1072 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 1073 return c.json(result); 1074 } catch (e: any) { 1075 return c.json({ error: "ContainerError", message: e.message }, 500); 1076 } 1077 }); 1078 1079 // Run strata analysis — delegates to container 1080 app.post("/xrpc/org.latha.island.analyze", async (c) => { 1081 const body = await c.req.json().catch(() => ({})); 1082 if (!body.island) { 1083 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 1084 } 1085 1086 try { 1087 // Read carry data from D1 for the user's DID 1088 const carryData = await getCarryData(db, body.did); 1089 1090 // Pass carry data to container alongside island 1091 const payload = { ...body, carryData }; 1092 const result = await callContainer("/analyze", "POST", payload); 1093 1094 // Create new connection records on PDS 1095 const newConnections: Array<{ source: string; target: string; connectionType: string; note: string }> = result.newConnections || []; 1096 const createdUris: string[] = []; 1097 1098 if (newConnections.length > 0) { 1099 const appPassword = env.PDS_APP_PASSWORD; 1100 if (appPassword && body.did) { 1101 const pdsHost = "amanita.us-east.host.bsky.network"; 1102 const authResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, { 1103 method: "POST", 1104 headers: { "Content-Type": "application/json" }, 1105 body: JSON.stringify({ identifier: body.did, password: appPassword }), 1106 }); 1107 if (authResp.ok) { 1108 const { accessJwt } = await authResp.json() as { accessJwt: string }; 1109 1110 for (const conn of newConnections) { 1111 const rkey = crypto.randomUUID().replace(/-/g, "").slice(0, 13); // TID format 1112 const record = { 1113 "$type": "network.cosmik.connection", 1114 source: conn.source, 1115 target: conn.target, 1116 connectionType: conn.connectionType, 1117 note: conn.note, 1118 createdAt: new Date().toISOString(), 1119 }; 1120 1121 const createResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, { 1122 method: "POST", 1123 headers: { 1124 "Content-Type": "application/json", 1125 "Authorization": `Bearer ${accessJwt}`, 1126 }, 1127 body: JSON.stringify({ 1128 repo: body.did, 1129 collection: "network.cosmik.connection", 1130 rkey, 1131 record, 1132 }), 1133 }); 1134 1135 if (createResp.ok) { 1136 const createResult = await createResp.json() as { uri: string }; 1137 createdUris.push(createResult.uri); 1138 } 1139 } 1140 } 1141 } 1142 } 1143 1144 // Add new connection URIs to highlights 1145 result.highlights = [...(result.highlights || []), ...createdUris]; 1146 result.createdConnections = createdUris; 1147 1148 return c.json(result); 1149 } catch (e: any) { 1150 return c.json({ error: "ContainerError", message: e.message }, 500); 1151 } 1152 }); 1153 1154 // ── Update strata record (write back to PDS) ──────────────────── 1155 app.put("/xrpc/org.latha.island.updateRecord", async (c) => { 1156 const body = await c.req.json().catch(() => ({})) as { 1157 uri?: string; 1158 analysis?: Record<string, any>; 1159 }; 1160 if (!body.uri || !body.analysis) { 1161 return c.json({ error: "MissingRequiredField", message: "uri and analysis are required" }, 400); 1162 } 1163 1164 const appPassword = env.PDS_APP_PASSWORD; 1165 if (!appPassword) { 1166 return c.json({ error: "NotConfigured", message: "PDS_APP_PASSWORD not set" }, 500); 1167 } 1168 1169 // Parse the AT URI to extract repo DID, collection, rkey 1170 const parts = body.uri.replace("at://", "").split("/"); 1171 const repoDid = parts[0]; 1172 const collection = parts[1]; 1173 const rkey = parts[2]; 1174 if (!repoDid || !collection || !rkey) { 1175 return c.json({ error: "InvalidUri", message: "Expected at://did:.../org.latha.island/rkey" }, 400); 1176 } 1177 1178 // Read existing record from D1 1179 const row = await db 1180 .prepare("SELECT record FROM records_strata WHERE uri = ?") 1181 .bind(body.uri) 1182 .first<{ record: string }>(); 1183 if (!row) { 1184 return c.json({ error: "RecordNotFound" }, 404); 1185 } 1186 1187 // Merge updated analysis into existing record 1188 const record = JSON.parse(row.record); 1189 record.analysis = { ...record.analysis, ...body.analysis }; 1190 // Remove stale fields that are no longer in the lexicon 1191 delete record.analysis.connections; 1192 delete record.connections; 1193 record.updatedAt = new Date().toISOString(); 1194 1195 // Write back to PDS via com.atproto.repo.putRecord 1196 const pdsHost = "amanita.us-east.host.bsky.network"; 1197 const authResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, { 1198 method: "POST", 1199 headers: { "Content-Type": "application/json" }, 1200 body: JSON.stringify({ identifier: repoDid, password: appPassword }), 1201 }); 1202 if (!authResp.ok) { 1203 const err = await authResp.text(); 1204 return c.json({ error: "AuthFailed", message: err }, 500); 1205 } 1206 const { accessJwt } = await authResp.json() as { accessJwt: string }; 1207 1208 const putResp = await fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, { 1209 method: "POST", 1210 headers: { 1211 "Content-Type": "application/json", 1212 "Authorization": `Bearer ${accessJwt}`, 1213 }, 1214 body: JSON.stringify({ 1215 repo: repoDid, 1216 collection, 1217 rkey, 1218 record, 1219 }), 1220 }); 1221 if (!putResp.ok) { 1222 const err = await putResp.text(); 1223 return c.json({ error: "PutRecordFailed", message: err }, 500); 1224 } 1225 1226 // Update D1 cache 1227 await db 1228 .prepare("UPDATE records_strata SET record = ? WHERE uri = ?") 1229 .bind(JSON.stringify(record), body.uri) 1230 .run(); 1231 1232 return c.json({ ok: true, uri: body.uri }); 1233 }); 1234 1235 // ── R2 sync endpoints ─────────────────────────────────────────── 1236 1237 app.put("/api/sync/vault/:did/*", async (c) => { 1238 const did = c.req.param("did"); 1239 const path = c.req.param("path"); 1240 if (!did || !path) { 1241 return c.json({ error: "Missing path" }, 400); 1242 } 1243 1244 const key = `${did}/${path}`; 1245 const body = await c.req.raw.arrayBuffer(); 1246 await env.VAULT_BUCKET.put(key, body, { 1247 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 1248 }); 1249 1250 return c.json({ ok: true, key }); 1251 }); 1252 1253 app.put("/api/sync/carry/:did/*", async (c) => { 1254 const did = c.req.param("did"); 1255 const path = c.req.param("path"); 1256 if (!did || !path) { 1257 return c.json({ error: "Missing path" }, 400); 1258 } 1259 1260 const key = `${did}/${path}`; 1261 const body = await c.req.raw.arrayBuffer(); 1262 await env.CARRY_BUCKET.put(key, body, { 1263 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 1264 }); 1265 1266 return c.json({ ok: true, key }); 1267 }); 1268 1269 // ── Graph neighborhood query ──────────────────────────────────── 1270 1271 app.get("/xrpc/org.latha.island.connection.getGraph", async (c) => { 1272 const uri = c.req.query("uri"); 1273 if (!uri) { 1274 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 1275 } 1276 1277 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 1278 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 1279 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 1280 1281 // Reuse island detection for the subgraph 1282 const islands = await detectIslands(db); 1283 const island = islands.find(i => i.vertices.includes(uri)); 1284 1285 if (!island) { 1286 return c.json({ connections: [], resources: [], depth, uri }); 1287 } 1288 1289 // Convert to graph format 1290 const resources = island.vertices.map(v => ({ 1291 uri: v, 1292 title: v, 1293 type: "unknown", 1294 })); 1295 1296 return c.json({ 1297 connections: island.edges, 1298 resources, 1299 depth, 1300 uri, 1301 }); 1302 }); 1303 1304 // ── OAuth client metadata ────────────────────────────────────── 1305 1306 app.get("/oauth-client-metadata.json", (c) => { 1307 const host = new URL(c.req.url).host; 1308 return c.json({ 1309 client_id: `https://${host}/oauth-client-metadata.json`, 1310 client_name: "Stigmergic", 1311 client_uri: `https://${host}`, 1312 redirect_uris: [`https://${host}/`], 1313 scope: "atproto transition:generic", 1314 grant_types: ["authorization_code", "refresh_token"], 1315 response_types: ["code"], 1316 token_endpoint_auth_method: "none", 1317 application_type: "web", 1318 dpop_bound_access_tokens: true, 1319 }); 1320 }); 1321 1322 // ── SPA fallback: serve landing page for client-side routes ────── 1323 for (const path of ["/island", "/island/*", "/strata", "/connect"]) { 1324 app.get(path, async (c) => { 1325 let islandsJson = "[]"; 1326 try { 1327 await ensureContrailReady(db); 1328 const rows = await db 1329 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 1330 .all<{ uri: string; record: string }>(); 1331 const islands: any[] = []; 1332 for (const row of rows.results || []) { 1333 try { 1334 const rec = JSON.parse(row.record); 1335 const centroid = rec.source?.uri; 1336 if (!centroid) continue; 1337 const id = row.uri.split("/").pop() || row.uri; 1338 // Compute island hash from centroid (same as island-shared.ts) 1339 const encoder = new TextEncoder(); 1340 const data = encoder.encode(centroid); 1341 const hashBuffer = await crypto.subtle.digest("SHA-256", data); 1342 const hashArray = Array.from(new Uint8Array(hashBuffer)); 1343 const islandHash = hashArray.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 12); 1344 islands.push({ id, islandHash, centroid, title: rec.analysis?.title || null, recordUri: row.uri }); 1345 } catch {} 1346 } 1347 islandsJson = JSON.stringify(islands); 1348 } catch {} 1349 1350 const page = LANDING_PAGE.replace( 1351 "</head>", 1352 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 1353 ); 1354 return c.html(page); 1355 }); 1356 } 1357 1358 // ── All other routes pass through to contrail ────────────────── 1359 1360 app.all("*", async (c) => { 1361 const response = await contrailWorker.fetch( 1362 c.req.raw, 1363 c.env as unknown as Record<string, unknown>, 1364 ); 1365 return response; 1366 }); 1367 1368 return app; 1369} 1370 1371// ─── Export ───────────────────────────────────────────────────────── 1372 1373export { StrataContainer }; 1374 1375export default { 1376 fetch(request: Request, env: Env): Response | Promise<Response> { 1377 app ??= buildApp(env); 1378 return app.fetch(request, env); 1379 }, 1380 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 1381 // Run Contrail indexing 1382 await contrailWorker.scheduled( 1383 event, 1384 env as unknown as Record<string, unknown>, 1385 ctx, 1386 ); 1387 }, 1388}; 1389