From 516b5ec017a0e4a273233271517b32705c629d46 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 3 Feb 2026 10:02:28 +0100 Subject: [PATCH] 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 --- src/cli/commands/config.ts | 81 ++++++++++ src/cli/commands/context.ts | 85 +++++++++++ src/cli/index.ts | 5 + src/core/config.ts | 119 +++++++++++++++ src/core/context.ts | 291 ++++++++++++++++++++++++++++++++++++ src/core/summary.ts | 165 ++++++++++++++++++++ src/mcp/index.ts | 31 ++++ src/server/routes.ts | 37 +++++ 8 files changed, 814 insertions(+) create mode 100644 src/cli/commands/config.ts create mode 100644 src/cli/commands/context.ts create mode 100644 src/core/config.ts create mode 100644 src/core/context.ts create mode 100644 src/core/summary.ts diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts new file mode 100644 index 0000000..c0433c6 --- /dev/null +++ b/src/cli/commands/config.ts @@ -0,0 +1,81 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { getConfig, setConfig, listConfig, resetConfig, CortexConfig } from '../../core/config'; + +export const configCommand = new Command('config') + .description('Manage Cortex configuration'); + +configCommand + .command('get ') + .description('Get a config value') + .action((key: string) => { + try { + const value = getConfig(key as keyof CortexConfig); + console.log(value); + } catch (err) { + console.error(chalk.red(`Unknown config key: ${key}`)); + process.exit(1); + } + }); + +configCommand + .command('set ') + .description('Set a config value') + .action((key: string, value: string) => { + // Validate key exists + const validKeys = [ + 'context.maxTokens', + 'context.maxNodes', + 'context.includeRecent', + 'context.includeProject', + 'context.includeTasks', + 'context.includeDecisions', + ]; + + if (!validKeys.includes(key)) { + console.error(chalk.red(`Unknown config key: ${key}`)); + console.error(chalk.dim(`Valid keys: ${validKeys.join(', ')}`)); + process.exit(1); + } + + // Parse value based on key type + let parsed: string | number | boolean = value; + if (key.startsWith('context.max')) { + parsed = parseInt(value, 10); + if (isNaN(parsed)) { + console.error(chalk.red(`Invalid number: ${value}`)); + process.exit(1); + } + } else if (key.startsWith('context.include')) { + if (value !== 'true' && value !== 'false') { + console.error(chalk.red(`Invalid boolean: ${value} (use true or false)`)); + process.exit(1); + } + parsed = value === 'true'; + } + + setConfig(key as keyof CortexConfig, parsed as any); + console.log(chalk.green(`Set ${key} = ${parsed}`)); + }); + +configCommand + .command('list') + .description('List all config values') + .action(() => { + const configs = listConfig(); + console.log(chalk.bold('Configuration:')); + console.log(); + + for (const { key, value, isDefault } of configs) { + const displayValue = isDefault ? chalk.dim(`${value} (default)`) : chalk.cyan(value); + console.log(` ${chalk.yellow(key)}: ${displayValue}`); + } + }); + +configCommand + .command('reset ') + .description('Reset a config key to default') + .action((key: string) => { + resetConfig(key as keyof CortexConfig); + console.log(chalk.green(`Reset ${key} to default`)); + }); diff --git a/src/cli/commands/context.ts b/src/cli/commands/context.ts new file mode 100644 index 0000000..c00d461 --- /dev/null +++ b/src/cli/commands/context.ts @@ -0,0 +1,85 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import * as path from 'path'; +import { gatherContext, DEFAULT_CONTEXT_CONFIG } from '../../core/context'; + +export const contextCommand = new Command('context') + .description('Preview or inject context for a Claude session') + .option('-p, --project ', 'Project name (defaults to current directory name)') + .option('-q, --query ', 'Additional semantic search query') + .option('--max-tokens ', 'Maximum tokens', String(DEFAULT_CONTEXT_CONFIG.maxTokens)) + .option('--max-nodes ', 'Maximum nodes', String(DEFAULT_CONTEXT_CONFIG.maxNodes)) + .option('--format ', 'Output format: markdown or json', 'markdown') + .option('--no-recent', 'Exclude recent activity') + .option('--no-tasks', 'Exclude open tasks') + .option('--no-decisions', 'Exclude decisions') + .action(async (opts) => { + const project = opts.project ?? path.basename(process.cwd()); + + const result = await gatherContext({ + project, + semanticQuery: opts.query, + config: { + maxTokens: parseInt(opts.maxTokens), + maxNodes: parseInt(opts.maxNodes), + includeRecent: opts.recent !== false, + includeTasks: opts.tasks !== false, + includeDecisions: opts.decisions !== false, + }, + }); + + if (opts.format === 'json') { + console.log(JSON.stringify({ + project, + nodeCount: result.nodes.length, + nodes: result.nodes.map(r => ({ + id: r.node.id, + kind: r.node.kind, + title: r.node.title, + score: r.score, + reason: r.reason, + })), + }, null, 2)); + return; + } + + // Markdown output + if (result.nodes.length === 0) { + console.log(chalk.yellow('No relevant context found.')); + return; + } + + console.log(chalk.cyan(`# Context for ${project}`)); + console.log(chalk.dim(`(${result.nodes.length} nodes)`)); + console.log(); + console.log(result.formatted); + }); + +/** + * Hook command for Claude Code integration + * Outputs context to stdout for injection + */ +export const contextHookCommand = new Command('context-hook') + .description('Hook handler for Claude Code session start (outputs context to stdout)') + .option('-p, --project ', 'Project name (defaults to current directory name)') + .option('--max-tokens ', 'Maximum tokens', String(DEFAULT_CONTEXT_CONFIG.maxTokens)) + .action(async (opts) => { + const project = opts.project ?? path.basename(process.cwd()); + + const result = await gatherContext({ + project, + config: { + maxTokens: parseInt(opts.maxTokens), + }, + }); + + if (result.nodes.length === 0) { + // Output nothing if no context + return; + } + + // Output markdown directly to stdout for Claude to consume + console.log(``); + console.log(result.formatted); + console.log(''); + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 0a1f253..375cd4f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,8 @@ import { graphCommand } from './commands/graph'; import { serveCommand } from './commands/serve'; import { decayCommand } from './commands/decay'; import { childrenCommand } from './commands/children'; +import { contextCommand, contextHookCommand } from './commands/context'; +import { configCommand } from './commands/config'; import { closeDb } from '../core/db'; const program = new Command(); @@ -31,6 +33,9 @@ program.addCommand(graphCommand); program.addCommand(serveCommand); program.addCommand(decayCommand); program.addCommand(childrenCommand); +program.addCommand(contextCommand); +program.addCommand(contextHookCommand); +program.addCommand(configCommand); program.hook('postAction', () => { closeDb(); diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..132e2e4 --- /dev/null +++ b/src/core/config.ts @@ -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(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(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'), + }; +} diff --git a/src/core/context.ts b/src/core/context.ts new file mode 100644 index 0000000..7dc61a6 --- /dev/null +++ b/src/core/context.ts @@ -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 { + 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(); + + 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 { + const groups: Record = {}; + + 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; + } = {} +): 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, + }; +} 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/mcp/index.ts b/src/mcp/index.ts index b499fe0..6020f90 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -367,6 +367,37 @@ server.tool( } ); +// --- memory_context --- +import { gatherContext, DEFAULT_CONTEXT_CONFIG } from '../core/context'; + +server.tool( + 'memory_context', + 'Get relevant context for a Claude session. Gathers recent activity, project-specific nodes, open tasks, and decisions. Use at session start.', + { + project: z.string().optional().describe('Project name to filter by (e.g. "cortex")'), + query: z.string().optional().describe('Optional semantic search query'), + maxTokens: z.number().optional().describe(`Max tokens in output (default ${DEFAULT_CONTEXT_CONFIG.maxTokens})`), + maxNodes: z.number().optional().describe(`Max nodes to include (default ${DEFAULT_CONTEXT_CONFIG.maxNodes})`), + }, + async ({ project, query: semanticQuery, maxTokens, maxNodes }) => { + const result = await gatherContext({ + project, + semanticQuery, + config: { + maxTokens: maxTokens ?? DEFAULT_CONTEXT_CONFIG.maxTokens, + maxNodes: maxNodes ?? DEFAULT_CONTEXT_CONFIG.maxNodes, + }, + }); + + return { + content: [{ + type: 'text' as const, + text: result.formatted, + }], + }; + } +); + // --- memory_summary --- import { getCachedSummary, generateSummary } from '../core/summary'; diff --git a/src/server/routes.ts b/src/server/routes.ts index cad09cd..ba2817c 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 { gatherContext } from '../core/context'; const router = Router(); @@ -166,6 +167,42 @@ router.post('/maintenance/run', async (_req: Request, res: Response) => { } }); +// Context — get session context for Claude +router.get('/context', async (req: Request, res: Response) => { + try { + const project = req.query.project as string | undefined; + const semanticQuery = req.query.query as string | undefined; + const maxTokens = req.query.maxTokens ? parseInt(req.query.maxTokens as string) : undefined; + const maxNodes = req.query.maxNodes ? parseInt(req.query.maxNodes as string) : undefined; + const format = req.query.format as string || 'markdown'; + + const result = await gatherContext({ + project, + semanticQuery, + config: { maxTokens, maxNodes } as any, + }); + + if (format === 'json') { + res.json({ + project, + nodeCount: result.nodes.length, + nodes: result.nodes.map(r => ({ + id: r.node.id, + kind: r.node.kind, + title: r.node.title, + score: r.score, + reason: r.reason, + })), + formatted: result.formatted, + }); + } else { + res.type('text/markdown').send(result.formatted); + } + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + // Prompt — AI-driven natural language instruction router.post('/prompt', async (req: Request, res: Response) => { try {