Merge feature/context-injection into main
This commit is contained in:
119
src/core/config.ts
Normal file
119
src/core/config.ts
Normal 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
291
src/core/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user