This repository has no description
0

Configure Feed

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

stigmergic / src / island-publish.ts
8.6 kB 240 lines
1/** 2 * island:publish 3 * 4 * Publish a discovered connected component as an org.latha.island record 5 * on the user's PDS. Reads the SQLite cache from island:discover. 6 * 7 * The canonical island ID is the first 12 hex chars of the SHA-256 of the 8 * centroid vertex (highest closeness centrality in the component). This is 9 * more stable than lexmin — adding a peripheral node doesn't shift the centroid. 10 * 11 * Usage: 12 * bun island:publish # list islands with their IDs 13 * bun island:publish a1b2c3d4e5f6 # publish by canonical ID 14 * bun island:publish a1b2c3d4e5f6 --dry-run # preview without writing 15 * bun island:publish a1b2c3d4e5f6 --json # JSON output 16 */ 17 18import { resolve, dirname } from 'node:path'; 19import { fileURLToPath } from 'node:url'; 20import { AtpAgent } from '@atproto/api'; 21import { loadDotEnv } from './cli-utils.js'; 22import { openDb, findComponents, fetchComponentEdges, domainFromUrl, type Component, type EdgeRow } from './island-shared.js'; 23 24const __dirname = dirname(fileURLToPath(import.meta.url)); 25await loadDotEnv(resolve(__dirname, '..', '.env')); 26await loadDotEnv('/home/nandi/code/swarm/.env'); // fallback for shared credentials 27 28const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; 29const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; 30const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? BLUESKY_HANDLE; 31const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? BLUESKY_APP_PASSWORD; 32const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 33 34// --- Build island record --- 35 36interface AnalysisRow { 37 island_id: string; 38 title: string | null; 39 themes: string | null; 40 highlights: string | null; 41 tensions: string | null; 42 open_questions: string | null; 43 synthesis: string | null; 44 new_connections: string | null; 45 analyzed_at: string; 46} 47 48function loadAnalysis(db: ReturnType<typeof openDb>, islandId: string): AnalysisRow | null { 49 try { 50 return db.query('SELECT * FROM analyses WHERE island_id = ?').get(islandId) as AnalysisRow | null; 51 } catch { 52 return null; 53 } 54} 55 56function buildIslandRecord(component: Component, edges: EdgeRow[], analysis: AnalysisRow | null) { 57 const centroid = component.centroid; 58 const domain = domainFromUrl(centroid); 59 const nodeCount = component.nodes.size; 60 const edgeCount = edges.length; 61 const contributorCount = component.dids.size; 62 63 const connections = edges 64 .filter((e) => e.atUri) 65 .map((e) => ({ uri: e.atUri })); 66 67 // New connections from analysis don't have AT URIs — skip them. 68 // They belong in the analysis synthesis text, not as edge references. 69 70 // Use analysis data if available, otherwise fall back to generic 71 const title = analysis?.title ?? `Island: ${domain} (${nodeCount} nodes, ${edgeCount} edges)`; 72 const themes = analysis?.themes ? JSON.parse(analysis.themes) : ['discovered-island']; 73 const synthesis = analysis?.synthesis ?? [ 74 `Discovered island centered on ${domain}.`, 75 `${nodeCount} nodes connected by ${edgeCount} edges, contributed by ${contributorCount} DIDs.`, 76 `Centroid vertex: ${centroid}`, 77 ].join('\n\n'); 78 79 const record: Record<string, unknown> = { 80 $type: 'org.latha.island', 81 source: { 82 uri: centroid, 83 collection: 'network.cosmik.connection', 84 }, 85 connections, 86 analysis: { 87 title, 88 themes, 89 synthesis, 90 }, 91 createdAt: new Date().toISOString(), 92 }; 93 94 // Add analysis fields if present 95 if (analysis) { 96 const a = record.analysis as Record<string, unknown>; 97 if (analysis.highlights) a.highlights = JSON.parse(analysis.highlights); 98 if (analysis.tensions) a.tensions = JSON.parse(analysis.tensions); 99 if (analysis.open_questions) a.openQuestions = JSON.parse(analysis.open_questions); 100 } 101 102 return record; 103} 104 105// --- Main --- 106 107async function main(): Promise<void> { 108 const args = process.argv.slice(2); 109 const wantJson = args.includes('--json'); 110 const dryRun = args.includes('--dry-run'); 111 const targetId = args.find((a) => !a.startsWith('--')); 112 113 // Open DB 114 let db; 115 try { 116 db = openDb(); 117 const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; 118 if (count === 0) { 119 console.error('No edges found. Run `bun island:discover` first.'); 120 process.exit(1); 121 } 122 } catch { 123 console.error('Database not found. Run `bun island:discover` first.'); 124 process.exit(1); 125 } 126 127 console.error('Finding components...'); 128 const components = findComponents(db); 129 130 // No positional arg: list all islands 131 if (!targetId) { 132 if (wantJson) { 133 console.log(JSON.stringify(components.map((c) => ({ 134 id: c.islandId, 135 lexmin: c.lexmin, 136 centroid: c.centroid, 137 nodeCount: c.nodes.size, 138 didCount: c.dids.size, 139 })), null, 2)); 140 } else { 141 console.log(`\n${components.length} islands:\n`); 142 for (const c of components.slice(0, 50)) { 143 console.log(` ${c.islandId} ${c.nodes.size} nodes ${domainFromUrl(c.centroid)}`); 144 } 145 if (components.length > 50) console.log(` ... and ${components.length - 50} more`); 146 console.log(`\nUsage: bun island:publish <island-id> [--dry-run] [--json]`); 147 } 148 db.close(); 149 return; 150 } 151 152 const component = components.find((c) => c.islandId === targetId); 153 if (!component) { 154 console.error(`Island ${targetId} not found. Run without args to list available islands.`); 155 process.exit(1); 156 } 157 158 console.error(`Island ${component.islandId}: ${component.nodes.size} nodes, ${component.dids.size} DIDs (centroid: ${component.centroid})`); 159 160 const edges = fetchComponentEdges(db, component); 161 const analysis = loadAnalysis(db, component.islandId); 162 const record = buildIslandRecord(component, edges, analysis); 163 164 if (dryRun) { 165 console.error('[dry-run] Would publish:'); 166 if (wantJson) { 167 console.log(JSON.stringify({ islandId: component.islandId, record }, null, 2)); 168 } else { 169 const a = record.analysis as Record<string, unknown>; 170 console.log(`Title: ${a.title}`); 171 console.log(`Source: ${(record.source as Record<string, string>).uri}`); 172 console.log(`Connections: ${(record.connections as unknown[]).length}`); 173 console.log(`Themes: ${(a.themes as string[]).join(', ')}`); 174 console.log(`\nSynthesis:\n${a.synthesis}`); 175 if (analysis) { 176 if (analysis.highlights) console.log(`Highlights: ${JSON.parse(analysis.highlights).length}`); 177 if (analysis.tensions) console.log(`Tensions: ${JSON.parse(analysis.tensions).length}`); 178 if (analysis.open_questions) console.log(`Open questions: ${JSON.parse(analysis.open_questions).length}`); 179 } 180 console.log(`\nConnections:`); 181 for (const c of record.connections as Array<Record<string, string>>) { 182 console.log(` ${c.uri}`); 183 } 184 } 185 db.close(); 186 return; 187 } 188 189 // Publish to PDS 190 if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { 191 console.error('PDS_IDENTIFIER and PDS_APP_PASSWORD (or BLUESKY_IDENTIFIER/BLUESKY_APP_PASSWORD) must be set'); 192 process.exit(1); 193 } 194 195 const APPVIEW = process.env.STIGMERGIC_APPVIEW ?? 'https://stigmergic.latha.org'; 196 197 const agent = new AtpAgent({ service: PDS_SERVICE }); 198 await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); 199 200 const result = await agent.api.com.atproto.repo.putRecord({ 201 repo: agent.session!.did, 202 collection: 'org.latha.island', 203 rkey: component.islandId, 204 record, 205 }); 206 207 const atUri = result.data.uri; 208 console.error(`Published: ${atUri}`); 209 210 // Notify appview to crawl the new record 211 try { 212 const did = agent.session!.did; 213 const notifyResp = await fetch(`${APPVIEW}/xrpc/org.latha.island.notifyOfUpdate`, { 214 method: 'POST', 215 headers: { 'Content-Type': 'application/json' }, 216 body: JSON.stringify({ did }), 217 }); 218 if (notifyResp.ok) { 219 const notifyData = await notifyResp.json() as { ingested?: number }; 220 console.error(`Appview ingested: ${notifyData.ingested ?? 0} record(s)`); 221 } else { 222 console.error(`Appview notify failed: ${notifyResp.status} (non-fatal, cron will catch up)`); 223 } 224 } catch (e: any) { 225 console.error(`Appview notify error: ${e.message} (non-fatal, cron will catch up)`); 226 } 227 228 if (wantJson) { 229 console.log(JSON.stringify({ islandId: component.islandId, atUri, record }, null, 2)); 230 } else { 231 console.log(atUri); 232 } 233 234 db.close(); 235} 236 237main().catch((err) => { 238 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 239 process.exit(1); 240});