/** * island:analyze * * Analyze a discovered component using an LLM agent (letta-code-sdk). * Writes analysis results to SQLite (not PDS — use publish for that). * * Usage: * bun island:analyze a1b2c3d4e5f6 # analyze by island hash * bun island:analyze a1b2c3d4e5f6 --force # re-analyze even if cached * bun island:analyze --all # analyze all components * bun island:analyze --all --min-size 5 # only components with >= 5 nodes * bun island:analyze a1b2c3d4e5f6 --json # machine-readable output */ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Database } from 'bun:sqlite'; import { createAgent, createSession, type AnyAgentTool, type AgentToolResult, type SDKResultMessage, } from '@letta-ai/letta-code-sdk'; import { loadDotEnv } from './cli-utils.js'; import { findComponents, domainFromUrl, type Component, type EdgeRow, fetchComponentEdges, resolveDidHandles } from './island-shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); await loadDotEnv(resolve(__dirname, '..', '.env')); const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); // --- Types --- interface AnalysisResult { islandId: string; lexmin: string; title: string; themes: string[]; highlights: string[]; tensions: string[]; openQuestions: string[]; synthesis: string; newConnections: Array<{ source: string; target: string; connectionType: string; note: string }>; analyzedAt: string; } // --- SQLite --- function openDb(): Database { const db = new Database(DB_PATH, { create: true }); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); db.exec(` CREATE TABLE IF NOT EXISTS analyses ( island_id TEXT PRIMARY KEY, title TEXT, themes TEXT, highlights TEXT, tensions TEXT, open_questions TEXT, synthesis TEXT, new_connections TEXT, analyzed_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); return db; } function saveAnalysis(db: Database, result: AnalysisResult): void { db.query(` INSERT INTO analyses (island_id, title, themes, highlights, tensions, open_questions, synthesis, new_connections, analyzed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(island_id) DO UPDATE SET title=excluded.title, themes=excluded.themes, highlights=excluded.highlights, tensions=excluded.tensions, open_questions=excluded.open_questions, synthesis=excluded.synthesis, new_connections=excluded.new_connections, analyzed_at=excluded.analyzed_at `).run( result.islandId, result.title, JSON.stringify(result.themes), JSON.stringify(result.highlights), JSON.stringify(result.tensions), JSON.stringify(result.openQuestions), result.synthesis, JSON.stringify(result.newConnections), result.analyzedAt, ); } function loadAnalysis(db: Database, islandId: string): AnalysisResult | null { const row = db.query('SELECT * FROM analyses WHERE island_id = ?').get(islandId) as Record | null; if (!row) return null; return { islandId: row.island_id, lexmin: '', title: row.title ?? '', themes: JSON.parse(row.themes ?? '[]'), highlights: JSON.parse(row.highlights ?? '[]'), tensions: JSON.parse(row.tensions ?? '[]'), openQuestions: JSON.parse(row.open_questions ?? '[]'), synthesis: row.synthesis ?? '', newConnections: JSON.parse(row.new_connections ?? '[]'), analyzedAt: row.analyzed_at, }; } // --- Build island context from SQLite data --- function buildIslandContext(db: Database, comp: Component): string { const edges = fetchComponentEdges(db, comp); const didHandles = resolveDidHandles(db, comp.dids); // Domain distribution const domainCounts = new Map(); for (const url of comp.nodes) { const d = domainFromUrl(url); domainCounts.set(d, (domainCounts.get(d) ?? 0) + 1); } const topDomains = [...domainCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 15) .map(([d, c]) => `${d} (${c})`) .join(', '); // Edge type distribution const edgeTypes = new Map(); for (const e of edges) { const t = e.relation || 'relates'; edgeTypes.set(t, (edgeTypes.get(t) ?? 0) + 1); } const edgeTypeStr = [...edgeTypes.entries()] .sort((a, b) => b[1] - a[1]) .map(([t, c]) => `${t} (${c})`) .join(', '); // Contributors const contributorStr = [...comp.dids] .map((d) => didHandles.get(d) ?? d) .join(', '); // Vertices (grouped by domain for readability) const verticesByDomain = new Map(); for (const url of comp.nodes) { const d = domainFromUrl(url); if (!verticesByDomain.has(d)) verticesByDomain.set(d, []); verticesByDomain.get(d)!.push(url); } const vertexSections = [...verticesByDomain.entries()] .sort((a, b) => b[1].length - a[1].length) .slice(0, 10) .map(([domain, urls]) => `${domain}:\n${urls.slice(0, 5).map((u) => ` ${u}`).join('\n')}${urls.length > 5 ? `\n ... and ${urls.length - 5} more` : ''}`) .join('\n\n'); // Edges (sample) const edgeSample = edges.slice(0, 40) .map((e) => { const handle = didHandles.get(e.did) ?? e.did; return `${domainFromUrl(e.source)} → ${domainFromUrl(e.target)} [${e.relation || 'relates'}] (by ${handle})`; }) .join('\n'); return [ `# Island ${comp.islandId}`, '', `## Stats`, `- ${comp.nodes.size} vertices, ${edges.length} edges, ${comp.dids.size} contributors`, `- Top domains: ${topDomains}`, `- Edge types: ${edgeTypeStr}`, `- Contributors: ${contributorStr}`, '', `## Vertices`, vertexSections, '', `## Edges (sample)`, edgeSample, edges.length > 40 ? `\n... and ${edges.length - 40} more edges` : '', ].join('\n'); } // --- island_save tool --- let pendingResult: AnalysisResult | null = null; const islandSaveTool: AnyAgentTool = { label: 'island_save', name: 'island_save', 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.`, parameters: { type: 'object', properties: { title: { type: 'string', description: 'Concise sentence capturing the core narrative' }, themes: { type: 'string', description: 'Comma-separated key themes' }, highlights: { type: 'string', description: 'Comma-separated semantically significant vertices or edges' }, tensions: { type: 'string', description: 'Comma-separated contradictions or unresolved tensions' }, openQuestions: { type: 'string', description: 'Comma-separated questions raised by this island' }, synthesis: { type: 'string', description: 'Prose synthesis of the island' }, newConnections: { type: 'string', description: 'Semicolon-separated new edges as: source|target|type|note' }, }, required: ['title', 'themes', 'synthesis'], }, execute: async (_id, args): Promise> => { const a = args as Record; const parseList = (s: string | undefined) => (s ? s.split(',').map(x => x.trim()).filter(Boolean) : []); const parseConnections = (s: string | undefined) => { if (!s) return []; return s.split(';').map(x => x.trim()).filter(Boolean).map(part => { const [source, target, connectionType, ...noteParts] = part.split('|').map(x => x.trim()); return { source: source ?? '', target: target ?? '', connectionType: connectionType ?? 'related-to', note: noteParts.join('|') || '' }; }); }; pendingResult = { islandId: '', lexmin: '', title: a.title ?? '', themes: parseList(a.themes), highlights: parseList(a.highlights), tensions: parseList(a.tensions), openQuestions: parseList(a.openQuestions), synthesis: a.synthesis ?? '', newConnections: parseConnections(a.newConnections), analyzedAt: new Date().toISOString(), }; return { content: [{ type: 'text', text: 'Analysis saved.' }], }; }, }; // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const wantJson = args.includes('--json'); const force = args.includes('--force'); const analyzeAll = args.includes('--all'); const minSizeArg = args.find((a) => a.startsWith('--min-size=')); const minSize = minSizeArg ? Number(minSizeArg.split('=')[1]) : 3; const modelArg = args.find((a) => a.startsWith('--model=')); const model = modelArg ? modelArg.split('=')[1] : undefined; const targetId = args.find((a) => !a.startsWith('--')); if (!targetId && !analyzeAll) { console.error('Usage: bun island:analyze [--force] [--json] [--model=auto-fast]'); console.error(' bun island:analyze --all [--min-size 5] [--force] [--json]'); process.exit(1); } let db: Database; 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 edges 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('Finding components...'); const components = findComponents(db); let targets: Component[]; if (analyzeAll) { targets = components.filter((c) => c.nodes.size >= minSize); } else { const comp = components.find((c) => c.islandId === targetId); if (!comp) { console.error(`Island ${targetId} not found. Run \`bun island:discover\` to list available islands.`); process.exit(1); } targets = [comp]; } console.error(`Analyzing ${targets.length} island(s)...`); // Reuse existing agent or create one const sessionOpts: Record = { tools: [islandSaveTool], permissionMode: 'bypassPermissions' as const, }; if (model) sessionOpts.model = model; let agentId: string; const savedAgentId = (db.query('SELECT value FROM config WHERE key = ?').get('island_analyzer_agent_id') as { value: string } | null)?.value; if (savedAgentId) { agentId = savedAgentId; console.error(`Reusing agent: ${agentId}`); } else { agentId = await createAgent({ persona: `You are a research analyst that synthesizes connected components (islands) from a knowledge graph into structured analysis records. Given 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. Your output must be a single island_save tool call with these fields: - title: concise sentence capturing the island's core narrative - themes: list of key themes - highlights: semantically significant vertices or edges - tensions: contradictions or unresolved tensions - openQuestions: questions raised by this island - synthesis: prose narrative connecting the vertices and edges - newConnections: suggested new edges between vertices CRITICAL: You MUST call island_save. Do not just describe your analysis in text. Call the tool.`, human: 'island-analyzer', tools: [islandSaveTool], permissionMode: 'bypassPermissions', memfs: false, systemInfoReminder: false, }); db.query('INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('island_analyzer_agent_id', agentId); console.error(`Created agent: ${agentId}`); } const session = createSession(agentId, { memfsStartup: 'skip', ...sessionOpts, }); await session.initialize(); const results: AnalysisResult[] = []; for (const comp of targets) { // Check if already analyzed if (!force) { const existing = db.query('SELECT analyzed_at FROM analyses WHERE island_id = ?').get(comp.islandId) as { analyzed_at: string } | null; if (existing) { console.error(`${comp.islandId}: already analyzed at ${existing.analyzed_at} (use --force to re-analyze)`); const cached = loadAnalysis(db, comp.islandId); if (cached) { cached.lexmin = comp.lexmin; results.push(cached); } continue; } } console.error(`${comp.islandId}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); const context = buildIslandContext(db, comp); pendingResult = null; const prompt = `Analyze this island and call island_save with your analysis. ${context}`; const result: SDKResultMessage = await session.runTurn(prompt); console.error(` Result: success=${result.success}, stopReason=${result.stopReason ?? 'none'}, duration=${result.durationMs}ms`); if (!result.success) { console.error(` Agent failed: ${result.error ?? result.errorCode ?? 'unknown'}`); continue; } if (!pendingResult) { console.error(` Agent did not call island_save`); // Log what the agent produced if (result.result) { console.error(` Agent output: ${result.result.slice(0, 500)}`); } continue; } // Fill in island metadata pendingResult.islandId = comp.islandId; pendingResult.lexmin = comp.lexmin; saveAnalysis(db, pendingResult); results.push(pendingResult); console.error(` Title: ${pendingResult.title.slice(0, 80)}`); console.error(` Themes: ${pendingResult.themes.join(', ') || 'none'}`); console.error(` New connections: ${pendingResult.newConnections.length}`); console.error(` Tensions: ${pendingResult.tensions.length}`); } await session[Symbol.asyncDispose](); if (wantJson) { console.log(JSON.stringify(results, null, 2)); db.close(); return; } for (const r of results) { console.log(`\n${r.islandId}: ${r.title}`); console.log(` Themes: ${r.themes.join(', ') || 'none'}`); console.log(` New connections: ${r.newConnections.length}`); if (r.newConnections.length > 0) { for (const nc of r.newConnections.slice(0, 10)) { console.log(` ${nc.source} → ${nc.target} (${nc.connectionType})`); } if (r.newConnections.length > 10) { console.log(` ... and ${r.newConnections.length - 10} more`); } } console.log(` Tensions: ${r.tensions.length}`); for (const t of r.tensions) console.log(` ${t.slice(0, 100)}`); console.log(` Highlights: ${r.highlights.length}`); for (const h of r.highlights.slice(0, 5)) console.log(` ${h.slice(0, 100)}`); console.log(` Open questions: ${r.openQuestions.length}`); for (const q of r.openQuestions) console.log(` ${q.slice(0, 100)}`); console.log(` Synthesis: ${r.synthesis.slice(0, 300)}${r.synthesis.length > 300 ? '...' : ''}`); } db.close(); } main().catch((err) => { console.error('Fatal:', err instanceof Error ? err.message : String(err)); process.exit(1); });