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
9.0 kB 255 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 * lexmin vertex (lexicographically smallest URL in the component). This is 9 * stable across runs — the same component always gets the same ID. 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 lexmin = component.lexmin; 58 const domain = domainFromUrl(lexmin); 59 const nodeCount = component.nodes.size; 60 const edgeCount = edges.length; 61 const contributorCount = component.dids.size; 62 63 const connections = edges.map((e) => ({ 64 uri: e.atUri ?? '', 65 source: e.source, 66 target: e.target, 67 ...(e.relation ? { connectionType: e.relation } : {}), 68 })); 69 70 // Merge new connections from analysis 71 if (analysis?.new_connections) { 72 try { 73 const newConns = JSON.parse(analysis.new_connections) as Array<{ source: string; target: string; connectionType: string; note: string }>; 74 for (const nc of newConns) { 75 connections.push({ 76 uri: '', 77 source: nc.source, 78 target: nc.target, 79 connectionType: nc.connectionType, 80 note: nc.note, 81 }); 82 } 83 } catch {} 84 } 85 86 // Use analysis data if available, otherwise fall back to generic 87 const title = analysis?.title ?? `Island: ${domain} (${nodeCount} nodes, ${edgeCount} edges)`; 88 const themes = analysis?.themes ? JSON.parse(analysis.themes) : ['discovered-island']; 89 const synthesis = analysis?.synthesis ?? [ 90 `Discovered island centered on ${domain}.`, 91 `${nodeCount} nodes connected by ${edgeCount} edges, contributed by ${contributorCount} DIDs.`, 92 `Lexmin vertex: ${lexmin}`, 93 ].join('\n\n'); 94 95 const record: Record<string, unknown> = { 96 $type: 'org.latha.island', 97 source: { 98 uri: lexmin, 99 collection: 'network.cosmik.connection', 100 }, 101 connections, 102 analysis: { 103 title, 104 themes, 105 synthesis, 106 }, 107 createdAt: new Date().toISOString(), 108 }; 109 110 // Add analysis fields if present 111 if (analysis) { 112 const a = record.analysis as Record<string, unknown>; 113 if (analysis.highlights) a.highlights = JSON.parse(analysis.highlights); 114 if (analysis.tensions) a.tensions = JSON.parse(analysis.tensions); 115 if (analysis.open_questions) a.openQuestions = JSON.parse(analysis.open_questions); 116 } 117 118 return record; 119} 120 121// --- Main --- 122 123async function main(): Promise<void> { 124 const args = process.argv.slice(2); 125 const wantJson = args.includes('--json'); 126 const dryRun = args.includes('--dry-run'); 127 const targetId = args.find((a) => !a.startsWith('--')); 128 129 // Open DB 130 let db; 131 try { 132 db = openDb(); 133 const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; 134 if (count === 0) { 135 console.error('No edges found. Run `bun island:discover` first.'); 136 process.exit(1); 137 } 138 } catch { 139 console.error('Database not found. Run `bun island:discover` first.'); 140 process.exit(1); 141 } 142 143 console.error('Finding components...'); 144 const components = findComponents(db); 145 146 // No positional arg: list all islands 147 if (!targetId) { 148 if (wantJson) { 149 console.log(JSON.stringify(components.map((c) => ({ 150 id: c.islandId, 151 lexmin: c.lexmin, 152 nodeCount: c.nodes.size, 153 didCount: c.dids.size, 154 })), null, 2)); 155 } else { 156 console.log(`\n${components.length} islands:\n`); 157 for (const c of components.slice(0, 50)) { 158 console.log(` ${c.islandId} ${c.nodes.size} nodes ${domainFromUrl(c.lexmin)}`); 159 } 160 if (components.length > 50) console.log(` ... and ${components.length - 50} more`); 161 console.log(`\nUsage: bun island:publish <island-id> [--dry-run] [--json]`); 162 } 163 db.close(); 164 return; 165 } 166 167 const component = components.find((c) => c.islandId === targetId); 168 if (!component) { 169 console.error(`Island ${targetId} not found. Run without args to list available islands.`); 170 process.exit(1); 171 } 172 173 console.error(`Island ${component.islandId}: ${component.nodes.size} nodes, ${component.dids.size} DIDs (lexmin: ${component.lexmin})`); 174 175 const edges = fetchComponentEdges(db, component); 176 const analysis = loadAnalysis(db, component.islandId); 177 const record = buildIslandRecord(component, edges, analysis); 178 179 if (dryRun) { 180 console.error('[dry-run] Would publish:'); 181 if (wantJson) { 182 console.log(JSON.stringify({ islandId: component.islandId, record }, null, 2)); 183 } else { 184 const a = record.analysis as Record<string, unknown>; 185 console.log(`Title: ${a.title}`); 186 console.log(`Source: ${(record.source as Record<string, string>).uri}`); 187 console.log(`Connections: ${(record.connections as unknown[]).length}`); 188 console.log(`Themes: ${(a.themes as string[]).join(', ')}`); 189 console.log(`\nSynthesis:\n${a.synthesis}`); 190 if (analysis) { 191 if (analysis.highlights) console.log(`Highlights: ${JSON.parse(analysis.highlights).length}`); 192 if (analysis.tensions) console.log(`Tensions: ${JSON.parse(analysis.tensions).length}`); 193 if (analysis.open_questions) console.log(`Open questions: ${JSON.parse(analysis.open_questions).length}`); 194 } 195 console.log(`\nConnections:`); 196 for (const c of record.connections as Array<Record<string, string>>) { 197 console.log(` ${c.source} -> ${c.target} ${c.connectionType ?? ''} (${c.note})`); 198 } 199 } 200 db.close(); 201 return; 202 } 203 204 // Publish to PDS 205 if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { 206 console.error('PDS_IDENTIFIER and PDS_APP_PASSWORD (or BLUESKY_IDENTIFIER/BLUESKY_APP_PASSWORD) must be set'); 207 process.exit(1); 208 } 209 210 const APPVIEW = process.env.STIGMERGIC_APPVIEW ?? 'https://stigmergic.latha.org'; 211 212 const agent = new AtpAgent({ service: PDS_SERVICE }); 213 await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); 214 215 const result = await agent.api.com.atproto.repo.putRecord({ 216 repo: agent.session!.did, 217 collection: 'org.latha.island', 218 rkey: component.islandId, 219 record, 220 }); 221 222 const atUri = result.data.uri; 223 console.error(`Published: ${atUri}`); 224 225 // Notify appview to crawl the new record 226 try { 227 const did = agent.session!.did; 228 const notifyResp = await fetch(`${APPVIEW}/xrpc/org.latha.island.notifyOfUpdate`, { 229 method: 'POST', 230 headers: { 'Content-Type': 'application/json' }, 231 body: JSON.stringify({ did }), 232 }); 233 if (notifyResp.ok) { 234 const notifyData = await notifyResp.json() as { ingested?: number }; 235 console.error(`Appview ingested: ${notifyData.ingested ?? 0} record(s)`); 236 } else { 237 console.error(`Appview notify failed: ${notifyResp.status} (non-fatal, cron will catch up)`); 238 } 239 } catch (e: any) { 240 console.error(`Appview notify error: ${e.message} (non-fatal, cron will catch up)`); 241 } 242 243 if (wantJson) { 244 console.log(JSON.stringify({ islandId: component.islandId, atUri, record }, null, 2)); 245 } else { 246 console.log(atUri); 247 } 248 249 db.close(); 250} 251 252main().catch((err) => { 253 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 254 process.exit(1); 255});