This repository has no description
0

Configure Feed

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

stigmergic / container / analysis.ts
14 kB 412 lines
1// ─── Strata Analysis Engine ───────────────────────────────────────── 2// Reads vault (markdown) + carry (JSON) from R2 3// Cross-references source content against local knowledge 4// Returns structured analysis with carry cross-refs 5// 6// This runs inside a Cloudflare Container with R2 access. 7// The container is per-user (each DID gets its own instance). 8 9import { 10 S3Client, 11 GetObjectCommand, 12 ListObjectsV2Command, 13} from "@aws-sdk/client-s3"; 14 15// R2 config — injected via Worker envVars when the container starts 16const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || ""; 17const R2_ACCESS_KEY = process.env.R2_ACCESS_KEY || ""; 18const R2_SECRET_KEY = process.env.R2_SECRET_KEY || ""; 19 20const 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 29const VAULT_BUCKET = "stigmergic-vault"; 30const CARRY_BUCKET = "stigmergic-carry"; 31 32// ─── Types ────────────────────────────────────────────────────────── 33 34interface VaultNote { 35 path: string; 36 content: string; 37} 38 39interface CarryClaim { 40 id: string; 41 body: string; 42 summary: string; 43 sentiment: string; 44 qualifier: string; 45 atUri: string; 46 entityType: string; 47} 48 49interface Connection { 50 description: string; 51 targetUri: string; 52 relation: string; 53 source: "vault" | "carry"; 54 sourcePath?: string; 55 carryId?: string; 56} 57 58interface CarryRef { 59 entityType: "claim" | "citation" | "entity" | "reasoning"; 60 carryId: string; 61 atUri: string; 62 summary: string; 63} 64 65interface AnalysisInput { 66 sourceUri: string; 67 sourceCid?: string; 68 sourceRecord: any; 69 did: string; 70} 71 72interface AnalysisResult { 73 themes: string[]; 74 connections: Connection[]; 75 tensions: string[]; 76 openQuestions: string[]; 77 synthesis: string; 78 carryRefs: CarryRef[]; 79} 80 81// ─── Main analysis function ──────────────────────────────────────── 82 83export async function analyze(input: AnalysisInput): Promise<AnalysisResult> { 84 const { sourceRecord, did } = input; 85 86 // 1. Read vault notes from R2 87 const vaultNotes = await readVault(did); 88 89 // 2. Read carry claims from R2 90 const carryClaims = await readCarry(did); 91 92 // 3. Extract themes from source record 93 const sourceText = extractText(sourceRecord); 94 const themes = extractThemes(sourceText); 95 96 // 4. Find connections in vault + carry 97 const connections = findConnections(sourceText, vaultNotes, carryClaims); 98 99 // 5. Find tensions (contradictions, gaps) 100 const tensions = findTensions(sourceText, carryClaims); 101 102 // 6. Generate open questions 103 const openQuestions = generateOpenQuestions(sourceText, connections, tensions); 104 105 // 7. Synthesize prose 106 const synthesis = synthesize( 107 sourceText, 108 themes, 109 connections, 110 tensions, 111 openQuestions 112 ); 113 114 // 8. Build carry cross-refs 115 const carryRefs = buildCarryRefs(connections, carryClaims); 116 117 return { 118 themes, 119 connections, 120 tensions, 121 openQuestions, 122 synthesis, 123 carryRefs, 124 }; 125} 126 127// ─── R2 readers ───────────────────────────────────────────────────── 128 129async function readVault(did: string): Promise<VaultNote[]> { 130 const prefix = `${did}/`; 131 try { 132 const list = await s3.send( 133 new ListObjectsV2Command({ Bucket: VAULT_BUCKET, Prefix: prefix }) 134 ); 135 136 const notes: VaultNote[] = []; 137 for (const obj of list.Contents || []) { 138 if (!obj.Key?.endsWith(".md")) continue; 139 try { 140 const get = await s3.send( 141 new GetObjectCommand({ Bucket: VAULT_BUCKET, Key: obj.Key }) 142 ); 143 const body = await get.Body?.transformToString(); 144 if (body) { 145 notes.push({ 146 path: obj.Key.replace(prefix, ""), 147 content: body, 148 }); 149 } 150 } catch (e) { 151 console.error(`Failed to read vault file ${obj.Key}:`, e); 152 } 153 } 154 return notes; 155 } catch (e) { 156 console.error("Failed to list vault objects:", e); 157 return []; 158 } 159} 160 161async function readCarry(did: string): Promise<CarryClaim[]> { 162 const prefix = `${did}/`; 163 try { 164 const list = await s3.send( 165 new ListObjectsV2Command({ Bucket: CARRY_BUCKET, Prefix: prefix }) 166 ); 167 168 const claims: CarryClaim[] = []; 169 for (const obj of list.Contents || []) { 170 if (!obj.Key?.endsWith(".json")) continue; 171 try { 172 const get = await s3.send( 173 new GetObjectCommand({ Bucket: CARRY_BUCKET, Key: obj.Key }) 174 ); 175 const body = await get.Body?.transformToString(); 176 if (body) { 177 try { 178 const data = JSON.parse(body); 179 if (Array.isArray(data)) { 180 claims.push(...data.map(normalizeClaim)); 181 } else { 182 claims.push(normalizeClaim(data)); 183 } 184 } catch { 185 // Skip malformed JSON 186 } 187 } 188 } catch (e) { 189 console.error(`Failed to read carry file ${obj.Key}:`, e); 190 } 191 } 192 return claims; 193 } catch (e) { 194 console.error("Failed to list carry objects:", e); 195 return []; 196 } 197} 198 199// ─── Analysis functions ───────────────────────────────────────────── 200 201function extractText(record: any): string { 202 // Extract text from various Semble record shapes 203 const parts: string[] = []; 204 if (record.title) parts.push(record.title); 205 if (record.text) parts.push(record.text); 206 if (record.body) parts.push(record.body); 207 if (record.content) parts.push(record.content); 208 if (record.description) parts.push(record.description); 209 if (record.embed?.title) parts.push(record.embed.title); 210 if (record.embed?.description) parts.push(record.embed.description); 211 // Semble card shape 212 if (record.type === "URL" && record.content?.url) parts.push(record.content.url); 213 if (record.content?.metadata?.title) parts.push(record.content.metadata.title); 214 if (record.content?.metadata?.description) parts.push(record.content.metadata.description); 215 if (record.type === "NOTE" && record.content?.text) parts.push(record.content.text); 216 return parts.join(" "); 217} 218 219function extractThemes(text: string): string[] { 220 // Keyword-based theme extraction. In production, call an LLM. 221 const keywords: Record<string, string[]> = { 222 "open science": ["open science", "open access", "oa", "preprint"], 223 "data sharing": ["data sharing", "shared data", "open data", "dataset"], 224 "reproducibility": ["reproducib", "replicab", "replication"], 225 "atproto": ["atproto", "at protocol", "bluesky", "bsky", "semble"], 226 "decentralization": ["decentraliz", "federat", "self-host"], 227 "knowledge graphs": ["knowledge graph", "ontolog", "linked data", "semantic"], 228 "ai/ml": ["machine learning", "deep learning", "neural", "llm", "gpt", "claude"], 229 "community": ["community", "collective", "cooperative", "commons"], 230 "governance": ["governance", "policy", "regulation", "standards"], 231 "infrastructure": ["infrastructure", "platform", "tooling", "pipeline"], 232 "stigmergy": ["stigmerg", "pheromone", "emergent", "self-organiz"], 233 "provenance": ["provenance", "lineage", "traceability", "attribution"], 234 "doi": ["doi", "zenodo", "citeable", "citation"], 235 "privacy": ["privacy", "encryption", "e2ee", "consent"], 236 "sovereignty": ["sovereignty", "self-determin", "autonomy", "agency"], 237 }; 238 239 const lower = text.toLowerCase(); 240 return Object.entries(keywords) 241 .filter(([_, terms]) => terms.some(t => lower.includes(t))) 242 .map(([theme]) => theme); 243} 244 245function findConnections( 246 sourceText: string, 247 vaultNotes: VaultNote[], 248 carryClaims: CarryClaim[] 249): Connection[] { 250 const connections: Connection[] = []; 251 const lower = sourceText.toLowerCase(); 252 253 // Check vault notes for overlapping themes 254 for (const note of vaultNotes) { 255 const noteLower = note.content.toLowerCase(); 256 const overlap = keywordOverlap(lower, noteLower); 257 if (overlap.length >= 2) { 258 connections.push({ 259 description: `Vault note "${note.path}" shares themes: ${overlap.join(", ")}`, 260 targetUri: "", 261 relation: "contextualizes", 262 source: "vault", 263 sourcePath: note.path, 264 }); 265 } 266 } 267 268 // Check carry claims for related claims 269 for (const claim of carryClaims) { 270 const claimText = (claim.body || claim.summary || "").toLowerCase(); 271 const overlap = keywordOverlap(lower, claimText); 272 if (overlap.length >= 1) { 273 connections.push({ 274 description: `Carry claim: "${claim.summary || claim.body?.slice(0, 100)}"`, 275 targetUri: claim.atUri || "", 276 relation: claim.sentiment === "negative" ? "contradicts" : "supports", 277 source: "carry", 278 carryId: claim.id, 279 }); 280 } 281 } 282 283 return connections.slice(0, 10); 284} 285 286function findTensions( 287 sourceText: string, 288 carryClaims: CarryClaim[] 289): string[] { 290 const tensions: string[] = []; 291 const lower = sourceText.toLowerCase(); 292 293 for (const claim of carryClaims) { 294 if (claim.sentiment === "negative" || claim.qualifier === "but" || claim.qualifier === "however") { 295 const claimText = (claim.body || claim.summary || "").toLowerCase(); 296 const overlap = keywordOverlap(lower, claimText); 297 if (overlap.length >= 1) { 298 tensions.push( 299 `Your carry data notes a tension: "${claim.summary || claim.body?.slice(0, 150)}"` 300 ); 301 } 302 } 303 } 304 305 return tensions.slice(0, 5); 306} 307 308function generateOpenQuestions( 309 sourceText: string, 310 connections: Connection[], 311 tensions: string[] 312): string[] { 313 const questions: string[] = []; 314 315 if (connections.length > 0) { 316 const carryCount = connections.filter(c => c.source === "carry").length; 317 questions.push( 318 `How does this source extend or challenge your existing ${carryCount} carry claims?` 319 ); 320 } 321 322 if (tensions.length > 0) { 323 questions.push( 324 `What evidence would resolve the ${tensions.length} tension(s) identified?` 325 ); 326 } 327 328 const lower = sourceText.toLowerCase(); 329 if (lower.includes("data") || lower.includes("dataset")) { 330 questions.push("What data artifacts does this source produce, and are they in Zenodo?"); 331 } 332 if (lower.includes("standard") || lower.includes("protocol")) { 333 questions.push("Does this propose a new standard, and who are the adopters?"); 334 } 335 if (lower.includes("community") || lower.includes("collective")) { 336 questions.push("What governance model does this community use?"); 337 } 338 339 return questions.slice(0, 5); 340} 341 342function synthesize( 343 sourceText: string, 344 themes: string[], 345 connections: Connection[], 346 tensions: string[], 347 openQuestions: string[] 348): string { 349 // Template-based synthesis. In production, call an LLM. 350 const themeStr = themes.length > 0 351 ? `This source touches on ${themes.join(", ")}.` 352 : "This source covers a specific topic."; 353 354 const connStr = connections.length > 0 355 ? `It connects to ${connections.length} items in your knowledge base: ${connections.slice(0, 3).map(c => c.description).join("; ")}.` 356 : "No direct connections found in your existing knowledge."; 357 358 const tensionStr = tensions.length > 0 359 ? `There ${tensions.length === 1 ? "is 1 tension" : `are ${tensions.length} tensions`} with your existing claims.` 360 : ""; 361 362 const questionStr = openQuestions.length > 0 363 ? `Open questions: ${openQuestions.join(" ")}` 364 : ""; 365 366 return [themeStr, connStr, tensionStr, questionStr].filter(Boolean).join(" "); 367} 368 369function buildCarryRefs( 370 connections: Connection[], 371 _carryClaims: CarryClaim[] 372): CarryRef[] { 373 return connections 374 .filter(c => c.source === "carry") 375 .map(c => ({ 376 entityType: "claim" as const, 377 carryId: c.carryId || "", 378 atUri: c.targetUri || "", 379 summary: c.description, 380 })); 381} 382 383// ─── Utilities ────────────────────────────────────────────────────── 384 385const STOP_WORDS = new Set([ 386 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", 387 "have", "has", "had", "do", "does", "did", "will", "would", "could", 388 "should", "may", "might", "shall", "can", "to", "of", "in", "for", 389 "on", "with", "at", "by", "from", "as", "into", "through", "during", 390 "before", "after", "above", "below", "between", "and", "but", "or", 391 "not", "no", "nor", "so", "if", "then", "that", "this", "it", "its", 392]); 393 394function keywordOverlap(textA: string, textB: string): string[] { 395 const wordsA = new Set( 396 textA.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)) 397 ); 398 const wordsB = textB.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); 399 return wordsB.filter(w => wordsA.has(w)); 400} 401 402function normalizeClaim(data: any): CarryClaim { 403 return { 404 id: data.id || data.claimId || crypto.randomUUID(), 405 body: data.body || data.text || data.claim || "", 406 summary: data.summary || data.title || "", 407 sentiment: data.sentiment || "neutral", 408 qualifier: data.qualifier || "", 409 atUri: data.atUri || "", 410 entityType: data.entityType || "claim", 411 }; 412}