This repository has no description
0

Configure Feed

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

stigmergic / container / analysis.ts
21 kB 594 lines
1// ─── Strata Analysis Engine ───────────────────────────────────────── 2// Cross-references an island (cluster of linked URLs) against: 3// 1. Vault + carry markdown from local filesystem (synced via ob/git) 4// 2. Structured carry records from D1 (entities, citations, reasonings) 5// Returns structured analysis + new connection records to be created on PDS. 6 7import { readdirSync, readFileSync, statSync } from "fs"; 8import { join, extname } from "path"; 9 10const VAULT_PATH = process.env.VAULT_PATH || "/data/vault"; 11const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 12 13// ─── Types ────────────────────────────────────────────────────────── 14 15interface VaultNote { 16 path: string; 17 content: string; 18} 19 20interface CarryNote { 21 path: string; 22 domain: string; 23 content: string; 24} 25 26interface VertexInfo { 27 uri: string; 28 title: string; 29 description: string; 30 type: string; 31} 32 33interface EdgeInfo { 34 uri: string; 35 did: string; 36 source: string; 37 target: string; 38 connectionType: string; 39 note: string; 40} 41 42interface CarryEntity { 43 atUri: string; 44 name: string; 45 entityType: string; 46 description?: string; 47} 48 49interface CarryCitation { 50 atUri: string; 51 url: string; 52 title: string; 53 takeaway: string; 54 confidence?: string; 55} 56 57interface CarryReasoning { 58 atUri: string; 59 claim: string; 60 evidence: string; 61 reasoningType?: string; 62} 63 64interface CarryData { 65 entities: CarryEntity[]; 66 citations: CarryCitation[]; 67 reasonings: CarryReasoning[]; 68} 69 70interface NewConnection { 71 source: string; // island vertex URI 72 target: string; // carry record AT URI 73 connectionType: string; // e.g. "network.cosmik.connection#annotates" 74 note: string; // why this connection exists 75} 76 77interface AnalysisInput { 78 did: string; 79 island: { 80 vertices: VertexInfo[]; 81 edges: EdgeInfo[]; 82 }; 83 carryData?: CarryData; 84} 85 86interface AnalysisResult { 87 themes: string[]; 88 highlights: string[]; 89 tensions: string[]; 90 openQuestions: string[]; 91 synthesis: string; 92 newConnections: NewConnection[]; 93} 94 95// ─── Main analysis function ──────────────────────────────────────── 96 97export async function analyze(input: AnalysisInput): Promise<AnalysisResult> { 98 const { island, carryData } = input; 99 100 // 1. Read vault + carry from filesystem 101 const vaultNotes = readVault(); 102 const carryNotes = readCarry(); 103 104 // 2. Build corpus from the entire island 105 const islandText = buildIslandText(island); 106 107 // 3. Extract themes from the island 108 const themes = extractThemes(islandText); 109 110 // 4. Find carry touchpoints — semantic matches from D1 records 111 const newConnections = findCarryTouchpoints(island, carryData || { entities: [], citations: [], reasonings: [] }); 112 113 // 5. Find vault/carry touchpoints — semantic matches from filesystem notes 114 const fsConnections = findFilesystemTouchpoints(island, vaultNotes, carryNotes); 115 newConnections.push(...fsConnections); 116 117 // 6. Flag hot edges — existing edges that are semantically significant 118 const highlights = findHotEdges(islandText, island, vaultNotes, carryNotes, newConnections); 119 120 // 7. Find tensions from carry notes + reasonings 121 const tensions = [ 122 ...findTensionsFromNotes(islandText, carryNotes), 123 ...findTensionsFromReasonings(islandText, carryData?.reasonings || []), 124 ]; 125 126 // 8. Generate open questions 127 const openQuestions = generateOpenQuestions(islandText, island, highlights, tensions, newConnections); 128 129 // 9. Synthesize prose 130 const synthesis = synthesize(island, themes, highlights, tensions, openQuestions, newConnections); 131 132 // Deduplicate newConnections 133 const seen = new Set<string>(); 134 const deduped = newConnections.filter(c => { 135 const key = `${c.source}|${c.target}`; 136 if (seen.has(key)) return false; 137 seen.add(key); 138 return true; 139 }); 140 141 return { themes, highlights, tensions, openQuestions, synthesis, newConnections: deduped.slice(0, 20) }; 142} 143 144// ─── Filesystem readers ──────────────────────────────────────────── 145 146function readVault(): VaultNote[] { 147 return readMarkdownTree(VAULT_PATH).map(({ relPath, content }) => ({ 148 path: relPath, 149 content, 150 })); 151} 152 153function readCarry(): CarryNote[] { 154 const notes: CarryNote[] = []; 155 try { 156 const domains = readdirSync(CARRY_PATH); 157 for (const domain of domains) { 158 const domainPath = join(CARRY_PATH, domain); 159 if (!statSync(domainPath).isDirectory()) continue; 160 if (domain.startsWith(".")) continue; 161 const files = readMarkdownTree(domainPath); 162 for (const { relPath, content } of files) { 163 notes.push({ path: relPath, domain, content }); 164 } 165 } 166 } catch (e: any) { 167 console.error("Failed to read carry:", e.message); 168 } 169 return notes; 170} 171 172function readMarkdownTree(root: string): Array<{ relPath: string; content: string }> { 173 const results: Array<{ relPath: string; content: string }> = []; 174 try { 175 const entries = readdirSync(root, { withFileTypes: true }); 176 for (const entry of entries) { 177 const fullPath = join(root, entry.name); 178 if (entry.name.startsWith(".")) continue; 179 if (entry.isDirectory()) { 180 results.push(...readMarkdownTree(fullPath)); 181 } else if (extname(entry.name) === ".md") { 182 try { 183 const content = readFileSync(fullPath, "utf-8"); 184 results.push({ relPath: fullPath.replace(root + "/", ""), content }); 185 } catch {} 186 } 187 } 188 } catch {} 189 return results; 190} 191 192// ─── Build island text corpus ────────────────────────────────────── 193 194function buildIslandText(island: { vertices: VertexInfo[]; edges: EdgeInfo[] }): string { 195 const parts: string[] = []; 196 197 for (const v of island.vertices) { 198 parts.push(v.title); 199 if (v.description) parts.push(v.description); 200 } 201 202 for (const e of island.edges) { 203 if (e.note) parts.push(e.note); 204 } 205 206 return parts.join(" "); 207} 208 209// ─── Carry touchpoint matching (D1 records) ──────────────────────── 210 211function findCarryTouchpoints( 212 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 213 carryData: CarryData, 214): NewConnection[] { 215 const connections: NewConnection[] = []; 216 217 // --- Match citations by URL --- 218 for (const cite of carryData.citations) { 219 // Direct URL match: citation URL is a vertex URI 220 const vertexMatch = island.vertices.find(v => v.uri === cite.url); 221 if (vertexMatch) { 222 connections.push({ 223 source: cite.url, 224 target: cite.atUri, 225 connectionType: "network.cosmik.connection#annotates", 226 note: cite.takeaway.slice(0, 500), 227 }); 228 continue; 229 } 230 231 // Semantic match: citation title/takeaway overlaps with island vertices 232 const citeText = `${cite.title} ${cite.takeaway}`.toLowerCase(); 233 const bestVertex = findBestVertexMatch(citeText, island.vertices); 234 if (bestVertex) { 235 const overlap = keywordOverlap(citeText, `${bestVertex.title} ${bestVertex.description || ""}`); 236 if (overlap.length >= 2) { 237 connections.push({ 238 source: bestVertex.uri, 239 target: cite.atUri, 240 connectionType: "network.cosmik.connection#annotates", 241 note: cite.takeaway.slice(0, 500), 242 }); 243 } 244 } 245 } 246 247 // --- Match entities by name/description --- 248 for (const entity of carryData.entities) { 249 const entityText = `${entity.name} ${entity.description || ""}`.toLowerCase(); 250 const bestVertex = findBestVertexMatch(entityText, island.vertices); 251 if (bestVertex) { 252 const overlap = keywordOverlap(entityText, `${bestVertex.title} ${bestVertex.description || ""}`); 253 if (overlap.length >= 2) { 254 connections.push({ 255 source: bestVertex.uri, 256 target: entity.atUri, 257 connectionType: "network.cosmik.connection#relates", 258 note: `Related to tracked ${entity.entityType.replace("org.latha.strata.defs#", "")}: ${entity.name}`, 259 }); 260 } 261 } 262 } 263 264 // --- Match reasonings by claim/evidence --- 265 for (const reasoning of carryData.reasonings) { 266 const reasoningText = `${reasoning.claim} ${reasoning.evidence}`.toLowerCase(); 267 const bestVertex = findBestVertexMatch(reasoningText, island.vertices); 268 if (bestVertex) { 269 const overlap = keywordOverlap(reasoningText, `${bestVertex.title} ${bestVertex.description || ""}`); 270 if (overlap.length >= 2) { 271 const connType = reasoning.reasoningType?.includes("contradict") 272 ? "network.cosmik.connection#contradicts" 273 : "network.cosmik.connection#extends"; 274 connections.push({ 275 source: bestVertex.uri, 276 target: reasoning.atUri, 277 connectionType: connType, 278 note: reasoning.claim.slice(0, 500), 279 }); 280 } 281 } 282 } 283 284 return connections; 285} 286 287// ─── Filesystem touchpoint matching (vault + carry notes) ─────────── 288 289function findFilesystemTouchpoints( 290 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 291 vaultNotes: VaultNote[], 292 carryNotes: CarryNote[], 293): NewConnection[] { 294 const connections: NewConnection[] = []; 295 const islandText = buildIslandText(island).toLowerCase(); 296 297 // Match vault notes to island vertices 298 for (const note of vaultNotes) { 299 const noteLower = note.content.toLowerCase(); 300 const overlap = keywordOverlap(islandText, noteLower); 301 if (overlap.length < 3) continue; 302 303 // Find the best vertex for this vault note 304 const bestVertex = findBestVertexMatch(noteLower, island.vertices); 305 if (bestVertex) { 306 // Extract title from first heading or first line 307 const title = note.content.split("\n").find(l => l.startsWith("#"))?.replace(/^#+\s*/, "") || note.path; 308 connections.push({ 309 source: bestVertex.uri, 310 target: `vault://${note.path}`, 311 connectionType: "network.cosmik.connection#annotates", 312 note: `Vault note: ${title.slice(0, 200)}`, 313 }); 314 } 315 } 316 317 // Match carry notes to island vertices 318 for (const note of carryNotes) { 319 const noteLower = note.content.toLowerCase(); 320 const overlap = keywordOverlap(islandText, noteLower); 321 if (overlap.length < 2) continue; 322 323 const bestVertex = findBestVertexMatch(noteLower, island.vertices); 324 if (bestVertex) { 325 const title = note.content.split("\n").find(l => l.startsWith("#"))?.replace(/^#+\s*/, "") || note.path; 326 connections.push({ 327 source: bestVertex.uri, 328 target: `carry://${note.domain}/${note.path}`, 329 connectionType: "network.cosmik.connection#relates", 330 note: `Carry [${note.domain}]: ${title.slice(0, 200)}`, 331 }); 332 } 333 } 334 335 return connections; 336} 337 338function findBestVertexMatch( 339 text: string, 340 vertices: VertexInfo[], 341): VertexInfo | null { 342 let bestScore = 0; 343 let bestVertex: VertexInfo | null = null; 344 345 for (const v of vertices) { 346 const vertexText = `${v.title} ${v.description || ""}`.toLowerCase(); 347 const overlap = keywordOverlap(text, vertexText); 348 if (overlap.length > bestScore) { 349 bestScore = overlap.length; 350 bestVertex = v; 351 } 352 } 353 354 return bestScore >= 2 ? bestVertex : null; 355} 356 357// ─── Analysis functions ───────────────────────────────────────────── 358 359function extractThemes(text: string): string[] { 360 const keywords: Record<string, string[]> = { 361 "open science": ["open science", "open access", "oa", "preprint"], 362 "data sharing": ["data sharing", "shared data", "open data", "dataset"], 363 "reproducibility": ["reproducib", "replicab", "replication"], 364 "decentralization": ["decentraliz", "federat", "self-host"], 365 "knowledge graphs": ["knowledge graph", "ontolog", "linked data", "semantic"], 366 "ai/ml": ["machine learning", "deep learning", "neural", "llm", "gpt", "claude"], 367 "community": ["community", "collective", "cooperative", "commons"], 368 "governance": ["governance", "policy", "regulation", "standards"], 369 "infrastructure": ["infrastructure", "platform", "tooling", "pipeline"], 370 "stigmergy": ["stigmerg", "pheromone", "emergent", "self-organiz"], 371 "provenance": ["provenance", "lineage", "traceability", "attribution"], 372 "doi": ["doi", "zenodo", "citeable", "citation"], 373 "privacy": ["privacy", "encryption", "e2ee", "consent"], 374 "sovereignty": ["sovereignty", "self-determin", "autonomy", "agency"], 375 "content authenticity": ["content authenticity", "c2pa", "provenance watermark", "deepfake"], 376 "at protocol": ["atproto", "at protocol", "bluesky", "pds", "appview"], 377 "semantic web": ["semantic web", "rdf", "sparql", "linked data"], 378 }; 379 380 const lower = text.toLowerCase(); 381 return Object.entries(keywords) 382 .filter(([_, terms]) => terms.some(t => lower.includes(t))) 383 .map(([theme]) => theme); 384} 385 386function findHotEdges( 387 islandText: string, 388 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 389 vaultNotes: VaultNote[], 390 carryNotes: CarryNote[], 391 newConnections: NewConnection[], 392): string[] { 393 const highlights: string[] = []; 394 const lower = islandText.toLowerCase(); 395 396 // Score each edge by semantic significance 397 const edgeScores = new Map<string, number>(); 398 399 for (const edge of island.edges) { 400 let score = 0; 401 402 // Check if source or target appears in vault notes 403 for (const note of vaultNotes) { 404 const noteLower = note.content.toLowerCase(); 405 const overlap = keywordOverlap(lower, noteLower); 406 if (overlap.length >= 2) { 407 const sourceTitle = island.vertices.find(v => v.uri === edge.source)?.title?.toLowerCase() || ""; 408 const targetTitle = island.vertices.find(v => v.uri === edge.target)?.title?.toLowerCase() || ""; 409 if (sourceTitle && noteLower.includes(sourceTitle)) score += 2; 410 if (targetTitle && noteLower.includes(targetTitle)) score += 2; 411 } 412 } 413 414 // Check if source or target appears in carry notes 415 for (const note of carryNotes) { 416 const noteLower = note.content.toLowerCase(); 417 const sourceTitle = island.vertices.find(v => v.uri === edge.source)?.title?.toLowerCase() || ""; 418 const targetTitle = island.vertices.find(v => v.uri === edge.target)?.title?.toLowerCase() || ""; 419 if (sourceTitle && noteLower.includes(sourceTitle)) score += 1; 420 if (targetTitle && noteLower.includes(targetTitle)) score += 1; 421 } 422 423 // Edges with notes are more interesting 424 if (edge.note && edge.note.length > 10) score += 1; 425 426 // Edges with semantic connection types are more interesting 427 const semanticTypes = ["supports", "contradicts", "extends", "contextualizes", "exemplifies"]; 428 if (semanticTypes.some(t => edge.connectionType.toLowerCase().includes(t))) score += 2; 429 430 // Boost edges whose source or target is also connected to carry data 431 for (const nc of newConnections) { 432 if (edge.source === nc.source || edge.target === nc.source) score += 3; 433 if (edge.source === nc.target || edge.target === nc.target) score += 3; 434 } 435 436 if (score > 0) { 437 edgeScores.set(edge.uri, score); 438 } 439 } 440 441 // Return top edges by score 442 const sorted = [...edgeScores.entries()].sort((a, b) => b[1] - a[1]); 443 for (const [uri] of sorted.slice(0, 10)) { 444 highlights.push(uri); 445 } 446 447 return highlights; 448} 449 450function findTensionsFromNotes( 451 islandText: string, 452 carryNotes: CarryNote[], 453): string[] { 454 const tensions: string[] = []; 455 const lower = islandText.toLowerCase(); 456 457 for (const note of carryNotes) { 458 const noteLower = note.content.toLowerCase(); 459 if (noteLower.includes("but") || noteLower.includes("however") || noteLower.includes("tension") || noteLower.includes("contradicts")) { 460 const overlap = keywordOverlap(lower, noteLower); 461 if (overlap.length >= 1) { 462 tensions.push( 463 `Carry note "${note.path}" (${note.domain}) notes a tension with this island's themes` 464 ); 465 } 466 } 467 } 468 469 return tensions.slice(0, 5); 470} 471 472function findTensionsFromReasonings( 473 islandText: string, 474 reasonings: CarryReasoning[], 475): string[] { 476 const tensions: string[] = []; 477 const lower = islandText.toLowerCase(); 478 479 for (const r of reasonings) { 480 const rLower = `${r.claim} ${r.evidence}`.toLowerCase(); 481 if (rLower.includes("contradict") || rLower.includes("however") || rLower.includes("but") || rLower.includes("tension")) { 482 const overlap = keywordOverlap(lower, rLower); 483 if (overlap.length >= 1) { 484 tensions.push(r.claim.slice(0, 500)); 485 } 486 } 487 } 488 489 return tensions.slice(0, 5); 490} 491 492function generateOpenQuestions( 493 islandText: string, 494 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 495 highlights: string[], 496 tensions: string[], 497 newConnections: NewConnection[], 498): string[] { 499 const questions: string[] = []; 500 const lower = islandText.toLowerCase(); 501 502 if (newConnections.length > 0) { 503 questions.push( 504 `${newConnections.length} carry touchpoints found. How does your existing knowledge reshape this island?` 505 ); 506 } 507 508 if (tensions.length > 0) { 509 questions.push( 510 `What evidence would resolve the ${tensions.length} tension(s) identified?` 511 ); 512 } 513 514 const vertexCount = island.vertices.length; 515 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 516 517 if (edgeTypes.size > 1) { 518 questions.push( 519 `This island has ${edgeTypes.size} connection types. What's the dominant relationship?` 520 ); 521 } 522 523 if (vertexCount > 5) { 524 questions.push( 525 `This is a ${vertexCount}-vertex cluster. Are there sub-clusters within it?` 526 ); 527 } 528 529 if (lower.includes("data") || lower.includes("dataset")) { 530 questions.push("What data artifacts does this cluster produce, and are they in Zenodo?"); 531 } 532 if (lower.includes("standard") || lower.includes("protocol")) { 533 questions.push("Does this cluster propose a new standard, and who are the adopters?"); 534 } 535 536 return questions.slice(0, 5); 537} 538 539function synthesize( 540 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 541 themes: string[], 542 highlights: string[], 543 tensions: string[], 544 openQuestions: string[], 545 newConnections: NewConnection[], 546): string { 547 const vertexCount = island.vertices.length; 548 const edgeCount = island.edges.length; 549 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 550 551 const clusterDesc = `${vertexCount} vertices, ${edgeCount} edges, ${edgeTypes.size} connection types`; 552 553 const themeStr = themes.length > 0 554 ? `Themes: ${themes.join(", ")}.` 555 : ""; 556 557 const touchpointStr = newConnections.length > 0 558 ? `${newConnections.length} carry touchpoints — new edges connecting this island to your knowledge base.` 559 : "No carry touchpoints found."; 560 561 const highlightStr = highlights.length > 0 562 ? `${highlights.length} existing edges flagged as semantically significant.` 563 : ""; 564 565 const tensionStr = tensions.length > 0 566 ? `There ${tensions.length === 1 ? "is 1 tension" : `are ${tensions.length} tensions`} with your existing claims.` 567 : ""; 568 569 const questionStr = openQuestions.length > 0 570 ? `Open questions: ${openQuestions.join(" ")}` 571 : ""; 572 573 return [`Island (${clusterDesc}).`, themeStr, touchpointStr, highlightStr, tensionStr, questionStr].filter(Boolean).join(" "); 574} 575 576// ─── Utilities ────────────────────────────────────────────────────── 577 578const STOP_WORDS = new Set([ 579 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", 580 "have", "has", "had", "do", "does", "did", "will", "would", "could", 581 "should", "may", "might", "shall", "can", "to", "of", "in", "for", 582 "on", "with", "at", "by", "from", "as", "into", "through", "during", 583 "before", "after", "above", "below", "between", "and", "but", "or", 584 "not", "no", "nor", "so", "if", "then", "that", "this", "it", "its", 585 "http", "https", "www", "com", "org", 586]); 587 588function keywordOverlap(textA: string, textB: string): string[] { 589 const wordsA = new Set( 590 textA.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)) 591 ); 592 const wordsB = textB.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); 593 return wordsB.filter(w => wordsA.has(w)); 594}