diff --git a/src/core/summary.ts b/src/core/summary.ts new file mode 100644 index 0000000..7e46f3a --- /dev/null +++ b/src/core/summary.ts @@ -0,0 +1,165 @@ +import { getDb } from './db'; +import { isGenAvailable, generate } from './search/ollamaGen'; + +export interface SummaryData { + generatedAt: number; + overview: string; + components: { title: string; status?: string; summary?: string }[]; + decisions: { title: string; status?: string }[]; + tasks: { todo: string[]; in_progress: string[]; done: string[] }; + memories: { byTag: Record }; + stats: { total: number; stale: number; orphans: number; edges: number }; +} + +// Ensure cache table exists +function ensureCacheTable(): void { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS system_cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); +} + +export function getCachedSummary(): SummaryData | null { + ensureCacheTable(); + const db = getDb(); + const row = db.prepare('SELECT value FROM system_cache WHERE key = ?').get('summary') as { value: string } | undefined; + if (!row) return null; + try { + return JSON.parse(row.value); + } catch { + return null; + } +} + +export function cacheSummary(data: SummaryData): void { + ensureCacheTable(); + const db = getDb(); + db.prepare(` + INSERT INTO system_cache (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run('summary', JSON.stringify(data), Date.now()); +} + +export async function generateSummary(): Promise { + const db = getDb(); + const now = Date.now(); + + // Gather all active nodes + const rows = db.prepare(` + SELECT id, kind, title, status, tags, metadata, content + FROM nodes WHERE is_stale = 0 + ORDER BY kind, title + `).all() as any[]; + + // Stats + const totalNodes = rows.length; + const staleCount = (db.prepare('SELECT COUNT(*) as c FROM nodes WHERE is_stale = 1').get() as any).c; + const edgeCount = (db.prepare('SELECT COUNT(*) as c FROM edges').get() as any).c; + + const edgeRows = db.prepare('SELECT from_id, to_id FROM edges').all() as any[]; + const hasEdge = new Set(); + for (const e of edgeRows) { + hasEdge.add(e.from_id); + hasEdge.add(e.to_id); + } + const orphanCount = rows.filter(r => !hasEdge.has(r.id)).length; + + // Categorize nodes + const components: SummaryData['components'] = []; + const decisions: SummaryData['decisions'] = []; + const tasks: SummaryData['tasks'] = { todo: [], in_progress: [], done: [] }; + const memoryTags: Record = {}; + + for (const row of rows) { + const meta = JSON.parse(row.metadata || '{}'); + + if (row.kind === 'component') { + components.push({ + title: row.title, + status: row.status, + summary: meta.summary || (row.content?.slice(0, 100) + (row.content?.length > 100 ? '...' : '')), + }); + } else if (row.kind === 'decision') { + decisions.push({ title: row.title, status: row.status }); + } else if (row.kind === 'task') { + const status = (row.status || 'todo').toLowerCase(); + if (status === 'done' || status === 'completed') { + tasks.done.push(row.title); + } else if (status === 'in_progress' || status === 'in-progress') { + tasks.in_progress.push(row.title); + } else { + tasks.todo.push(row.title); + } + } else if (row.kind === 'memory') { + const tags: string[] = JSON.parse(row.tags || '[]'); + // Group by first tag, or 'untagged' + const primaryTag = tags[0] || 'untagged'; + if (!memoryTags[primaryTag]) memoryTags[primaryTag] = []; + memoryTags[primaryTag].push(row.title); + } + } + + // Sort tags by count descending, limit to top 10 + const sortedTags = Object.entries(memoryTags) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 10); + const limitedMemoryTags: Record = {}; + for (const [tag, titles] of sortedTags) { + // Limit to 5 titles per tag + limitedMemoryTags[tag] = titles.slice(0, 5); + if (titles.length > 5) { + limitedMemoryTags[tag].push(`+${titles.length - 5} more`); + } + } + + // Generate overview + let overview: string; + const aiAvailable = await isGenAvailable(); + + if (aiAvailable) { + const prompt = `Write a single sentence (max 30 words) summarizing this knowledge graph for a developer starting a coding session. + +Stats: ${totalNodes} nodes (${components.length} components, ${decisions.length} decisions, ${tasks.todo.length + tasks.in_progress.length + tasks.done.length} tasks, ${totalNodes - components.length - decisions.length - tasks.todo.length - tasks.in_progress.length - tasks.done.length} memories), ${edgeCount} edges. + +Components: ${components.map(c => c.title).join(', ')} +Active tasks: ${[...tasks.todo, ...tasks.in_progress].join(', ') || 'none'} + +Output ONLY the summary sentence.`; + + const aiOverview = await generate(prompt); + overview = aiOverview || buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount); + } else { + overview = buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount); + } + + const summary: SummaryData = { + generatedAt: now, + overview, + components, + decisions, + tasks, + memories: { byTag: limitedMemoryTags }, + stats: { total: totalNodes, stale: staleCount, orphans: orphanCount, edges: edgeCount }, + }; + + cacheSummary(summary); + return summary; +} + +function buildFallbackOverview( + total: number, + components: number, + decisions: number, + tasks: SummaryData['tasks'], + edges: number +): string { + const pending = tasks.todo.length + tasks.in_progress.length; + const parts = [`${total} nodes`, `${components} components`, `${decisions} decisions`]; + if (pending > 0) parts.push(`${pending} open tasks`); + parts.push(`${edges} edges`); + return parts.join(', ') + '.'; +} diff --git a/src/server/routes.ts b/src/server/routes.ts index cad09cd..5da4c17 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -4,6 +4,7 @@ import { getConnections, buildTree } from '../core/graph'; import { getDb } from '../core/db'; import { determineGroupingStrategy, groupResults } from './queryOrganizer'; import { getLastReport, markDirty, runMaintenance } from './heartbeat'; +import { getCachedSummary, generateSummary } from '../core/summary'; const router = Router(); @@ -149,6 +150,20 @@ router.post('/query/organize', async (req: Request, res: Response) => { } }); +// Summary — hierarchical pre-computed graph summary +router.get('/summary', async (req: Request, res: Response) => { + try { + const refresh = req.query.refresh === 'true'; + let summary = refresh ? null : getCachedSummary(); + if (!summary) { + summary = await generateSummary(); + } + res.json(summary); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + // Maintenance status router.get('/maintenance/status', (_req: Request, res: Response) => { const report = getLastReport();