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