This repository has no description
0

Configure Feed

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

feat: container-backed deriveIsland and analyze xrpc methods

- Container reads vault via ob sync, carry via git pull
- deriveIsland: with seed → single island, without → all islands
- analyze: cross-references island against vault+carry
- Lexicon methods: org.latha.strata.deriveIsland, org.latha.strata.analyze
- StrataContainer Durable Object manages container lifecycle
- carry-vault pushed to github.com/codegod100/carry-vault (private)

👾 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, 2:25 AM UTC) commit 0b74a001 parent bdf84a31
+696 -164
+14
container/Dockerfile
··· 1 1 FROM node:22-slim 2 2 3 + # Install git and other deps 4 + RUN apt-get update && apt-get install -y \ 5 + git \ 6 + curl \ 7 + ca-certificates \ 8 + && rm -rf /var/lib/apt/lists/* 9 + 3 10 WORKDIR /app 4 11 12 + # Install ob (Obsidian CLI) globally 13 + RUN npm install -g obsidian-cli 14 + 5 15 # Install deps 6 16 COPY container/package.json ./ 7 17 RUN npm install --production ··· 9 19 # Copy analysis engine 10 20 COPY container/server.ts ./ 11 21 COPY container/analysis.ts ./ 22 + COPY container/sync.ts ./ 23 + 24 + # Create data directories 25 + RUN mkdir -p /data/vault /data/carry 12 26 13 27 EXPOSE 8080 14 28
+94 -148
container/analysis.ts
··· 1 1 // ─── Strata Analysis Engine ───────────────────────────────────────── 2 - // Reads vault (markdown) + carry (JSON) from R2 2 + // Reads vault (markdown) + carry (markdown) from local filesystem 3 3 // Cross-references an island (cluster of linked URLs) against local knowledge 4 4 // Returns structured analysis with carry cross-refs 5 5 // 6 - // This runs inside a Cloudflare Container with R2 access. 7 - // The container is per-user (each DID gets its own instance). 6 + // This runs inside a Cloudflare Container with synced vault + carry data. 8 7 9 - import { 10 - S3Client, 11 - GetObjectCommand, 12 - ListObjectsV2Command, 13 - } from "@aws-sdk/client-s3"; 8 + import { readdirSync, readFileSync, statSync } from "fs"; 9 + import { join, extname } from "path"; 14 10 15 - // R2 config — injected via Worker envVars when the container starts 16 - const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || ""; 17 - const R2_ACCESS_KEY = process.env.R2_ACCESS_KEY || ""; 18 - const R2_SECRET_KEY = process.env.R2_SECRET_KEY || ""; 19 - 20 - const s3 = new S3Client({ 21 - region: "auto", 22 - endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, 23 - credentials: { 24 - accessKeyId: R2_ACCESS_KEY, 25 - secretAccessKey: R2_SECRET_KEY, 26 - }, 27 - }); 28 - 29 - const VAULT_BUCKET = "stigmergic-vault"; 30 - const CARRY_BUCKET = "stigmergic-carry"; 11 + const VAULT_PATH = process.env.VAULT_PATH || "/data/vault"; 12 + const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 31 13 32 14 // ─── Types ────────────────────────────────────────────────────────── 33 15 ··· 36 18 content: string; 37 19 } 38 20 39 - interface CarryClaim { 40 - id: string; 41 - body: string; 42 - summary: string; 43 - sentiment: string; 44 - qualifier: string; 45 - atUri: string; 46 - entityType: string; 21 + interface CarryNote { 22 + path: string; 23 + domain: string; 24 + content: string; 47 25 } 48 26 49 27 interface VertexInfo { ··· 68 46 relation: string; 69 47 source: "vault" | "carry"; 70 48 sourcePath?: string; 71 - carryId?: string; 72 49 } 73 50 74 51 interface CarryRef { 75 - entityType: "claim" | "citation" | "entity" | "reasoning"; 76 52 carryId: string; 77 53 atUri: string; 78 54 summary: string; ··· 98 74 // ─── Main analysis function ──────────────────────────────────────── 99 75 100 76 export async function analyze(input: AnalysisInput): Promise<AnalysisResult> { 101 - const { did, island } = input; 77 + const { island } = input; 102 78 103 - // 1. Read vault notes from R2 104 - const vaultNotes = await readVault(did); 79 + // 1. Read vault notes from filesystem 80 + const vaultNotes = readVault(); 105 81 106 - // 2. Read carry claims from R2 107 - const carryClaims = await readCarry(did); 82 + // 2. Read carry notes from filesystem 83 + const carryNotes = readCarry(); 108 84 109 85 // 3. Build corpus from the entire island 110 86 const islandText = buildIslandText(island); ··· 113 89 const themes = extractThemes(islandText); 114 90 115 91 // 5. Find connections to vault + carry 116 - const connections = findConnections(islandText, island, vaultNotes, carryClaims); 92 + const connections = findConnections(islandText, island, vaultNotes, carryNotes); 117 93 118 94 // 6. Find tensions 119 - const tensions = findTensions(islandText, carryClaims); 95 + const tensions = findTensions(islandText, carryNotes); 120 96 121 97 // 7. Generate open questions 122 98 const openQuestions = generateOpenQuestions(islandText, island, connections, tensions); ··· 130 106 return { themes, connections, tensions, openQuestions, synthesis, carryRefs }; 131 107 } 132 108 109 + // ─── Filesystem readers ──────────────────────────────────────────── 110 + 111 + function readVault(): VaultNote[] { 112 + return readMarkdownTree(VAULT_PATH).map(({ relPath, content }) => ({ 113 + path: relPath, 114 + content, 115 + })); 116 + } 117 + 118 + function readCarry(): CarryNote[] { 119 + const notes: CarryNote[] = []; 120 + try { 121 + const domains = readdirSync(CARRY_PATH); 122 + for (const domain of domains) { 123 + const domainPath = join(CARRY_PATH, domain); 124 + if (!statSync(domainPath).isDirectory()) continue; 125 + if (domain.startsWith(".")) continue; 126 + const files = readMarkdownTree(domainPath); 127 + for (const { relPath, content } of files) { 128 + notes.push({ path: relPath, domain, content }); 129 + } 130 + } 131 + } catch (e: any) { 132 + console.error("Failed to read carry:", e.message); 133 + } 134 + return notes; 135 + } 136 + 137 + function readMarkdownTree(root: string): Array<{ relPath: string; content: string }> { 138 + const results: Array<{ relPath: string; content: string }> = []; 139 + try { 140 + const entries = readdirSync(root, { withFileTypes: true }); 141 + for (const entry of entries) { 142 + const fullPath = join(root, entry.name); 143 + if (entry.name.startsWith(".")) continue; 144 + if (entry.isDirectory()) { 145 + results.push(...readMarkdownTree(fullPath)); 146 + } else if (extname(entry.name) === ".md") { 147 + try { 148 + const content = readFileSync(fullPath, "utf-8"); 149 + results.push({ relPath: fullPath.replace(root + "/", ""), content }); 150 + } catch {} 151 + } 152 + } 153 + } catch {} 154 + return results; 155 + } 156 + 133 157 // ─── Build island text corpus ────────────────────────────────────── 134 158 135 159 function buildIslandText(island: { vertices: VertexInfo[]; edges: EdgeInfo[] }): string { ··· 147 171 return parts.join(" "); 148 172 } 149 173 150 - // ─── R2 readers ───────────────────────────────────────────────────── 151 - 152 - async function readVault(did: string): Promise<VaultNote[]> { 153 - const prefix = `${did}/`; 154 - try { 155 - const list = await s3.send( 156 - new ListObjectsV2Command({ Bucket: VAULT_BUCKET, Prefix: prefix }) 157 - ); 158 - 159 - const notes: VaultNote[] = []; 160 - for (const obj of list.Contents || []) { 161 - if (!obj.Key?.endsWith(".md")) continue; 162 - try { 163 - const get = await s3.send( 164 - new GetObjectCommand({ Bucket: VAULT_BUCKET, Key: obj.Key }) 165 - ); 166 - const body = await get.Body?.transformToString(); 167 - if (body) { 168 - notes.push({ 169 - path: obj.Key.replace(prefix, ""), 170 - content: body, 171 - }); 172 - } 173 - } catch (e) { 174 - console.error(`Failed to read vault file ${obj.Key}:`, e); 175 - } 176 - } 177 - return notes; 178 - } catch (e) { 179 - console.error("Failed to list vault objects:", e); 180 - return []; 181 - } 182 - } 183 - 184 - async function readCarry(did: string): Promise<CarryClaim[]> { 185 - const prefix = `${did}/`; 186 - try { 187 - const list = await s3.send( 188 - new ListObjectsV2Command({ Bucket: CARRY_BUCKET, Prefix: prefix }) 189 - ); 190 - 191 - const claims: CarryClaim[] = []; 192 - for (const obj of list.Contents || []) { 193 - if (!obj.Key?.endsWith(".json")) continue; 194 - try { 195 - const get = await s3.send( 196 - new GetObjectCommand({ Bucket: CARRY_BUCKET, Key: obj.Key }) 197 - ); 198 - const body = await get.Body?.transformToString(); 199 - if (body) { 200 - try { 201 - const data = JSON.parse(body); 202 - if (Array.isArray(data)) { 203 - claims.push(...data.map(normalizeClaim)); 204 - } else { 205 - claims.push(normalizeClaim(data)); 206 - } 207 - } catch { 208 - // Skip malformed JSON 209 - } 210 - } 211 - } catch (e) { 212 - console.error(`Failed to read carry file ${obj.Key}:`, e); 213 - } 214 - } 215 - return claims; 216 - } catch (e) { 217 - console.error("Failed to list carry objects:", e); 218 - return []; 219 - } 220 - } 221 - 222 174 // ─── Analysis functions ───────────────────────────────────────────── 223 175 224 176 function extractThemes(text: string): string[] { ··· 237 189 "doi": ["doi", "zenodo", "citeable", "citation"], 238 190 "privacy": ["privacy", "encryption", "e2ee", "consent"], 239 191 "sovereignty": ["sovereignty", "self-determin", "autonomy", "agency"], 192 + "content authenticity": ["content authenticity", "c2pa", "provenance watermark", "deepfake"], 193 + "at protocol": ["atproto", "at protocol", "bluesky", "pds", "appview"], 194 + "semantic web": ["semantic web", "rdf", "sparql", "linked data"], 240 195 }; 241 196 242 197 const lower = text.toLowerCase(); ··· 249 204 islandText: string, 250 205 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 251 206 vaultNotes: VaultNote[], 252 - carryClaims: CarryClaim[] 207 + carryNotes: CarryNote[], 253 208 ): Connection[] { 254 209 const connections: Connection[] = []; 255 210 const lower = islandText.toLowerCase(); ··· 269 224 } 270 225 } 271 226 272 - // Check carry claims for related claims 273 - for (const claim of carryClaims) { 274 - const claimText = (claim.body || claim.summary || "").toLowerCase(); 275 - const overlap = keywordOverlap(lower, claimText); 227 + // Check carry notes for related claims 228 + for (const note of carryNotes) { 229 + const noteLower = note.content.toLowerCase(); 230 + const overlap = keywordOverlap(lower, noteLower); 276 231 if (overlap.length >= 1) { 232 + // Extract wikilinks as potential AT URIs 233 + const wikilinks = note.content.match(/\[\[([^\]]+)\]\]/g) || []; 234 + const atUri = wikilinks[0]?.replace("[[", "").replace("]]", "") || ""; 235 + 277 236 connections.push({ 278 - description: `Carry claim: "${claim.summary || claim.body?.slice(0, 100)}"`, 279 - targetUri: claim.atUri || "", 280 - relation: claim.sentiment === "negative" ? "contradicts" : "supports", 237 + description: `Carry (${note.domain}): "${note.path}" — overlapping: ${overlap.join(", ")}`, 238 + targetUri: atUri, 239 + relation: "supports", 281 240 source: "carry", 282 - carryId: claim.id, 241 + sourcePath: note.path, 283 242 }); 284 243 } 285 244 } ··· 289 248 290 249 function findTensions( 291 250 islandText: string, 292 - carryClaims: CarryClaim[] 251 + carryNotes: CarryNote[], 293 252 ): string[] { 294 253 const tensions: string[] = []; 295 254 const lower = islandText.toLowerCase(); 296 255 297 - for (const claim of carryClaims) { 298 - if (claim.sentiment === "negative" || claim.qualifier === "but" || claim.qualifier === "however") { 299 - const claimText = (claim.body || claim.summary || "").toLowerCase(); 300 - const overlap = keywordOverlap(lower, claimText); 256 + for (const note of carryNotes) { 257 + const noteLower = note.content.toLowerCase(); 258 + // Look for tension markers in carry notes 259 + if (noteLower.includes("but") || noteLower.includes("however") || noteLower.includes("tension") || noteLower.includes("contradicts")) { 260 + const overlap = keywordOverlap(lower, noteLower); 301 261 if (overlap.length >= 1) { 302 262 tensions.push( 303 - `Your carry data notes a tension: "${claim.summary || claim.body?.slice(0, 150)}"` 263 + `Carry note "${note.path}" (${note.domain}) notes a tension with this island's themes` 304 264 ); 305 265 } 306 266 } ··· 313 273 islandText: string, 314 274 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 315 275 connections: Connection[], 316 - tensions: string[] 276 + tensions: string[], 317 277 ): string[] { 318 278 const questions: string[] = []; 319 279 const lower = islandText.toLowerCase(); 320 280 321 281 if (connections.length > 0) { 322 282 const carryCount = connections.filter(c => c.source === "carry").length; 283 + const vaultCount = connections.filter(c => c.source === "vault").length; 323 284 questions.push( 324 - `How does this island extend or challenge your existing ${carryCount} carry claims?` 285 + `How does this island extend or challenge your existing ${carryCount} carry and ${vaultCount} vault connections?` 325 286 ); 326 287 } 327 288 ··· 331 292 ); 332 293 } 333 294 334 - // Island-specific questions 335 295 const vertexCount = island.vertices.length; 336 - const edgeCount = island.edges.length; 337 296 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 338 297 339 298 if (edgeTypes.size > 1) { 340 299 questions.push( 341 - `This island has ${edgeTypes.size} connection types (${[...edgeTypes].join(", ")}). What's the dominant relationship?` 300 + `This island has ${edgeTypes.size} connection types. What's the dominant relationship?` 342 301 ); 343 302 } 344 303 ··· 363 322 themes: string[], 364 323 connections: Connection[], 365 324 tensions: string[], 366 - openQuestions: string[] 325 + openQuestions: string[], 367 326 ): string { 368 327 const vertexCount = island.vertices.length; 369 328 const edgeCount = island.edges.length; ··· 394 353 return connections 395 354 .filter(c => c.source === "carry") 396 355 .map(c => ({ 397 - entityType: "claim" as const, 398 - carryId: c.carryId || "", 356 + carryId: c.sourcePath || "", 399 357 atUri: c.targetUri || "", 400 358 summary: c.description, 401 359 })); ··· 420 378 const wordsB = textB.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); 421 379 return wordsB.filter(w => wordsA.has(w)); 422 380 } 423 - 424 - function normalizeClaim(data: any): CarryClaim { 425 - return { 426 - id: data.id || data.claimId || crypto.randomUUID(), 427 - body: data.body || data.text || data.claim || "", 428 - summary: data.summary || data.title || "", 429 - sentiment: data.sentiment || "neutral", 430 - qualifier: data.qualifier || "", 431 - atUri: data.atUri || "", 432 - entityType: data.entityType || "claim", 433 - }; 434 - }
+2 -4
container/package.json
··· 1 1 { 2 2 "name": "stigmergic-analysis", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "private": true, 5 5 "type": "module", 6 - "dependencies": { 7 - "@aws-sdk/client-s3": "^3.600.0" 8 - } 6 + "dependencies": {} 9 7 }
+150 -8
container/server.ts
··· 1 1 // ─── Strata Analysis Container Server ──────────────────────────────── 2 2 // HTTP server running inside the Cloudflare Container. 3 - // Accepts analysis requests, reads vault + carry from R2, returns 4 - // structured analysis. 3 + // Endpoints: 4 + // POST /analyze — run strata analysis on an island 5 + // POST /deriveIsland — derive island (connected component) from a lexmin vertex 6 + // POST /sync — sync vault + carry data 7 + // GET /health — health check 5 8 6 9 import { analyze } from "./analysis.js"; 10 + import { syncAll, syncVault, syncCarry } from "./sync.js"; 7 11 import { createServer } from "http"; 12 + import { 13 + execSync, 14 + } from "child_process"; 8 15 9 16 const PORT = 8080; 17 + const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 10 18 11 19 createServer(async (req, res) => { 12 20 const url = new URL(req.url || "/", `http://localhost:${PORT}`); 13 21 22 + // ── Health ── 23 + if (url.pathname === "/health" && req.method === "GET") { 24 + res.writeHead(200); 25 + res.end("ok"); 26 + return; 27 + } 28 + 29 + // ── Sync ── 30 + if (url.pathname === "/sync" && req.method === "POST") { 31 + try { 32 + await syncAll(); 33 + res.writeHead(200, { "Content-Type": "application/json" }); 34 + res.end(JSON.stringify({ ok: true })); 35 + } catch (e: any) { 36 + res.writeHead(500, { "Content-Type": "application/json" }); 37 + res.end(JSON.stringify({ error: e.message })); 38 + } 39 + return; 40 + } 41 + 42 + // ── Derive Islands ── 43 + // Input: { connections: Array<{source, target, connectionType, note, did, rkey}>, seed?: string } 44 + // - With seed: derive single island containing that vertex 45 + // - Without seed: detect all islands (all connected components) 46 + // Output: { islands: Array<{id, lexmin, vertices, edges}> } 47 + if (url.pathname === "/deriveIsland" && req.method === "POST") { 48 + const chunks: Buffer[] = []; 49 + for await (const chunk of req) chunks.push(chunk); 50 + const body = Buffer.concat(chunks).toString(); 51 + 52 + try { 53 + const input = JSON.parse(body); 54 + const { connections, seed } = input; 55 + 56 + if (!Array.isArray(connections)) { 57 + res.writeHead(400, { "Content-Type": "application/json" }); 58 + res.end(JSON.stringify({ error: "connections[] required" })); 59 + return; 60 + } 61 + 62 + // Build adjacency list 63 + const adj = new Map<string, Array<typeof connections[0]>>(); 64 + const allVertices = new Set<string>(); 65 + for (const conn of connections) { 66 + const s = conn.source as string; 67 + const t = conn.target as string; 68 + if (!adj.has(s)) adj.set(s, []); 69 + if (!adj.has(t)) adj.set(t, []); 70 + adj.get(s)!.push(conn); 71 + adj.get(t)!.push(conn); 72 + allVertices.add(s); 73 + allVertices.add(t); 74 + } 75 + 76 + // BFS from a seed vertex to find one connected component 77 + function bfsComponent(start: string): { 78 + vertices: string[]; 79 + edges: any[]; 80 + } { 81 + const visited = new Set<string>(); 82 + const queue = [start]; 83 + const componentEdges: any[] = []; 84 + 85 + while (queue.length > 0) { 86 + const current = queue.shift()!; 87 + if (visited.has(current)) continue; 88 + visited.add(current); 89 + 90 + for (const conn of adj.get(current) || []) { 91 + const s = conn.source as string; 92 + const t = conn.target as string; 93 + componentEdges.push({ 94 + uri: `at://${conn.did}/network.cosmik.connection/${conn.rkey}`, 95 + did: conn.did, 96 + rkey: conn.rkey, 97 + source: s, 98 + target: t, 99 + connectionType: conn.connectionType || "relates", 100 + note: conn.note || "", 101 + }); 102 + if (!visited.has(s)) queue.push(s); 103 + if (!visited.has(t)) queue.push(t); 104 + } 105 + } 106 + 107 + // Dedupe edges 108 + const vertexSet = new Set(visited); 109 + const edges = componentEdges.filter(e => vertexSet.has(e.source) && vertexSet.has(e.target)); 110 + const seenEdges = new Set<string>(); 111 + const dedupedEdges = edges.filter(e => { 112 + const key = `${e.source}|${e.target}|${e.rkey}`; 113 + if (seenEdges.has(key)) return false; 114 + seenEdges.add(key); 115 + return true; 116 + }); 117 + 118 + const sortedVertices = [...visited].sort(); 119 + return { vertices: sortedVertices, edges: dedupedEdges }; 120 + } 121 + 122 + const { createHash } = await import("crypto"); 123 + 124 + if (seed) { 125 + // Single island from seed vertex 126 + const { vertices, edges } = bfsComponent(seed); 127 + const lexmin = vertices[0]; 128 + const id = createHash("sha256").update(lexmin).digest("hex").slice(0, 16); 129 + res.writeHead(200, { "Content-Type": "application/json" }); 130 + res.end(JSON.stringify({ islands: [{ id, lexmin, vertices, edges }] })); 131 + } else { 132 + // All islands — find all connected components 133 + const globalVisited = new Set<string>(); 134 + const islands: any[] = []; 135 + 136 + for (const vertex of allVertices) { 137 + if (globalVisited.has(vertex)) continue; 138 + const { vertices, edges } = bfsComponent(vertex); 139 + for (const v of vertices) globalVisited.add(v); 140 + const lexmin = vertices[0]; 141 + const id = createHash("sha256").update(lexmin).digest("hex").slice(0, 16); 142 + islands.push({ id, lexmin, vertices, edges }); 143 + } 144 + 145 + // Sort by vertex count descending 146 + islands.sort((a, b) => b.vertices.length - a.vertices.length); 147 + 148 + res.writeHead(200, { "Content-Type": "application/json" }); 149 + res.end(JSON.stringify({ islands })); 150 + } 151 + } catch (e: any) { 152 + console.error("deriveIsland error:", e); 153 + res.writeHead(500, { "Content-Type": "application/json" }); 154 + res.end(JSON.stringify({ error: e.message })); 155 + } 156 + return; 157 + } 158 + 159 + // ── Analyze ── 14 160 if (url.pathname === "/analyze" && req.method === "POST") { 15 161 const chunks: Buffer[] = []; 16 162 for await (const chunk of req) chunks.push(chunk); ··· 29 175 return; 30 176 } 31 177 32 - if (url.pathname === "/health") { 33 - res.writeHead(200); 34 - res.end("ok"); 35 - return; 36 - } 37 - 38 178 res.writeHead(404); 39 179 res.end("Not found"); 40 180 }).listen(PORT, () => { 41 181 console.log(`Strata analysis container listening on port ${PORT}`); 182 + // Sync on startup 183 + syncAll().catch(e => console.error("Initial sync failed:", e)); 42 184 });
+54
container/sync.ts
··· 1 + // ─── Sync: pull vault + carry into container ────────────────────── 2 + // 3 + // vault: ob sync (Obsidian Sync) 4 + // carry: git pull from GitHub private repo 5 + 6 + import { execSync } from "child_process"; 7 + 8 + const VAULT_PATH = process.env.VAULT_PATH || "/data/vault"; 9 + const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 10 + const CARRY_REMOTE = process.env.CARRY_REMOTE || ""; 11 + const CARRY_BRANCH = process.env.CARRY_BRANCH || "main"; 12 + 13 + export async function syncVault(): Promise<void> { 14 + try { 15 + execSync(`ob sync ${VAULT_PATH}`, { stdio: "pipe", timeout: 60000 }); 16 + console.log("[sync] vault synced via ob"); 17 + } catch (e: any) { 18 + console.error("[sync] vault sync failed:", e.message); 19 + } 20 + } 21 + 22 + export async function syncCarry(): Promise<void> { 23 + if (!CARRY_REMOTE) { 24 + console.error("[sync] CARRY_REMOTE not set, skipping carry sync"); 25 + return; 26 + } 27 + 28 + try { 29 + // If not cloned yet, clone; otherwise pull 30 + try { 31 + execSync(`git -C ${CARRY_PATH} rev-parse --git-dir`, { stdio: "pipe" }); 32 + execSync(`git -C ${CARRY_PATH} pull origin ${CARRY_BRANCH}`, { 33 + stdio: "pipe", 34 + timeout: 30000, 35 + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, 36 + }); 37 + console.log("[sync] carry pulled via git"); 38 + } catch { 39 + // Not a git repo — clone 40 + execSync(`git clone --branch ${CARRY_BRANCH} --single-branch ${CARRY_REMOTE} ${CARRY_PATH}`, { 41 + stdio: "pipe", 42 + timeout: 60000, 43 + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, 44 + }); 45 + console.log("[sync] carry cloned via git"); 46 + } 47 + } catch (e: any) { 48 + console.error("[sync] carry sync failed:", e.message); 49 + } 50 + } 51 + 52 + export async function syncAll(): Promise<void> { 53 + await Promise.all([syncVault(), syncCarry()]); 54 + }
+157
lexicons/org/latha/strata/analyze.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "org.latha.strata.analyze", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Run strata analysis on an island. Cross-references the island's vertices and edges against vault and carry data, producing themes, connections, tensions, and a synthesis. Returns an org.latha.strata record ready to be written to PDS.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["island"], 13 + "properties": { 14 + "island": { 15 + "type": "ref", 16 + "ref": "#islandInput" 17 + }, 18 + "did": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the user requesting analysis (used to look up vault/carry data)." 22 + } 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["analysis"], 31 + "properties": { 32 + "analysis": { 33 + "type": "ref", 34 + "ref": "#analysisResult" 35 + }, 36 + "record": { 37 + "type": "ref", 38 + "ref": "org.latha.strata#main", 39 + "description": "Complete org.latha.strata record ready to be written to PDS." 40 + } 41 + } 42 + } 43 + } 44 + }, 45 + "islandInput": { 46 + "type": "object", 47 + "required": ["vertices", "edges"], 48 + "properties": { 49 + "vertices": { 50 + "type": "array", 51 + "items": { 52 + "type": "ref", 53 + "ref": "#vertexInfo" 54 + } 55 + }, 56 + "edges": { 57 + "type": "array", 58 + "items": { 59 + "type": "ref", 60 + "ref": "#edgeInfo" 61 + } 62 + } 63 + } 64 + }, 65 + "vertexInfo": { 66 + "type": "object", 67 + "required": ["uri", "title"], 68 + "properties": { 69 + "uri": { 70 + "type": "string", 71 + "description": "Vertex URI (HTTP URL or AT URI)." 72 + }, 73 + "title": { 74 + "type": "string", 75 + "description": "Resolved title of the vertex." 76 + }, 77 + "description": { 78 + "type": "string", 79 + "description": "Resolved description." 80 + }, 81 + "type": { 82 + "type": "string", 83 + "description": "Vertex type (url, card, collection, note, unknown)." 84 + } 85 + } 86 + }, 87 + "edgeInfo": { 88 + "type": "object", 89 + "required": ["uri", "did", "source", "target", "connectionType"], 90 + "properties": { 91 + "uri": { 92 + "type": "string", 93 + "format": "at-uri" 94 + }, 95 + "did": { 96 + "type": "string", 97 + "format": "did" 98 + }, 99 + "source": { 100 + "type": "string" 101 + }, 102 + "target": { 103 + "type": "string" 104 + }, 105 + "connectionType": { 106 + "type": "string" 107 + }, 108 + "note": { 109 + "type": "string" 110 + }, 111 + "handle": { 112 + "type": "string" 113 + } 114 + } 115 + }, 116 + "analysisResult": { 117 + "type": "object", 118 + "required": ["themes", "synthesis"], 119 + "properties": { 120 + "title": { 121 + "type": "string", 122 + "maxGraphemes": 300, 123 + "description": "Concise sentence capturing the core argument." 124 + }, 125 + "themes": { 126 + "type": "array", 127 + "items": { 128 + "type": "string" 129 + } 130 + }, 131 + "connections": { 132 + "type": "array", 133 + "items": { 134 + "type": "ref", 135 + "ref": "org.latha.strata#connection" 136 + } 137 + }, 138 + "tensions": { 139 + "type": "array", 140 + "items": { 141 + "type": "string" 142 + } 143 + }, 144 + "openQuestions": { 145 + "type": "array", 146 + "items": { 147 + "type": "string" 148 + } 149 + }, 150 + "synthesis": { 151 + "type": "string", 152 + "description": "Prose synthesis connecting the island to existing knowledge." 153 + } 154 + } 155 + } 156 + } 157 + }
+91
lexicons/org/latha/strata/deriveIsland.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "org.latha.strata.deriveIsland", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Derive connected components (islands) from the connection graph. Without a seed, returns all islands. With a seed vertex, returns the single island containing it.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "seed": { 12 + "type": "string", 13 + "description": "Optional seed vertex. If provided, returns only the island containing this vertex. If omitted, returns all islands." 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["islands"], 22 + "properties": { 23 + "islands": { 24 + "type": "array", 25 + "items": { 26 + "type": "ref", 27 + "ref": "#island" 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }, 34 + "island": { 35 + "type": "object", 36 + "required": ["id", "lexmin", "vertices", "edges"], 37 + "properties": { 38 + "id": { 39 + "type": "string", 40 + "description": "Stable ID: truncated SHA-256 of the lexmin vertex." 41 + }, 42 + "lexmin": { 43 + "type": "string", 44 + "description": "Lexicographically smallest vertex in the component — the canonical reference." 45 + }, 46 + "vertices": { 47 + "type": "array", 48 + "items": { 49 + "type": "string" 50 + } 51 + }, 52 + "edges": { 53 + "type": "array", 54 + "items": { 55 + "type": "ref", 56 + "ref": "#edge" 57 + } 58 + } 59 + } 60 + }, 61 + "edge": { 62 + "type": "object", 63 + "required": ["uri", "did", "source", "target", "connectionType"], 64 + "properties": { 65 + "uri": { 66 + "type": "string", 67 + "format": "at-uri" 68 + }, 69 + "did": { 70 + "type": "string", 71 + "format": "did" 72 + }, 73 + "source": { 74 + "type": "string" 75 + }, 76 + "target": { 77 + "type": "string" 78 + }, 79 + "connectionType": { 80 + "type": "string" 81 + }, 82 + "note": { 83 + "type": "string" 84 + }, 85 + "handle": { 86 + "type": "string" 87 + } 88 + } 89 + } 90 + } 91 + }
+17 -2
pnpm-lock.yaml
··· 1019 1019 '@atcute/util-fetch': 1.0.5 1020 1020 '@badrap/valita': 0.4.6 1021 1021 1022 + '@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@2.0.0))(@atcute/lexicons@2.0.0)': 1023 + dependencies: 1024 + '@atcute/identity': 1.1.5(@atcute/lexicons@2.0.0) 1025 + '@atcute/lexicons': 2.0.0 1026 + '@atcute/util-fetch': 1.0.5 1027 + '@badrap/valita': 0.4.6 1028 + 1022 1029 '@atcute/identity@1.1.5(@atcute/lexicons@1.3.1)': 1023 1030 dependencies: 1024 1031 '@atcute/lexicons': 1.3.1 ··· 1064 1071 '@atcute/util-text': 1.3.1 1065 1072 '@badrap/valita': 0.4.6 1066 1073 1074 + '@atcute/lexicon-doc@2.2.1(@atcute/lexicons@2.0.0)': 1075 + dependencies: 1076 + '@atcute/identity': 1.1.5(@atcute/lexicons@2.0.0) 1077 + '@atcute/lexicons': 2.0.0 1078 + '@atcute/uint8array': 1.1.2 1079 + '@atcute/util-text': 1.3.1 1080 + '@badrap/valita': 0.4.6 1081 + 1067 1082 '@atcute/lexicon-resolver@0.1.7(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@2.0.0))(@atcute/lexicons@2.0.0))(@atcute/identity@1.1.5(@atcute/lexicons@2.0.0))(@atcute/lexicon-doc@2.2.1(@atcute/lexicons@2.0.0))(@atcute/lexicons@1.3.1)': 1068 1083 dependencies: 1069 1084 '@atcute/crypto': 2.4.1 1070 1085 '@atcute/identity': 1.1.5(@atcute/lexicons@2.0.0) 1071 - '@atcute/identity-resolver': 1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@2.0.0))(@atcute/lexicons@1.3.1) 1072 - '@atcute/lexicon-doc': 2.2.1(@atcute/lexicons@1.3.1) 1086 + '@atcute/identity-resolver': 1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@2.0.0))(@atcute/lexicons@2.0.0) 1087 + '@atcute/lexicon-doc': 2.2.1(@atcute/lexicons@2.0.0) 1073 1088 '@atcute/lexicons': 1.3.1 1074 1089 '@atcute/repo': 0.1.5(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/lexicons@1.3.1) 1075 1090 '@atcute/util-fetch': 1.0.5
+22
src/container.ts
··· 1 + import { Container } from "@cloudflare/containers"; 2 + 3 + export class StrataContainer extends Container { 4 + defaultPort = 8080; 5 + sleepAfter = "5m"; 6 + 7 + // Env vars passed to the container process 8 + env: Record<string, string> = { 9 + VAULT_PATH: "/data/vault", 10 + CARRY_PATH: "/data/carry", 11 + CARRY_REMOTE: "", 12 + CARRY_BRANCH: "main", 13 + }; 14 + 15 + async onStart() { 16 + console.log("[strata-container] started"); 17 + } 18 + 19 + async onStop() { 20 + console.log("[strata-container] stopped"); 21 + } 22 + }
+76 -2
src/worker.ts
··· 5 5 import { config } from "./contrail.config.js"; 6 6 import { lexicons } from "../lexicons/generated/index.js"; 7 7 import { LANDING_PAGE } from "./landing-page.js"; 8 + import { StrataContainer } from "./container.js"; 8 9 9 10 // ─── Env ──────────────────────────────────────────────────────────── 10 11 ··· 12 13 DB: D1Database; 13 14 VAULT_BUCKET: R2Bucket; 14 15 CARRY_BUCKET: R2Bucket; 16 + STRATA_CONTAINER: DurableObjectNamespace; 15 17 } 16 18 17 19 // ─── Contrail setup ───────────────────────────────────────────────── ··· 268 270 269 271 async function deriveIsland( 270 272 db: D1Database, 271 - lexmin: string, 273 + seed: string, 272 274 ): Promise<Island | null> { 273 275 await ensureContrailReady(db); 274 276 275 277 // BFS in application code — recursive CTE OOMs in D1 due to cross join 276 278 const visited = new Set<string>(); 277 - const queue = [lexmin]; 279 + const queue = [seed]; 278 280 const allEdges: Array<{ 279 281 uri: string; did: string; rkey: string; 280 282 source: string; target: string; connectionType: string; ··· 530 532 }); 531 533 }); 532 534 535 + // ── Container-backed xrpc methods ──────────────────────────────── 536 + 537 + // Helper: call the strata container 538 + async function callContainer(path: string, method: string, body?: any): Promise<any> { 539 + const id = env.STRATA_CONTAINER.idFromName("strata"); 540 + const stub = env.STRATA_CONTAINER.get(id); 541 + const url = new URL(path, "http://container"); 542 + const init: RequestInit = { method }; 543 + if (body) { 544 + init.body = JSON.stringify(body); 545 + init.headers = { "Content-Type": "application/json" }; 546 + } 547 + const resp = await stub.fetch(new Request(url.toString(), init)); 548 + return resp.json(); 549 + } 550 + 551 + // Derive islands — delegates to container 552 + // With seed: single island. Without: all islands. 553 + app.get("/xrpc/org.latha.strata.deriveIsland", async (c) => { 554 + const seed = c.req.query("seed"); 555 + 556 + // Load all connections from D1 for the container to traverse 557 + await ensureContrailReady(db); 558 + const rows = await db 559 + .prepare( 560 + `SELECT r.record, r.did, r.rkey, i.handle 561 + FROM records_connection r 562 + LEFT JOIN identities i ON r.did = i.did`, 563 + ) 564 + .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 565 + 566 + const connections = (rows.results || []).map(r => { 567 + try { 568 + const value = JSON.parse(r.record); 569 + return { 570 + source: value.source, 571 + target: value.target, 572 + connectionType: value.connectionType || "relates", 573 + note: value.note || "", 574 + did: r.did, 575 + rkey: r.rkey, 576 + }; 577 + } catch { 578 + return null; 579 + } 580 + }).filter(Boolean); 581 + 582 + try { 583 + const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 584 + return c.json(result); 585 + } catch (e: any) { 586 + return c.json({ error: "ContainerError", message: e.message }, 500); 587 + } 588 + }); 589 + 590 + // Run strata analysis — delegates to container 591 + app.post("/xrpc/org.latha.strata.analyze", async (c) => { 592 + const body = await c.req.json().catch(() => ({})); 593 + if (!body.island) { 594 + return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 595 + } 596 + 597 + try { 598 + const result = await callContainer("/analyze", "POST", body); 599 + return c.json(result); 600 + } catch (e: any) { 601 + return c.json({ error: "ContainerError", message: e.message }, 500); 602 + } 603 + }); 604 + 533 605 // ── R2 sync endpoints ─────────────────────────────────────────── 534 606 535 607 app.put("/api/sync/vault/:did/*", async (c) => { ··· 648 720 } 649 721 650 722 // ─── Export ───────────────────────────────────────────────────────── 723 + 724 + export { StrataContainer }; 651 725 652 726 export default { 653 727 fetch(request: Request, env: Env): Response | Promise<Response> {
+19
wrangler.jsonc
··· 13 13 } 14 14 ], 15 15 16 + // Container for strata analysis 17 + "containers": [ 18 + { 19 + "name": "strata-analysis", 20 + "image": "./container/Dockerfile", 21 + "instances": 1, 22 + "durable_object": { "binding": "STRATA_CONTAINER" } 23 + } 24 + ], 25 + 26 + "durable_objects": { 27 + "bindings": [ 28 + { 29 + "name": "STRATA_CONTAINER", 30 + "class_name": "StrataContainer" 31 + } 32 + ] 33 + }, 34 + 16 35 "triggers": { 17 36 "crons": ["*/1 * * * *"] 18 37 },