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