Add context injection system (Milestone 3)

- Add src/core/context.ts with context gathering, ranking, and formatting
- Add src/core/config.ts for persistent configuration storage
- Add CLI commands: cortex context, cortex context-hook, cortex config
- Add memory_context MCP tool for Claude Code integration
- Add GET /api/context endpoint
- Include summary.ts from main branch for heartbeat dependency
This commit is contained in:
2026-02-03 10:02:28 +01:00
parent 999c748d3d
commit 516b5ec017
8 changed files with 814 additions and 0 deletions

119
src/core/config.ts Normal file
View File

@@ -0,0 +1,119 @@
import { getDb } from './db';
export interface CortexConfig {
// Context injection settings
'context.maxTokens': number;
'context.maxNodes': number;
'context.includeRecent': boolean;
'context.includeProject': boolean;
'context.includeTasks': boolean;
'context.includeDecisions': boolean;
}
const DEFAULTS: CortexConfig = {
'context.maxTokens': 4000,
'context.maxNodes': 20,
'context.includeRecent': true,
'context.includeProject': true,
'context.includeTasks': true,
'context.includeDecisions': true,
};
/**
* Ensure the config table exists
*/
function ensureConfigTable(): void {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
/**
* Get a config value
*/
export function getConfig<K extends keyof CortexConfig>(key: K): CortexConfig[K] {
ensureConfigTable();
const db = getDb();
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | undefined;
if (!row) {
return DEFAULTS[key];
}
// Parse based on expected type
const defaultValue = DEFAULTS[key];
if (typeof defaultValue === 'boolean') {
return (row.value === 'true') as unknown as CortexConfig[K];
}
if (typeof defaultValue === 'number') {
return parseInt(row.value, 10) as unknown as CortexConfig[K];
}
return row.value as unknown as CortexConfig[K];
}
/**
* Set a config value
*/
export function setConfig<K extends keyof CortexConfig>(key: K, value: CortexConfig[K]): void {
ensureConfigTable();
const db = getDb();
const now = Date.now();
const strValue = String(value);
db.prepare(`
INSERT INTO config (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?
`).run(key, strValue, now, strValue, now);
}
/**
* List all config values
*/
export function listConfig(): Array<{ key: string; value: string; isDefault: boolean }> {
ensureConfigTable();
const db = getDb();
const rows = db.prepare('SELECT key, value FROM config').all() as Array<{ key: string; value: string }>;
const stored = new Map(rows.map(r => [r.key, r.value]));
const result: Array<{ key: string; value: string; isDefault: boolean }> = [];
for (const [key, defaultValue] of Object.entries(DEFAULTS)) {
const storedValue = stored.get(key);
if (storedValue !== undefined) {
result.push({ key, value: storedValue, isDefault: false });
} else {
result.push({ key, value: String(defaultValue), isDefault: true });
}
}
return result;
}
/**
* Reset a config key to default
*/
export function resetConfig(key: keyof CortexConfig): void {
ensureConfigTable();
const db = getDb();
db.prepare('DELETE FROM config WHERE key = ?').run(key);
}
/**
* Get context config as a structured object
*/
export function getContextConfig() {
return {
maxTokens: getConfig('context.maxTokens'),
maxNodes: getConfig('context.maxNodes'),
includeRecent: getConfig('context.includeRecent'),
includeProject: getConfig('context.includeProject'),
includeTasks: getConfig('context.includeTasks'),
includeDecisions: getConfig('context.includeDecisions'),
};
}

291
src/core/context.ts Normal file
View File

@@ -0,0 +1,291 @@
import { getDb } from './db';
import { query, listNodes } from './store';
import { Node, NodeKind } from '../types';
import { getContextConfig } from './config';
export interface RankedNode {
node: Node;
score: number;
reason: 'recent' | 'project' | 'task' | 'decision' | 'semantic';
}
export interface ContextConfig {
maxTokens: number;
maxNodes: number;
includeRecent: boolean;
includeProject: boolean;
includeTasks: boolean;
includeDecisions: boolean;
}
export const DEFAULT_CONTEXT_CONFIG: ContextConfig = {
maxTokens: 4000,
maxNodes: 20,
includeRecent: true,
includeProject: true,
includeTasks: true,
includeDecisions: true,
};
/**
* Get nodes accessed within the last N hours
*/
export function getRecentNodes(hours: number = 48, limit: number = 10): Node[] {
const db = getDb();
const cutoff = Date.now() - hours * 60 * 60 * 1000;
const rows = db.prepare(`
SELECT * FROM nodes
WHERE is_stale = 0 AND last_accessed_at > ?
ORDER BY last_accessed_at DESC
LIMIT ?
`).all(cutoff, limit) as any[];
return rows.map(rowToNode);
}
/**
* Get nodes tagged with a specific project name
*/
export function getProjectNodes(projectName: string, limit: number = 10): Node[] {
return listNodes({ tags: [projectName.toLowerCase()], limit });
}
/**
* Get open tasks (status: todo, in_progress, or active)
*/
export function getOpenTasks(limit: number = 10): Node[] {
const db = getDb();
const rows = db.prepare(`
SELECT * FROM nodes
WHERE is_stale = 0
AND kind = 'task'
AND (status = 'todo' OR status = 'in_progress' OR status = 'active' OR status IS NULL)
ORDER BY updated_at DESC
LIMIT ?
`).all(limit) as any[];
return rows.map(rowToNode);
}
/**
* Get recent decisions
*/
export function getRecentDecisions(limit: number = 5): Node[] {
return listNodes({ kind: 'decision', limit });
}
/**
* Get semantic matches for a query
*/
export async function getSemanticMatches(queryText: string, limit: number = 10): Promise<RankedNode[]> {
const results = await query(queryText, { limit });
return results.map(r => ({
node: r.node,
score: r.score,
reason: 'semantic' as const,
}));
}
/**
* Estimate token count for a node (rough approximation)
*/
function estimateTokens(node: Node): number {
const text = `${node.title} ${node.content || ''} ${node.tags.join(' ')}`;
// Rough estimate: 1 token ≈ 4 characters
return Math.ceil(text.length / 4);
}
/**
* Deduplicate and rank nodes, keeping highest score per node
*/
function dedupeAndRank(candidates: RankedNode[]): RankedNode[] {
const byId = new Map<string, RankedNode>();
for (const c of candidates) {
const existing = byId.get(c.node.id);
if (!existing || c.score > existing.score) {
byId.set(c.node.id, c);
}
}
return Array.from(byId.values()).sort((a, b) => b.score - a.score);
}
/**
* Select nodes within token budget
*/
function selectWithinBudget(candidates: RankedNode[], maxTokens: number, maxNodes: number): RankedNode[] {
const selected: RankedNode[] = [];
let totalTokens = 0;
for (const c of candidates) {
if (selected.length >= maxNodes) break;
const tokens = estimateTokens(c.node);
if (totalTokens + tokens > maxTokens) continue;
selected.push(c);
totalTokens += tokens;
}
return selected;
}
/**
* Group nodes by their reason
*/
function groupByReason(nodes: RankedNode[]): Record<string, RankedNode[]> {
const groups: Record<string, RankedNode[]> = {};
for (const n of nodes) {
if (!groups[n.reason]) groups[n.reason] = [];
groups[n.reason].push(n);
}
return groups;
}
/**
* Format a single node for context output
*/
function formatNode(ranked: RankedNode): string {
const n = ranked.node;
const lines: string[] = [];
lines.push(`### ${n.title}`);
if (n.content) {
// Truncate long content
const content = n.content.length > 500 ? n.content.slice(0, 500) + '...' : n.content;
lines.push(content);
}
if (n.tags.length) {
lines.push(`*Tags: ${n.tags.join(', ')}*`);
}
return lines.join('\n');
}
/**
* Format context as markdown
*/
export function formatContext(nodes: RankedNode[]): string {
if (nodes.length === 0) {
return '*No relevant context found.*';
}
const groups = groupByReason(nodes);
const sections: string[] = [];
// Order: project > decisions > tasks > recent > semantic
const order: Array<{ key: string; title: string }> = [
{ key: 'project', title: 'Project Context' },
{ key: 'decision', title: 'Key Decisions' },
{ key: 'task', title: 'Open Tasks' },
{ key: 'recent', title: 'Recent Activity' },
{ key: 'semantic', title: 'Related Memories' },
];
for (const { key, title } of order) {
const group = groups[key];
if (group?.length) {
sections.push(`## ${title}\n\n${group.map(formatNode).join('\n\n')}`);
}
}
return sections.join('\n\n---\n\n');
}
/**
* Main context gathering function
*/
export async function gatherContext(
options: {
project?: string;
semanticQuery?: string;
config?: Partial<ContextConfig>;
} = {}
): Promise<{ nodes: RankedNode[]; formatted: string }> {
// Load stored config, then override with any passed options
const storedConfig = getContextConfig();
const config = { ...storedConfig, ...options.config };
const candidates: RankedNode[] = [];
// 1. Project-specific nodes (highest priority)
if (config.includeProject && options.project) {
const projectNodes = getProjectNodes(options.project, 10);
candidates.push(...projectNodes.map(node => ({
node,
score: 0.95,
reason: 'project' as const,
})));
}
// 2. Recent decisions
if (config.includeDecisions) {
const decisions = getRecentDecisions(5);
candidates.push(...decisions.map(node => ({
node,
score: 0.85,
reason: 'decision' as const,
})));
}
// 3. Open tasks
if (config.includeTasks) {
const tasks = getOpenTasks(5);
candidates.push(...tasks.map(node => ({
node,
score: 0.80,
reason: 'task' as const,
})));
}
// 4. Recent activity
if (config.includeRecent) {
const recent = getRecentNodes(48, 10);
candidates.push(...recent.map(node => ({
node,
score: 0.70,
reason: 'recent' as const,
})));
}
// 5. Semantic search on project context
if (options.semanticQuery) {
const semantic = await getSemanticMatches(options.semanticQuery, 10);
// Scale semantic scores to fit our priority scheme
candidates.push(...semantic.map(r => ({
...r,
score: r.score * 0.6, // Max 0.6 for semantic matches
})));
}
// Dedupe, rank, and select within budget
const ranked = dedupeAndRank(candidates);
const selected = selectWithinBudget(ranked, config.maxTokens, config.maxNodes);
return {
nodes: selected,
formatted: formatContext(selected),
};
}
// Helper to convert DB row to Node (duplicated from store.ts to avoid circular deps)
function rowToNode(row: any): Node {
return {
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status ?? undefined,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
embedding: null, // Don't load embeddings for context
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_accessed_at ?? row.updated_at,
isStale: !!row.is_stale,
};
}

165
src/core/summary.ts Normal file
View 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(', ') + '.';
}