This repository has no description
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});