This repository has no description
0

Configure Feed

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

Refactor island commands: shared utils, improved detect/discover/explore

- Extract shared carry query, URL normalization, and component logic into island-shared.ts
- island:detect: simplified, uses shared utils
- island:discover: improved component reporting
- island:explore: enhanced carry-backed scoring
- Add island:publish stub

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

author
nandi
co-author
Letta Code
date (May 15, 2026, 4:44 AM UTC) commit 6346dbdb parent a497e137
+411 -93
+13 -69
src/island-detect.ts
··· 5 5 * Uses the SQLite cache from island:discover. 6 6 * 7 7 * Usage: 8 - * bun island:detect # find which component we're closest to 9 - * bun island:detect --top 5 # show top 5 closest components 8 + * bun island:detect # find which island we're closest to 9 + * bun island:detect --top 5 # show top 5 closest islands 10 10 * bun island:detect --json # machine-readable output 11 11 */ 12 12 13 13 import { resolve, dirname } from 'node:path'; 14 14 import { fileURLToPath } from 'node:url'; 15 15 import { execFileSync } from 'node:child_process'; 16 - import { Database } from 'bun:sqlite'; 17 16 import { AtpAgent } from '@atproto/api'; 18 17 import { loadDotEnv } from './cli-utils.js'; 18 + import { openDb, findComponents, islandId, domainFromUrl, type Component } from './island-shared.js'; 19 19 20 20 const __dirname = dirname(fileURLToPath(import.meta.url)); 21 21 await loadDotEnv(resolve(__dirname, '..', '.env')); 22 22 23 - const RELAY = process.env.ATPROTO_RELAY ?? 'https://bsky.network'; 24 23 const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; 25 24 const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; 26 25 const ATPROTO_SERVICE = process.env.ATPROTO_SERVICE ?? 'https://bsky.social'; 27 26 const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 28 27 const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 29 - const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 30 28 const COLLECTION = 'network.cosmik.connection'; 31 29 32 - interface Component { 33 - id: number; 34 - nodes: Set<string>; 35 - dids: Set<string>; 36 - } 37 - 38 30 interface MatchResult { 39 - componentId: number; 31 + islandId: string; 32 + lexmin: string; 40 33 nodeCount: number; 41 34 didCount: number; 42 35 sharedCount: number; 43 36 similarity: number; 44 37 sharedUrls: string[]; 45 - } 46 - 47 - // --- SQLite --- 48 - 49 - function openDb(): Database { 50 - const db = new Database(DB_PATH, { readonly: true }); 51 - return db; 52 38 } 53 39 54 40 // --- Our graph --- ··· 142 128 return urls; 143 129 } 144 130 145 - // --- Components --- 146 - 147 - function findComponents(db: Database): Component[] { 148 - const adj = new Map<string, Set<string>>(); 149 - const edgeDids = new Map<string, Set<string>>(); 150 - 151 - const rows = db.query('SELECT source, target, did FROM edges').all() as Array<{ source: string; target: string; did: string }>; 152 - for (const row of rows) { 153 - const s = row.source; 154 - const t = row.target; 155 - if (!adj.has(s)) adj.set(s, new Set()); 156 - if (!adj.has(t)) adj.set(t, new Set()); 157 - adj.get(s)!.add(t); 158 - adj.get(t)!.add(s); 159 - if (!edgeDids.has(s)) edgeDids.set(s, new Set()); 160 - if (!edgeDids.has(t)) edgeDids.set(t, new Set()); 161 - edgeDids.get(s)!.add(row.did); 162 - edgeDids.get(t)!.add(row.did); 163 - } 164 - 165 - const visited = new Set<string>(); 166 - const components: Component[] = []; 167 - let compId = 0; 168 - 169 - for (const node of adj.keys()) { 170 - if (visited.has(node)) continue; 171 - compId++; 172 - const compNodes = new Set<string>(); 173 - const compDids = new Set<string>(); 174 - const queue = [node]; 175 - while (queue.length > 0) { 176 - const current = queue.pop()!; 177 - if (visited.has(current)) continue; 178 - visited.add(current); 179 - compNodes.add(current); 180 - for (const did of edgeDids.get(current) ?? []) compDids.add(did); 181 - for (const neighbor of adj.get(current) ?? []) { 182 - if (!visited.has(neighbor)) queue.push(neighbor); 183 - } 184 - } 185 - components.push({ id: compId, nodes: compNodes, dids: compDids }); 186 - } 187 - 188 - return components.sort((a, b) => b.nodes.size - a.nodes.size); 189 - } 131 + // --- Similarity --- 190 132 191 133 function jaccard(a: Set<string>, b: Set<string>): number { 192 134 if (a.size === 0 && b.size === 0) return 0; ··· 215 157 const topN = topArg ? Number(topArg.split('=')[1]) : 5; 216 158 217 159 // Check if discover has been run 218 - let db: Database; 160 + let db; 219 161 try { 220 162 db = openDb(); 221 163 const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; ··· 245 187 const shared = sharedUrls(ourUrls, comp.nodes); 246 188 if (shared.length > 0) { 247 189 matches.push({ 248 - componentId: comp.id, 190 + islandId: comp.islandId, 191 + lexmin: comp.lexmin, 249 192 nodeCount: comp.nodes.size, 250 193 didCount: comp.dids.size, 251 194 sharedCount: shared.length, ··· 273 216 } 274 217 275 218 console.log(`\nIsland detect: our graph (${ourUrls.size} URLs) matched against ${components.length} components`); 276 - console.log(`Components with overlap: ${matches.length}\n`); 219 + console.log(`Islands with overlap: ${matches.length}\n`); 277 220 278 221 for (const m of topMatches) { 279 - console.log(`Component ${m.componentId}: ${m.nodeCount} nodes, ${m.didCount} DIDs`); 222 + const domain = domainFromUrl(m.lexmin); 223 + console.log(`${m.islandId} ${m.nodeCount} nodes, ${m.didCount} DIDs ${domain}`); 280 224 console.log(` Similarity: ${(m.similarity * 100).toFixed(2)}% (${m.sharedCount} shared URLs)`); 281 225 for (const url of m.sharedUrls) { 282 226 console.log(` ${url}`); ··· 286 230 287 231 if (topMatches.length > 0) { 288 232 const best = topMatches[0]; 289 - console.log(`\n→ We belong to Component ${best.componentId} (${(best.similarity * 100).toFixed(1)}% match)`); 233 + console.log(`\n→ We belong to island ${best.islandId} (${(best.similarity * 100).toFixed(1)}% match)`); 290 234 } 291 235 292 236 db.close();
+24 -20
src/island-discover.ts
··· 7 7 * Usage: 8 8 * bun island:discover # find all connected components 9 9 * bun island:discover --refresh # force re-fetch all DIDs 10 - * bun island:discover --component 1 # show full details for component 1 10 + * bun island:discover <island-id> # show full details for an island 11 11 * bun island:discover --min-size 5 # only show components with >= 5 nodes 12 12 * bun island:discover --json # machine-readable output 13 13 */ 14 14 15 15 import { resolve, dirname } from 'node:path'; 16 16 import { fileURLToPath } from 'node:url'; 17 - import { execFileSync } from 'node:fs'; 17 + import { createHash } from 'node:crypto'; 18 18 import { Database } from 'bun:sqlite'; 19 19 import { loadDotEnv } from './cli-utils.js'; 20 + import { islandId, domainFromUrl } from './island-shared.js'; 20 21 21 22 const __dirname = dirname(fileURLToPath(import.meta.url)); 22 23 await loadDotEnv(resolve(__dirname, '..', '.env')); ··· 28 29 const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour 29 30 30 31 interface Component { 31 - id: number; 32 32 nodes: Set<string>; 33 33 dids: Set<string>; 34 + lexmin: string; 35 + islandId: string; 34 36 } 35 37 36 38 // --- SQLite --- ··· 182 184 // BFS to find components 183 185 const visited = new Set<string>(); 184 186 const components: Component[] = []; 185 - let compId = 0; 186 187 187 188 for (const node of adj.keys()) { 188 189 if (visited.has(node)) continue; 189 - compId++; 190 190 const compNodes = new Set<string>(); 191 191 const compDids = new Set<string>(); 192 192 const queue = [node]; ··· 200 200 if (!visited.has(neighbor)) queue.push(neighbor); 201 201 } 202 202 } 203 - components.push({ id: compId, nodes: compNodes, dids: compDids }); 203 + const lexmin = [...compNodes].sort()[0]; 204 + components.push({ nodes: compNodes, dids: compDids, lexmin, islandId: islandId(lexmin) }); 204 205 } 205 206 206 207 return components.sort((a, b) => b.nodes.size - a.nodes.size); ··· 212 213 const args = process.argv.slice(2); 213 214 const wantJson = args.includes('--json'); 214 215 const refresh = args.includes('--refresh'); 215 - const componentArg = args.find((a) => a.startsWith('--component=')); 216 - const showComponent = componentArg ? Number(componentArg.split('=')[1]) : null; 216 + const targetId = args.find((a) => !a.startsWith('--')); 217 217 const minSizeArg = args.find((a) => a.startsWith('--min-size=')); 218 218 const minSize = minSizeArg ? Number(minSizeArg.split('=')[1]) : 3; 219 219 ··· 256 256 const components = findComponents(db); 257 257 const filtered = components.filter((c) => c.nodes.size >= minSize); 258 258 259 - if (showComponent !== null) { 260 - const comp = components[showComponent - 1]; 261 - if (!comp) { console.error(`Component ${showComponent} not found`); process.exit(1); } 262 - const didRows = db.query('SELECT did, handle, edge_count FROM dids WHERE did IN (?)').all(...comp.dids) as Array<{ did: string; handle: string | null; edge_count: number }>; 259 + // Show a specific island by ID 260 + if (targetId) { 261 + const comp = components.find((c) => c.islandId === targetId); 262 + if (!comp) { console.error(`Island ${targetId} not found`); process.exit(1); } 263 + const didPlaceholders = [...comp.dids].map(() => '?').join(','); 264 + const didRows = db.query(`SELECT did, handle, edge_count FROM dids WHERE did IN (${didPlaceholders})`).all(...comp.dids) as Array<{ did: string; handle: string | null; edge_count: number }>; 263 265 if (wantJson) { 264 266 console.log(JSON.stringify({ 265 - id: comp.id, 267 + id: comp.islandId, 268 + lexmin: comp.lexmin, 266 269 nodeCount: comp.nodes.size, 267 270 didCount: comp.dids.size, 268 271 nodes: [...comp.nodes].sort(), 269 272 dids: didRows, 270 273 }, null, 2)); 271 274 } else { 272 - console.log(`Component ${showComponent}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); 275 + console.log(`Island ${comp.islandId}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); 276 + console.log(`Lexmin: ${comp.lexmin}`); 273 277 console.log('\nNodes:'); 274 278 for (const url of [...comp.nodes].sort()) console.log(` ${url}`); 275 279 console.log('\nDIDs:'); ··· 286 290 uniqueUrls: uniqueUrls.size, 287 291 componentCount: filtered.length, 288 292 components: filtered.map((c) => ({ 289 - id: c.id, 293 + id: c.islandId, 294 + lexmin: c.lexmin, 290 295 nodeCount: c.nodes.size, 291 296 didCount: c.dids.size, 292 297 sampleNodes: [...c.nodes].sort().slice(0, 10), ··· 297 302 return; 298 303 } 299 304 300 - console.log(`\nIsland detect: ${totalDids} DIDs, ${totalEdges} edges, ${uniqueUrls.size} unique URLs`); 305 + console.log(`\nIsland discover: ${totalDids} DIDs, ${totalEdges} edges, ${uniqueUrls.size} unique URLs`); 301 306 console.log(`Connected components (>= ${minSize} nodes): ${filtered.length}\n`); 302 307 303 308 for (const comp of filtered.slice(0, 30)) { 304 - const sampleNodes = [...comp.nodes].sort().slice(0, 5); 305 - console.log(`Component ${comp.id}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); 306 - console.log(` URLs: ${sampleNodes.join(', ')}${comp.nodes.size > 5 ? ` ... (+${comp.nodes.size - 5})` : ''}`); 309 + const domain = domainFromUrl(comp.lexmin); 310 + console.log(`${comp.islandId} ${comp.nodes.size} nodes, ${comp.dids.size} DIDs ${domain}`); 307 311 } 308 312 309 313 // Show which component we're in (if we can determine our DID) ··· 313 317 if (ourDid) { 314 318 const ourComp = components.find((c) => c.dids.has(ourDid.did)); 315 319 if (ourComp) { 316 - console.log(`\nOur island: Component ${ourComp.id} (${ourComp.nodes.size} nodes, shared with ${ourComp.dids.size - 1} other DIDs)`); 320 + console.log(`\nOur island: ${ourComp.islandId} (${ourComp.nodes.size} nodes, shared with ${ourComp.dids.size - 1} other DIDs)`); 317 321 } 318 322 } 319 323
+65 -4
src/island-explore.ts
··· 1 1 /** 2 - * island:explore <aturi> 2 + * island:explore <island-id> 3 3 * 4 4 * Local-first island explorer that ingests an org.latha.island record, identifies 5 5 * fringe nodes, and emits endofunctor suggestions for re-attaching them to the island. 6 6 * 7 + * Accepts either an island hash (12 hex chars, e.g. a1b2c3d4e5f6) or an AT URI. 8 + * When given an island hash, resolves it to the component's lexmin and looks up 9 + * the published record on the PDS. 10 + * 7 11 * Scoring is carry-backed: crawled page text is matched against the carry entity 8 12 * and citation index, and overlap with island edges determines confidence. 9 13 */ ··· 14 18 import { execFileSync } from 'node:child_process'; 15 19 import { AtpAgent } from '@atproto/api'; 16 20 import { loadDotEnv } from './cli-utils.js'; 21 + import { findComponentById, islandId } from './island-shared.js'; 22 + import { Database } from 'bun:sqlite'; 17 23 import { JSDOM, VirtualConsole } from 'jsdom'; 18 24 19 25 const __dirname = dirname(fileURLToPath(import.meta.url)); ··· 35 41 source?: { uri?: string; collection?: string }; 36 42 analysis?: { title?: string; themes?: string[]; highlights?: string[]; tensions?: string[]; openQuestions?: string[]; synthesis?: string }; 37 43 edges?: Array<{ source?: string; target?: string; relation?: string; title?: string; description?: string }>; 44 + connections?: Array<{ source?: string; target?: string; connectionType?: string; note?: string; uri?: string }>; 38 45 } 39 46 40 47 interface CardContent { url?: string; title?: string; description?: string; author?: string; siteName?: string; type?: string; } ··· 247 254 } 248 255 249 256 function islandEdgeTargets(island: IslandRecord): string[] { 250 - return [...new Set((island.edges ?? []).map((e) => normalizeUrl(e.target)).filter(isHttps))]; 257 + // Support both `edges` (legacy) and `connections` (lexicon) field names 258 + const allEdges = [ 259 + ...(island.edges ?? []), 260 + ...(island.connections ?? []).map((c) => ({ source: c.source, target: c.target, relation: c.connectionType })), 261 + ]; 262 + return [...new Set(allEdges.map((e) => normalizeUrl(e.target)).filter(isHttps))]; 251 263 } 252 264 253 265 function carryAssertEdge(source: string, target: string, relation: string, context: string): void { ··· 261 273 } 262 274 263 275 async function main(): Promise<void> { 264 - const aturi = process.argv[2]; 265 - if (!aturi) throw new Error('Usage: bun island:explore <aturi> [--json] [--write]'); 276 + const input = process.argv[2]; 277 + if (!input) throw new Error('Usage: bun island:explore <island-id|aturi> [--json] [--write]'); 266 278 const wantJson = process.argv.includes('--json'); 267 279 const wantWrite = process.argv.includes('--write'); 268 280 const limitArg = process.argv.find((arg) => arg.startsWith('--limit=')); 269 281 const limit = limitArg ? Number(limitArg.split('=')[1]) : FEED_LIMIT; 282 + 283 + // Resolve island hash to AT URI 284 + let aturi: string; 285 + const ISLAND_HASH_RE = /^[0-9a-f]{12}$/; 286 + if (ISLAND_HASH_RE.test(input)) { 287 + // Island hash — resolve to component lexmin, then find published record 288 + const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 289 + let db: Database; 290 + try { 291 + db = new Database(DB_PATH, { readonly: true }); 292 + } catch { 293 + throw new Error('Database not found. Run `bun island:discover` first.'); 294 + } 295 + const component = findComponentById(db, input); 296 + db.close(); 297 + if (!component) throw new Error(`Island ${input} not found in discovery DB. Run \`bun island:discover\` first.`); 298 + console.error(`Resolved island ${input} → lexmin ${component.lexmin}`); 299 + 300 + // Look for a published org.latha.island record on our PDS 301 + const agent = new AtpAgent({ service: ATPROTO_SERVICE }); 302 + await agent.login({ identifier: BLUESKY_HANDLE, password: BLUESKY_APP_PASSWORD }); 303 + const did = agent.session!.did; 304 + let cursor = ''; 305 + let foundUri: string | null = null; 306 + while (true) { 307 + const url = new URL(`${PDS_SERVICE}/xrpc/com.atproto.repo.listRecords`); 308 + url.searchParams.set('repo', did); 309 + url.searchParams.set('collection', 'org.latha.island'); 310 + url.searchParams.set('limit', '100'); 311 + if (cursor) url.searchParams.set('cursor', cursor); 312 + const res = await fetch(url.toString()); 313 + if (!res.ok) break; 314 + const data = await res.json() as { records: Array<{ uri: string; value: { source?: { uri?: string } } }>; cursor?: string }; 315 + for (const r of data.records) { 316 + if (r.value.source?.uri === component.lexmin) { 317 + foundUri = r.uri; 318 + break; 319 + } 320 + } 321 + if (foundUri) break; 322 + cursor = data.cursor ?? ''; 323 + if (!cursor) break; 324 + } 325 + if (!foundUri) throw new Error(`No published org.latha.island record found for lexmin ${component.lexmin}. Run \`bun island:publish ${input}\` first.`); 326 + aturi = foundUri; 327 + console.error(`Found record: ${aturi}`); 328 + } else { 329 + aturi = input; 330 + } 270 331 271 332 const island = await fetchIslandRecord(aturi); 272 333 const targets = islandEdgeTargets(island);
+183
src/island-publish.ts
··· 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 + 18 + import { resolve, dirname } from 'node:path'; 19 + import { fileURLToPath } from 'node:url'; 20 + import { AtpAgent } from '@atproto/api'; 21 + import { loadDotEnv } from './cli-utils.js'; 22 + import { openDb, findComponents, findComponentById, fetchComponentEdges, resolveDidHandles, domainFromUrl, type Component, type EdgeRow } from './island-shared.js'; 23 + 24 + const __dirname = dirname(fileURLToPath(import.meta.url)); 25 + await loadDotEnv(resolve(__dirname, '..', '.env')); 26 + await loadDotEnv('/home/nandi/code/swarm/.env'); // fallback for shared credentials 27 + 28 + const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; 29 + const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; 30 + const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? BLUESKY_HANDLE; 31 + const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? BLUESKY_APP_PASSWORD; 32 + const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 33 + 34 + // --- Build island record --- 35 + 36 + function buildIslandRecord(component: Component, edges: EdgeRow[], didHandles: Map<string, string | null>) { 37 + const lexmin = component.lexmin; 38 + const domain = domainFromUrl(lexmin); 39 + const nodeCount = component.nodes.size; 40 + const edgeCount = edges.length; 41 + const contributorCount = component.dids.size; 42 + 43 + const connections = edges.map((e) => ({ 44 + uri: '', // no AT URI for discovered edges — they're aggregated from multiple DIDs 45 + source: e.source, 46 + target: e.target, 47 + ...(e.relation ? { connectionType: e.relation } : {}), 48 + note: `from ${didHandles.get(e.did) ?? e.did}`, 49 + })); 50 + 51 + const title = `Island: ${domain} (${nodeCount} nodes, ${edgeCount} edges)`; 52 + const themes = ['discovered-island']; 53 + const synthesis = [ 54 + `Discovered island centered on ${domain}.`, 55 + `${nodeCount} nodes connected by ${edgeCount} edges, contributed by ${contributorCount} DIDs.`, 56 + `Lexmin vertex: ${lexmin}`, 57 + `Contributors: ${[...component.dids].map((d) => didHandles.get(d) ?? d).join(', ')}`, 58 + ].join('\n\n'); 59 + 60 + return { 61 + $type: 'org.latha.island', 62 + source: { 63 + uri: lexmin, 64 + collection: 'network.cosmik.connection', 65 + }, 66 + connections, 67 + analysis: { 68 + title, 69 + themes, 70 + synthesis, 71 + }, 72 + createdAt: new Date().toISOString(), 73 + }; 74 + } 75 + 76 + // --- Main --- 77 + 78 + async function main(): Promise<void> { 79 + const args = process.argv.slice(2); 80 + const wantJson = args.includes('--json'); 81 + const dryRun = args.includes('--dry-run'); 82 + const targetId = args.find((a) => !a.startsWith('--')); 83 + 84 + // Open DB 85 + let db; 86 + try { 87 + db = openDb(); 88 + const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; 89 + if (count === 0) { 90 + console.error('No edges found. Run `bun island:discover` first.'); 91 + process.exit(1); 92 + } 93 + } catch { 94 + console.error('Database not found. Run `bun island:discover` first.'); 95 + process.exit(1); 96 + } 97 + 98 + console.error('Finding components...'); 99 + const components = findComponents(db); 100 + 101 + // No positional arg: list all islands 102 + if (!targetId) { 103 + if (wantJson) { 104 + console.log(JSON.stringify(components.map((c) => ({ 105 + id: c.islandId, 106 + lexmin: c.lexmin, 107 + nodeCount: c.nodes.size, 108 + didCount: c.dids.size, 109 + })), null, 2)); 110 + } else { 111 + console.log(`\n${components.length} islands:\n`); 112 + for (const c of components.slice(0, 50)) { 113 + console.log(` ${c.islandId} ${c.nodes.size} nodes ${domainFromUrl(c.lexmin)}`); 114 + } 115 + if (components.length > 50) console.log(` ... and ${components.length - 50} more`); 116 + console.log(`\nUsage: bun island:publish <island-id> [--dry-run] [--json]`); 117 + } 118 + db.close(); 119 + return; 120 + } 121 + 122 + const component = components.find((c) => c.islandId === targetId); 123 + if (!component) { 124 + console.error(`Island ${targetId} not found. Run without args to list available islands.`); 125 + process.exit(1); 126 + } 127 + 128 + console.error(`Island ${component.islandId}: ${component.nodes.size} nodes, ${component.dids.size} DIDs (lexmin: ${component.lexmin})`); 129 + 130 + const edges = fetchComponentEdges(db, component); 131 + const didHandles = resolveDidHandles(db, component.dids); 132 + const record = buildIslandRecord(component, edges, didHandles); 133 + 134 + if (dryRun) { 135 + console.error('[dry-run] Would publish:'); 136 + if (wantJson) { 137 + console.log(JSON.stringify({ islandId: component.islandId, record }, null, 2)); 138 + } else { 139 + console.log(`Title: ${record.analysis.title}`); 140 + console.log(`Source: ${record.source.uri}`); 141 + console.log(`Connections: ${record.connections.length}`); 142 + console.log(`Themes: ${record.analysis.themes.join(', ')}`); 143 + console.log(`\nSynthesis:\n${record.analysis.synthesis}`); 144 + console.log(`\nConnections:`); 145 + for (const c of record.connections) { 146 + console.log(` ${c.source} -> ${c.target} ${c.connectionType ?? ''} (${c.note})`); 147 + } 148 + } 149 + db.close(); 150 + return; 151 + } 152 + 153 + // Publish to PDS 154 + if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { 155 + console.error('PDS_IDENTIFIER and PDS_APP_PASSWORD (or BLUESKY_IDENTIFIER/BLUESKY_APP_PASSWORD) must be set'); 156 + process.exit(1); 157 + } 158 + 159 + const agent = new AtpAgent({ service: PDS_SERVICE }); 160 + await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); 161 + 162 + const result = await agent.api.com.atproto.repo.createRecord({ 163 + repo: agent.session!.did, 164 + collection: 'org.latha.island', 165 + record, 166 + }); 167 + 168 + const atUri = result.data.uri; 169 + console.error(`Published: ${atUri}`); 170 + 171 + if (wantJson) { 172 + console.log(JSON.stringify({ islandId: component.islandId, atUri, record }, null, 2)); 173 + } else { 174 + console.log(atUri); 175 + } 176 + 177 + db.close(); 178 + } 179 + 180 + main().catch((err) => { 181 + console.error('Fatal:', err instanceof Error ? err.message : String(err)); 182 + process.exit(1); 183 + });
+126
src/island-shared.ts
··· 1 + /** 2 + * Shared island utilities: canonical ID, component types, BFS, DB access. 3 + */ 4 + 5 + import { resolve } from 'node:path'; 6 + import { createHash } from 'node:crypto'; 7 + import { Database } from 'bun:sqlite'; 8 + 9 + const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 10 + const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 11 + 12 + export interface Component { 13 + nodes: Set<string>; 14 + dids: Set<string>; 15 + lexmin: string; 16 + islandId: string; 17 + } 18 + 19 + export interface EdgeRow { 20 + source: string; 21 + target: string; 22 + relation: string | null; 23 + did: string; 24 + } 25 + 26 + // --- Canonical island ID: first 12 hex chars of SHA-256(lexmin) --- 27 + 28 + export function islandId(lexmin: string): string { 29 + return createHash('sha256').update(lexmin).digest('hex').slice(0, 12); 30 + } 31 + 32 + // --- SQLite --- 33 + 34 + export function openDb(): Database { 35 + return new Database(DB_PATH, { readonly: true }); 36 + } 37 + 38 + // --- Connected components (BFS) --- 39 + 40 + export function findComponents(db: Database): Component[] { 41 + const adj = new Map<string, Set<string>>(); 42 + const edgeDids = new Map<string, Set<string>>(); 43 + 44 + const rows = db.query('SELECT source, target, did FROM edges').all() as Array<{ source: string; target: string; did: string }>; 45 + for (const row of rows) { 46 + const s = row.source; 47 + const t = row.target; 48 + if (!adj.has(s)) adj.set(s, new Set()); 49 + if (!adj.has(t)) adj.set(t, new Set()); 50 + adj.get(s)!.add(t); 51 + adj.get(t)!.add(s); 52 + if (!edgeDids.has(s)) edgeDids.set(s, new Set()); 53 + if (!edgeDids.has(t)) edgeDids.set(t, new Set()); 54 + edgeDids.get(s)!.add(row.did); 55 + edgeDids.get(t)!.add(row.did); 56 + } 57 + 58 + const visited = new Set<string>(); 59 + const components: Component[] = []; 60 + 61 + for (const node of adj.keys()) { 62 + if (visited.has(node)) continue; 63 + const compNodes = new Set<string>(); 64 + const compDids = new Set<string>(); 65 + const queue = [node]; 66 + while (queue.length > 0) { 67 + const current = queue.pop()!; 68 + if (visited.has(current)) continue; 69 + visited.add(current); 70 + compNodes.add(current); 71 + for (const did of edgeDids.get(current) ?? []) compDids.add(did); 72 + for (const neighbor of adj.get(current) ?? []) { 73 + if (!visited.has(neighbor)) queue.push(neighbor); 74 + } 75 + } 76 + const lexmin = [...compNodes].sort()[0]; 77 + components.push({ nodes: compNodes, dids: compDids, lexmin, islandId: islandId(lexmin) }); 78 + } 79 + 80 + return components.sort((a, b) => b.nodes.size - a.nodes.size); 81 + } 82 + 83 + /** Find a component by its island ID (lexmin hash). */ 84 + export function findComponentById(db: Database, targetId: string): Component | undefined { 85 + return findComponents(db).find((c) => c.islandId === targetId); 86 + } 87 + 88 + /** Fetch edges for a component from the DB. */ 89 + export function fetchComponentEdges(db: Database, component: Component): EdgeRow[] { 90 + const nodePlaceholders = [...component.nodes].map(() => '?').join(','); 91 + const nodeValues = [...component.nodes]; 92 + const edges = db.query( 93 + `SELECT source, target, relation, did FROM edges WHERE source IN (${nodePlaceholders}) OR target IN (${nodePlaceholders})`, 94 + ).all(...nodeValues, ...nodeValues) as EdgeRow[]; 95 + 96 + // Deduplicate (same source+target from multiple DIDs) 97 + const seen = new Set<string>(); 98 + const unique: EdgeRow[] = []; 99 + for (const e of edges) { 100 + const key = `${e.source}::${e.target}`; 101 + if (!seen.has(key)) { 102 + seen.add(key); 103 + unique.push(e); 104 + } 105 + } 106 + return unique; 107 + } 108 + 109 + /** Resolve handles for a set of DIDs. */ 110 + export function resolveDidHandles(db: Database, dids: Set<string>): Map<string, string | null> { 111 + const handles = new Map<string, string | null>(); 112 + if (dids.size === 0) return handles; 113 + const placeholders = [...dids].map(() => '?').join(','); 114 + const rows = db.query(`SELECT did, handle FROM dids WHERE did IN (${placeholders})`).all(...dids) as Array<{ did: string; handle: string | null }>; 115 + for (const r of rows) handles.set(r.did, r.handle); 116 + return handles; 117 + } 118 + 119 + /** Extract domain from a URL for display. */ 120 + export function domainFromUrl(url: string): string { 121 + try { 122 + return new URL(url).hostname.replace(/^www\./, ''); 123 + } catch { 124 + return url; 125 + } 126 + }