This repository has no description
0

Configure Feed

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

feat: local strata analysis with prose + arrows, no container

Strata analysis now runs on cloudy (local machine) via
scripts/strata-analyze.py, not in a Cloudflare Container. The script
fetches islands from the API, asks letta to produce structured prose
with semantic arrows (→, ←, ↔, ⇏), then uploads the results to
Cloudflare via PUT /api/strata/islands/:id/analysis.

The strata page renders prose with styled arrows showing relationships
between vertices — no canvas graph. Island cards link directly to the
strata page and show "View Strata" when analysis exists.

- Removed: AnalysisContainer durable object, container analysis endpoint,
CarryGraph class, apiPost helper, POST /api/strata/analyze
- Added: PUT /api/strata/islands/:id/analysis endpoint, strata column
in island_cache, scripts/strata-analyze.py, D1 migration
- Frontend: strata page shows prose with arrows, themes, tensions,
open questions, synthesis

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

author
nandi
co-author
Letta Code
date (May 13, 2026, 5:09 AM UTC) commit 63550b80 parent fd371ae4
+420 -754
+2
migrations/0001_add_strata_column.sql
··· 1 + -- Add strata column to island_cache for storing pre-computed strata analysis 2 + ALTER TABLE island_cache ADD COLUMN strata TEXT;
+281
scripts/strata-analyze.py
··· 1 + #!/usr/bin/env python3 2 + """Strata analysis: fetch islands, produce prose with semantic arrows, upload to Cloudflare. 3 + 4 + Produces a strata record for each island containing: 5 + - prose: structured prose with arrows (→, ←, ↔) showing semantic relationships 6 + - themes: list of identified themes 7 + - tensions: list of tensions 8 + - open_questions: list of open questions 9 + - carry_refs: list of carry cross-references 10 + 11 + Usage: python3 scripts/strata-analyze.py [--force] [--island ID] 12 + """ 13 + 14 + import json 15 + import subprocess 16 + import sys 17 + import time 18 + import urllib.parse 19 + import urllib.request 20 + 21 + API_BASE = "https://stigmergic.latha.org" 22 + LETTA_BIN = "/home/nandi/.local/npm-global/bin/letta" 23 + 24 + 25 + def fetch_islands(): 26 + req = urllib.request.Request( 27 + f"{API_BASE}/api/strata/islands", 28 + headers={"User-Agent": "stigmergic-strata/1.0"}, 29 + ) 30 + with urllib.request.urlopen(req) as resp: 31 + return json.loads(resp.read())["islands"] 32 + 33 + 34 + def build_island_context(island): 35 + """Build a rich context for the LLM including vertex titles and edge relationships.""" 36 + vertices = island["vertices"] 37 + edges = island["edges"] 38 + meta = island.get("vertexMeta", {}) 39 + 40 + # Vertex summaries with titles 41 + vertex_lines = [] 42 + for v in vertices[:30]: 43 + m = meta.get(v, {}) 44 + title = m.get("title", v) 45 + desc = m.get("description", "") 46 + vtype = m.get("type", "unknown") 47 + line = f" - [{vtype}] {title}" 48 + if desc: 49 + line += f": {desc[:100]}" 50 + vertex_lines.append(line) 51 + 52 + # Edge relationships as arrows 53 + edge_lines = [] 54 + for e in edges[:30]: 55 + source_meta = meta.get(e.get("source", ""), {}) 56 + target_meta = meta.get(e.get("target", ""), {}) 57 + source_title = source_meta.get("title", e.get("source", ""))[:60] 58 + target_title = target_meta.get("title", e.get("target", ""))[:60] 59 + ctype = e.get("connectionType", "relates") 60 + # Normalize connection type 61 + raw = ctype.split("#")[-1] if "#" in ctype else ctype 62 + label_map = { 63 + "RELATED": "relates to", 64 + "LEADS_TO": "leads to", 65 + "SUPPLEMENT": "supplements", 66 + "SUPPLEMENTS": "supplements", 67 + "OPPOSES": "opposes", 68 + "SUPPORTS": "supports", 69 + "ADDRESSES": "addresses", 70 + "EXPLAINER": "explains", 71 + "HELPFUL": "helpful for", 72 + "CONTAINS": "contains", 73 + "CITES": "cites", 74 + "INSPIRED": "inspired by", 75 + "CONTRADICTS": "contradicts", 76 + "EXTENDS": "extends", 77 + "FORKS": "forks from", 78 + "ANNOTATES": "annotates", 79 + "RELATES": "relates to", 80 + } 81 + relation = label_map.get(raw, raw.lower().replace("_", " ")) 82 + note = e.get("note", "") 83 + line = f" {source_title} → {relation} → {target_title}" 84 + if note: 85 + line += f" ({note})" 86 + edge_lines.append(line) 87 + 88 + # Domains 89 + domains = {} 90 + for v in vertices: 91 + if v.startswith("http"): 92 + try: 93 + from urllib.parse import urlparse 94 + h = urlparse(v).hostname.replace("www.", "") 95 + domains[h] = domains.get(h, 0) + 1 96 + except Exception: 97 + pass 98 + top_domains = sorted(domains.items(), key=lambda x: -x[1])[:5] 99 + 100 + return { 101 + "vertex_count": len(vertices), 102 + "edge_count": len(edges), 103 + "top_domains": [f"{d[0]} ({d[1]})" for d in top_domains], 104 + "vertices": vertex_lines, 105 + "edges": edge_lines, 106 + } 107 + 108 + 109 + def analyze_with_letta(context): 110 + """Ask letta to produce strata prose with semantic arrows.""" 111 + prompt = """You are a knowledge synthesis engine. Given a knowledge graph island (connected component), produce a strata analysis as structured prose. 112 + 113 + Rules: 114 + - Use arrows to show semantic relationships: → (forward), ← (backward), ↔ (bidirectional), ⇏ (blocked/failed) 115 + - Each relationship line should be: Source → relation → Target 116 + - Group related vertices into thematic clusters 117 + - Note tensions with ⚠ 118 + - End with open questions marked with ? 119 + - Be concise and specific — reference actual titles, not generic descriptions 120 + - No preamble, no markdown, just the prose 121 + 122 + Format: 123 + [Themes] 124 + theme1, theme2, ... 125 + 126 + [Relationships] 127 + Source → relation → Target 128 + Source ← relation ← Target 129 + ... 130 + 131 + [Tensions] 132 + ⚠ tension description 133 + 134 + [Open Questions] 135 + ? question 136 + 137 + [Synthesis] 138 + 2-3 sentence synthesis paragraph 139 + 140 + Island data: 141 + """ + json.dumps(context, indent=2) 142 + 143 + try: 144 + result = subprocess.run( 145 + [LETTA_BIN, "-p", prompt, "--output-format", "json", "--system", "letta", "--memfs"], 146 + capture_output=True, 147 + text=True, 148 + timeout=120, 149 + ) 150 + except subprocess.TimeoutExpired: 151 + return None 152 + if result.returncode != 0: 153 + print(f" letta error: {result.stderr[:200]}", file=sys.stderr) 154 + return None 155 + try: 156 + data = json.loads(result.stdout) 157 + if data.get("type") == "result": 158 + return data["result"].strip() 159 + except Exception: 160 + pass 161 + return result.stdout.strip()[:5000] or None 162 + 163 + 164 + def parse_strata(raw_prose): 165 + """Parse the LLM output into structured sections.""" 166 + if not raw_prose: 167 + return None 168 + 169 + result = { 170 + "prose": raw_prose, 171 + "themes": [], 172 + "relationships": [], 173 + "tensions": [], 174 + "open_questions": [], 175 + "synthesis": "", 176 + } 177 + 178 + current_section = None 179 + for line in raw_prose.split("\n"): 180 + line = line.strip() 181 + if not line: 182 + continue 183 + 184 + if line.startswith("[Themes]"): 185 + current_section = "themes" 186 + continue 187 + elif line.startswith("[Relationships]"): 188 + current_section = "relationships" 189 + continue 190 + elif line.startswith("[Tensions]"): 191 + current_section = "tensions" 192 + continue 193 + elif line.startswith("[Open Questions]"): 194 + current_section = "questions" 195 + continue 196 + elif line.startswith("[Synthesis]"): 197 + current_section = "synthesis" 198 + continue 199 + 200 + if current_section == "themes": 201 + result["themes"].extend([t.strip() for t in line.split(",") if t.strip()]) 202 + elif current_section == "relationships": 203 + result["relationships"].append(line) 204 + elif current_section == "tensions": 205 + result["tensions"].append(line.lstrip("⚠ ").strip()) 206 + elif current_section == "questions": 207 + result["open_questions"].append(line.lstrip("? ").strip()) 208 + elif current_section == "synthesis": 209 + result["synthesis"] += line + " " 210 + 211 + result["synthesis"] = result["synthesis"].strip() 212 + return result 213 + 214 + 215 + def put_analysis(island_id, strata_data): 216 + encoded_id = urllib.parse.quote(island_id, safe="") 217 + body = json.dumps(strata_data).encode() 218 + req = urllib.request.Request( 219 + f"{API_BASE}/api/strata/islands/{encoded_id}/analysis", 220 + data=body, 221 + method="PUT", 222 + headers={"Content-Type": "application/json", "User-Agent": "stigmergic-strata/1.0"}, 223 + ) 224 + try: 225 + with urllib.request.urlopen(req) as resp: 226 + return resp.status == 200 227 + except urllib.error.HTTPError as e: 228 + print(f" Upload failed: {e.code} {e.read().decode()[:200]}", file=sys.stderr) 229 + return False 230 + 231 + 232 + def main(): 233 + force = "--force" in sys.argv 234 + filter_island = None 235 + for arg in sys.argv[1:]: 236 + if arg.startswith("--island="): 237 + filter_island = arg.split("=", 1)[1] 238 + 239 + islands = fetch_islands() 240 + total = len(islands) 241 + print(f"Strata analysis: {total} islands") 242 + 243 + for i, island in enumerate(islands): 244 + island_id = island["id"] 245 + 246 + if filter_island and filter_island not in island_id: 247 + continue 248 + 249 + if island.get("strata") and not force: 250 + print(f"[{i+1}/{total}] Skip (has strata): {island_id[:60]}...") 251 + continue 252 + 253 + print(f"[{i+1}/{total}] Analyzing: {island_id[:60]}...") 254 + context = build_island_context(island) 255 + raw_prose = analyze_with_letta(context) 256 + 257 + if not raw_prose: 258 + print(" Skipping (no output)") 259 + continue 260 + 261 + strata_data = parse_strata(raw_prose) 262 + if not strata_data: 263 + print(" Skipping (parse failed)") 264 + continue 265 + 266 + print(f" Themes: {', '.join(strata_data['themes'][:5])}") 267 + print(f" Relationships: {len(strata_data['relationships'])}") 268 + print(f" Tensions: {len(strata_data['tensions'])}") 269 + 270 + if put_analysis(island_id, strata_data): 271 + print(" Uploaded.") 272 + else: 273 + print(" Upload failed.") 274 + 275 + time.sleep(2) 276 + 277 + print("Done.") 278 + 279 + 280 + if __name__ == "__main__": 281 + main()
+83 -500
src/frontend/app.ts
··· 31 31 edges: IslandEdge[]; 32 32 vertexMeta: Record<string, VertexMeta>; 33 33 summary: string | null; 34 + strata: StrataData | null; 34 35 } 35 36 36 - interface StrataRecord { 37 - uri: string; 38 - did: string; 39 - rkey: string; 40 - value: { 41 - $type: "org.latha.strata"; 42 - source: { uri: string; collection: string }; 43 - island?: { 44 - vertices: Array<{ uri: string; title: string; description: string; type: string }>; 45 - edges: Array<{ source: string; target: string; connectionType: string; note: string }>; 46 - }; 47 - analysis: { 48 - themes: string[]; 49 - connections: Array<{ description: string; targetUri: string; relation: string }>; 50 - tensions: string[]; 51 - openQuestions: string[]; 52 - synthesis: string; 53 - }; 54 - carryRefs: Array<{ entityType: string; carryId: string; atUri: string; summary: string }>; 55 - createdAt: string; 56 - }; 37 + interface StrataData { 38 + prose: string; 39 + themes: string[]; 40 + relationships: string[]; 41 + tensions: string[]; 42 + open_questions: string[]; 43 + synthesis: string; 57 44 } 58 45 59 46 // ── State ────────────────────────────────────────────────────────────────── ··· 66 53 67 54 async function apiGet<T>(path: string): Promise<T> { 68 55 const res = await fetch(path); 69 - if (!res.ok) { 70 - const err = await res.text(); 71 - throw new Error(`API ${path} failed: ${res.status} ${err}`); 72 - } 73 - return res.json(); 74 - } 75 - 76 - async function apiPost<T>(path: string, body: any): Promise<T> { 77 - const res = await fetch(path, { 78 - method: "POST", 79 - headers: { "Content-Type": "application/json" }, 80 - body: JSON.stringify(body), 81 - }); 82 56 if (!res.ok) { 83 57 const err = await res.text(); 84 58 throw new Error(`API ${path} failed: ${res.status} ${err}`); ··· 394 368 }; 395 369 } 396 370 397 - // ── Carry graph renderer ────────────────────────────────────────────────── 398 - // 399 - // Renders the island's network graph overlaid with carry cross-reference 400 - // nodes. Carry nodes are shown as diamonds connected to the vertices 401 - // they reference, creating a bipartite view of the knowledge graph. 402 - 403 - const CARRY_COLORS: Record<string, string> = { 404 - claim: "#f0883e", 405 - citation: "#bc8cff", 406 - entity: "#58a6ff", 407 - reasoning: "#3fb950", 408 - }; 409 - 410 - class CarryGraph { 411 - nodes: GraphNode[] = []; 412 - edges: GraphEdge[] = []; 413 - nodeMap = new Map<string, GraphNode>(); 414 - width: number; 415 - height: number; 416 - alpha = 1; 417 - private rafId = 0; 418 - private canvas: HTMLCanvasElement; 419 - private ctx: CanvasRenderingContext2D; 420 - private hoverNode: string | null = null; 421 - private dragNode: string | null = null; 422 - 423 - constructor( 424 - canvas: HTMLCanvasElement, 425 - island: { vertices: Array<{ uri: string; title: string; description: string; type: string }>; edges: Array<{ source: string; target: string; connectionType: string; note: string }> }, 426 - carryRefs: Array<{ entityType: string; carryId: string; atUri: string; summary: string }>, 427 - ) { 428 - this.canvas = canvas; 429 - this.ctx = canvas.getContext("2d")!; 430 - const rect = canvas.getBoundingClientRect(); 431 - this.width = rect.width; 432 - this.height = rect.height; 433 - canvas.width = rect.width * devicePixelRatio; 434 - canvas.height = rect.height * devicePixelRatio; 435 - this.ctx.scale(devicePixelRatio, devicePixelRatio); 436 - 437 - const cx = this.width / 2; 438 - const cy = this.height / 2; 439 - const r = Math.min(this.width, this.height) * 0.3; 440 - 441 - // Network nodes (circles) 442 - for (const v of island.vertices) { 443 - const angle = Math.random() * Math.PI * 2; 444 - const dist = Math.random() * r; 445 - const node: GraphNode = { 446 - id: v.uri, 447 - label: v.title || truncateUri(v.uri), 448 - x: cx + Math.cos(angle) * dist, 449 - y: cy + Math.sin(angle) * dist, 450 - vx: 0, vy: 0, 451 - type: v.type || "unknown", 452 - }; 453 - this.nodes.push(node); 454 - this.nodeMap.set(v.uri, node); 455 - } 456 - 457 - // Carry cross-reference nodes (diamonds) 458 - for (const ref of carryRefs) { 459 - const carryId = `carry:${ref.carryId}`; 460 - const angle = Math.random() * Math.PI * 2; 461 - const dist = r + 40 + Math.random() * 60; 462 - const node: GraphNode = { 463 - id: carryId, 464 - label: ref.summary ? ref.summary.slice(0, 40) : `${ref.entityType}:${ref.carryId.slice(0, 8)}`, 465 - x: cx + Math.cos(angle) * dist, 466 - y: cy + Math.sin(angle) * dist, 467 - vx: 0, vy: 0, 468 - type: `carry-${ref.entityType}`, 469 - }; 470 - this.nodes.push(node); 471 - this.nodeMap.set(carryId, node); 472 - 473 - // Connect carry node to the first vertex (source) with a dashed-style edge 474 - const sourceUri = island.vertices[0]?.uri; 475 - if (sourceUri && this.nodeMap.has(sourceUri)) { 476 - this.edges.push({ 477 - source: sourceUri, 478 - target: carryId, 479 - label: ref.entityType, 480 - color: CARRY_COLORS[ref.entityType] || "#f0883e", 481 - }); 482 - } 483 - } 484 - 485 - // Network edges 486 - for (const e of island.edges) { 487 - const sNode = this.nodeMap.get(e.source); 488 - const tNode = this.nodeMap.get(e.target); 489 - if (!sNode || !tNode) continue; 490 - const label = connectionTypeLabel(e.connectionType); 491 - this.edges.push({ 492 - source: e.source, 493 - target: e.target, 494 - label, 495 - color: EDGE_COLORS[label] || "#8b949e", 496 - }); 497 - } 498 - 499 - // Interactions 500 - canvas.addEventListener("mousemove", this.onMouseMove); 501 - canvas.addEventListener("mousedown", this.onMouseDown); 502 - canvas.addEventListener("mouseup", this.onMouseUp); 503 - canvas.addEventListener("mouseleave", this.onMouseLeave); 504 - 505 - // Warm up 506 - for (let i = 0; i < 150; i++) this.tick(); 507 - this.draw(); 508 - } 509 - 510 - destroy() { 511 - cancelAnimationFrame(this.rafId); 512 - this.canvas.removeEventListener("mousemove", this.onMouseMove); 513 - this.canvas.removeEventListener("mousedown", this.onMouseDown); 514 - this.canvas.removeEventListener("mouseup", this.onMouseUp); 515 - this.canvas.removeEventListener("mouseleave", this.onMouseLeave); 516 - } 517 - 518 - tick() { 519 - if (this.alpha < 0.001) return; 520 - this.alpha *= 0.99; 521 - 522 - const cx = this.width / 2; 523 - const cy = this.height / 2; 524 - 525 - for (const n of this.nodes) { 526 - n.vx += (cx - n.x) * 0.008 * this.alpha; 527 - n.vy += (cy - n.y) * 0.008 * this.alpha; 528 - } 529 - 530 - for (let i = 0; i < this.nodes.length; i++) { 531 - for (let j = i + 1; j < this.nodes.length; j++) { 532 - const a = this.nodes[i]; 533 - const b = this.nodes[j]; 534 - let dx = b.x - a.x; 535 - let dy = b.y - a.y; 536 - let dist = Math.sqrt(dx * dx + dy * dy) || 1; 537 - const force = -250 * this.alpha / (dist * dist); 538 - const fx = (dx / dist) * force; 539 - const fy = (dy / dist) * force; 540 - a.vx += fx; a.vy += fy; 541 - b.vx -= fx; b.vy -= fy; 542 - } 543 - } 544 - 545 - for (const e of this.edges) { 546 - const a = this.nodeMap.get(e.source)!; 547 - const b = this.nodeMap.get(e.target)!; 548 - let dx = b.x - a.x; 549 - let dy = b.y - a.y; 550 - let dist = Math.sqrt(dx * dx + dy * dy) || 1; 551 - const force = (dist - 70) * 0.04 * this.alpha; 552 - const fx = (dx / dist) * force; 553 - const fy = (dy / dist) * force; 554 - a.vx += fx; a.vy += fy; 555 - b.vx -= fx; b.vy -= fy; 556 - } 557 - 558 - for (const n of this.nodes) { 559 - if (n.id === this.dragNode) continue; 560 - n.vx *= 0.6; 561 - n.vy *= 0.6; 562 - n.x += n.vx; 563 - n.y += n.vy; 564 - const pad = 20; 565 - n.x = Math.max(pad, Math.min(this.width - pad, n.x)); 566 - n.y = Math.max(pad, Math.min(this.height - pad, n.y)); 567 - } 568 - } 569 - 570 - draw() { 571 - const ctx = this.ctx; 572 - ctx.clearRect(0, 0, this.width, this.height); 573 - 574 - // Edges 575 - for (const e of this.edges) { 576 - const a = this.nodeMap.get(e.source)!; 577 - const b = this.nodeMap.get(e.target)!; 578 - const isCarry = e.target.startsWith("carry:"); 579 - const isHovered = this.hoverNode === e.source || this.hoverNode === e.target; 580 - 581 - ctx.beginPath(); 582 - ctx.moveTo(a.x, a.y); 583 - ctx.lineTo(b.x, b.y); 584 - 585 - if (isCarry) { 586 - // Dashed line for carry edges 587 - ctx.setLineDash([4, 4]); 588 - ctx.strokeStyle = isHovered ? e.color : `${e.color}66`; 589 - ctx.lineWidth = isHovered ? 2 : 1; 590 - } else { 591 - ctx.setLineDash([]); 592 - ctx.strokeStyle = isHovered ? e.color : `${e.color}44`; 593 - ctx.lineWidth = isHovered ? 2 : 1; 594 - } 595 - ctx.stroke(); 596 - ctx.setLineDash([]); 597 - 598 - // Arrow for network edges 599 - if (!isCarry) { 600 - const dx = b.x - a.x; 601 - const dy = b.y - a.y; 602 - const dist = Math.sqrt(dx * dx + dy * dy) || 1; 603 - const arrowLen = 8; 604 - const arrowX = b.x - (dx / dist) * 16; 605 - const arrowY = b.y - (dy / dist) * 16; 606 - const angle = Math.atan2(dy, dx); 607 - ctx.beginPath(); 608 - ctx.moveTo(arrowX, arrowY); 609 - ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4)); 610 - ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4)); 611 - ctx.closePath(); 612 - ctx.fillStyle = isHovered ? e.color : `${e.color}44`; 613 - ctx.fill(); 614 - } 615 - } 616 - 617 - // Nodes 618 - for (const n of this.nodes) { 619 - const isHovered = this.hoverNode === n.id; 620 - const isCarry = n.id.startsWith("carry:"); 621 - 622 - if (isCarry) { 623 - // Diamond shape for carry nodes 624 - const carryType = n.type.replace("carry-", ""); 625 - const color = CARRY_COLORS[carryType] || "#f0883e"; 626 - const size = isHovered ? 8 : 6; 627 - 628 - ctx.beginPath(); 629 - ctx.moveTo(n.x, n.y - size); 630 - ctx.lineTo(n.x + size, n.y); 631 - ctx.lineTo(n.x, n.y + size); 632 - ctx.lineTo(n.x - size, n.y); 633 - ctx.closePath(); 634 - ctx.fillStyle = isHovered ? color : `${color}cc`; 635 - ctx.fill(); 636 - ctx.strokeStyle = isHovered ? "#fff" : color; 637 - ctx.lineWidth = isHovered ? 2 : 1; 638 - ctx.stroke(); 639 - 640 - // Label 641 - if (isHovered || this.nodes.length <= 15) { 642 - ctx.font = `${isHovered ? "10" : "9"}px -apple-system, sans-serif`; 643 - ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; 644 - ctx.textAlign = "center"; 645 - ctx.fillText(n.label.slice(0, 35), n.x, n.y - size - 4); 646 - } 647 - } else { 648 - // Circle for network nodes 649 - const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; 650 - const radius = isHovered ? 7 : 5; 651 - 652 - ctx.beginPath(); 653 - ctx.arc(n.x, n.y, radius, 0, Math.PI * 2); 654 - ctx.fillStyle = isHovered ? color : `${color}cc`; 655 - ctx.fill(); 656 - ctx.strokeStyle = isHovered ? "#fff" : color; 657 - ctx.lineWidth = isHovered ? 2 : 1; 658 - ctx.stroke(); 659 - 660 - // Label 661 - if (isHovered || this.nodes.length <= 12) { 662 - ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`; 663 - ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; 664 - ctx.textAlign = "center"; 665 - ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4); 666 - } 667 - } 668 - } 669 - 670 - // Legend 671 - const legendX = 12; 672 - const legendY = this.height - 60; 673 - ctx.font = "9px -apple-system, sans-serif"; 674 - ctx.textAlign = "left"; 675 - 676 - // Network node legend 677 - ctx.beginPath(); 678 - ctx.arc(legendX + 4, legendY, 4, 0, Math.PI * 2); 679 - ctx.fillStyle = "#8b949e"; 680 - ctx.fill(); 681 - ctx.fillStyle = "#8b949e"; 682 - ctx.fillText("Network node", legendX + 12, legendY + 3); 683 - 684 - // Carry node legend 685 - ctx.beginPath(); 686 - ctx.moveTo(legendX + 4, legendY + 16); 687 - ctx.lineTo(legendX + 8, legendY + 20); 688 - ctx.lineTo(legendX + 4, legendY + 24); 689 - ctx.lineTo(legendX, legendY + 20); 690 - ctx.closePath(); 691 - ctx.fillStyle = "#f0883e"; 692 - ctx.fill(); 693 - ctx.fillStyle = "#8b949e"; 694 - ctx.fillText("Carry cross-ref", legendX + 12, legendY + 23); 695 - 696 - // Dashed line legend 697 - ctx.beginPath(); 698 - ctx.setLineDash([4, 4]); 699 - ctx.moveTo(legendX, legendY + 36); 700 - ctx.lineTo(legendX + 8, legendY + 36); 701 - ctx.strokeStyle = "#f0883e66"; 702 - ctx.lineWidth = 1; 703 - ctx.stroke(); 704 - ctx.setLineDash([]); 705 - ctx.fillStyle = "#8b949e"; 706 - ctx.fillText("Carry link", legendX + 12, legendY + 39); 707 - } 708 - 709 - private getMousePos = (e: MouseEvent) => { 710 - const rect = this.canvas.getBoundingClientRect(); 711 - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 712 - }; 713 - 714 - private findNode(x: number, y: number): string | null { 715 - for (const n of this.nodes) { 716 - const dx = n.x - x; 717 - const dy = n.y - y; 718 - if (dx * dx + dy * dy < 144) return n.id; 719 - } 720 - return null; 721 - } 722 - 723 - private onMouseMove = (e: MouseEvent) => { 724 - const pos = this.getMousePos(e); 725 - if (this.dragNode) { 726 - const n = this.nodeMap.get(this.dragNode)!; 727 - n.x = pos.x; 728 - n.y = pos.y; 729 - n.vx = 0; n.vy = 0; 730 - this.alpha = 0.3; 731 - this.tick(); 732 - this.draw(); 733 - return; 734 - } 735 - const prev = this.hoverNode; 736 - this.hoverNode = this.findNode(pos.x, pos.y); 737 - if (this.hoverNode !== prev) this.draw(); 738 - this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; 739 - }; 740 - 741 - private onMouseDown = (e: MouseEvent) => { 742 - const pos = this.getMousePos(e); 743 - const id = this.findNode(pos.x, pos.y); 744 - if (id) { 745 - this.dragNode = id; 746 - this.canvas.style.cursor = "grabbing"; 747 - } 748 - }; 749 - 750 - private onMouseUp = () => { 751 - this.dragNode = null; 752 - this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; 753 - }; 754 - 755 - private onMouseLeave = () => { 756 - this.hoverNode = null; 757 - this.dragNode = null; 758 - this.canvas.style.cursor = "default"; 759 - this.draw(); 760 - }; 761 - } 762 - 763 371 // ── Rendering helpers ────────────────────────────────────────────────────── 764 372 765 373 function escHtml(s: string): string { ··· 859 467 } 860 468 }); 861 469 862 - // Wire up Strata buttons on island cards 470 + // Wire up Strata buttons on island cards — go to strata page 863 471 document.querySelectorAll(".island-strata-btn").forEach(btn => { 864 - btn.addEventListener("click", async (e) => { 472 + btn.addEventListener("click", (e) => { 865 473 const target = e.currentTarget as HTMLElement; 866 474 const islandId = target.dataset.islandId; 867 475 if (!islandId) return; 868 - 869 - // Navigate to island detail page which has the Strata button 870 - window.location.href = `/island?id=${encodeURIComponent(islandId)}`; 476 + window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 871 477 }); 872 478 }); 873 479 } ··· 903 509 904 510 function renderIslandCard(island: Island): string { 905 511 const summary = summarizeIsland(island); 512 + const hasStrata = !!island.strata; 513 + const strataBtnLabel = hasStrata ? "View Strata" : "Strata"; 514 + const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn"; 906 515 907 516 return ` 908 517 <div class="island-card" data-island-id="${escHtml(island.id)}"> 909 518 <canvas class="island-graph" data-island-id="${escHtml(island.id)}" width="400" height="260"></canvas> 910 519 <div class="island-card-footer"> 911 520 <p class="island-summary">${escHtml(summary)}</p> 912 - <button class="strata-btn island-strata-btn" data-island-id="${escHtml(island.id)}">Strata</button> 521 + <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button> 913 522 </div> 914 523 </div> 915 524 `; ··· 972 581 } 973 582 974 583 document.getElementById("island-strata-btn")?.addEventListener("click", async () => { 975 - if (!session?.did) { 976 - alert("Sign in to run Strata analysis."); 977 - return; 978 - } 979 - const btn = document.getElementById("island-strata-btn") as HTMLButtonElement; 980 - btn.textContent = "Analyzing..."; 981 - btn.disabled = true; 982 - 983 - try { 984 - const result = await apiPost<{ record: any; atUri: string; islandId: string }>( 985 - "/api/strata/analyze", 986 - { islandId, did: session.did }, 987 - ); 988 - window.location.href = `/strata?uri=${encodeURIComponent(result.atUri)}`; 989 - } catch (err: any) { 990 - alert("Analysis failed: " + err.message); 991 - btn.textContent = "Run Strata Analysis"; 992 - btn.disabled = false; 993 - } 584 + window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 994 585 }); 995 586 } 996 587 997 - // ── Strata record page ───────────────────────────────────────────────────── 588 + // ── Strata page — prose with arrows ─────────────────────────────────────── 998 589 999 - async function showStrataRecord(atUri: string): Promise<void> { 590 + async function showStrata(islandId: string): Promise<void> { 1000 591 currentPage = "strata"; 1001 592 const content = document.getElementById("page-content"); 1002 593 if (!content) return; 1003 594 1004 - content.innerHTML = '<div class="analysis-loading"><div class="spinner"></div><div>Loading record...</div></div>'; 595 + // Find the island from loaded data 596 + let island = islands.find(i => i.id === islandId); 597 + 598 + // If not in memory, fetch from API 599 + if (!island) { 600 + try { 601 + const data = await apiGet<{ islands: Island[] }>("/api/strata/islands"); 602 + island = data.islands.find(i => i.id === islandId); 603 + if (island) islands = data.islands; 604 + } catch {} 605 + } 606 + 607 + if (!island) { 608 + content.innerHTML = '<div class="empty">Island not found.</div>'; 609 + return; 610 + } 1005 611 1006 - try { 1007 - const data = await apiGet<StrataRecord>( 1008 - `/api/strata/record?uri=${encodeURIComponent(atUri)}`, 1009 - ); 1010 - renderStrataRecord(data); 1011 - } catch (err: any) { 1012 - content.innerHTML = `<div class="empty">Record not found: ${escHtml(err.message)}</div>`; 612 + if (!island.strata) { 613 + content.innerHTML = ` 614 + <div class="strata-page"> 615 + <a href="/" class="back-link">&larr; Explore</a> 616 + <h2>Strata</h2> 617 + <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div> 618 + </div> 619 + `; 620 + return; 1013 621 } 622 + 623 + renderStrata(island); 1014 624 } 1015 625 1016 - function renderStrataRecord(record: StrataRecord): void { 626 + function renderStrata(island: Island): void { 1017 627 const content = document.getElementById("page-content"); 1018 628 if (!content) return; 629 + const s = island.strata!; 1019 630 1020 - const v = record.value; 1021 - const a = v.analysis; 1022 - const hasIsland = v.island && v.island.vertices.length > 0; 631 + // Themes 632 + const themesHtml = s.themes.length > 0 633 + ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>` 634 + : ""; 1023 635 1024 - const themesHtml = a.themes.length > 0 1025 - ? `<div class="theme-tags">${a.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>` 1026 - : '<div class="empty">No themes identified</div>'; 636 + // Relationships — render as prose with styled arrows 637 + const relsHtml = s.relationships.length > 0 638 + ? `<div class="strata-relationships">${s.relationships.map(r => { 639 + // Highlight arrows in the line 640 + const styled = escHtml(r) 641 + .replace(/→/g, '<span class="arrow arrow-forward">→</span>') 642 + .replace(/←/g, '<span class="arrow arrow-back">←</span>') 643 + .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>') 644 + .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>'); 645 + return `<div class="strata-rel-line">${styled}</div>`; 646 + }).join("")}</div>` 647 + : ""; 1027 648 1028 - const connectionsHtml = a.connections.length > 0 1029 - ? a.connections.map(c => ` 1030 - <div class="analysis-connection"> 1031 - <span class="relation">${escHtml(c.relation)}</span> 1032 - <div>${escHtml(c.description)}</div> 1033 - ${c.targetUri ? `<a href="/strata?uri=${encodeURIComponent(c.targetUri)}" class="conn-uri">${escHtml(truncateUri(c.targetUri))}</a>` : ""} 1034 - </div> 1035 - `).join("") 1036 - : '<div class="empty">No connections found</div>'; 1037 - 1038 - const tensionsHtml = a.tensions.length > 0 1039 - ? a.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("") 649 + // Tensions 650 + const tensionsHtml = s.tensions.length > 0 651 + ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>` 1040 652 : ""; 1041 653 1042 - const questionsHtml = a.openQuestions.length > 0 1043 - ? a.openQuestions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("") 654 + // Open questions 655 + const questionsHtml = s.open_questions.length > 0 656 + ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>` 1044 657 : ""; 1045 658 1046 - const carryRefsHtml = v.carryRefs && v.carryRefs.length > 0 1047 - ? v.carryRefs.map(r => ` 1048 - <span class="carry-ref" title="${escHtml(r.summary)}">${escHtml(r.entityType)}: ${escHtml(r.carryId.slice(0, 12))}...</span> 1049 - `).join("") 659 + // Synthesis 660 + const synthesisHtml = s.synthesis 661 + ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>` 1050 662 : ""; 1051 663 1052 664 content.innerHTML = ` 1053 - <div class="strata-record"> 665 + <div class="strata-page"> 1054 666 <a href="/" class="back-link">&larr; Explore</a> 1055 - <h2>Strata Record</h2> 1056 - <div class="record-uri">${escHtml(record.uri)}</div> 1057 - <div class="record-meta">Created ${escHtml(v.createdAt)} by ${escHtml(record.did.slice(0, 30))}...</div> 1058 - 1059 - ${hasIsland ? ` 1060 - <div class="analysis-section"> 1061 - <h3>Carry Graph</h3> 1062 - <canvas class="carry-graph-canvas" id="carry-graph" width="900" height="500"></canvas> 1063 - </div> 1064 - ` : ""} 667 + <h2>Strata</h2> 668 + <p class="island-meta">${island.vertices.length} vertices &middot; ${island.edges.length} edges</p> 1065 669 1066 - <div class="analysis-section"> 1067 - <h3>Themes</h3> 1068 - ${themesHtml} 1069 - </div> 670 + ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""} 1070 671 1071 - <div class="analysis-section"> 1072 - <h3>Connections</h3> 1073 - ${connectionsHtml} 1074 - </div> 672 + ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""} 1075 673 1076 - ${tensionsHtml ? `<div class="analysis-section"><h3>Tensions</h3>${tensionsHtml}</div>` : ""} 1077 - 1078 - ${questionsHtml ? `<div class="analysis-section"><h3>Open Questions</h3>${questionsHtml}</div>` : ""} 1079 - 1080 - <div class="analysis-section"> 1081 - <h3>Synthesis</h3> 1082 - <div class="synthesis-text">${escHtml(a.synthesis)}</div> 1083 - </div> 1084 - 1085 - ${carryRefsHtml ? `<div class="analysis-section"><h3>Carry Cross-References</h3>${carryRefsHtml}</div>` : ""} 674 + ${tensionsHtml} 675 + ${questionsHtml} 676 + ${synthesisHtml} 1086 677 </div> 1087 678 `; 1088 - 1089 - // Initialize carry graph if island data is available 1090 - if (hasIsland) { 1091 - const canvas = document.getElementById("carry-graph") as HTMLCanvasElement; 1092 - if (canvas) { 1093 - new CarryGraph(canvas, v.island!, v.carryRefs || []); 1094 - } 1095 - } 1096 679 } 1097 680 1098 681 // ── Connect page ─────────────────────────────────────────────────────────── ··· 1197 780 return { page: "island", params: { id: url.searchParams.get("id") || "" } }; 1198 781 } 1199 782 if (path === "/strata") { 1200 - return { page: "strata", params: { uri: url.searchParams.get("uri") || "" } }; 783 + return { page: "strata", params: { id: url.searchParams.get("id") || "" } }; 1201 784 } 1202 785 if (path === "/connect") { 1203 786 return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; ··· 1210 793 1211 794 if (page === "island" && params.id) { 1212 795 await showIsland(params.id); 1213 - } else if (page === "strata" && params.uri) { 1214 - await showStrataRecord(params.uri); 796 + } else if (page === "strata" && params.id) { 797 + await showStrata(params.id); 1215 798 } else if (page === "connect") { 1216 799 renderConnectForm(params.source); 1217 800 } else {
+40 -57
src/landing-page.ts
··· 895 895 margin-top: 1.5rem; 896 896 } 897 897 898 - /* ── Strata record page ──────────────────────────────────────── */ 899 - .strata-record { } 900 - .record-uri { 901 - font-size: 0.8125rem; 902 - color: var(--accent); 903 - word-break: break-all; 904 - padding: 0.5rem 0.75rem; 905 - background: var(--surface); 906 - border: 1px solid var(--border); 907 - border-radius: 6px; 908 - margin-bottom: 0.5rem; 898 + /* ── Strata prose page ─────────────────────────────────────────── */ 899 + .strata-page { } 900 + .strata-section { 901 + margin-bottom: 1.5rem; 909 902 } 910 - .record-meta { 911 - font-size: 0.75rem; 903 + .strata-section h3 { 904 + font-size: 0.9375rem; 905 + margin-bottom: 0.75rem; 912 906 color: var(--text-muted); 913 - margin-bottom: 1.5rem; 914 907 } 915 - .theme-tags { 908 + .strata-relationships { 916 909 display: flex; 917 - flex-wrap: wrap; 910 + flex-direction: column; 918 911 gap: 0.375rem; 919 912 } 920 - .theme-tag { 921 - padding: 0.25rem 0.625rem; 922 - border-radius: 12px; 923 - background: var(--border); 913 + .strata-rel-line { 914 + font-size: 0.875rem; 915 + line-height: 1.6; 924 916 color: var(--text); 925 - font-size: 0.75rem; 917 + padding: 0.25rem 0; 926 918 } 927 - .analysis-connection { 928 - padding: 0.5rem 0.75rem; 929 - border: 1px solid var(--border); 930 - border-radius: 6px; 931 - margin-bottom: 0.5rem; 932 - font-size: 0.8125rem; 919 + .arrow { 920 + font-weight: 600; 921 + font-size: 1rem; 922 + margin: 0 0.25rem; 933 923 } 934 - .analysis-connection .relation { 935 - color: var(--purple); 936 - font-weight: 500; 937 - font-size: 0.6875rem; 938 - display: block; 939 - margin-bottom: 0.25rem; 940 - } 924 + .arrow-forward { color: var(--accent); } 925 + .arrow-back { color: var(--green); } 926 + .arrow-bidi { color: var(--purple); } 927 + .arrow-blocked { color: var(--red); } 941 928 .analysis-tension { 942 929 padding: 0.5rem 0.75rem; 943 930 border-left: 3px solid var(--red); ··· 955 942 font-size: 0.8125rem; 956 943 } 957 944 .synthesis-text { 958 - font-size: 0.875rem; 945 + font-size: 0.9375rem; 959 946 line-height: 1.7; 960 947 color: var(--text); 961 948 } 962 - .carry-ref { 963 - display: inline-flex; 964 - align-items: center; 965 - gap: 0.25rem; 966 - padding: 0.25rem 0.5rem; 967 - border-radius: 4px; 968 - background: var(--bg); 969 - border: 1px solid var(--border); 970 - font-size: 0.6875rem; 971 - color: var(--text-muted); 972 - margin: 0.25rem; 973 - } 974 - 975 - /* ── Carry graph ─────────────────────────────────────────────── */ 976 - .carry-graph-canvas { 977 - width: 100%; 978 - height: 400px; 979 - border-radius: 8px; 980 - background: var(--bg); 981 - border: 1px solid var(--border); 982 - cursor: default; 983 - margin-bottom: 1rem; 949 + .strata-btn.has-strata { 950 + background: var(--purple); 951 + color: #fff; 952 + border-color: var(--purple); 984 953 } 985 954 986 955 /* ── Island card footer ──────────────────────────────────────── */ ··· 998 967 .island-card-footer .strata-btn { 999 968 flex-shrink: 0; 1000 969 margin-left: 1rem; 970 + } 971 + 972 + /* ── Theme tags (shared) ─────────────────────────────────────── */ 973 + .theme-tags { 974 + display: flex; 975 + flex-wrap: wrap; 976 + gap: 0.375rem; 977 + } 978 + .theme-tag { 979 + padding: 0.25rem 0.625rem; 980 + border-radius: 12px; 981 + background: var(--border); 982 + color: var(--text); 983 + font-size: 0.75rem; 1001 984 } 1002 985 </style> 1003 986 </head>
+14 -197
src/worker.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { cors } from "hono/cors"; 3 - import { Container } from "@cloudflare/containers"; 4 3 import { createWorker } from "@atmo-dev/contrail/worker"; 5 4 import { Contrail } from "@atmo-dev/contrail"; 6 5 import { config } from "./contrail.config.js"; ··· 11 10 12 11 interface Env { 13 12 DB: D1Database; 14 - ANALYSIS: DurableObjectNamespace; 15 13 VAULT_BUCKET: R2Bucket; 16 14 CARRY_BUCKET: R2Bucket; 17 - ANALYSIS_SECRET?: string; 18 - R2_ACCOUNT_ID?: string; 19 - R2_ACCESS_KEY?: string; 20 - R2_SECRET_KEY?: string; 21 - } 22 - 23 - // ─── Analysis Container (Durable Object) ───────────────────────────── 24 - 25 - export class AnalysisContainer extends Container { 26 - sleepAfter = "10m"; 27 - maxInstances = 10; 28 - 29 - override envVars = { 30 - R2_ACCOUNT_ID: (env: any) => env.R2_ACCOUNT_ID || "", 31 - R2_ACCESS_KEY: (env: any) => env.R2_ACCESS_KEY || "", 32 - R2_SECRET_KEY: (env: any) => env.R2_SECRET_KEY || "", 33 - }; 34 - 35 - override async fetch(request: Request): Promise<Response> { 36 - return this.containerFetch(request); 37 - } 38 15 } 39 16 40 17 // ─── Contrail setup ───────────────────────────────────────────────── ··· 278 255 async function readIslandCache(db: D1Database): Promise<any[]> { 279 256 const rows = await db 280 257 .prepare( 281 - `SELECT data, summary FROM island_cache ORDER BY vertex_count DESC LIMIT 50`, 258 + `SELECT data, summary, strata FROM island_cache ORDER BY vertex_count DESC LIMIT 50`, 282 259 ) 283 - .all<{ data: string; summary: string | null }>(); 260 + .all<{ data: string; summary: string | null; strata: string | null }>(); 284 261 285 262 return (rows.results || []).map(r => { 286 263 const island = JSON.parse(r.data); 287 264 island.summary = r.summary || null; 265 + island.strata = r.strata ? JSON.parse(r.strata) : null; 288 266 return island; 289 267 }); 290 - } 291 - 292 - // ─── Container helper ─────────────────────────────────────────────── 293 - 294 - async function runAnalysis( 295 - env: Env, 296 - did: string, 297 - island: { vertices: string[]; edges: any[] }, 298 - vertexMeta: Map<string, { title: string; description: string; type: string }>, 299 - ): Promise<any> { 300 - const id = env.ANALYSIS.idFromName(did); 301 - const stub = env.ANALYSIS.get(id); 302 - 303 - const analysisReq = new Request("http://container/analyze", { 304 - method: "POST", 305 - headers: { "Content-Type": "application/json" }, 306 - body: JSON.stringify({ 307 - did, 308 - island: { 309 - vertices: island.vertices.map(uri => ({ 310 - uri, 311 - title: vertexMeta.get(uri)?.title || uri, 312 - description: vertexMeta.get(uri)?.description || "", 313 - type: vertexMeta.get(uri)?.type || "unknown", 314 - })), 315 - edges: island.edges, 316 - }, 317 - }), 318 - }); 319 - 320 - const res = await stub.fetch(analysisReq); 321 - if (!res.ok) { 322 - const err = await res.text(); 323 - throw new Error(`Container analysis failed: ${err}`); 324 - } 325 - 326 - return res.json(); 327 268 } 328 269 329 270 // ─── Hono app ─────────────────────────────────────────────────────── ··· 363 304 // Update an island's summary 364 305 app.put("/api/strata/islands/:id/summary", async (c) => { 365 306 const id = decodeURIComponent(c.req.param("id")); 366 - const body = await c.req.json<{ summary?: string }>().catch(() => ({})); 307 + const body = await c.req.json<{ summary?: string }>().catch(() => ({ summary: undefined })); 367 308 if (!body.summary) { 368 309 return c.json({ error: "MissingSummary" }, 400); 369 310 } ··· 374 315 return c.json({ ok: true }); 375 316 }); 376 317 377 - // ── Strata analysis — runs on an island ────────────────────────── 378 - // POST /api/strata/analyze 379 - // Body: { islandId, did } 380 - // Returns: { record, atUri } 381 - 382 - app.post("/api/strata/analyze", async (c) => { 383 - const body = await c.req.json<{ 384 - islandId?: string; 385 - did?: string; 386 - }>().catch(() => ({})); 387 - 388 - const { islandId, did } = body; 389 - if (!islandId || !did) { 390 - return c.json({ error: "MissingRequiredParameter", message: "islandId and did are required" }, 400); 391 - } 392 - 393 - // Find the island 394 - await ensureContrailReady(db); 395 - const islands = await detectIslands(db); 396 - const island = islands.find(i => i.id === islandId); 397 - 398 - if (!island) { 399 - return c.json({ error: "IslandNotFound", message: "Island not found" }, 404); 318 + // Upload strata analysis for an island 319 + app.put("/api/strata/islands/:id/analysis", async (c) => { 320 + const id = decodeURIComponent(c.req.param("id")); 321 + const body = await c.req.json().catch(() => ({})); 322 + if (!body || !body.prose) { 323 + return c.json({ error: "MissingRequiredField", message: "prose is required" }, 400); 400 324 } 401 - 402 - // Resolve vertex metadata 403 - const vertexMeta = await resolveVertexMeta(db, island.vertices); 404 - 405 - // Run analysis in container 406 - let analysis: any; 407 - try { 408 - analysis = await runAnalysis(env, did, island, vertexMeta); 409 - } catch (e: any) { 410 - console.error("Analysis failed:", e); 411 - return c.json({ error: "AnalysisFailed", message: e.message }, 500); 412 - } 413 - 414 - // Create org.latha.strata record in D1 415 - const now = new Date().toISOString(); 416 - const rkey = crypto.randomUUID().replace(/-/g, "").slice(0, 13); // TID format 417 - 418 - const record = { 419 - $type: "org.latha.strata", 420 - source: { 421 - uri: island.vertices[0], // primary source is the first vertex 422 - collection: "network.cosmik.connection", 423 - }, 424 - island: { 425 - vertices: island.vertices.map(uri => ({ 426 - uri, 427 - title: vertexMeta.get(uri)?.title || uri, 428 - description: vertexMeta.get(uri)?.description || "", 429 - type: vertexMeta.get(uri)?.type || "unknown", 430 - })), 431 - edges: island.edges.map(e => ({ 432 - source: e.source, 433 - target: e.target, 434 - connectionType: e.connectionType, 435 - note: e.note || "", 436 - })), 437 - }, 438 - analysis: { 439 - themes: analysis.themes || [], 440 - connections: (analysis.connections || []).map((c: any) => ({ 441 - description: c.description, 442 - targetUri: c.targetUri || "", 443 - relation: c.relation || "contextualizes", 444 - })), 445 - tensions: analysis.tensions || [], 446 - openQuestions: analysis.openQuestions || [], 447 - synthesis: analysis.synthesis || "", 448 - }, 449 - carryRefs: (analysis.carryRefs || []).map((r: any) => ({ 450 - entityType: r.entityType || "claim", 451 - carryId: r.carryId || "", 452 - atUri: r.atUri || "", 453 - summary: r.summary || "", 454 - })), 455 - createdAt: now, 456 - }; 457 - 458 - const atUri = `at://${did}/org.latha.strata/${rkey}`; 459 - 325 + const strataJson = JSON.stringify(body); 460 326 await db 461 - .prepare( 462 - `INSERT OR IGNORE INTO records_strata (did, rkey, record, time_us) 463 - VALUES (?, ?, ?, ?)`, 464 - ) 465 - .bind(did, rkey, JSON.stringify(record), Date.now() * 1000) 327 + .prepare("UPDATE island_cache SET strata = ? WHERE id = ?") 328 + .bind(strataJson, id) 466 329 .run(); 467 - 468 - return c.json({ record, atUri, islandId }); 469 - }); 470 - 471 - // ── Strata record ─────────────────────────────────────────────── 472 - // GET /api/strata/record?uri=at://... 473 - 474 - app.get("/api/strata/record", async (c) => { 475 - const uri = c.req.query("uri"); 476 - if (!uri || !uri.startsWith("at://")) { 477 - return c.json({ error: "MissingRequiredParameter", message: "uri (at-uri) is required" }, 400); 478 - } 479 - 480 - await ensureContrailReady(db); 481 - 482 - // Parse at-uri 483 - const parts = uri.replace("at://", "").split("/"); 484 - const did = parts[0]; 485 - const rkey = parts[2]; 486 - 487 - if (!did || !rkey) { 488 - return c.json({ error: "InvalidUri", message: "Could not parse at-uri" }, 400); 489 - } 490 - 491 - const row = await db 492 - .prepare( 493 - `SELECT record, did, rkey, time_us FROM records_strata 494 - WHERE did = ? AND rkey = ?`, 495 - ) 496 - .bind(did, rkey) 497 - .first<{ record: string; did: string; rkey: string; time_us: number }>(); 498 - 499 - if (!row) { 500 - return c.json({ error: "RecordNotFound" }, 404); 501 - } 502 - 503 - try { 504 - const value = JSON.parse(row.record); 505 - return c.json({ 506 - uri: `at://${row.did}/org.latha.strata/${row.rkey}`, 507 - did: row.did, 508 - rkey: row.rkey, 509 - value, 510 - }); 511 - } catch { 512 - return c.json({ error: "InvalidRecord" }, 500); 513 - } 330 + return c.json({ ok: true }); 514 331 }); 515 332 516 333 // ── R2 sync endpoints ───────────────────────────────────────────