This repository has no description
0

Configure Feed

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

stigmergic / src / island-detect.ts
9.4 kB 298 lines
1/** 2 * island:detect 3 * 4 * Match our carry graph against discovered components to find closest island. 5 * Uses the SQLite cache from island:discover. 6 * 7 * Usage: 8 * bun island:detect # find which component we're closest to 9 * bun island:detect --top 5 # show top 5 closest components 10 * bun island:detect --json # machine-readable output 11 */ 12 13import { resolve, dirname } from 'node:path'; 14import { fileURLToPath } from 'node:url'; 15import { execFileSync } from 'node:child_process'; 16import { Database } from 'bun:sqlite'; 17import { AtpAgent } from '@atproto/api'; 18import { loadDotEnv } from './cli-utils.js'; 19 20const __dirname = dirname(fileURLToPath(import.meta.url)); 21await loadDotEnv(resolve(__dirname, '..', '.env')); 22 23const RELAY = process.env.ATPROTO_RELAY ?? 'https://bsky.network'; 24const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; 25const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; 26const ATPROTO_SERVICE = process.env.ATPROTO_SERVICE ?? 'https://bsky.social'; 27const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 28const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 29const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 30const COLLECTION = 'network.cosmik.connection'; 31 32interface Component { 33 id: number; 34 nodes: Set<string>; 35 dids: Set<string>; 36} 37 38interface MatchResult { 39 componentId: number; 40 nodeCount: number; 41 didCount: number; 42 sharedCount: number; 43 similarity: number; 44 sharedUrls: string[]; 45} 46 47// --- SQLite --- 48 49function openDb(): Database { 50 const db = new Database(DB_PATH, { readonly: true }); 51 return db; 52} 53 54// --- Our graph --- 55 56function normalizeUrl(url: string): string { 57 try { 58 const u = new URL(url); 59 u.hash = ''; 60 if (u.hostname.startsWith('www.')) u.hostname = u.hostname.slice(4); 61 return u.toString(); 62 } catch { 63 return url; 64 } 65} 66 67function carryQueryJson<T>(domain: string, fields: string[]): T[] { 68 try { 69 const out = execFileSync('carry', ['query', domain, ...fields, '--format', 'json'], { 70 cwd: CARRY_DIR, 71 encoding: 'utf-8', 72 timeout: 15_000, 73 }); 74 return JSON.parse(out) as T[]; 75 } catch { 76 return []; 77 } 78} 79 80function buildOurUrlSet(): Set<string> { 81 const urls = new Set<string>(); 82 83 // From carry connections 84 const connections = carryQueryJson<{ source?: string; target?: string }>('org.latha.connection', ['source', 'target']); 85 const entities = carryQueryJson<{ name?: string; url?: string }>('org.latha.entity', ['name', 'url']); 86 const entityUrls = new Map<string, string>(); 87 for (const e of entities) { 88 if (e.name && e.url?.startsWith('https://')) { 89 entityUrls.set(e.name, e.url); 90 } 91 } 92 93 for (const c of connections) { 94 const sUrl = entityUrls.get(c.source ?? '') ?? (c.source?.startsWith('https://') ? c.source : undefined); 95 const tUrl = entityUrls.get(c.target ?? '') ?? (c.target?.startsWith('https://') ? c.target : undefined); 96 if (sUrl) urls.add(normalizeUrl(sUrl)); 97 if (tUrl) urls.add(normalizeUrl(tUrl)); 98 } 99 100 return urls; 101} 102 103async function fetchOurPdsUrls(): Promise<Set<string>> { 104 const urls = new Set<string>(); 105 if (!BLUESKY_HANDLE) return urls; 106 107 try { 108 const agent = new AtpAgent({ service: ATPROTO_SERVICE }); 109 await agent.login({ identifier: BLUESKY_HANDLE, password: BLUESKY_APP_PASSWORD }); 110 const did = agent.session!.did; 111 112 // Resolve PDS 113 const doc = await fetch(`https://plc.directory/${did}`).then((r) => r.json()) as { service?: Array<{ id: string; serviceEndpoint: string }> }; 114 const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint ?? PDS_SERVICE; 115 116 // Fetch our connection records 117 let cursor = ''; 118 while (true) { 119 const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); 120 url.searchParams.set('repo', did); 121 url.searchParams.set('collection', COLLECTION); 122 url.searchParams.set('limit', '100'); 123 if (cursor) url.searchParams.set('cursor', cursor); 124 125 const res = await fetch(url.toString()); 126 if (!res.ok) break; 127 const data = await res.json() as { 128 records: Array<{ value: { source?: string; target?: string } }>; 129 cursor?: string; 130 }; 131 132 for (const r of data.records) { 133 if (r.value.source?.startsWith('https://')) urls.add(normalizeUrl(r.value.source)); 134 if (r.value.target?.startsWith('https://')) urls.add(normalizeUrl(r.value.target)); 135 } 136 137 cursor = data.cursor ?? ''; 138 if (!cursor) break; 139 } 140 } catch {} 141 142 return urls; 143} 144 145// --- Components --- 146 147function 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} 190 191function jaccard(a: Set<string>, b: Set<string>): number { 192 if (a.size === 0 && b.size === 0) return 0; 193 let intersection = 0; 194 for (const item of a) { 195 if (b.has(item)) intersection++; 196 } 197 const union = a.size + b.size - intersection; 198 return union === 0 ? 0 : intersection / union; 199} 200 201function sharedUrls(a: Set<string>, b: Set<string>): string[] { 202 const shared: string[] = []; 203 for (const url of a) { 204 if (b.has(url)) shared.push(url); 205 } 206 return shared.sort(); 207} 208 209// --- Main --- 210 211async function main(): Promise<void> { 212 const args = process.argv.slice(2); 213 const wantJson = args.includes('--json'); 214 const topArg = args.find((a) => a.startsWith('--top=')); 215 const topN = topArg ? Number(topArg.split('=')[1]) : 5; 216 217 // Check if discover has been run 218 let db: Database; 219 try { 220 db = openDb(); 221 const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; 222 if (count === 0) { 223 console.error('No components found. Run `bun island:discover` first.'); 224 process.exit(1); 225 } 226 } catch { 227 console.error('Database not found. Run `bun island:discover` first.'); 228 process.exit(1); 229 } 230 231 console.error('Building our carry graph URL set...'); 232 const ourUrls = buildOurUrlSet(); 233 const pdsUrls = await fetchOurPdsUrls(); 234 for (const url of pdsUrls) ourUrls.add(url); 235 console.error(`Our graph: ${ourUrls.size} unique HTTPS URLs`); 236 237 console.error('Finding components...'); 238 const components = findComponents(db); 239 console.error(`Found ${components.length} components`); 240 241 // Match against each component 242 const matches: MatchResult[] = []; 243 for (const comp of components) { 244 const sim = jaccard(ourUrls, comp.nodes); 245 const shared = sharedUrls(ourUrls, comp.nodes); 246 if (shared.length > 0) { 247 matches.push({ 248 componentId: comp.id, 249 nodeCount: comp.nodes.size, 250 didCount: comp.dids.size, 251 sharedCount: shared.length, 252 similarity: sim, 253 sharedUrls: shared, 254 }); 255 } 256 } 257 258 matches.sort((a, b) => b.similarity - a.similarity); 259 const topMatches = matches.slice(0, topN); 260 261 if (wantJson) { 262 console.log(JSON.stringify({ 263 ourUrlCount: ourUrls.size, 264 componentCount: components.length, 265 matchCount: matches.length, 266 topMatches: topMatches.map((m) => ({ 267 ...m, 268 sharedUrls: m.sharedUrls.slice(0, 20), 269 })), 270 }, null, 2)); 271 db.close(); 272 return; 273 } 274 275 console.log(`\nIsland detect: our graph (${ourUrls.size} URLs) matched against ${components.length} components`); 276 console.log(`Components with overlap: ${matches.length}\n`); 277 278 for (const m of topMatches) { 279 console.log(`Component ${m.componentId}: ${m.nodeCount} nodes, ${m.didCount} DIDs`); 280 console.log(` Similarity: ${(m.similarity * 100).toFixed(2)}% (${m.sharedCount} shared URLs)`); 281 for (const url of m.sharedUrls) { 282 console.log(` ${url}`); 283 } 284 console.log(); 285 } 286 287 if (topMatches.length > 0) { 288 const best = topMatches[0]; 289 console.log(`\n→ We belong to Component ${best.componentId} (${(best.similarity * 100).toFixed(1)}% match)`); 290 } 291 292 db.close(); 293} 294 295main().catch((err) => { 296 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 297 process.exit(1); 298});