This repository has no description
0

Configure Feed

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

stigmergic / src / island-explore.ts
18 kB 382 lines
1/** 2 * island:explore <aturi> 3 * 4 * Local-first island explorer that ingests an org.latha.island record, identifies 5 * fringe nodes, and emits endofunctor suggestions for re-attaching them to the island. 6 * 7 * Scoring is carry-backed: crawled page text is matched against the carry entity 8 * and citation index, and overlap with island edges determines confidence. 9 */ 10 11import { resolve, dirname } from 'node:path'; 12import { fileURLToPath } from 'node:url'; 13import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 14import { execFileSync } from 'node:child_process'; 15import { AtpAgent } from '@atproto/api'; 16import { loadDotEnv } from './cli-utils.js'; 17import { JSDOM, VirtualConsole } from 'jsdom'; 18 19const __dirname = dirname(fileURLToPath(import.meta.url)); 20await loadDotEnv(resolve(__dirname, '..', '.env')); 21 22const SEMBLE_API = 'https://api.semble.so'; 23const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; 24const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; 25const ATPROTO_SERVICE = process.env.ATPROTO_SERVICE ?? 'https://bsky.social'; 26const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? BLUESKY_HANDLE; 27const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? BLUESKY_APP_PASSWORD; 28const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 29const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 30const FEED_LIMIT = Number(process.env.ISLAND_EXPLORE_LIMIT ?? '50'); 31const PROVENANCE_PATH = process.env.ISLAND_EXPLORE_PROVENANCE ?? resolve(CARRY_DIR, '.island-explore.json'); 32 33interface IslandRecord { 34 $type?: string; 35 source?: { uri?: string; collection?: string }; 36 analysis?: { title?: string; themes?: string[]; highlights?: string[]; tensions?: string[]; openQuestions?: string[]; synthesis?: string }; 37 edges?: Array<{ source?: string; target?: string; relation?: string; title?: string; description?: string }>; 38} 39 40interface CardContent { url?: string; title?: string; description?: string; author?: string; siteName?: string; type?: string; } 41interface FeedItem { 42 id: string; 43 user: { handle?: string; name?: string }; 44 createdAt: string; 45 activityType: 'CARD_COLLECTED' | 'CONNECTION_CREATED'; 46 card?: { url?: string; cardContent?: CardContent }; 47 connection?: { sourceCard?: { url?: string; title?: string; cardContent?: CardContent }; targetCard?: { url?: string; title?: string; cardContent?: CardContent }; relation?: string }; 48} 49interface FeedResponse { activities: FeedItem[]; } 50 51type Suggestion = { source: string; target: string; connectionType: string; confidence: number; evidence: string; fringe: boolean; }; 52 53interface CarryEntity { name: string; url: string; description: string } 54interface CarryCitation { url: string; title: string; takeaway: string; domain: string } 55interface CarryIndex { 56 entitiesByName: Map<string, CarryEntity>; 57 entitiesByUrl: Map<string, CarryEntity>; 58 citationsByUrl: Map<string, CarryCitation>; 59 islandEntityUrls: Set<string>; 60} 61 62function normalizeUrl(url: string | undefined): string | null { 63 if (!url) return null; 64 try { 65 const u = new URL(url); 66 u.hash = ''; 67 if (u.hostname.startsWith('www.')) u.hostname = u.hostname.slice(4); 68 if (u.protocol !== 'https:') u.protocol = 'https:'; 69 return u.toString(); 70 } catch { 71 return url.trim().startsWith('https://') ? url.trim() : null; 72 } 73} 74 75function isHttps(url: string | null): url is string { 76 return !!url && url.startsWith('https://'); 77} 78 79function carryQueryJson<T>(domain: string, fields: string[]): T[] { 80 try { 81 const out = execFileSync('carry', ['query', domain, ...fields, '--format', 'json'], { 82 cwd: CARRY_DIR, 83 encoding: 'utf-8', 84 timeout: 15_000, 85 }); 86 return JSON.parse(out) as T[]; 87 } catch { 88 return []; 89 } 90} 91 92function buildCarryIndex(islandTargets: Set<string>): CarryIndex { 93 const entitiesByName = new Map<string, CarryEntity>(); 94 const entitiesByUrl = new Map<string, CarryEntity>(); 95 96 const rawEntities = carryQueryJson<{ name?: string; url?: string; description?: string }>('org.latha.entity', ['name', 'url', 'description']); 97 for (const e of rawEntities) { 98 if (!e.name) continue; 99 const ent: CarryEntity = { name: e.name, url: e.url ?? '', description: e.description ?? '' }; 100 entitiesByName.set(e.name.toLowerCase(), ent); 101 if (e.url) { 102 const norm = normalizeUrl(e.url); 103 if (norm) entitiesByUrl.set(norm, ent); 104 } 105 } 106 107 const citationsByUrl = new Map<string, CarryCitation>(); 108 const rawCitations = carryQueryJson<{ url?: string; title?: string; takeaway?: string; domain?: string }>('org.latha.citation', ['url', 'title', 'takeaway', 'domain']); 109 for (const c of rawCitations) { 110 if (!c.url) continue; 111 const norm = normalizeUrl(c.url); 112 if (norm) citationsByUrl.set(norm, { url: norm, title: c.title ?? '', takeaway: c.takeaway ?? '', domain: c.domain ?? '' }); 113 } 114 115 const islandEntityUrls = new Set<string>(); 116 for (const target of islandTargets) { 117 if (entitiesByUrl.has(target)) islandEntityUrls.add(target); 118 if (citationsByUrl.has(target)) islandEntityUrls.add(target); 119 } 120 121 return { entitiesByName, entitiesByUrl, citationsByUrl, islandEntityUrls }; 122} 123 124function extractCarryRefs(text: string, index: CarryIndex): { entities: CarryEntity[]; citations: CarryCitation[]; islandHits: number } { 125 const lower = text.toLowerCase(); 126 const foundEntities: CarryEntity[] = []; 127 const foundCitations: CarryCitation[] = []; 128 const seen = new Set<string>(); 129 let islandHits = 0; 130 131 for (const [name, entity] of index.entitiesByName) { 132 if (name.length < 4 || seen.has(name)) continue; 133 if (lower.includes(name)) { 134 seen.add(name); 135 foundEntities.push(entity); 136 const norm = normalizeUrl(entity.url); 137 if (norm && index.islandEntityUrls.has(norm)) islandHits++; 138 } 139 } 140 141 const urlPattern = /https?:\/\/[^\s)"'<>]+/gi; 142 const urls = lower.match(urlPattern) ?? []; 143 for (const raw of urls) { 144 const norm = normalizeUrl(raw); 145 if (!norm || seen.has(norm)) continue; 146 seen.add(norm); 147 const citation = index.citationsByUrl.get(norm); 148 if (citation) { 149 foundCitations.push(citation); 150 if (index.islandEntityUrls.has(norm)) islandHits++; 151 } 152 const entity = index.entitiesByUrl.get(norm); 153 if (entity && !foundEntities.includes(entity)) { 154 foundEntities.push(entity); 155 if (index.islandEntityUrls.has(norm)) islandHits++; 156 } 157 } 158 159 return { entities: foundEntities, citations: foundCitations, islandHits }; 160} 161 162function scoreCarryOverlap(refs: ReturnType<typeof extractCarryRefs>, islandTargets: Set<string>): { score: number; evidence: string; type: string } { 163 const totalRefs = refs.entities.length + refs.citations.length; 164 if (totalRefs === 0) return { score: 0, evidence: 'no carry refs found', type: 'RELATED' }; 165 166 const islandRatio = refs.islandHits / totalRefs; 167 const score = Math.min(0.97, 0.3 + islandRatio * 0.5 + Math.min(totalRefs, 10) * 0.02); 168 169 const entityNames = refs.entities.map((e) => e.name).slice(0, 8); 170 const citationTitles = refs.citations.map((c) => c.title).slice(0, 4); 171 const evidence = [ 172 entityNames.length > 0 ? `entities: ${entityNames.join(', ')}` : '', 173 citationTitles.length > 0 ? `citations: ${citationTitles.join(', ')}` : '', 174 `island overlap: ${refs.islandHits}/${totalRefs}`, 175 ].filter(Boolean).join('; '); 176 177 const type = islandRatio > 0.5 ? 'LEADS_TO' 178 : islandRatio > 0.2 ? 'SUPPORTS' 179 : refs.entities.some((e) => indexIsInIsland(e, islandTargets)) ? 'ANNOTATES' 180 : 'RELATED'; 181 182 return { score, evidence, type }; 183} 184 185function indexIsInIsland(entity: CarryEntity, islandTargets: Set<string>): boolean { 186 const norm = normalizeUrl(entity.url); 187 return !!norm && islandTargets.has(norm); 188} 189 190async function loginToSemble(): Promise<string> { 191 if (!BLUESKY_HANDLE || !BLUESKY_APP_PASSWORD) throw new Error('BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD must be set'); 192 const res = await fetch(`${SEMBLE_API}/api/users/login/app-password`, { 193 method: 'POST', headers: { 'Content-Type': 'application/json' }, 194 body: JSON.stringify({ identifier: BLUESKY_HANDLE, appPassword: BLUESKY_APP_PASSWORD }), redirect: 'manual', 195 }); 196 if (res.status !== 200 && res.status !== 302) { 197 const body = await res.text().catch(() => ''); 198 throw new Error(`Semble login failed (${res.status}): ${body.slice(0, 200)}`); 199 } 200 const setCookieHeaders = res.headers.getSetCookie?.() ?? []; 201 if (setCookieHeaders.length === 0) { 202 const rawCookie = res.headers.get('set-cookie'); 203 if (rawCookie) setCookieHeaders.push(rawCookie); 204 } 205 if (setCookieHeaders.length === 0) throw new Error('Semble login succeeded but no cookies returned'); 206 return setCookieHeaders.map((c: string) => c.split(';')[0].trim()).join('; '); 207} 208 209async function fetchFollowingFeed(cookies: string): Promise<FeedItem[]> { 210 const url = new URL(`${SEMBLE_API}/api/feeds/following`); 211 url.searchParams.set('limit', String(FEED_LIMIT)); 212 const res = await fetch(url.toString(), { headers: { Cookie: cookies, Accept: 'application/json' } }); 213 if (!res.ok) { 214 const body = await res.text().catch(() => ''); 215 throw new Error(`Semble feed fetch failed (${res.status}): ${body.slice(0, 200)}`); 216 } 217 const data = (await res.json()) as FeedResponse; 218 return data.activities ?? []; 219} 220 221async function scrapeUrlText(url: string): Promise<string | null> { 222 try { 223 const res = await fetch(url, { signal: AbortSignal.timeout(10_000), headers: { 'User-Agent': 'Mozilla/5.0 (compatible; island-explore/1.0)', Accept: 'text/html' }, redirect: 'follow' }); 224 if (!res.ok) return null; 225 const dom = new JSDOM(await res.text(), { url, virtualConsole: new VirtualConsole({ silent: true }) }); 226 const doc = dom.window.document; 227 for (const el of doc.querySelectorAll('nav, script, style, footer, header, aside')) el.remove(); 228 const root = doc.querySelector('article') ?? doc.querySelector('main') ?? doc.body; 229 const text = root?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; 230 return text.length >= 50 ? text.slice(0, 5000) : null; 231 } catch { 232 return null; 233 } 234} 235 236async function fetchIslandRecord(aturi: string): Promise<IslandRecord> { 237 const localPath = resolve(CARRY_DIR, '.islands', `${aturi.replace(/[^a-zA-Z0-9._-]+/g, '_')}.json`); 238 if (existsSync(localPath)) return JSON.parse(readFileSync(localPath, 'utf-8')) as IslandRecord; 239 const agent = new AtpAgent({ service: ATPROTO_SERVICE }); 240 await agent.login({ identifier: BLUESKY_HANDLE, password: BLUESKY_APP_PASSWORD }); 241 const parts = aturi.split('/'); 242 if (parts.length < 5) throw new Error(`Invalid AT URI: ${aturi}`); 243 const collection = parts[3]; 244 const rkey = parts[4]; 245 const res = await agent.api.com.atproto.repo.getRecord({ repo: parts[2], collection, rkey }); 246 return res.data.value as IslandRecord; 247} 248 249function islandEdgeTargets(island: IslandRecord): string[] { 250 return [...new Set((island.edges ?? []).map((e) => normalizeUrl(e.target)).filter(isHttps))]; 251} 252 253function carryAssertEdge(source: string, target: string, relation: string, context: string): void { 254 const payload = [`edge:`, ` org.latha.connection:`, ` source: ${JSON.stringify(source)}`, ` target: ${JSON.stringify(target)}`, ` relation: ${JSON.stringify(relation)}`, ` context: ${JSON.stringify(context.slice(0, 1000))}`, ''].join('\n'); 255 execFileSync('carry', ['assert', '-'], { cwd: CARRY_DIR, input: payload, encoding: 'utf-8' }); 256} 257 258async function createPdsEdge(agent: AtpAgent, source: string, target: string, connectionType: string, note: string): Promise<string | null> { 259 const r = await agent.api.com.atproto.repo.createRecord({ repo: agent.session!.did, collection: 'network.cosmik.connection', record: { $type: 'network.cosmik.connection', source, target, connectionType, note, createdAt: new Date().toISOString() } }); 260 return r.data.uri; 261} 262 263async function main(): Promise<void> { 264 const aturi = process.argv[2]; 265 if (!aturi) throw new Error('Usage: bun island:explore <aturi> [--json] [--write]'); 266 const wantJson = process.argv.includes('--json'); 267 const wantWrite = process.argv.includes('--write'); 268 const limitArg = process.argv.find((arg) => arg.startsWith('--limit=')); 269 const limit = limitArg ? Number(limitArg.split('=')[1]) : FEED_LIMIT; 270 271 const island = await fetchIslandRecord(aturi); 272 const targets = islandEdgeTargets(island); 273 if (targets.length === 0) { 274 throw new Error(`Island record has no canonical https edge URLs in edges (found ${island.edges?.length ?? 0} edges)`); 275 } 276 const islandTargetSet = new Set(targets); 277 278 console.error(`Building carry index...`); 279 const carryIndex = buildCarryIndex(islandTargetSet); 280 console.error(`Carry index: ${carryIndex.entitiesByName.size} entities, ${carryIndex.citationsByUrl.size} citations, ${carryIndex.islandEntityUrls.size} island overlaps`); 281 282 const cookies = await loginToSemble(); 283 const feed = (await fetchFollowingFeed(cookies)).slice(0, limit); 284 285 const entityIndex = new Map<string, { url?: string }>(); 286 for (const e of carryQueryJson<{ name?: string; url?: string }>('org.latha.entity', ['name', 'url'])) { 287 if (e.url) entityIndex.set(normalizeUrl(e.url) ?? e.url, e); 288 } 289 const carryConnections = new Set<string>(); 290 291 const fringeUrls = new Map<string, CardContent>(); 292 for (const item of feed) { 293 const url = item.activityType === 'CARD_COLLECTED' 294 ? normalizeUrl(item.card?.cardContent?.url ?? item.card?.url) 295 : normalizeUrl(item.connection?.sourceCard?.url ?? item.connection?.targetCard?.url); 296 const card = item.activityType === 'CARD_COLLECTED' ? item.card?.cardContent : item.connection?.targetCard?.cardContent ?? item.connection?.sourceCard?.cardContent; 297 if (url && card && !entityIndex.has(url)) fringeUrls.set(url, card); 298 } 299 300 const suggestions: Suggestion[] = []; 301 for (const [url, card] of fringeUrls) { 302 const crawled = await scrapeUrlText(url); 303 if (!crawled) continue; 304 const text = `${card.title ?? ''} ${card.description ?? ''} ${crawled}`; 305 const refs = extractCarryRefs(text, carryIndex); 306 if (refs.entities.length + refs.citations.length === 0) continue; 307 const scored = scoreCarryOverlap(refs, islandTargetSet); 308 if (scored.score < 0.3) continue; 309 // Suggest edges to the specific island targets that overlap 310 const overlapTargets = refs.entities 311 .map((e) => normalizeUrl(e.url)) 312 .filter((u): u is string => !!u && islandTargetSet.has(u)); 313 const citationOverlapTargets = refs.citations 314 .map((c) => c.url) 315 .filter((u) => islandTargetSet.has(u)); 316 const allTargets = [...new Set([...overlapTargets, ...citationOverlapTargets])]; 317 if (allTargets.length === 0) { 318 // No direct overlap — suggest to the first island target as a weaker connection 319 suggestions.push({ source: url, target: targets[0], connectionType: scored.type, confidence: scored.score * 0.6, evidence: `${scored.evidence} (no direct island overlap)`, fringe: true }); 320 } else { 321 for (const target of allTargets) { 322 suggestions.push({ source: url, target, connectionType: scored.type, confidence: scored.score, evidence: scored.evidence, fringe: true }); 323 } 324 } 325 } 326 327 const output = { 328 island: { aturi, title: island.analysis?.title ?? island.source?.uri ?? 'Island', targets, carryEntities: carryIndex.entitiesByName.size, carryCitations: carryIndex.citationsByUrl.size, islandOverlaps: carryIndex.islandEntityUrls.size }, 329 fringeCount: fringeUrls.size, 330 suggestionCount: suggestions.length, 331 suggestions: suggestions.sort((a, b) => b.confidence - a.confidence), 332 writeEnabled: wantWrite, 333 }; 334 335 if (wantWrite) { 336 const pds = new AtpAgent({ service: PDS_SERVICE }); 337 if (PDS_IDENTIFIER && PDS_APP_PASSWORD) await pds.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); 338 let wrote = 0; 339 for (const suggestion of output.suggestions) { 340 if (suggestion.confidence < 0.6) continue; 341 const source = normalizeUrl(suggestion.source); 342 const target = normalizeUrl(suggestion.target); 343 if (!isHttps(source) || !isHttps(target)) continue; 344 const key = `${source}::${target}`; 345 if (carryConnections.has(key)) continue; 346 carryAssertEdge(source, target, suggestion.connectionType, suggestion.evidence); 347 carryConnections.add(key); 348 const uri = await createPdsEdge(pds, source, target, suggestion.connectionType, suggestion.evidence); 349 if (uri) { 350 wrote += 1; 351 console.log(`Wrote edge: ${uri}`); 352 } 353 } 354 saveJson(PROVENANCE_PATH, { aturi, wrote, wroteAt: new Date().toISOString(), suggestions: output.suggestions }); 355 output.writeEnabled = true; 356 } 357 358 if (wantJson) { 359 console.log(JSON.stringify(output, null, 2)); 360 return; 361 } 362 363 console.log(`Island: ${output.island.title} (${aturi})`); 364 console.log(`Targets: ${output.island.targets.length} edge URLs`); 365 console.log(`Carry: ${output.island.carryEntities} entities, ${output.island.carryCitations} citations, ${output.island.islandOverlaps} island overlaps`); 366 console.log(`Fringe nodes: ${output.fringeCount}`); 367 console.log(`Suggestions: ${output.suggestionCount}`); 368 for (const s of output.suggestions.slice(0, 20)) { 369 console.log(`- ${s.connectionType} ${s.source} -> ${s.target} (${s.confidence.toFixed(2)})`); 370 console.log(` ${s.evidence}`); 371 } 372} 373 374function saveJson(path: string, value: unknown): void { 375 mkdirSync(dirname(path), { recursive: true }); 376 writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf-8'); 377} 378 379main().catch((err) => { 380 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 381 process.exit(1); 382});