This repository has no description
0

Configure Feed

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

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