/** * island:publish * * Publish a discovered connected component as an org.latha.island record * on the user's PDS. Reads the SQLite cache from island:discover. * * The canonical island ID is the first 12 hex chars of the SHA-256 of the * centroid vertex (highest closeness centrality in the component). This is * more stable than lexmin — adding a peripheral node doesn't shift the centroid. * * Usage: * bun island:publish # list islands with their IDs * bun island:publish a1b2c3d4e5f6 # publish by canonical ID * bun island:publish a1b2c3d4e5f6 --dry-run # preview without writing * bun island:publish a1b2c3d4e5f6 --json # JSON output */ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { AtpAgent } from '@atproto/api'; import { loadDotEnv } from './cli-utils.js'; import { openDb, findComponents, fetchComponentEdges, domainFromUrl, type Component, type EdgeRow } from './island-shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); await loadDotEnv(resolve(__dirname, '..', '.env')); await loadDotEnv('/home/nandi/code/swarm/.env'); // fallback for shared credentials const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? BLUESKY_HANDLE; const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? BLUESKY_APP_PASSWORD; const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; // --- Build island record --- interface AnalysisRow { island_id: string; title: string | null; themes: string | null; highlights: string | null; tensions: string | null; open_questions: string | null; synthesis: string | null; new_connections: string | null; analyzed_at: string; } function loadAnalysis(db: ReturnType, islandId: string): AnalysisRow | null { try { return db.query('SELECT * FROM analyses WHERE island_id = ?').get(islandId) as AnalysisRow | null; } catch { return null; } } function buildIslandRecord(component: Component, edges: EdgeRow[], analysis: AnalysisRow | null) { const centroid = component.centroid; const domain = domainFromUrl(centroid); const nodeCount = component.nodes.size; const edgeCount = edges.length; const contributorCount = component.dids.size; const connections = edges .filter((e) => e.atUri) .map((e) => ({ uri: e.atUri })); // New connections from analysis don't have AT URIs — skip them. // They belong in the analysis synthesis text, not as edge references. // Use analysis data if available, otherwise fall back to generic const title = analysis?.title ?? `Island: ${domain} (${nodeCount} nodes, ${edgeCount} edges)`; const themes = analysis?.themes ? JSON.parse(analysis.themes) : ['discovered-island']; const synthesis = analysis?.synthesis ?? [ `Discovered island centered on ${domain}.`, `${nodeCount} nodes connected by ${edgeCount} edges, contributed by ${contributorCount} DIDs.`, `Centroid vertex: ${centroid}`, ].join('\n\n'); const record: Record = { $type: 'org.latha.island', source: { uri: centroid, collection: 'network.cosmik.connection', }, connections, analysis: { title, themes, synthesis, }, createdAt: new Date().toISOString(), }; // Add analysis fields if present if (analysis) { const a = record.analysis as Record; if (analysis.highlights) a.highlights = JSON.parse(analysis.highlights); if (analysis.tensions) a.tensions = JSON.parse(analysis.tensions); if (analysis.open_questions) a.openQuestions = JSON.parse(analysis.open_questions); } return record; } // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const wantJson = args.includes('--json'); const dryRun = args.includes('--dry-run'); const targetId = args.find((a) => !a.startsWith('--')); // Open DB let db; try { db = openDb(); const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; if (count === 0) { console.error('No edges found. Run `bun island:discover` first.'); process.exit(1); } } catch { console.error('Database not found. Run `bun island:discover` first.'); process.exit(1); } console.error('Finding components...'); const components = findComponents(db); // No positional arg: list all islands if (!targetId) { if (wantJson) { console.log(JSON.stringify(components.map((c) => ({ id: c.islandId, lexmin: c.lexmin, centroid: c.centroid, nodeCount: c.nodes.size, didCount: c.dids.size, })), null, 2)); } else { console.log(`\n${components.length} islands:\n`); for (const c of components.slice(0, 50)) { console.log(` ${c.islandId} ${c.nodes.size} nodes ${domainFromUrl(c.centroid)}`); } if (components.length > 50) console.log(` ... and ${components.length - 50} more`); console.log(`\nUsage: bun island:publish [--dry-run] [--json]`); } db.close(); return; } const component = components.find((c) => c.islandId === targetId); if (!component) { console.error(`Island ${targetId} not found. Run without args to list available islands.`); process.exit(1); } console.error(`Island ${component.islandId}: ${component.nodes.size} nodes, ${component.dids.size} DIDs (centroid: ${component.centroid})`); const edges = fetchComponentEdges(db, component); const analysis = loadAnalysis(db, component.islandId); const record = buildIslandRecord(component, edges, analysis); if (dryRun) { console.error('[dry-run] Would publish:'); if (wantJson) { console.log(JSON.stringify({ islandId: component.islandId, record }, null, 2)); } else { const a = record.analysis as Record; console.log(`Title: ${a.title}`); console.log(`Source: ${(record.source as Record).uri}`); console.log(`Connections: ${(record.connections as unknown[]).length}`); console.log(`Themes: ${(a.themes as string[]).join(', ')}`); console.log(`\nSynthesis:\n${a.synthesis}`); if (analysis) { if (analysis.highlights) console.log(`Highlights: ${JSON.parse(analysis.highlights).length}`); if (analysis.tensions) console.log(`Tensions: ${JSON.parse(analysis.tensions).length}`); if (analysis.open_questions) console.log(`Open questions: ${JSON.parse(analysis.open_questions).length}`); } console.log(`\nConnections:`); for (const c of record.connections as Array>) { console.log(` ${c.uri}`); } } db.close(); return; } // Publish to PDS if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { console.error('PDS_IDENTIFIER and PDS_APP_PASSWORD (or BLUESKY_IDENTIFIER/BLUESKY_APP_PASSWORD) must be set'); process.exit(1); } const APPVIEW = process.env.STIGMERGIC_APPVIEW ?? 'https://stigmergic.latha.org'; const agent = new AtpAgent({ service: PDS_SERVICE }); await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); const result = await agent.api.com.atproto.repo.putRecord({ repo: agent.session!.did, collection: 'org.latha.island', rkey: component.islandId, record, }); const atUri = result.data.uri; console.error(`Published: ${atUri}`); // Notify appview to crawl the new record try { const did = agent.session!.did; const notifyResp = await fetch(`${APPVIEW}/xrpc/org.latha.island.notifyOfUpdate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did }), }); if (notifyResp.ok) { const notifyData = await notifyResp.json() as { ingested?: number }; console.error(`Appview ingested: ${notifyData.ingested ?? 0} record(s)`); } else { console.error(`Appview notify failed: ${notifyResp.status} (non-fatal, cron will catch up)`); } } catch (e: any) { console.error(`Appview notify error: ${e.message} (non-fatal, cron will catch up)`); } if (wantJson) { console.log(JSON.stringify({ islandId: component.islandId, atUri, record }, null, 2)); } else { console.log(atUri); } db.close(); } main().catch((err) => { console.error('Fatal:', err instanceof Error ? err.message : String(err)); process.exit(1); });