/** * island:detect * * Match our carry graph against discovered components to find closest island. * Uses the SQLite cache from island:discover. * * Usage: * bun island:detect # find which island we're closest to * bun island:detect --top 5 # show top 5 closest islands * bun island:detect --json # machine-readable output */ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { AtpAgent } from '@atproto/api'; import { loadDotEnv } from './cli-utils.js'; import { openDb, findComponents, islandId, domainFromUrl, type Component } from './island-shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); await loadDotEnv(resolve(__dirname, '..', '.env')); const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; const ATPROTO_SERVICE = process.env.ATPROTO_SERVICE ?? 'https://bsky.social'; const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; const COLLECTION = 'network.cosmik.connection'; interface MatchResult { islandId: string; lexmin: string; nodeCount: number; didCount: number; sharedCount: number; similarity: number; sharedUrls: string[]; } // --- Our graph --- function normalizeUrl(url: string): string { try { const u = new URL(url); u.hash = ''; if (u.hostname.startsWith('www.')) u.hostname = u.hostname.slice(4); return u.toString(); } catch { return url; } } function carryQueryJson(domain: string, fields: string[]): T[] { try { const out = execFileSync('carry', ['query', domain, ...fields, '--format', 'json'], { cwd: CARRY_DIR, encoding: 'utf-8', timeout: 15_000, }); return JSON.parse(out) as T[]; } catch { return []; } } function buildOurUrlSet(): Set { const urls = new Set(); // From carry connections const connections = carryQueryJson<{ source?: string; target?: string }>('org.latha.connection', ['source', 'target']); const entities = carryQueryJson<{ name?: string; url?: string }>('org.latha.entity', ['name', 'url']); const entityUrls = new Map(); for (const e of entities) { if (e.name && e.url?.startsWith('https://')) { entityUrls.set(e.name, e.url); } } for (const c of connections) { const sUrl = entityUrls.get(c.source ?? '') ?? (c.source?.startsWith('https://') ? c.source : undefined); const tUrl = entityUrls.get(c.target ?? '') ?? (c.target?.startsWith('https://') ? c.target : undefined); if (sUrl) urls.add(normalizeUrl(sUrl)); if (tUrl) urls.add(normalizeUrl(tUrl)); } return urls; } async function fetchOurPdsUrls(): Promise> { const urls = new Set(); if (!BLUESKY_HANDLE) return urls; try { const agent = new AtpAgent({ service: ATPROTO_SERVICE }); await agent.login({ identifier: BLUESKY_HANDLE, password: BLUESKY_APP_PASSWORD }); const did = agent.session!.did; // Resolve PDS const doc = await fetch(`https://plc.directory/${did}`).then((r) => r.json()) as { service?: Array<{ id: string; serviceEndpoint: string }> }; const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint ?? PDS_SERVICE; // Fetch our connection records let cursor = ''; while (true) { const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); url.searchParams.set('repo', did); url.searchParams.set('collection', COLLECTION); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url.toString()); if (!res.ok) break; const data = await res.json() as { records: Array<{ value: { source?: string; target?: string } }>; cursor?: string; }; for (const r of data.records) { if (r.value.source?.startsWith('https://')) urls.add(normalizeUrl(r.value.source)); if (r.value.target?.startsWith('https://')) urls.add(normalizeUrl(r.value.target)); } cursor = data.cursor ?? ''; if (!cursor) break; } } catch {} return urls; } // --- Similarity --- function jaccard(a: Set, b: Set): number { if (a.size === 0 && b.size === 0) return 0; let intersection = 0; for (const item of a) { if (b.has(item)) intersection++; } const union = a.size + b.size - intersection; return union === 0 ? 0 : intersection / union; } function sharedUrls(a: Set, b: Set): string[] { const shared: string[] = []; for (const url of a) { if (b.has(url)) shared.push(url); } return shared.sort(); } // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const wantJson = args.includes('--json'); const topArg = args.find((a) => a.startsWith('--top=')); const topN = topArg ? Number(topArg.split('=')[1]) : 5; // Check if discover has been run let db; try { db = openDb(); const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; if (count === 0) { console.error('No components found. Run `bun island:discover` first.'); process.exit(1); } } catch { console.error('Database not found. Run `bun island:discover` first.'); process.exit(1); } console.error('Building our carry graph URL set...'); const ourUrls = buildOurUrlSet(); const pdsUrls = await fetchOurPdsUrls(); for (const url of pdsUrls) ourUrls.add(url); console.error(`Our graph: ${ourUrls.size} unique HTTPS URLs`); console.error('Finding components...'); const components = findComponents(db); console.error(`Found ${components.length} components`); // Match against each component const matches: MatchResult[] = []; for (const comp of components) { const sim = jaccard(ourUrls, comp.nodes); const shared = sharedUrls(ourUrls, comp.nodes); if (shared.length > 0) { matches.push({ islandId: comp.islandId, lexmin: comp.lexmin, nodeCount: comp.nodes.size, didCount: comp.dids.size, sharedCount: shared.length, similarity: sim, sharedUrls: shared, }); } } matches.sort((a, b) => b.similarity - a.similarity); const topMatches = matches.slice(0, topN); if (wantJson) { console.log(JSON.stringify({ ourUrlCount: ourUrls.size, componentCount: components.length, matchCount: matches.length, topMatches: topMatches.map((m) => ({ ...m, sharedUrls: m.sharedUrls.slice(0, 20), })), }, null, 2)); db.close(); return; } console.log(`\nIsland detect: our graph (${ourUrls.size} URLs) matched against ${components.length} components`); console.log(`Islands with overlap: ${matches.length}\n`); for (const m of topMatches) { const domain = domainFromUrl(m.lexmin); console.log(`${m.islandId} ${m.nodeCount} nodes, ${m.didCount} DIDs ${domain}`); console.log(` Similarity: ${(m.similarity * 100).toFixed(2)}% (${m.sharedCount} shared URLs)`); for (const url of m.sharedUrls) { console.log(` ${url}`); } console.log(); } if (topMatches.length > 0) { const best = topMatches[0]; console.log(`\n→ We belong to island ${best.islandId} (${(best.similarity * 100).toFixed(1)}% match)`); } db.close(); } main().catch((err) => { console.error('Fatal:', err instanceof Error ? err.message : String(err)); process.exit(1); });