This repository has no description
0

Configure Feed

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

stigmergic / src / island-analyze.ts
16 kB 427 lines
1/** 2 * island:analyze <island-id> 3 * 4 * Analyze a discovered component using an LLM agent (letta-code-sdk). 5 * Writes analysis results to SQLite (not PDS — use publish for that). 6 * 7 * Usage: 8 * bun island:analyze a1b2c3d4e5f6 # analyze by island hash 9 * bun island:analyze a1b2c3d4e5f6 --force # re-analyze even if cached 10 * bun island:analyze --all # analyze all components 11 * bun island:analyze --all --min-size 5 # only components with >= 5 nodes 12 * bun island:analyze a1b2c3d4e5f6 --json # machine-readable output 13 */ 14 15import { resolve, dirname } from 'node:path'; 16import { fileURLToPath } from 'node:url'; 17import { Database } from 'bun:sqlite'; 18import { 19 createAgent, 20 createSession, 21 type AnyAgentTool, 22 type AgentToolResult, 23 type SDKResultMessage, 24 type SDKMessage, 25} from '@letta-ai/letta-code-sdk'; 26import { loadDotEnv } from './cli-utils.js'; 27import { findComponents, domainFromUrl, type Component, type EdgeRow, fetchComponentEdges, resolveDidHandles } from './island-shared.js'; 28 29const __dirname = dirname(fileURLToPath(import.meta.url)); 30await loadDotEnv(resolve(__dirname, '..', '.env')); 31 32const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 33const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 34 35// --- Types --- 36 37interface AnalysisResult { 38 islandId: string; 39 lexmin: string; 40 title: string; 41 themes: string[]; 42 highlights: string[]; 43 tensions: string[]; 44 openQuestions: string[]; 45 synthesis: string; 46 newConnections: Array<{ source: string; target: string; connectionType: string; note: string }>; 47 analyzedAt: string; 48} 49 50// --- SQLite --- 51 52function openDb(): Database { 53 const db = new Database(DB_PATH, { create: true }); 54 db.exec('PRAGMA journal_mode = WAL'); 55 db.exec('PRAGMA synchronous = NORMAL'); 56 db.exec(` 57 CREATE TABLE IF NOT EXISTS analyses ( 58 island_id TEXT PRIMARY KEY, 59 title TEXT, 60 themes TEXT, 61 highlights TEXT, 62 tensions TEXT, 63 open_questions TEXT, 64 synthesis TEXT, 65 new_connections TEXT, 66 analyzed_at TEXT NOT NULL 67 ); 68 `); 69 return db; 70} 71 72function saveAnalysis(db: Database, result: AnalysisResult): void { 73 db.query(` 74 INSERT INTO analyses (island_id, title, themes, highlights, tensions, open_questions, synthesis, new_connections, analyzed_at) 75 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 76 ON CONFLICT(island_id) DO UPDATE SET 77 title=excluded.title, themes=excluded.themes, highlights=excluded.highlights, 78 tensions=excluded.tensions, open_questions=excluded.open_questions, 79 synthesis=excluded.synthesis, new_connections=excluded.new_connections, analyzed_at=excluded.analyzed_at 80 `).run( 81 result.islandId, 82 result.title, 83 JSON.stringify(result.themes), 84 JSON.stringify(result.highlights), 85 JSON.stringify(result.tensions), 86 JSON.stringify(result.openQuestions), 87 result.synthesis, 88 JSON.stringify(result.newConnections), 89 result.analyzedAt, 90 ); 91} 92 93function loadAnalysis(db: Database, islandId: string): AnalysisResult | null { 94 const row = db.query('SELECT * FROM analyses WHERE island_id = ?').get(islandId) as Record<string, string> | null; 95 if (!row) return null; 96 return { 97 islandId: row.island_id, 98 lexmin: '', 99 title: row.title ?? '', 100 themes: JSON.parse(row.themes ?? '[]'), 101 highlights: JSON.parse(row.highlights ?? '[]'), 102 tensions: JSON.parse(row.tensions ?? '[]'), 103 openQuestions: JSON.parse(row.open_questions ?? '[]'), 104 synthesis: row.synthesis ?? '', 105 newConnections: JSON.parse(row.new_connections ?? '[]'), 106 analyzedAt: row.analyzed_at, 107 }; 108} 109 110// --- Build island context from SQLite data --- 111 112function buildIslandContext(db: Database, comp: Component): string { 113 const edges = fetchComponentEdges(db, comp); 114 const didHandles = resolveDidHandles(db, comp.dids); 115 116 // Domain distribution 117 const domainCounts = new Map<string, number>(); 118 for (const url of comp.nodes) { 119 const d = domainFromUrl(url); 120 domainCounts.set(d, (domainCounts.get(d) ?? 0) + 1); 121 } 122 const topDomains = [...domainCounts.entries()] 123 .sort((a, b) => b[1] - a[1]) 124 .slice(0, 15) 125 .map(([d, c]) => `${d} (${c})`) 126 .join(', '); 127 128 // Edge type distribution 129 const edgeTypes = new Map<string, number>(); 130 for (const e of edges) { 131 const t = e.relation || 'relates'; 132 edgeTypes.set(t, (edgeTypes.get(t) ?? 0) + 1); 133 } 134 const edgeTypeStr = [...edgeTypes.entries()] 135 .sort((a, b) => b[1] - a[1]) 136 .map(([t, c]) => `${t} (${c})`) 137 .join(', '); 138 139 // Contributors 140 const contributorStr = [...comp.dids] 141 .map((d) => didHandles.get(d) ?? d) 142 .join(', '); 143 144 // Vertices (grouped by domain for readability) 145 const verticesByDomain = new Map<string, string[]>(); 146 for (const url of comp.nodes) { 147 const d = domainFromUrl(url); 148 if (!verticesByDomain.has(d)) verticesByDomain.set(d, []); 149 verticesByDomain.get(d)!.push(url); 150 } 151 const vertexSections = [...verticesByDomain.entries()] 152 .sort((a, b) => b[1].length - a[1].length) 153 .slice(0, 10) 154 .map(([domain, urls]) => `${domain}:\n${urls.slice(0, 5).map((u) => ` ${u}`).join('\n')}${urls.length > 5 ? `\n ... and ${urls.length - 5} more` : ''}`) 155 .join('\n\n'); 156 157 // Edges (sample) 158 const edgeSample = edges.slice(0, 40) 159 .map((e) => { 160 const handle = didHandles.get(e.did) ?? e.did; 161 return `${domainFromUrl(e.source)}${domainFromUrl(e.target)} [${e.relation || 'relates'}] (by ${handle})`; 162 }) 163 .join('\n'); 164 165 return [ 166 `# Island ${comp.islandId}`, 167 '', 168 `## Stats`, 169 `- ${comp.nodes.size} vertices, ${edges.length} edges, ${comp.dids.size} contributors`, 170 `- Top domains: ${topDomains}`, 171 `- Edge types: ${edgeTypeStr}`, 172 `- Contributors: ${contributorStr}`, 173 '', 174 `## Vertices`, 175 vertexSections, 176 '', 177 `## Edges (sample)`, 178 edgeSample, 179 edges.length > 40 ? `\n... and ${edges.length - 40} more edges` : '', 180 ].join('\n'); 181} 182 183// --- island_save tool --- 184 185let pendingResult: AnalysisResult | null = null; 186 187const islandSaveTool: AnyAgentTool = { 188 label: 'island_save', 189 name: 'island_save', 190 description: `Save your analysis of this island. Call once when you have completed your analysis. 191 192Fields: 193- title: concise sentence capturing the island's core narrative (max 300 chars) 194- themes: list of key themes (max 20, each max 100 chars) 195- highlights: list of vertex URLs or edge descriptions that are semantically significant (max 20) 196- tensions: contradictions or unresolved tensions within the island (max 10, each max 500 chars) 197- openQuestions: questions raised by this island (max 10, each max 500 chars) 198- synthesis: prose synthesis connecting the island's vertices and edges into a coherent narrative (max 20000 chars) 199- newConnections: suggested new edges between vertices, with source URL, target URL, connectionType, and a note explaining why (max 50)`, 200 parameters: { 201 type: 'object', 202 properties: { 203 title: { type: 'string', description: 'Concise sentence capturing the core narrative' }, 204 themes: { type: 'array', items: { type: 'string' }, description: 'Key themes' }, 205 highlights: { type: 'array', items: { type: 'string' }, description: 'Semantically significant vertices or edges' }, 206 tensions: { type: 'array', items: { type: 'string' }, description: 'Contradictions or unresolved tensions' }, 207 openQuestions: { type: 'array', items: { type: 'string' }, description: 'Questions raised by this island' }, 208 synthesis: { type: 'string', description: 'Prose synthesis of the island' }, 209 newConnections: { 210 type: 'array', 211 items: { 212 type: 'object', 213 properties: { 214 source: { type: 'string', description: 'Source vertex URL' }, 215 target: { type: 'string', description: 'Target vertex URL' }, 216 connectionType: { type: 'string', description: 'Relationship type (e.g. related-to, supports, contradicts)' }, 217 note: { type: 'string', description: 'Why this connection exists' }, 218 }, 219 required: ['source', 'target', 'connectionType', 'note'], 220 }, 221 description: 'Suggested new edges between vertices', 222 }, 223 }, 224 required: ['title', 'themes', 'synthesis'], 225 }, 226 execute: async (_id, args): Promise<AgentToolResult<unknown>> => { 227 const a = args as Record<string, unknown>; 228 pendingResult = { 229 islandId: '', 230 lexmin: '', 231 title: String(a.title ?? ''), 232 themes: (a.themes as string[]) ?? [], 233 highlights: (a.highlights as string[]) ?? [], 234 tensions: (a.tensions as string[]) ?? [], 235 openQuestions: (a.openQuestions as string[]) ?? [], 236 synthesis: String(a.synthesis ?? ''), 237 newConnections: (a.newConnections as Array<{ source: string; target: string; connectionType: string; note: string }>) ?? [], 238 analyzedAt: new Date().toISOString(), 239 }; 240 return { 241 content: [{ type: 'text', text: 'Analysis saved.' }], 242 }; 243 }, 244}; 245 246// --- Main --- 247 248async function main(): Promise<void> { 249 const args = process.argv.slice(2); 250 const wantJson = args.includes('--json'); 251 const force = args.includes('--force'); 252 const analyzeAll = args.includes('--all'); 253 const minSizeArg = args.find((a) => a.startsWith('--min-size=')); 254 const minSize = minSizeArg ? Number(minSizeArg.split('=')[1]) : 3; 255 256 const targetId = args.find((a) => !a.startsWith('--')); 257 258 if (!targetId && !analyzeAll) { 259 console.error('Usage: bun island:analyze <island-id> [--force] [--json]'); 260 console.error(' bun island:analyze --all [--min-size 5] [--force] [--json]'); 261 process.exit(1); 262 } 263 264 let db: Database; 265 try { 266 db = openDb(); 267 const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; 268 if (count === 0) { 269 console.error('No edges found. Run `bun island:discover` first.'); 270 process.exit(1); 271 } 272 } catch { 273 console.error('Database not found. Run `bun island:discover` first.'); 274 process.exit(1); 275 } 276 277 console.error('Finding components...'); 278 const components = findComponents(db); 279 280 let targets: Component[]; 281 if (analyzeAll) { 282 targets = components.filter((c) => c.nodes.size >= minSize); 283 } else { 284 const comp = components.find((c) => c.islandId === targetId); 285 if (!comp) { 286 console.error(`Island ${targetId} not found. Run \`bun island:discover\` to list available islands.`); 287 process.exit(1); 288 } 289 targets = [comp]; 290 } 291 292 console.error(`Analyzing ${targets.length} island(s)...`); 293 294 // Create agent 295 const agentId = await createAgent({ 296 persona: `You are a research analyst that synthesizes connected components (islands) from a knowledge graph into structured analysis records. 297 298Given an island — a cluster of URLs connected by semantic edges — you produce: 2991. A title that captures the island's core narrative in one sentence 3002. Themes that describe what this cluster is about 3013. Highlights — vertices or edges that are semantically significant 3024. Tensions — contradictions or unresolved questions within the cluster 3035. Open questions raised by this island 3046. A synthesis — a prose narrative connecting the vertices and edges into a coherent story 3057. New connections — suggested edges between vertices that would strengthen the island 306 307You always call island_save once when done. You do not ask for permission.`, 308 human: 'island-analyzer', 309 tools: [islandSaveTool], 310 permissionMode: 'bypassPermissions', 311 memfs: false, 312 systemInfoReminder: false, 313 }); 314 315 console.error(`Created agent: ${agentId}`); 316 317 const session = createSession(agentId, { 318 memfsStartup: 'skip', 319 }); 320 321 await session.initialize(); 322 323 const results: AnalysisResult[] = []; 324 325 for (const comp of targets) { 326 // Check if already analyzed 327 if (!force) { 328 const existing = db.query('SELECT analyzed_at FROM analyses WHERE island_id = ?').get(comp.islandId) as { analyzed_at: string } | null; 329 if (existing) { 330 console.error(`${comp.islandId}: already analyzed at ${existing.analyzed_at} (use --force to re-analyze)`); 331 const cached = loadAnalysis(db, comp.islandId); 332 if (cached) { 333 cached.lexmin = comp.lexmin; 334 results.push(cached); 335 } 336 continue; 337 } 338 } 339 340 console.error(`${comp.islandId}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); 341 342 const context = buildIslandContext(db, comp); 343 pendingResult = null; 344 345 const prompt = `Analyze this island and call island_save with your analysis. 346 347${context}`; 348 349 // Send prompt and stream agent output in real-time 350 session.send(prompt); 351 for await (const msg of session.stream()) { 352 if (msg.type === 'assistant') { 353 console.error(` [agent] ${msg.content}`); 354 } else if (msg.type === 'tool_call') { 355 const input = JSON.stringify(msg.toolInput); 356 console.error(` [tool:${msg.toolName}] ${input.slice(0, 200)}${input.length > 200 ? '...' : ''}`); 357 } else if (msg.type === 'tool_result') { 358 console.error(` [result] ${msg.content.slice(0, 100)}${msg.content.length > 100 ? '...' : ''}`); 359 } else if (msg.type === 'result') { 360 // Terminal result — stream ends 361 const r = msg as SDKResultMessage; 362 if (!r.success) { 363 console.error(` Agent failed: ${r.error ?? r.errorCode ?? 'unknown'}`); 364 } 365 break; 366 } 367 } 368 369 if (!result.success) { 370 console.error(` Agent failed: ${result.error ?? result.errorCode ?? 'unknown'}`); 371 continue; 372 } 373 374 if (!pendingResult) { 375 console.error(` Agent did not call island_save`); 376 continue; 377 } 378 379 // Fill in island metadata 380 pendingResult.islandId = comp.islandId; 381 pendingResult.lexmin = comp.lexmin; 382 383 saveAnalysis(db, pendingResult); 384 results.push(pendingResult); 385 386 console.error(` Title: ${pendingResult.title.slice(0, 80)}`); 387 console.error(` Themes: ${pendingResult.themes.join(', ') || 'none'}`); 388 console.error(` New connections: ${pendingResult.newConnections.length}`); 389 console.error(` Tensions: ${pendingResult.tensions.length}`); 390 } 391 392 await session[Symbol.asyncDispose](); 393 394 if (wantJson) { 395 console.log(JSON.stringify(results, null, 2)); 396 db.close(); 397 return; 398 } 399 400 for (const r of results) { 401 console.log(`\n${r.islandId}: ${r.title}`); 402 console.log(` Themes: ${r.themes.join(', ') || 'none'}`); 403 console.log(` New connections: ${r.newConnections.length}`); 404 if (r.newConnections.length > 0) { 405 for (const nc of r.newConnections.slice(0, 10)) { 406 console.log(` ${nc.source}${nc.target} (${nc.connectionType})`); 407 } 408 if (r.newConnections.length > 10) { 409 console.log(` ... and ${r.newConnections.length - 10} more`); 410 } 411 } 412 console.log(` Tensions: ${r.tensions.length}`); 413 for (const t of r.tensions) console.log(` ${t.slice(0, 100)}`); 414 console.log(` Highlights: ${r.highlights.length}`); 415 for (const h of r.highlights.slice(0, 5)) console.log(` ${h.slice(0, 100)}`); 416 console.log(` Open questions: ${r.openQuestions.length}`); 417 for (const q of r.openQuestions) console.log(` ${q.slice(0, 100)}`); 418 console.log(` Synthesis: ${r.synthesis.slice(0, 300)}${r.synthesis.length > 300 ? '...' : ''}`); 419 } 420 421 db.close(); 422} 423 424main().catch((err) => { 425 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 426 process.exit(1); 427});