Add temporal versioning core and summary generation
- Add node_versions table with migrations for tracking history - Add version tracking to addNode/updateNode in store - Add getNodeHistory, getNodeAtTime, diffVersions, restoreVersion - Add NodeVersion, HistoricalNode, NodeDiff types - Add summary generation with caching for memory_summary
This commit is contained in:
165
src/core/summary.ts
Normal file
165
src/core/summary.ts
Normal file
@@ -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<string, string[]> };
|
||||||
|
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<SummaryData> {
|
||||||
|
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<string>();
|
||||||
|
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<string, string[]> = {};
|
||||||
|
|
||||||
|
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<string, string[]> = {};
|
||||||
|
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(', ') + '.';
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { getConnections, buildTree } from '../core/graph';
|
|||||||
import { getDb } from '../core/db';
|
import { getDb } from '../core/db';
|
||||||
import { determineGroupingStrategy, groupResults } from './queryOrganizer';
|
import { determineGroupingStrategy, groupResults } from './queryOrganizer';
|
||||||
import { getLastReport, markDirty, runMaintenance } from './heartbeat';
|
import { getLastReport, markDirty, runMaintenance } from './heartbeat';
|
||||||
|
import { getCachedSummary, generateSummary } from '../core/summary';
|
||||||
|
|
||||||
const router = Router();
|
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
|
// Maintenance status
|
||||||
router.get('/maintenance/status', (_req: Request, res: Response) => {
|
router.get('/maintenance/status', (_req: Request, res: Response) => {
|
||||||
const report = getLastReport();
|
const report = getLastReport();
|
||||||
|
|||||||
Reference in New Issue
Block a user