This repository has no description
0

Configure Feed

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

feat: strata as AT Protocol records with xrpc endpoints

- strata-analyze.py now creates org.latha.strata records on PDS
instead of pushing to API endpoints
- Added deriveIsland() with BFS traversal (recursive CTE OOMs in D1)
- New xrpc endpoints:
- GET /xrpc/org.latha.strata.getRecord?uri=<at-uri>
- GET /xrpc/org.latha.strata.listRecords
- GET /xrpc/org.latha.strata.getIslands
- Frontend supports AT URI as strata page ID
- Lexicon updated: added title field, relaxed source.uri format
- Migrated all /api/ routes to /xrpc/ namespace

๐Ÿ‘พ Generated with [Letta Code](https://letta.com)

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

author
nandi
co-author
Letta Code
date (May 14, 2026, 1:55 AM UTC) commit 1c5a24b5 parent 0837fc2e
+627 -64
+6 -2
lexicons/org/latha/strata/strata.json
··· 50 50 "properties": { 51 51 "uri": { 52 52 "type": "string", 53 - "format": "at-uri", 54 - "description": "AT URI of the source record (Semble card, Bluesky post, etc.)" 53 + "description": "URI of the source. For island-level analysis, this is the lexmin vertex (canonical component reference) โ€” can be an HTTP URL or AT URI." 55 54 }, 56 55 "cid": { 57 56 "type": "string", ··· 68 67 "description": "The structured output of a Strata analysis run.", 69 68 "required": ["themes"], 70 69 "properties": { 70 + "title": { 71 + "type": "string", 72 + "maxGraphemes": 300, 73 + "description": "A concise sentence capturing the core argument or narrative arc of this analysis." 74 + }, 71 75 "themes": { 72 76 "type": "array", 73 77 "maxLength": 20,
+8
pnpm-lock.yaml
··· 11 11 '@atmo-dev/contrail': 12 12 specifier: ^0.6.0 13 13 version: 0.6.0(wrangler@4.90.1(@cloudflare/workers-types@4.20260511.1)) 14 + '@cloudflare/containers': 15 + specifier: ^0.1.0 16 + version: 0.1.1 14 17 hono: 15 18 specifier: ^4.7.0 16 19 version: 4.12.18 ··· 226 229 '@badrap/valita@0.4.6': 227 230 resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 228 231 engines: {node: '>= 18'} 232 + 233 + '@cloudflare/containers@0.1.1': 234 + resolution: {integrity: sha512-YTdobRTnTlUOUPMFemufH367A9Z8pDfZ+UboYMLbGpO0VlvEXZDiioSmXPQMHld2vRtkL31mcRii3bcbQU6fdw==} 229 235 230 236 '@cloudflare/kv-asset-handler@0.5.0': 231 237 resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} ··· 1287 1293 zod: 3.25.76 1288 1294 1289 1295 '@badrap/valita@0.4.6': {} 1296 + 1297 + '@cloudflare/containers@0.1.1': {} 1290 1298 1291 1299 '@cloudflare/kv-asset-handler@0.5.0': {} 1292 1300
+140
scripts/analyze-islands.py
··· 1 + #!/usr/bin/env python3 2 + """Analyze each island with letta CLI and post summaries back to the API.""" 3 + 4 + import json 5 + import subprocess 6 + import sys 7 + import time 8 + import urllib.parse 9 + import urllib.request 10 + 11 + API_BASE = "https://stigmergic.latha.org" 12 + LETTA_BIN = "/home/nandi/.local/npm-global/bin/letta" 13 + 14 + 15 + def fetch_islands(): 16 + req = urllib.request.Request( 17 + f"{API_BASE}/api/strata/islands", 18 + headers={"User-Agent": "stigmergic-analyzer/1.0"}, 19 + ) 20 + with urllib.request.urlopen(req) as resp: 21 + return json.loads(resp.read())["islands"] 22 + 23 + 24 + def build_island_context(island): 25 + vertices = island["vertices"] 26 + edges = island["edges"] 27 + meta = island.get("vertexMeta", {}) 28 + 29 + titles = [meta.get(v, {}).get("title", v) for v in vertices[:30]] 30 + 31 + domains = {} 32 + for v in vertices: 33 + if v.startswith("http"): 34 + try: 35 + from urllib.parse import urlparse 36 + h = urlparse(v).hostname.replace("www.", "") 37 + domains[h] = domains.get(h, 0) + 1 38 + except Exception: 39 + pass 40 + top_domains = sorted(domains.items(), key=lambda x: -x[1])[:5] 41 + 42 + edge_types = {} 43 + for e in edges: 44 + t = e.get("connectionType", "unknown") 45 + edge_types[t] = edge_types.get(t, 0) + 1 46 + 47 + skip_patterns = ( 48 + "english", 49 + "japanese", 50 + "version", 51 + "translation", 52 + "original", 53 + "alternate url", 54 + "DID link", 55 + ) 56 + notes = [ 57 + e["note"] 58 + for e in edges 59 + if e.get("note") and not e["note"].lower().startswith(skip_patterns) 60 + ][:5] 61 + 62 + return { 63 + "vertex_count": len(vertices), 64 + "edge_count": len(edges), 65 + "top_domains": [f"{d[0]} ({d[1]})" for d in top_domains], 66 + "edge_types": edge_types, 67 + "sample_titles": titles[:15], 68 + "notes": notes, 69 + } 70 + 71 + 72 + def summarize_with_letta(context): 73 + prompt = ( 74 + "Write a single concise sentence (max 200 chars) summarizing what this " 75 + "cluster of links is about. No preamble, just the summary.\n\n" 76 + f"Island data:\n{json.dumps(context, indent=2)}" 77 + ) 78 + try: 79 + result = subprocess.run( 80 + [LETTA_BIN, "-p", prompt, "--output-format", "json", "--system", "letta", "--memfs"], 81 + capture_output=True, 82 + text=True, 83 + timeout=60, 84 + ) 85 + except subprocess.TimeoutExpired: 86 + return None 87 + if result.returncode != 0: 88 + return None 89 + try: 90 + data = json.loads(result.stdout) 91 + if data.get("type") == "result": 92 + return data["result"].strip().strip('"').strip()[:300] 93 + except Exception: 94 + pass 95 + return result.stdout.strip()[:300] or None 96 + 97 + 98 + def put_summary(island_id, summary): 99 + encoded_id = urllib.parse.quote(island_id, safe="") 100 + body = json.dumps({"summary": summary}).encode() 101 + req = urllib.request.Request( 102 + f"{API_BASE}/api/strata/islands/{encoded_id}/summary", 103 + data=body, 104 + method="PUT", 105 + headers={"Content-Type": "application/json", "User-Agent": "stigmergic-analyzer/1.0"}, 106 + ) 107 + with urllib.request.urlopen(req) as resp: 108 + return resp.status == 200 109 + 110 + 111 + def main(): 112 + islands = fetch_islands() 113 + total = len(islands) 114 + print(f"Analyzing {total} islands...") 115 + 116 + for i, island in enumerate(islands): 117 + island_id = island["id"] 118 + if island.get("summary"): 119 + print(f"[{i+1}/{total}] Skip (has summary): {island_id[:60]}...") 120 + continue 121 + 122 + print(f"[{i+1}/{total}] Analyzing: {island_id[:60]}...") 123 + context = build_island_context(island) 124 + summary = summarize_with_letta(context) 125 + 126 + if not summary: 127 + print(f" Skipping (no summary generated)") 128 + continue 129 + 130 + print(f" Summary: {summary}") 131 + put_summary(island_id, summary) 132 + print(f" Saved.") 133 + 134 + time.sleep(1) 135 + 136 + print("Done.") 137 + 138 + 139 + if __name__ == "__main__": 140 + main()
+117
scripts/analyze-islands.sh
··· 1 + #!/bin/bash 2 + # Analyze each island with letta CLI and post summaries back to the API. 3 + # Usage: ./scripts/analyze-islands.sh 4 + # 5 + # Requires: letta CLI, CLOUDFLARE_API_TOKEN set 6 + 7 + set -euo pipefail 8 + 9 + API_BASE="https://stigmergic.latha.org" 10 + LETTA_BIN="${LETTA_BIN:-/home/nandi/.local/npm-global/bin/letta}" 11 + 12 + # Fetch all islands 13 + islands=$(curl -sf "$API_BASE/api/strata/islands" | python3 -c " 14 + import json, sys 15 + data = json.load(sys.stdin) 16 + for island in data['islands']: 17 + print(island['id']) 18 + ") 19 + 20 + total=$(echo "$islands" | wc -l) 21 + current=0 22 + 23 + echo "Analyzing $total islands..." 24 + 25 + while IFS= read -r island_id; do 26 + current=$((current + 1)) 27 + echo "[$current/$total] Analyzing island: ${island_id:0:60}..." 28 + 29 + # Fetch this island's full data 30 + island_json=$(curl -sf "$API_BASE/api/strata/islands" | python3 -c " 31 + import json, sys 32 + data = json.load(sys.stdin) 33 + for island in data['islands']: 34 + if island['id'] == sys.argv[1]: 35 + # Build a concise context for the LLM 36 + vertices = island['vertices'] 37 + edges = island['edges'] 38 + meta = island.get('vertexMeta', {}) 39 + 40 + titles = [meta.get(v, {}).get('title', v) for v in vertices[:30]] 41 + domains = {} 42 + for v in vertices: 43 + if v.startswith('http'): 44 + try: 45 + from urllib.parse import urlparse 46 + h = urlparse(v).hostname.replace('www.', '') 47 + domains[h] = domains.get(h, 0) + 1 48 + except: pass 49 + top_domains = sorted(domains.items(), key=lambda x: -x[1])[:5] 50 + 51 + edge_types = {} 52 + for e in edges: 53 + t = e.get('connectionType', 'unknown') 54 + edge_types[t] = edge_types.get(t, 0) + 1 55 + 56 + notes = [e['note'] for e in edges if e.get('note')][:5] 57 + 58 + out = { 59 + 'vertex_count': len(vertices), 60 + 'edge_count': len(edges), 61 + 'top_domains': [d[0] + f' ({d[1]})' for d in top_domains], 62 + 'edge_types': edge_types, 63 + 'sample_titles': titles[:15], 64 + 'notes': notes, 65 + } 66 + print(json.dumps(out)) 67 + break 68 + " "$island_id") 69 + 70 + if [ -z "$island_json" ]; then 71 + echo " Skipping (no data)" 72 + continue 73 + fi 74 + 75 + # Ask letta for a one-sentence summary 76 + prompt="You are analyzing a knowledge graph island (connected component). Write a single concise sentence (max 200 chars) summarizing what this cluster of links is about. No preamble, just the summary. 77 + 78 + Island data: 79 + $island_json" 80 + 81 + summary=$($LETTA_BIN -p "$prompt" --output-format json --system letta --memfs 2>/dev/null | python3 -c " 82 + import json, sys 83 + try: 84 + data = json.load(sys.stdin) 85 + if data.get('type') == 'result': 86 + print(data['result'].strip().strip('\"').strip()) 87 + else: 88 + print(data.get('result', data.get('text', '')), end='') 89 + except: 90 + # Fallback: try reading as plain text 91 + sys.stdout.write(sys.stdin.read().strip()) 92 + " 2>/dev/null || echo "") 93 + 94 + if [ -z "$summary" ]; then 95 + echo " Skipping (no summary generated)" 96 + continue 97 + fi 98 + 99 + # Truncate to 300 chars 100 + summary=$(echo "$summary" | head -c 300) 101 + 102 + echo " Summary: $summary" 103 + 104 + # PUT the summary back 105 + encoded_id=$(python3 -c "import urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$island_id") 106 + curl -sf -X PUT "$API_BASE/api/strata/islands/$encoded_id/summary" \ 107 + -H "Content-Type: application/json" \ 108 + -d "{\"summary\":$(python3 -c "import json; print(json.dumps(sys.argv[1]))" "$summary")}" \ 109 + > /dev/null 110 + 111 + echo " Saved." 112 + 113 + # Rate limit 114 + sleep 2 115 + done <<< "$islands" 116 + 117 + echo "Done. $current islands analyzed."
+133 -48
scripts/strata-analyze.py
··· 1 1 #!/usr/bin/env python3 2 - """Strata analysis: fetch islands, produce prose with semantic arrows, upload to Cloudflare. 2 + """Strata analysis: fetch islands, produce prose with semantic arrows, push to PDS as org.latha.strata records. 3 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 4 + Produces an org.latha.strata record for each island containing: 5 + - source: lexmin vertex (canonical component reference) 6 + - analysis: themes, connections, tensions, open questions, synthesis 7 + - createdAt: timestamp 10 8 11 9 Usage: python3 scripts/strata-analyze.py [--force] [--island ID] 12 10 """ 13 11 14 12 import json 13 + import os 15 14 import subprocess 16 15 import sys 17 16 import time ··· 20 19 21 20 API_BASE = "https://stigmergic.latha.org" 22 21 LETTA_BIN = "/home/nandi/.local/npm-global/bin/letta" 22 + VENV_PYTHON = "/home/nandi/code/stigmergic/.venv/bin/python3" 23 23 24 24 25 25 def fetch_islands(): 26 26 req = urllib.request.Request( 27 - f"{API_BASE}/api/strata/islands", 27 + f"{API_BASE}/xrpc/org.latha.strata.getIslands", 28 28 headers={"User-Agent": "stigmergic-strata/1.0"}, 29 29 ) 30 30 with urllib.request.urlopen(req) as resp: ··· 267 267 return result 268 268 269 269 270 - def put_analysis(island_id, strata_data): 271 - encoded_id = urllib.parse.quote(island_id, safe="") 272 - body = json.dumps(strata_data).encode() 273 - req = urllib.request.Request( 274 - f"{API_BASE}/api/strata/islands/{encoded_id}/analysis", 275 - data=body, 276 - method="PUT", 277 - headers={"Content-Type": "application/json", "User-Agent": "stigmergic-strata/1.0"}, 278 - ) 279 - try: 280 - with urllib.request.urlopen(req) as resp: 281 - return resp.status == 200 282 - except urllib.error.HTTPError as e: 283 - print(f" Upload failed: {e.code} {e.read().decode()[:200]}", file=sys.stderr) 284 - return False 270 + def push_strata_record(island, strata_data): 271 + """Create an org.latha.strata record on the PDS.""" 272 + # Build the lexmin from sorted vertices 273 + lexmin = sorted(island["vertices"])[0] 274 + 275 + # Map relationship lines to #connection format 276 + # We need to match relationship text to vertex URIs 277 + meta = island.get("vertexMeta", {}) 278 + title_to_uri = {} 279 + for uri in island["vertices"]: 280 + m = meta.get(uri, {}) 281 + title = m.get("title", "") 282 + if title and len(title) > 3 and not title.startswith("http") and not title.startswith("at://"): 283 + title_to_uri[title] = uri 284 + 285 + connections = [] 286 + for rel_line in strata_data.get("relationships", []): 287 + # Parse "Source โ†’ relation โ†’ Target" 288 + parts = [p.strip() for p in rel_line.split("โ†’")] 289 + if len(parts) < 3: 290 + continue 291 + source_title = parts[0] 292 + target_title = parts[-1] 293 + relation_text = "โ†’".join(parts[1:-1]).strip() 294 + 295 + # Map relation text to lexicon tokens 296 + relation_map = { 297 + "leads to": "org.latha.strata#extends", 298 + "supports": "org.latha.strata#supports", 299 + "opposes": "org.latha.strata#contradicts", 300 + "contradicts": "org.latha.strata#contradicts", 301 + "extends": "org.latha.strata#extends", 302 + "contextualizes": "org.latha.strata#contextualizes", 303 + "exemplifies": "org.latha.strata#exemplifies", 304 + "relates to": "org.latha.strata#contextualizes", 305 + "supplements": "org.latha.strata#extends", 306 + "explains": "org.latha.strata#contextualizes", 307 + "cites": "org.latha.strata#contextualizes", 308 + "addresses": "org.latha.strata#contextualizes", 309 + } 310 + relation = relation_map.get(relation_text.lower(), "org.latha.strata#contextualizes") 311 + 312 + # Find target URI by fuzzy matching 313 + target_uri = None 314 + sorted_titles = sorted(title_to_uri.items(), key=lambda x: -len(x[0])) 315 + for title, uri in sorted_titles: 316 + if target_title in title or title in target_title: 317 + target_uri = uri 318 + break 319 + 320 + if target_uri: 321 + connections.append({ 322 + "description": rel_line, 323 + "targetUri": target_uri, 324 + "relation": relation, 325 + }) 326 + 327 + # Build the AT record 328 + from datetime import datetime, timezone 329 + now = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") 330 + 331 + record = { 332 + "$type": "org.latha.strata", 333 + "source": { 334 + "uri": lexmin, 335 + "collection": "network.cosmik.connection", 336 + }, 337 + "analysis": { 338 + "title": strata_data.get("title", ""), 339 + "themes": strata_data.get("themes", []), 340 + "connections": connections[:20], 341 + "tensions": strata_data.get("tensions", []), 342 + "openQuestions": strata_data.get("open_questions", []), 343 + "synthesis": strata_data.get("synthesis", ""), 344 + }, 345 + "createdAt": now, 346 + } 347 + 348 + # Push to PDS via atproto 349 + script = f""" 350 + import json 351 + from atproto import Client 352 + 353 + client = Client() 354 + client.login( 355 + {json.dumps(os.environ.get("BLUESKY_IDENTIFIER", "nandi.latha.org"))}, 356 + {json.dumps(os.environ.get("BLUESKY_APP_PASSWORD", ""))}, 357 + ) 358 + 359 + record = {json.dumps(record)} 285 360 361 + result = client.com.atproto.repo.create_record({{ 362 + "repo": client.me.did, 363 + "collection": "org.latha.strata", 364 + "record": record, 365 + }}) 366 + print(json.dumps({{"uri": result.uri, "cid": result.cid}})) 367 + """ 286 368 287 - def put_summary(island_id, summary): 288 - encoded_id = urllib.parse.quote(island_id, safe="") 289 - body = json.dumps({"summary": summary}).encode() 290 - req = urllib.request.Request( 291 - f"{API_BASE}/api/strata/islands/{encoded_id}/summary", 292 - data=body, 293 - method="PUT", 294 - headers={"Content-Type": "application/json", "User-Agent": "stigmergic-strata/1.0"}, 295 - ) 296 369 try: 297 - with urllib.request.urlopen(req) as resp: 298 - return resp.status == 200 299 - except urllib.error.HTTPError as e: 300 - print(f" Summary upload failed: {e.code} {e.read().decode()[:200]}", file=sys.stderr) 301 - return False 370 + result = subprocess.run( 371 + [VENV_PYTHON, "-c", script], 372 + capture_output=True, 373 + text=True, 374 + timeout=30, 375 + ) 376 + if result.returncode != 0: 377 + print(f" PDS push failed: {result.stderr[:300]}", file=sys.stderr) 378 + return None 379 + data = json.loads(result.stdout.strip()) 380 + return data.get("uri") 381 + except Exception as e: 382 + print(f" PDS push error: {e}", file=sys.stderr) 383 + return None 302 384 303 385 304 386 def main(): ··· 308 390 if arg.startswith("--island="): 309 391 filter_island = arg.split("=", 1)[1] 310 392 393 + # Load credentials from swarm .env if not in env 394 + env_path = "/home/nandi/code/swarm/.env" 395 + if not os.environ.get("BLUESKY_APP_PASSWORD"): 396 + with open(env_path) as f: 397 + for line in f: 398 + line = line.strip() 399 + if "=" in line and not line.startswith("#"): 400 + key, _, value = line.partition("=") 401 + os.environ.setdefault(key.strip(), value.strip()) 402 + 311 403 islands = fetch_islands() 312 404 total = len(islands) 313 405 print(f"Strata analysis: {total} islands") ··· 341 433 dg = strata_data.get("discourse_graph", {}) 342 434 print(f" Discourse nodes: {len(dg.get('nodes', []))}, edges: {len(dg.get('edges', []))}") 343 435 344 - if put_analysis(island_id, strata_data): 345 - print(" Uploaded.") 436 + uri = push_strata_record(island, strata_data) 437 + if uri: 438 + print(f" Pushed: {uri}") 346 439 else: 347 - print(" Upload failed.") 348 - 349 - # Upload title as summary 350 - title = strata_data.get("title", "").strip() 351 - if title: 352 - if put_summary(island_id, title): 353 - print(f" Summary: {title}") 354 - else: 355 - print(" Summary upload failed.") 440 + print(" Push failed.") 356 441 357 442 time.sleep(2) 358 443
+22 -9
src/frontend/app.ts
··· 596 596 const content = document.getElementById("page-content"); 597 597 if (!content) return; 598 598 599 - // Find the island from loaded data 600 - let island = islands.find(i => i.id === islandId); 599 + let island: Island | null = null; 601 600 602 - // If not in memory, fetch from API 603 - if (!island) { 601 + // If the ID is an AT URI, fetch from the strata record endpoint 602 + if (islandId.startsWith("at://")) { 604 603 try { 605 - const data = await apiGet<{ islands: Island[] }>("/api/strata/islands"); 606 - island = data.islands.find(i => i.id === islandId); 607 - if (island) islands = data.islands; 608 - } catch {} 604 + const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`); 605 + island = data.island; 606 + } catch { 607 + content.innerHTML = '<div class="empty">Strata record not found.</div>'; 608 + return; 609 + } 610 + } else { 611 + // Legacy: sha256 ID โ€” find from loaded islands 612 + island = islands.find(i => i.id === islandId) || null; 613 + 614 + // If not in memory, fetch from API 615 + if (!island) { 616 + try { 617 + const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); 618 + island = data.islands.find(i => i.id === islandId) || null; 619 + if (island) islands = data.islands; 620 + } catch {} 621 + } 609 622 } 610 623 611 624 if (!island) { ··· 832 845 833 846 // Try API first, fall back to server-rendered data 834 847 try { 835 - const data = await apiGet<{ islands: Island[] }>("/api/strata/islands"); 848 + const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); 836 849 islands = data.islands || []; 837 850 } catch { 838 851 const serverData = (window as any).__ISLANDS__;
+201 -5
src/worker.ts
··· 261 261 return meta; 262 262 } 263 263 264 + // โ”€โ”€โ”€ Derive island from lexmin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 265 + // 266 + // Given a lexmin vertex (canonical component reference), derive the 267 + // connected component by traversing network.cosmik.connection records. 268 + 269 + async function deriveIsland( 270 + db: D1Database, 271 + lexmin: string, 272 + ): Promise<Island | null> { 273 + await ensureContrailReady(db); 274 + 275 + // BFS in application code โ€” recursive CTE OOMs in D1 due to cross join 276 + const visited = new Set<string>(); 277 + const queue = [lexmin]; 278 + const allEdges: Array<{ 279 + uri: string; did: string; rkey: string; 280 + source: string; target: string; connectionType: string; 281 + note: string; handle: string | null; 282 + }> = []; 283 + 284 + while (queue.length > 0) { 285 + // Drain the current queue into a batch 286 + const batch = queue.splice(0, 50); 287 + const toQuery = batch.filter(v => !visited.has(v)); 288 + if (toQuery.length === 0) continue; 289 + 290 + // Mark visited 291 + for (const v of toQuery) visited.add(v); 292 + 293 + // Query connections where any of these vertices are source or target 294 + const placeholders = toQuery.map(() => "?").join(","); 295 + const rows = await db 296 + .prepare( 297 + `SELECT r.record, r.did, r.rkey, i.handle 298 + FROM records_connection r 299 + LEFT JOIN identities i ON r.did = i.did 300 + WHERE json_extract(r.record, '$.source') IN (${placeholders}) 301 + OR json_extract(r.record, '$.target') IN (${placeholders})`, 302 + ) 303 + .bind(...toQuery, ...toQuery) 304 + .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 305 + 306 + for (const row of rows.results || []) { 307 + try { 308 + const value = JSON.parse(row.record); 309 + const source = value.source as string; 310 + const target = value.target as string; 311 + if (!source || !target) continue; 312 + 313 + // Collect edge 314 + const edgeKey = `${source}|${target}|${row.rkey}`; 315 + allEdges.push({ 316 + uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 317 + did: row.did, 318 + rkey: row.rkey, 319 + source, 320 + target, 321 + connectionType: value.connectionType || "relates", 322 + note: value.note || "", 323 + handle: row.handle, 324 + }); 325 + 326 + // Enqueue unvisited neighbors 327 + if (!visited.has(source)) queue.push(source); 328 + if (!visited.has(target)) queue.push(target); 329 + } catch {} 330 + } 331 + } 332 + 333 + if (visited.size === 0) return null; 334 + 335 + const vertices = [...visited]; 336 + 337 + // Filter edges to only those with both endpoints in the component 338 + const vertexSet = new Set(vertices); 339 + const edges: Island["edges"] = []; 340 + const seenEdges = new Set<string>(); 341 + for (const e of allEdges) { 342 + if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 343 + const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 344 + if (seenEdges.has(edgeKey)) continue; 345 + seenEdges.add(edgeKey); 346 + edges.push({ 347 + uri: e.uri, 348 + did: e.did, 349 + source: e.source, 350 + target: e.target, 351 + connectionType: e.connectionType, 352 + note: e.note, 353 + handle: e.handle, 354 + }); 355 + } 356 + 357 + // Compute stable ID from lexmin 358 + const sortedVertices = [...vertices].sort(); 359 + const actualLexmin = sortedVertices[0]; 360 + const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actualLexmin)); 361 + const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 362 + 363 + return { id, vertices, edges }; 364 + } 365 + 264 366 // โ”€โ”€โ”€ Island cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 265 367 // 266 368 // The scheduled handler computes islands and writes them to the ··· 376 478 377 479 // โ”€โ”€ Islands API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 378 480 379 - app.get("/api/strata/islands", async (c) => { 481 + app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 380 482 const islands = await readIslandCache(db); 381 483 return c.json({ islands }); 484 + }); 485 + 486 + // โ”€โ”€ List strata records (AT URI-based) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 487 + 488 + app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 489 + const rows = await db 490 + .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 491 + .all<{ uri: string; record: string }>(); 492 + 493 + const records = (rows.results || []).map(r => { 494 + try { 495 + const rec = JSON.parse(r.record); 496 + return { 497 + uri: r.uri, 498 + source: rec.source?.uri || "", 499 + title: rec.analysis?.title || "", 500 + themes: rec.analysis?.themes || [], 501 + createdAt: rec.createdAt || "", 502 + }; 503 + } catch { 504 + return null; 505 + } 506 + }).filter(Boolean); 507 + 508 + return c.json({ records }); 382 509 }); 383 510 384 511 // Update an island's summary 385 - app.put("/api/strata/islands/:id/summary", async (c) => { 386 - const id = decodeURIComponent(c.req.param("id")); 512 + app.put("/xrpc/org.latha.strata.putSummary", async (c) => { 513 + const id = c.req.query("id"); 514 + if (!id) { 515 + return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 516 + } 387 517 const body = await c.req.json<{ summary?: string }>().catch(() => ({ summary: undefined })); 388 518 if (!body.summary) { 389 519 return c.json({ error: "MissingSummary" }, 400); ··· 396 526 }); 397 527 398 528 // Upload strata analysis for an island 399 - app.put("/api/strata/islands/:id/analysis", async (c) => { 400 - const id = decodeURIComponent(c.req.param("id")); 529 + app.put("/xrpc/org.latha.strata.putAnalysis", async (c) => { 530 + const id = c.req.query("id"); 531 + if (!id) { 532 + return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 533 + } 401 534 const body = await c.req.json().catch(() => ({})); 402 535 if (!body || !body.prose) { 403 536 return c.json({ error: "MissingRequiredField", message: "prose is required" }, 400); ··· 408 541 .bind(strataJson, id) 409 542 .run(); 410 543 return c.json({ ok: true }); 544 + }); 545 + 546 + // โ”€โ”€ Strata record endpoint (AT URI-based) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 547 + 548 + app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 549 + const uri = c.req.query("uri"); 550 + if (!uri) { 551 + return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 552 + } 553 + 554 + // Fetch the strata record from D1 (indexed by Contrail) 555 + const row = await db 556 + .prepare("SELECT record FROM records_strata WHERE uri = ?") 557 + .bind(uri) 558 + .first<{ record: string }>(); 559 + 560 + if (!row) { 561 + return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 562 + } 563 + 564 + let strataRecord: any; 565 + try { 566 + strataRecord = JSON.parse(row.record); 567 + } catch { 568 + return c.json({ error: "InvalidRecord" }, 500); 569 + } 570 + 571 + // Derive island from lexmin (source.uri) 572 + const lexmin = strataRecord.source?.uri; 573 + if (!lexmin) { 574 + return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 575 + } 576 + 577 + const island = await deriveIsland(db, lexmin); 578 + if (!island) { 579 + return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 580 + } 581 + 582 + // Resolve vertex metadata 583 + const meta = await resolveVertexMeta(db, island.vertices); 584 + 585 + // Build the response in the same format as the island cache 586 + const analysis = strataRecord.analysis || {}; 587 + const strataData = { 588 + prose: analysis.synthesis || "", 589 + title: analysis.title || "", 590 + themes: analysis.themes || [], 591 + relationships: (analysis.connections || []).map((c: any) => c.description || ""), 592 + tensions: analysis.tensions || [], 593 + open_questions: analysis.openQuestions || [], 594 + synthesis: analysis.synthesis || "", 595 + }; 596 + 597 + return c.json({ 598 + island: { 599 + ...island, 600 + vertexMeta: Object.fromEntries(meta), 601 + summary: analysis.title || null, 602 + strata: strataData, 603 + }, 604 + record: strataRecord, 605 + recordUri: uri, 606 + }); 411 607 }); 412 608 413 609 // โ”€โ”€ R2 sync endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€