This repository has no description
0

Configure Feed

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

stigmergic / container / analysis.ts
13 kB 380 lines
1// ─── Strata Analysis Engine ───────────────────────────────────────── 2// Reads vault (markdown) + carry (markdown) from local filesystem 3// Cross-references an island (cluster of linked URLs) against local knowledge 4// Returns structured analysis with carry cross-refs 5// 6// This runs inside a Cloudflare Container with synced vault + carry data. 7 8import { readdirSync, readFileSync, statSync } from "fs"; 9import { join, extname } from "path"; 10 11const VAULT_PATH = process.env.VAULT_PATH || "/data/vault"; 12const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 13 14// ─── Types ────────────────────────────────────────────────────────── 15 16interface VaultNote { 17 path: string; 18 content: string; 19} 20 21interface CarryNote { 22 path: string; 23 domain: string; 24 content: string; 25} 26 27interface VertexInfo { 28 uri: string; 29 title: string; 30 description: string; 31 type: string; 32} 33 34interface EdgeInfo { 35 uri: string; 36 did: string; 37 source: string; 38 target: string; 39 connectionType: string; 40 note: string; 41} 42 43interface Connection { 44 description: string; 45 targetUri: string; 46 relation: string; 47 source: "vault" | "carry"; 48 sourcePath?: string; 49} 50 51interface CarryRef { 52 carryId: string; 53 atUri: string; 54 summary: string; 55} 56 57interface AnalysisInput { 58 did: string; 59 island: { 60 vertices: VertexInfo[]; 61 edges: EdgeInfo[]; 62 }; 63} 64 65interface AnalysisResult { 66 themes: string[]; 67 connections: Connection[]; 68 tensions: string[]; 69 openQuestions: string[]; 70 synthesis: string; 71 carryRefs: CarryRef[]; 72} 73 74// ─── Main analysis function ──────────────────────────────────────── 75 76export async function analyze(input: AnalysisInput): Promise<AnalysisResult> { 77 const { island } = input; 78 79 // 1. Read vault notes from filesystem 80 const vaultNotes = readVault(); 81 82 // 2. Read carry notes from filesystem 83 const carryNotes = readCarry(); 84 85 // 3. Build corpus from the entire island 86 const islandText = buildIslandText(island); 87 88 // 4. Extract themes from the island 89 const themes = extractThemes(islandText); 90 91 // 5. Find connections to vault + carry 92 const connections = findConnections(islandText, island, vaultNotes, carryNotes); 93 94 // 6. Find tensions 95 const tensions = findTensions(islandText, carryNotes); 96 97 // 7. Generate open questions 98 const openQuestions = generateOpenQuestions(islandText, island, connections, tensions); 99 100 // 8. Synthesize prose 101 const synthesis = synthesize(island, themes, connections, tensions, openQuestions); 102 103 // 9. Build carry cross-refs 104 const carryRefs = buildCarryRefs(connections); 105 106 return { themes, connections, tensions, openQuestions, synthesis, carryRefs }; 107} 108 109// ─── Filesystem readers ──────────────────────────────────────────── 110 111function readVault(): VaultNote[] { 112 return readMarkdownTree(VAULT_PATH).map(({ relPath, content }) => ({ 113 path: relPath, 114 content, 115 })); 116} 117 118function 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 137function 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 157// ─── Build island text corpus ────────────────────────────────────── 158 159function buildIslandText(island: { vertices: VertexInfo[]; edges: EdgeInfo[] }): string { 160 const parts: string[] = []; 161 162 for (const v of island.vertices) { 163 parts.push(v.title); 164 if (v.description) parts.push(v.description); 165 } 166 167 for (const e of island.edges) { 168 if (e.note) parts.push(e.note); 169 } 170 171 return parts.join(" "); 172} 173 174// ─── Analysis functions ───────────────────────────────────────────── 175 176function extractThemes(text: string): string[] { 177 const keywords: Record<string, string[]> = { 178 "open science": ["open science", "open access", "oa", "preprint"], 179 "data sharing": ["data sharing", "shared data", "open data", "dataset"], 180 "reproducibility": ["reproducib", "replicab", "replication"], 181 "decentralization": ["decentraliz", "federat", "self-host"], 182 "knowledge graphs": ["knowledge graph", "ontolog", "linked data", "semantic"], 183 "ai/ml": ["machine learning", "deep learning", "neural", "llm", "gpt", "claude"], 184 "community": ["community", "collective", "cooperative", "commons"], 185 "governance": ["governance", "policy", "regulation", "standards"], 186 "infrastructure": ["infrastructure", "platform", "tooling", "pipeline"], 187 "stigmergy": ["stigmerg", "pheromone", "emergent", "self-organiz"], 188 "provenance": ["provenance", "lineage", "traceability", "attribution"], 189 "doi": ["doi", "zenodo", "citeable", "citation"], 190 "privacy": ["privacy", "encryption", "e2ee", "consent"], 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"], 195 }; 196 197 const lower = text.toLowerCase(); 198 return Object.entries(keywords) 199 .filter(([_, terms]) => terms.some(t => lower.includes(t))) 200 .map(([theme]) => theme); 201} 202 203function findConnections( 204 islandText: string, 205 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 206 vaultNotes: VaultNote[], 207 carryNotes: CarryNote[], 208): Connection[] { 209 const connections: Connection[] = []; 210 const lower = islandText.toLowerCase(); 211 212 // Check vault notes for overlapping themes 213 for (const note of vaultNotes) { 214 const noteLower = note.content.toLowerCase(); 215 const overlap = keywordOverlap(lower, noteLower); 216 if (overlap.length >= 2) { 217 connections.push({ 218 description: `Vault note "${note.path}" shares themes: ${overlap.join(", ")}`, 219 targetUri: "", 220 relation: "contextualizes", 221 source: "vault", 222 sourcePath: note.path, 223 }); 224 } 225 } 226 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); 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 236 connections.push({ 237 description: `Carry (${note.domain}): "${note.path}" — overlapping: ${overlap.join(", ")}`, 238 targetUri: atUri, 239 relation: "supports", 240 source: "carry", 241 sourcePath: note.path, 242 }); 243 } 244 } 245 246 return connections.slice(0, 10); 247} 248 249function findTensions( 250 islandText: string, 251 carryNotes: CarryNote[], 252): string[] { 253 const tensions: string[] = []; 254 const lower = islandText.toLowerCase(); 255 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); 261 if (overlap.length >= 1) { 262 tensions.push( 263 `Carry note "${note.path}" (${note.domain}) notes a tension with this island's themes` 264 ); 265 } 266 } 267 } 268 269 return tensions.slice(0, 5); 270} 271 272function generateOpenQuestions( 273 islandText: string, 274 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 275 connections: Connection[], 276 tensions: string[], 277): string[] { 278 const questions: string[] = []; 279 const lower = islandText.toLowerCase(); 280 281 if (connections.length > 0) { 282 const carryCount = connections.filter(c => c.source === "carry").length; 283 const vaultCount = connections.filter(c => c.source === "vault").length; 284 questions.push( 285 `How does this island extend or challenge your existing ${carryCount} carry and ${vaultCount} vault connections?` 286 ); 287 } 288 289 if (tensions.length > 0) { 290 questions.push( 291 `What evidence would resolve the ${tensions.length} tension(s) identified?` 292 ); 293 } 294 295 const vertexCount = island.vertices.length; 296 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 297 298 if (edgeTypes.size > 1) { 299 questions.push( 300 `This island has ${edgeTypes.size} connection types. What's the dominant relationship?` 301 ); 302 } 303 304 if (vertexCount > 5) { 305 questions.push( 306 `This is a ${vertexCount}-vertex cluster. Are there sub-clusters within it?` 307 ); 308 } 309 310 if (lower.includes("data") || lower.includes("dataset")) { 311 questions.push("What data artifacts does this cluster produce, and are they in Zenodo?"); 312 } 313 if (lower.includes("standard") || lower.includes("protocol")) { 314 questions.push("Does this cluster propose a new standard, and who are the adopters?"); 315 } 316 317 return questions.slice(0, 5); 318} 319 320function synthesize( 321 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 322 themes: string[], 323 connections: Connection[], 324 tensions: string[], 325 openQuestions: string[], 326): string { 327 const vertexCount = island.vertices.length; 328 const edgeCount = island.edges.length; 329 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 330 331 const clusterDesc = `${vertexCount} vertices, ${edgeCount} edges, ${edgeTypes.size} connection types`; 332 333 const themeStr = themes.length > 0 334 ? `Themes: ${themes.join(", ")}.` 335 : ""; 336 337 const connStr = connections.length > 0 338 ? `Connects to ${connections.length} items in your knowledge base: ${connections.slice(0, 3).map(c => c.description).join("; ")}.` 339 : "No direct connections found in your existing knowledge."; 340 341 const tensionStr = tensions.length > 0 342 ? `There ${tensions.length === 1 ? "is 1 tension" : `are ${tensions.length} tensions`} with your existing claims.` 343 : ""; 344 345 const questionStr = openQuestions.length > 0 346 ? `Open questions: ${openQuestions.join(" ")}` 347 : ""; 348 349 return [`Island (${clusterDesc}).`, themeStr, connStr, tensionStr, questionStr].filter(Boolean).join(" "); 350} 351 352function buildCarryRefs(connections: Connection[]): CarryRef[] { 353 return connections 354 .filter(c => c.source === "carry") 355 .map(c => ({ 356 carryId: c.sourcePath || "", 357 atUri: c.targetUri || "", 358 summary: c.description, 359 })); 360} 361 362// ─── Utilities ────────────────────────────────────────────────────── 363 364const STOP_WORDS = new Set([ 365 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", 366 "have", "has", "had", "do", "does", "did", "will", "would", "could", 367 "should", "may", "might", "shall", "can", "to", "of", "in", "for", 368 "on", "with", "at", "by", "from", "as", "into", "through", "during", 369 "before", "after", "above", "below", "between", "and", "but", "or", 370 "not", "no", "nor", "so", "if", "then", "that", "this", "it", "its", 371 "http", "https", "www", "com", "org", 372]); 373 374function keywordOverlap(textA: string, textB: string): string[] { 375 const wordsA = new Set( 376 textA.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)) 377 ); 378 const wordsB = textB.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); 379 return wordsB.filter(w => wordsA.has(w)); 380}