/** * rdf:ingest * * Ingest the SciOS Resilient Data Futures discourse graph from * https://github.com/jring-o/rdf into network.cosmik.connection records * on the PDS. * * Each node becomes a vertex at https://rdf.scios.tech/node/{ID} * Each edge from frontmatter becomes a network.cosmik.connection record. * * Edge type mapping: * addresses → ADDRESSES (Claim → Question) * supports → SUPPORTS (Evidence→Claim, Claim→Claim) * opposes → OPPOSES (Claim→Claim, Evidence→Claim) * derivedFrom → DERIVED_FROM (Evidence → Source) * informs → INFORMS (Method → Claim) * usesMethod → USES_METHOD (Claim → Method) * * Usage: * bun rdf:ingest # dry run (prints plan) * bun rdf:ingest --write # write to PDS * bun rdf:ingest --write --json # write + JSON output */ import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { AtpAgent } from '@atproto/api'; import { loadDotEnv } from './cli-utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); await loadDotEnv(resolve(__dirname, '..', '.env')); await loadDotEnv('/home/nandi/code/swarm/.env'); const RDF_BASE = 'https://rdf.scios.tech/node/'; const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? process.env.BLUESKY_IDENTIFIER ?? ''; const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? process.env.BLUESKY_APP_PASSWORD ?? ''; const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; const RDF_REPO = process.env.RDF_REPO ?? '/home/nandi/code/rdf'; const PROVENANCE_PATH = process.env.RDF_INGEST_PROVENANCE ?? resolve(RDF_REPO, '.rdf-ingest.json'); // --- Types --- interface NodeData { id: string; type: string; title: string; status: string; sourceSection: string; created: string; edges: Record; body: string; url: string; } interface EdgeToWrite { source: string; // https://rdf.scios.tech/node/{from-id} target: string; // https://rdf.scios.tech/node/{to-id} connectionType: string; note: string; fromId: string; toId: string; edgeType: string; // original RDF edge type } const EDGE_TYPE_MAP: Record = { addresses: 'ADDRESSES', supports: 'SUPPORTS', opposes: 'OPPOSES', derivedFrom: 'DERIVED_FROM', informs: 'INFORMS', usesMethod: 'USES_METHOD', }; const TYPE_DIRS: Record = { claim: 'claims', evidence: 'evidence', question: 'questions', source: 'sources', method: 'methods', }; // --- Parse --- function parseFrontmatter(content: string): { fm: Record; body: string } { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { fm: {}, body: content }; const fmText = match[1]; const body = match[2].trim(); // Two-pass YAML parser: // Pass 1: extract top-level keys and nested dicts (like edges:) // Pass 2: for each nested dict, parse sub-keys with inline arrays const fm: Record = {}; const lines = fmText.split('\n'); let i = 0; while (i < lines.length) { const line = lines[i]; // Top-level key: value const kvMatch = line.match(/^(\w+):\s*(.*)$/); if (kvMatch) { const key = kvMatch[1]; const val = kvMatch[2].trim(); if (val === '') { // Could be a nested dict or empty — check next lines for indentation const subDict: Record = {}; let j = i + 1; while (j < lines.length && lines[j].match(/^\s{2,}/)) { const subMatch = lines[j].match(/^\s+(\w+):\s*(.*)$/); if (subMatch) { const subKey = subMatch[1]; const subVal = subMatch[2].trim(); if (subVal.startsWith('[') && subVal.endsWith(']')) { subDict[subKey] = subVal.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); } else if (subVal === '' || subVal === '[]') { subDict[subKey] = []; } else { subDict[subKey] = [subVal.replace(/^['"]|['"]$/g, '')]; } } j++; } if (Object.keys(subDict).length > 0) { fm[key] = subDict; i = j; continue; } // Empty dict fm[key] = {}; i++; continue; } if (val.startsWith('[') && val.endsWith(']')) { fm[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); } else if (val === '[]') { fm[key] = []; } else { fm[key] = val.replace(/^['"]|['"]$/g, ''); } } i++; } return { fm, body }; } function loadNodes(): NodeData[] { const nodes: NodeData[] = []; for (const [type, dir] of Object.entries(TYPE_DIRS)) { const dirPath = join(RDF_REPO, 'graph', dir); let files: string[]; try { files = readdirSync(dirPath).filter(f => f.endsWith('.md')).sort(); } catch { console.error(` Skipping ${dirPath}: not found`); continue; } for (const file of files) { const content = readFileSync(join(dirPath, file), 'utf-8'); const { fm, body } = parseFrontmatter(content); const id = (fm.id as string) ?? file.replace('.md', ''); const edges: Record = {}; // fm.edges is the nested dict from YAML: { addresses: [Q-0001], supports: [...], ... } const fmEdges = fm.edges as Record | undefined; if (fmEdges && typeof fmEdges === 'object') { for (const [key, val] of Object.entries(fmEdges)) { if (EDGE_TYPE_MAP[key]) { edges[key] = Array.isArray(val) ? val : [val as string]; } } } // Also check top-level edge keys (flat frontmatter style) for (const [key, val] of Object.entries(fm)) { if (key === 'edges') continue; if (EDGE_TYPE_MAP[key] && !edges[key]) { edges[key] = Array.isArray(val) ? val : [val as string]; } } nodes.push({ id, type: (fm.type as string) ?? type, title: (fm.title as string) ?? id, status: (fm.status as string) ?? 'draft', sourceSection: (fm.source_section as string) ?? '', created: (fm.created as string) ?? '', edges, body, url: `${RDF_BASE}${id}`, }); } } return nodes; } function extractEdges(nodes: NodeData[]): EdgeToWrite[] { const nodeMap = new Map(nodes.map(n => [n.id, n])); const edges: EdgeToWrite[] = []; const seen = new Set(); for (const node of nodes) { for (const [edgeType, targets] of Object.entries(node.edges)) { const connectionType = EDGE_TYPE_MAP[edgeType]; if (!connectionType) continue; for (const toId of targets) { const key = `${node.id}:${edgeType}:${toId}`; if (seen.has(key)) continue; seen.add(key); if (!nodeMap.has(toId)) { console.error(` Warning: ${node.id} references missing node ${toId}`); } edges.push({ source: node.url, target: `${RDF_BASE}${toId}`, connectionType, note: `${node.id} --${edgeType}--> ${toId}`, fromId: node.id, toId, edgeType, }); } } } return edges; } // --- PDS writes --- async function writeEdges(edges: EdgeToWrite[]): Promise<{ wrote: number; uris: string[] }> { if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { throw new Error('PDS_IDENTIFIER and PDS_APP_PASSWORD must be set'); } const agent = new AtpAgent({ service: PDS_SERVICE }); await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); const did = agent.session!.did; let wrote = 0; const uris: string[] = []; // Batch in groups of 10 to avoid rate limits for (let i = 0; i < edges.length; i += 10) { const batch = edges.slice(i, i + 10); const results = await Promise.allSettled( batch.map(async (edge) => { const record = { $type: 'network.cosmik.connection', source: edge.source, target: edge.target, connectionType: edge.connectionType, note: edge.note, createdAt: new Date().toISOString(), }; // Use createRecord to get auto-generated TID rkeys (putRecord with custom rkeys fails validation) const res = await agent.api.com.atproto.repo.createRecord({ repo: did, collection: 'network.cosmik.connection', record, }); return res.data.uri; }), ); for (const result of results) { if (result.status === 'fulfilled') { wrote++; uris.push(result.value); } else { console.error(` Failed: ${result.reason}`); } } // Small delay between batches if (i + 10 < edges.length) { await new Promise(r => setTimeout(r, 500)); } } return { wrote, uris }; } // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const wantWrite = args.includes('--write'); const wantJson = args.includes('--json'); console.error('Loading nodes from RDF repo...'); const nodes = loadNodes(); const typeCounts: Record = {}; for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1; console.error(` ${nodes.length} nodes: ${Object.entries(typeCounts).map(([t, c]) => `${t}: ${c}`).join(', ')}`); console.error('Extracting edges...'); const edges = extractEdges(nodes); const edgeTypeCounts: Record = {}; for (const e of edges) edgeTypeCounts[e.edgeType] = (edgeTypeCounts[e.edgeType] ?? 0) + 1; console.error(` ${edges.length} edges: ${Object.entries(edgeTypeCounts).map(([t, c]) => `${t}: ${c}`).join(', ')}`); if (wantWrite) { console.error(`Writing ${edges.length} edges to PDS...`); const { wrote, uris } = await writeEdges(edges); console.error(` Wrote ${wrote}/${edges.length} edges`); saveJson(PROVENANCE_PATH, { ingestedAt: new Date().toISOString(), nodes: nodes.length, edges: edges.length, wrote, uris: uris.slice(0, 100), repo: 'https://github.com/jring-o/rdf', }); } if (wantJson) { console.log(JSON.stringify({ nodes, edges, wrote: wantWrite ? edges.length : 0 }, null, 2)); return; } // Summary output console.log(`\nRDF Discourse Graph: ${nodes.length} nodes, ${edges.length} edges`); if (!wantWrite) { console.log('(dry run — use --write to publish to PDS)'); } // Sample edges console.log('\nEdge sample:'); for (const e of edges.slice(0, 20)) { console.log(` ${e.fromId} --${e.edgeType}--> ${e.toId} [${e.connectionType}]`); } if (edges.length > 20) { console.log(` ... and ${edges.length - 20} more`); } // Node sample console.log('\nNode sample:'); for (const n of nodes.slice(0, 10)) { const edgeCount = Object.values(n.edges).flat().length; console.log(` ${n.id} [${n.type}] "${n.title.slice(0, 60)}" (${edgeCount} edges)`); } } function saveJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf-8'); } main().catch((err) => { console.error('Fatal:', err instanceof Error ? err.message : String(err)); process.exit(1); });