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