This repository has no description
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});