// ─── Strata Analysis Engine ───────────────────────────────────────── // Reads vault (markdown) + carry (JSON) from R2 // Cross-references source content against local knowledge // Returns structured analysis with carry cross-refs // // This runs inside a Cloudflare Container with R2 access. // The container is per-user (each DID gets its own instance). import { S3Client, GetObjectCommand, ListObjectsV2Command, } from "@aws-sdk/client-s3"; // R2 config — injected via Worker envVars when the container starts const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID || ""; const R2_ACCESS_KEY = process.env.R2_ACCESS_KEY || ""; const R2_SECRET_KEY = process.env.R2_SECRET_KEY || ""; const s3 = new S3Client({ region: "auto", endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, credentials: { accessKeyId: R2_ACCESS_KEY, secretAccessKey: R2_SECRET_KEY, }, }); const VAULT_BUCKET = "stigmergic-vault"; const CARRY_BUCKET = "stigmergic-carry"; // ─── Types ────────────────────────────────────────────────────────── interface VaultNote { path: string; content: string; } interface CarryClaim { id: string; body: string; summary: string; sentiment: string; qualifier: string; atUri: string; entityType: string; } interface Connection { description: string; targetUri: string; relation: string; source: "vault" | "carry"; sourcePath?: string; carryId?: string; } interface CarryRef { entityType: "claim" | "citation" | "entity" | "reasoning"; carryId: string; atUri: string; summary: string; } interface AnalysisInput { sourceUri: string; sourceCid?: string; sourceRecord: any; did: string; } interface AnalysisResult { themes: string[]; connections: Connection[]; tensions: string[]; openQuestions: string[]; synthesis: string; carryRefs: CarryRef[]; } // ─── Main analysis function ──────────────────────────────────────── export async function analyze(input: AnalysisInput): Promise { const { sourceRecord, did } = input; // 1. Read vault notes from R2 const vaultNotes = await readVault(did); // 2. Read carry claims from R2 const carryClaims = await readCarry(did); // 3. Extract themes from source record const sourceText = extractText(sourceRecord); const themes = extractThemes(sourceText); // 4. Find connections in vault + carry const connections = findConnections(sourceText, vaultNotes, carryClaims); // 5. Find tensions (contradictions, gaps) const tensions = findTensions(sourceText, carryClaims); // 6. Generate open questions const openQuestions = generateOpenQuestions(sourceText, connections, tensions); // 7. Synthesize prose const synthesis = synthesize( sourceText, themes, connections, tensions, openQuestions ); // 8. Build carry cross-refs const carryRefs = buildCarryRefs(connections, carryClaims); return { themes, connections, tensions, openQuestions, synthesis, carryRefs, }; } // ─── R2 readers ───────────────────────────────────────────────────── async function readVault(did: string): Promise { const prefix = `${did}/`; try { const list = await s3.send( new ListObjectsV2Command({ Bucket: VAULT_BUCKET, Prefix: prefix }) ); const notes: VaultNote[] = []; for (const obj of list.Contents || []) { if (!obj.Key?.endsWith(".md")) continue; try { const get = await s3.send( new GetObjectCommand({ Bucket: VAULT_BUCKET, Key: obj.Key }) ); const body = await get.Body?.transformToString(); if (body) { notes.push({ path: obj.Key.replace(prefix, ""), content: body, }); } } catch (e) { console.error(`Failed to read vault file ${obj.Key}:`, e); } } return notes; } catch (e) { console.error("Failed to list vault objects:", e); return []; } } async function readCarry(did: string): Promise { const prefix = `${did}/`; try { const list = await s3.send( new ListObjectsV2Command({ Bucket: CARRY_BUCKET, Prefix: prefix }) ); const claims: CarryClaim[] = []; for (const obj of list.Contents || []) { if (!obj.Key?.endsWith(".json")) continue; try { const get = await s3.send( new GetObjectCommand({ Bucket: CARRY_BUCKET, Key: obj.Key }) ); const body = await get.Body?.transformToString(); if (body) { try { const data = JSON.parse(body); if (Array.isArray(data)) { claims.push(...data.map(normalizeClaim)); } else { claims.push(normalizeClaim(data)); } } catch { // Skip malformed JSON } } } catch (e) { console.error(`Failed to read carry file ${obj.Key}:`, e); } } return claims; } catch (e) { console.error("Failed to list carry objects:", e); return []; } } // ─── Analysis functions ───────────────────────────────────────────── function extractText(record: any): string { // Extract text from various Semble record shapes const parts: string[] = []; if (record.title) parts.push(record.title); if (record.text) parts.push(record.text); if (record.body) parts.push(record.body); if (record.content) parts.push(record.content); if (record.description) parts.push(record.description); if (record.embed?.title) parts.push(record.embed.title); if (record.embed?.description) parts.push(record.embed.description); // Semble card shape if (record.type === "URL" && record.content?.url) parts.push(record.content.url); if (record.content?.metadata?.title) parts.push(record.content.metadata.title); if (record.content?.metadata?.description) parts.push(record.content.metadata.description); if (record.type === "NOTE" && record.content?.text) parts.push(record.content.text); return parts.join(" "); } function extractThemes(text: string): string[] { // Keyword-based theme extraction. In production, call an LLM. const keywords: Record = { "open science": ["open science", "open access", "oa", "preprint"], "data sharing": ["data sharing", "shared data", "open data", "dataset"], "reproducibility": ["reproducib", "replicab", "replication"], "atproto": ["atproto", "at protocol", "bluesky", "bsky", "semble"], "decentralization": ["decentraliz", "federat", "self-host"], "knowledge graphs": ["knowledge graph", "ontolog", "linked data", "semantic"], "ai/ml": ["machine learning", "deep learning", "neural", "llm", "gpt", "claude"], "community": ["community", "collective", "cooperative", "commons"], "governance": ["governance", "policy", "regulation", "standards"], "infrastructure": ["infrastructure", "platform", "tooling", "pipeline"], "stigmergy": ["stigmerg", "pheromone", "emergent", "self-organiz"], "provenance": ["provenance", "lineage", "traceability", "attribution"], "doi": ["doi", "zenodo", "citeable", "citation"], "privacy": ["privacy", "encryption", "e2ee", "consent"], "sovereignty": ["sovereignty", "self-determin", "autonomy", "agency"], }; const lower = text.toLowerCase(); return Object.entries(keywords) .filter(([_, terms]) => terms.some(t => lower.includes(t))) .map(([theme]) => theme); } function findConnections( sourceText: string, vaultNotes: VaultNote[], carryClaims: CarryClaim[] ): Connection[] { const connections: Connection[] = []; const lower = sourceText.toLowerCase(); // Check vault notes for overlapping themes for (const note of vaultNotes) { const noteLower = note.content.toLowerCase(); const overlap = keywordOverlap(lower, noteLower); if (overlap.length >= 2) { connections.push({ description: `Vault note "${note.path}" shares themes: ${overlap.join(", ")}`, targetUri: "", relation: "contextualizes", source: "vault", sourcePath: note.path, }); } } // Check carry claims for related claims for (const claim of carryClaims) { const claimText = (claim.body || claim.summary || "").toLowerCase(); const overlap = keywordOverlap(lower, claimText); if (overlap.length >= 1) { connections.push({ description: `Carry claim: "${claim.summary || claim.body?.slice(0, 100)}"`, targetUri: claim.atUri || "", relation: claim.sentiment === "negative" ? "contradicts" : "supports", source: "carry", carryId: claim.id, }); } } return connections.slice(0, 10); } function findTensions( sourceText: string, carryClaims: CarryClaim[] ): string[] { const tensions: string[] = []; const lower = sourceText.toLowerCase(); for (const claim of carryClaims) { if (claim.sentiment === "negative" || claim.qualifier === "but" || claim.qualifier === "however") { const claimText = (claim.body || claim.summary || "").toLowerCase(); const overlap = keywordOverlap(lower, claimText); if (overlap.length >= 1) { tensions.push( `Your carry data notes a tension: "${claim.summary || claim.body?.slice(0, 150)}"` ); } } } return tensions.slice(0, 5); } function generateOpenQuestions( sourceText: string, connections: Connection[], tensions: string[] ): string[] { const questions: string[] = []; if (connections.length > 0) { const carryCount = connections.filter(c => c.source === "carry").length; questions.push( `How does this source extend or challenge your existing ${carryCount} carry claims?` ); } if (tensions.length > 0) { questions.push( `What evidence would resolve the ${tensions.length} tension(s) identified?` ); } const lower = sourceText.toLowerCase(); if (lower.includes("data") || lower.includes("dataset")) { questions.push("What data artifacts does this source produce, and are they in Zenodo?"); } if (lower.includes("standard") || lower.includes("protocol")) { questions.push("Does this propose a new standard, and who are the adopters?"); } if (lower.includes("community") || lower.includes("collective")) { questions.push("What governance model does this community use?"); } return questions.slice(0, 5); } function synthesize( sourceText: string, themes: string[], connections: Connection[], tensions: string[], openQuestions: string[] ): string { // Template-based synthesis. In production, call an LLM. const themeStr = themes.length > 0 ? `This source touches on ${themes.join(", ")}.` : "This source covers a specific topic."; const connStr = connections.length > 0 ? `It connects to ${connections.length} items in your knowledge base: ${connections.slice(0, 3).map(c => c.description).join("; ")}.` : "No direct connections found in your existing knowledge."; const tensionStr = tensions.length > 0 ? `There ${tensions.length === 1 ? "is 1 tension" : `are ${tensions.length} tensions`} with your existing claims.` : ""; const questionStr = openQuestions.length > 0 ? `Open questions: ${openQuestions.join(" ")}` : ""; return [themeStr, connStr, tensionStr, questionStr].filter(Boolean).join(" "); } function buildCarryRefs( connections: Connection[], _carryClaims: CarryClaim[] ): CarryRef[] { return connections .filter(c => c.source === "carry") .map(c => ({ entityType: "claim" as const, carryId: c.carryId || "", atUri: c.targetUri || "", summary: c.description, })); } // ─── Utilities ────────────────────────────────────────────────────── const STOP_WORDS = new Set([ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "to", "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through", "during", "before", "after", "above", "below", "between", "and", "but", "or", "not", "no", "nor", "so", "if", "then", "that", "this", "it", "its", ]); function keywordOverlap(textA: string, textB: string): string[] { const wordsA = new Set( textA.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)) ); const wordsB = textB.split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); return wordsB.filter(w => wordsA.has(w)); } function normalizeClaim(data: any): CarryClaim { return { id: data.id || data.claimId || crypto.randomUUID(), body: data.body || data.text || data.claim || "", summary: data.summary || data.title || "", sentiment: data.sentiment || "neutral", qualifier: data.qualifier || "", atUri: data.atUri || "", entityType: data.entityType || "claim", }; }