Compare commits
1 Commits
feature/co
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a4dc07038 |
176
src/cli/commands/capture.ts
Normal file
176
src/cli/commands/capture.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { captureConversation, captureText, getCaptureConfig, setCaptureConfig, CaptureMode } from '../../core/capture';
|
||||||
|
|
||||||
|
const VALID_MODES: CaptureMode[] = ['always', 'manual', 'decisions', 'off'];
|
||||||
|
|
||||||
|
export const captureCommand = new Command('capture')
|
||||||
|
.description('Capture text or conversation as a memory node')
|
||||||
|
.argument('[text]', 'Text to capture (or pipe via stdin)')
|
||||||
|
.option('--tags <tags>', 'Comma-separated tags')
|
||||||
|
.option('--source <source>', 'Source identifier', 'manual')
|
||||||
|
.action(async (text: string | undefined, opts) => {
|
||||||
|
let content = text;
|
||||||
|
|
||||||
|
// Read from stdin if no text provided
|
||||||
|
if (!content) {
|
||||||
|
content = await readStdin();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
console.error(chalk.red('No text provided. Pass text as argument or pipe via stdin.'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : [];
|
||||||
|
|
||||||
|
const result = await captureText(content, { tags, source: opts.source });
|
||||||
|
|
||||||
|
if (!result.captured) {
|
||||||
|
console.log(chalk.yellow(`⚠ Not captured: ${result.reason}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.green(`✓ Memory ${result.action}`));
|
||||||
|
if (result.node) {
|
||||||
|
console.log(` ID: ${chalk.cyan(result.node.id)}`);
|
||||||
|
console.log(` Title: ${result.node.title}`);
|
||||||
|
if (result.node.tags.length) console.log(` Tags: ${result.node.tags.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (result.reason) {
|
||||||
|
console.log(` ${chalk.dim(result.reason)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const captureHookCommand = new Command('capture-hook')
|
||||||
|
.description('Hook handler for Claude Code auto-capture (receives JSON on stdin)')
|
||||||
|
.option('--session <id>', 'Session ID')
|
||||||
|
.action(async (opts) => {
|
||||||
|
const input = await readStdin();
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
// Silent exit - hook may be called with empty input
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: { conversation?: string; files_changed?: string[]; session_id?: string };
|
||||||
|
try {
|
||||||
|
data = JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
// Not JSON, treat as plain conversation text
|
||||||
|
data = { conversation: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = data.conversation;
|
||||||
|
if (!conversation) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await captureConversation({
|
||||||
|
conversation,
|
||||||
|
sessionId: data.session_id || opts.session,
|
||||||
|
filesChanged: data.files_changed,
|
||||||
|
source: 'claude-code',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Output result as JSON for hook system
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
captured: result.captured,
|
||||||
|
action: result.action,
|
||||||
|
nodeId: result.node?.id,
|
||||||
|
reason: result.reason,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const configCommand = new Command('config')
|
||||||
|
.description('Manage capture configuration')
|
||||||
|
.argument('<action>', 'Action: get, set, or list')
|
||||||
|
.argument('[key]', 'Config key (for get/set)')
|
||||||
|
.argument('[value]', 'Config value (for set)')
|
||||||
|
.action(async (action: string, key?: string, value?: string) => {
|
||||||
|
const config = getCaptureConfig();
|
||||||
|
|
||||||
|
if (action === 'list') {
|
||||||
|
console.log(chalk.bold('Capture Configuration:'));
|
||||||
|
console.log(` mode: ${chalk.cyan(config.mode)}`);
|
||||||
|
console.log(` minLength: ${config.minLength}`);
|
||||||
|
console.log(` excludePatterns: ${config.excludePatterns.length ? config.excludePatterns.join(', ') : chalk.dim('(none)')}`);
|
||||||
|
console.log(` autoTag: ${config.autoTag}`);
|
||||||
|
console.log(` linkRelated: ${config.linkRelated}`);
|
||||||
|
console.log(` similarityThreshold: ${config.similarityThreshold}`);
|
||||||
|
console.log(` mergeThreshold: ${config.mergeThreshold}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
if (!key) {
|
||||||
|
console.error(chalk.red('Key required for get'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!(key in config)) {
|
||||||
|
console.error(chalk.red(`Unknown config key: ${key}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const val = config[key as keyof typeof config];
|
||||||
|
console.log(Array.isArray(val) ? val.join(', ') : String(val));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'set') {
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
console.error(chalk.red('Key and value required for set'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedValue: any = value;
|
||||||
|
|
||||||
|
// Parse value based on key type
|
||||||
|
if (key === 'mode') {
|
||||||
|
if (!VALID_MODES.includes(value as CaptureMode)) {
|
||||||
|
console.error(chalk.red(`Invalid mode. Must be one of: ${VALID_MODES.join(', ')}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
parsedValue = value;
|
||||||
|
} else if (key === 'minLength' || key === 'similarityThreshold' || key === 'mergeThreshold') {
|
||||||
|
parsedValue = parseFloat(value);
|
||||||
|
if (isNaN(parsedValue)) {
|
||||||
|
console.error(chalk.red('Value must be a number'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else if (key === 'autoTag' || key === 'linkRelated') {
|
||||||
|
parsedValue = value === 'true' || value === '1';
|
||||||
|
} else if (key === 'excludePatterns') {
|
||||||
|
parsedValue = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red(`Unknown config key: ${key}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCaptureConfig({ [key]: parsedValue });
|
||||||
|
console.log(chalk.green(`✓ Set ${key} = ${JSON.stringify(parsedValue)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(chalk.red('Invalid action. Use: get, set, or list'));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function readStdin(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Check if stdin has data (non-TTY mode)
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
resolve('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { data += chunk; });
|
||||||
|
process.stdin.on('end', () => { resolve(data.trim()); });
|
||||||
|
|
||||||
|
// Timeout after 100ms if no data
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!data) resolve('');
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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 <key>')
|
|
||||||
.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 <key> <value>')
|
|
||||||
.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 <key>')
|
|
||||||
.description('Reset a config key to default')
|
|
||||||
.action((key: string) => {
|
|
||||||
resetConfig(key as keyof CortexConfig);
|
|
||||||
console.log(chalk.green(`Reset ${key} to default`));
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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 <name>', 'Project name (defaults to current directory name)')
|
|
||||||
.option('-q, --query <text>', 'Additional semantic search query')
|
|
||||||
.option('--max-tokens <n>', 'Maximum tokens', String(DEFAULT_CONTEXT_CONFIG.maxTokens))
|
|
||||||
.option('--max-nodes <n>', 'Maximum nodes', String(DEFAULT_CONTEXT_CONFIG.maxNodes))
|
|
||||||
.option('--format <fmt>', '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 <name>', 'Project name (defaults to current directory name)')
|
|
||||||
.option('--max-tokens <n>', '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(`<cortex-context project="${project}">`);
|
|
||||||
console.log(result.formatted);
|
|
||||||
console.log('</cortex-context>');
|
|
||||||
});
|
|
||||||
@@ -11,8 +11,7 @@ import { graphCommand } from './commands/graph';
|
|||||||
import { serveCommand } from './commands/serve';
|
import { serveCommand } from './commands/serve';
|
||||||
import { decayCommand } from './commands/decay';
|
import { decayCommand } from './commands/decay';
|
||||||
import { childrenCommand } from './commands/children';
|
import { childrenCommand } from './commands/children';
|
||||||
import { contextCommand, contextHookCommand } from './commands/context';
|
import { captureCommand, captureHookCommand, configCommand } from './commands/capture';
|
||||||
import { configCommand } from './commands/config';
|
|
||||||
import { closeDb } from '../core/db';
|
import { closeDb } from '../core/db';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -33,8 +32,8 @@ program.addCommand(graphCommand);
|
|||||||
program.addCommand(serveCommand);
|
program.addCommand(serveCommand);
|
||||||
program.addCommand(decayCommand);
|
program.addCommand(decayCommand);
|
||||||
program.addCommand(childrenCommand);
|
program.addCommand(childrenCommand);
|
||||||
program.addCommand(contextCommand);
|
program.addCommand(captureCommand);
|
||||||
program.addCommand(contextHookCommand);
|
program.addCommand(captureHookCommand);
|
||||||
program.addCommand(configCommand);
|
program.addCommand(configCommand);
|
||||||
|
|
||||||
program.hook('postAction', () => {
|
program.hook('postAction', () => {
|
||||||
|
|||||||
68
src/core/capture/config.ts
Normal file
68
src/core/capture/config.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { getDb } from '../db';
|
||||||
|
|
||||||
|
export type CaptureMode = 'always' | 'manual' | 'decisions' | 'off';
|
||||||
|
|
||||||
|
export interface CaptureConfig {
|
||||||
|
mode: CaptureMode;
|
||||||
|
minLength: number;
|
||||||
|
excludePatterns: string[];
|
||||||
|
autoTag: boolean;
|
||||||
|
linkRelated: boolean;
|
||||||
|
similarityThreshold: number;
|
||||||
|
mergeThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: CaptureConfig = {
|
||||||
|
mode: 'always',
|
||||||
|
minLength: 100,
|
||||||
|
excludePatterns: [],
|
||||||
|
autoTag: true,
|
||||||
|
linkRelated: true,
|
||||||
|
similarityThreshold: 0.75,
|
||||||
|
mergeThreshold: 0.90,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureConfigTable(): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaptureConfig(): CaptureConfig {
|
||||||
|
ensureConfigTable();
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT value FROM system_config WHERE key = ?').get('capture') as { value: string } | undefined;
|
||||||
|
if (!row) return DEFAULT_CONFIG;
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_CONFIG, ...JSON.parse(row.value) };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCaptureConfig(updates: Partial<CaptureConfig>): CaptureConfig {
|
||||||
|
ensureConfigTable();
|
||||||
|
const db = getDb();
|
||||||
|
const current = getCaptureConfig();
|
||||||
|
const updated = { ...current, ...updates };
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO system_config (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`).run('capture', JSON.stringify(updated), Date.now());
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigValue<K extends keyof CaptureConfig>(key: K): CaptureConfig[K] {
|
||||||
|
return getCaptureConfig()[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfigValue<K extends keyof CaptureConfig>(key: K, value: CaptureConfig[K]): void {
|
||||||
|
setCaptureConfig({ [key]: value } as Partial<CaptureConfig>);
|
||||||
|
}
|
||||||
113
src/core/capture/dedupe.ts
Normal file
113
src/core/capture/dedupe.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { listNodes, addEdge, updateNode } from '../store';
|
||||||
|
import { getEmbedding } from '../search/ollama';
|
||||||
|
import { cosineSimilarity } from '../search/vector';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
import { getCaptureConfig } from './config';
|
||||||
|
|
||||||
|
export interface SimilarNode {
|
||||||
|
node: Node;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DedupeResult {
|
||||||
|
action: 'create' | 'merge' | 'link';
|
||||||
|
existingNode?: Node;
|
||||||
|
similarity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSimilarNodes(
|
||||||
|
text: string,
|
||||||
|
limit: number = 5
|
||||||
|
): Promise<SimilarNode[]> {
|
||||||
|
const embedding = await getEmbedding(text);
|
||||||
|
if (!embedding) return [];
|
||||||
|
|
||||||
|
const nodes = listNodes({ includeStale: false });
|
||||||
|
const withEmbeddings = nodes.filter(n => n.embedding && n.embedding.length > 0);
|
||||||
|
|
||||||
|
const scored: SimilarNode[] = [];
|
||||||
|
for (const node of withEmbeddings) {
|
||||||
|
const similarity = cosineSimilarity(embedding, node.embedding!);
|
||||||
|
if (similarity > 0.5) {
|
||||||
|
scored.push({ node, similarity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scored
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkDuplicate(
|
||||||
|
summary: string,
|
||||||
|
content: string
|
||||||
|
): Promise<DedupeResult> {
|
||||||
|
const config = getCaptureConfig();
|
||||||
|
const textToCompare = `${summary} ${content}`;
|
||||||
|
|
||||||
|
const similar = await findSimilarNodes(textToCompare, 1);
|
||||||
|
|
||||||
|
if (similar.length === 0) {
|
||||||
|
return { action: 'create' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node, similarity } = similar[0];
|
||||||
|
|
||||||
|
if (similarity >= config.mergeThreshold) {
|
||||||
|
return {
|
||||||
|
action: 'merge',
|
||||||
|
existingNode: node,
|
||||||
|
similarity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarity >= config.similarityThreshold) {
|
||||||
|
return {
|
||||||
|
action: 'link',
|
||||||
|
existingNode: node,
|
||||||
|
similarity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'create' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeIntoNode(
|
||||||
|
existingId: string,
|
||||||
|
newSummary: string,
|
||||||
|
newContent: string,
|
||||||
|
newTags: string[]
|
||||||
|
): Promise<Node | null> {
|
||||||
|
const existing = listNodes({ includeStale: false }).find(n => n.id === existingId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
// Append new content with timestamp
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10);
|
||||||
|
const mergedContent = existing.content
|
||||||
|
? `${existing.content}\n\n---\n[${timestamp}]\n${newContent}`
|
||||||
|
: newContent;
|
||||||
|
|
||||||
|
// Merge tags (dedupe)
|
||||||
|
const mergedTags = [...new Set([...existing.tags, ...newTags])];
|
||||||
|
|
||||||
|
// Update the existing node
|
||||||
|
return updateNode(existingId, {
|
||||||
|
content: mergedContent,
|
||||||
|
tags: mergedTags,
|
||||||
|
metadata: {
|
||||||
|
...existing.metadata,
|
||||||
|
lastMergedAt: Date.now(),
|
||||||
|
mergeCount: (existing.metadata.mergeCount || 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkRelatedNode(
|
||||||
|
newNodeId: string,
|
||||||
|
existingNodeId: string
|
||||||
|
): Promise<void> {
|
||||||
|
addEdge(newNodeId, existingNodeId, 'relates_to', {
|
||||||
|
reason: 'auto-capture-similarity',
|
||||||
|
linkedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
192
src/core/capture/index.ts
Normal file
192
src/core/capture/index.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { addNode } from '../store';
|
||||||
|
import { getCaptureConfig, CaptureConfig } from './config';
|
||||||
|
import { extractMemoryData, shouldCapture, ExtractedMemory } from './summarize';
|
||||||
|
import { checkDuplicate, mergeIntoNode, linkRelatedNode } from './dedupe';
|
||||||
|
import { Node } from '../../types';
|
||||||
|
|
||||||
|
export { getCaptureConfig, setCaptureConfig, CaptureMode, CaptureConfig } from './config';
|
||||||
|
export { extractMemoryData, shouldCapture, ExtractedMemory } from './summarize';
|
||||||
|
export { findSimilarNodes, checkDuplicate, mergeIntoNode } from './dedupe';
|
||||||
|
|
||||||
|
export interface CaptureInput {
|
||||||
|
conversation: string;
|
||||||
|
sessionId?: string;
|
||||||
|
filesChanged?: string[];
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureResult {
|
||||||
|
captured: boolean;
|
||||||
|
action: 'created' | 'merged' | 'linked' | 'skipped';
|
||||||
|
node?: Node;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureConversation(input: CaptureInput): Promise<CaptureResult> {
|
||||||
|
const config = getCaptureConfig();
|
||||||
|
|
||||||
|
// Check if capture is enabled
|
||||||
|
if (config.mode === 'off') {
|
||||||
|
return { captured: false, action: 'skipped', reason: 'capture disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum length
|
||||||
|
if (!shouldCapture(input.conversation, config.minLength)) {
|
||||||
|
return { captured: false, action: 'skipped', reason: 'conversation too short or trivial' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exclude patterns
|
||||||
|
for (const pattern of config.excludePatterns) {
|
||||||
|
try {
|
||||||
|
if (new RegExp(pattern, 'i').test(input.conversation)) {
|
||||||
|
return { captured: false, action: 'skipped', reason: `matched exclude pattern: ${pattern}` };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid regex, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract memory data using Ollama
|
||||||
|
const extracted = await extractMemoryData(input.conversation);
|
||||||
|
if (!extracted) {
|
||||||
|
return { captured: false, action: 'skipped', reason: 'failed to extract memory data' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "decisions" mode, only capture if decisions were found
|
||||||
|
if (config.mode === 'decisions' && extracted.decisions.length === 0) {
|
||||||
|
return { captured: false, action: 'skipped', reason: 'no decisions found (decisions mode)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
const contentParts: string[] = [extracted.summary];
|
||||||
|
|
||||||
|
if (extracted.decisions.length > 0) {
|
||||||
|
contentParts.push('\n## Decisions');
|
||||||
|
for (const d of extracted.decisions) {
|
||||||
|
contentParts.push(`- ${d}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.filesDiscussed.length > 0 || input.filesChanged?.length) {
|
||||||
|
const files = [...new Set([...extracted.filesDiscussed, ...(input.filesChanged || [])])];
|
||||||
|
contentParts.push('\n## Files');
|
||||||
|
for (const f of files) {
|
||||||
|
contentParts.push(`- ${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = contentParts.join('\n');
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
const dedupeResult = await checkDuplicate(extracted.summary, content);
|
||||||
|
|
||||||
|
// Build tags
|
||||||
|
const tags = ['auto-capture'];
|
||||||
|
if (config.autoTag && extracted.topics.length > 0) {
|
||||||
|
tags.push(...extracted.topics);
|
||||||
|
}
|
||||||
|
if (input.source) {
|
||||||
|
tags.push(`source:${input.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedupeResult.action === 'merge' && dedupeResult.existingNode) {
|
||||||
|
// Merge into existing node
|
||||||
|
const merged = await mergeIntoNode(
|
||||||
|
dedupeResult.existingNode.id,
|
||||||
|
extracted.summary,
|
||||||
|
content,
|
||||||
|
tags
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
captured: true,
|
||||||
|
action: 'merged',
|
||||||
|
node: merged || undefined,
|
||||||
|
reason: `merged with existing node (similarity: ${(dedupeResult.similarity! * 100).toFixed(1)}%)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new node
|
||||||
|
const node = await addNode({
|
||||||
|
kind: 'memory',
|
||||||
|
title: extracted.summary.slice(0, 100),
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
status: 'active',
|
||||||
|
metadata: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
filesChanged: input.filesChanged,
|
||||||
|
source: input.source || 'claude-code',
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
decisions: extracted.decisions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link to related node if found
|
||||||
|
if (dedupeResult.action === 'link' && dedupeResult.existingNode && config.linkRelated) {
|
||||||
|
await linkRelatedNode(node.id, dedupeResult.existingNode.id);
|
||||||
|
return {
|
||||||
|
captured: true,
|
||||||
|
action: 'linked',
|
||||||
|
node,
|
||||||
|
reason: `linked to related node (similarity: ${(dedupeResult.similarity! * 100).toFixed(1)}%)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
captured: true,
|
||||||
|
action: 'created',
|
||||||
|
node,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureText(
|
||||||
|
text: string,
|
||||||
|
options: { tags?: string[]; source?: string } = {}
|
||||||
|
): Promise<CaptureResult> {
|
||||||
|
const config = getCaptureConfig();
|
||||||
|
|
||||||
|
if (config.mode === 'off') {
|
||||||
|
return { captured: false, action: 'skipped', reason: 'capture disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text capture - no summarization needed
|
||||||
|
const dedupeResult = await checkDuplicate(text, text);
|
||||||
|
|
||||||
|
const tags = ['manual-capture', ...(options.tags || [])];
|
||||||
|
if (options.source) {
|
||||||
|
tags.push(`source:${options.source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedupeResult.action === 'merge' && dedupeResult.existingNode) {
|
||||||
|
const merged = await mergeIntoNode(
|
||||||
|
dedupeResult.existingNode.id,
|
||||||
|
text.slice(0, 100),
|
||||||
|
text,
|
||||||
|
tags
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
captured: true,
|
||||||
|
action: 'merged',
|
||||||
|
node: merged || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await addNode({
|
||||||
|
kind: 'memory',
|
||||||
|
title: text.slice(0, 100),
|
||||||
|
content: text,
|
||||||
|
tags,
|
||||||
|
status: 'active',
|
||||||
|
metadata: {
|
||||||
|
source: options.source || 'manual',
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dedupeResult.action === 'link' && dedupeResult.existingNode && config.linkRelated) {
|
||||||
|
await linkRelatedNode(node.id, dedupeResult.existingNode.id);
|
||||||
|
return { captured: true, action: 'linked', node };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { captured: true, action: 'created', node };
|
||||||
|
}
|
||||||
160
src/core/capture/summarize.ts
Normal file
160
src/core/capture/summarize.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { generate, isGenAvailable } from '../search/ollamaGen';
|
||||||
|
|
||||||
|
export interface ExtractedMemory {
|
||||||
|
summary: string;
|
||||||
|
topics: string[];
|
||||||
|
decisions: string[];
|
||||||
|
filesDiscussed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARIZE_PROMPT = `Summarize this Claude Code conversation in 1-2 sentences.
|
||||||
|
Focus on: what was accomplished, decisions made, problems solved.
|
||||||
|
Do NOT include greetings or meta-discussion.
|
||||||
|
|
||||||
|
Conversation:
|
||||||
|
{conversation}
|
||||||
|
|
||||||
|
Summary:`;
|
||||||
|
|
||||||
|
const EXTRACT_PROMPT = `Extract from this conversation:
|
||||||
|
1. Main topics (as tags, lowercase, hyphenated, max 5)
|
||||||
|
2. Decisions made (if any, max 3)
|
||||||
|
3. Code files discussed or modified (if any)
|
||||||
|
|
||||||
|
Conversation:
|
||||||
|
{conversation}
|
||||||
|
|
||||||
|
Output as JSON only, no explanation:
|
||||||
|
{"topics": [], "decisions": [], "files": []}`;
|
||||||
|
|
||||||
|
export async function summarizeConversation(conversation: string): Promise<string | null> {
|
||||||
|
if (!(await isGenAvailable())) return null;
|
||||||
|
|
||||||
|
const prompt = SUMMARIZE_PROMPT.replace('{conversation}', conversation);
|
||||||
|
return generate(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractMemoryData(conversation: string): Promise<ExtractedMemory | null> {
|
||||||
|
const available = await isGenAvailable();
|
||||||
|
|
||||||
|
// Get summary
|
||||||
|
const summary = available
|
||||||
|
? await summarizeConversation(conversation)
|
||||||
|
: createFallbackSummary(conversation);
|
||||||
|
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
// Extract structured data
|
||||||
|
let topics: string[] = [];
|
||||||
|
let decisions: string[] = [];
|
||||||
|
let filesDiscussed: string[] = [];
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
const extractPrompt = EXTRACT_PROMPT.replace('{conversation}', conversation);
|
||||||
|
const extracted = await generate(extractPrompt);
|
||||||
|
|
||||||
|
if (extracted) {
|
||||||
|
try {
|
||||||
|
// Find JSON in response (handle cases where model adds explanation)
|
||||||
|
const jsonMatch = extracted.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const data = JSON.parse(jsonMatch[0]);
|
||||||
|
topics = Array.isArray(data.topics) ? data.topics.slice(0, 5) : [];
|
||||||
|
decisions = Array.isArray(data.decisions) ? data.decisions.slice(0, 3) : [];
|
||||||
|
filesDiscussed = Array.isArray(data.files) ? data.files : [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to basic extraction
|
||||||
|
topics = extractTopicsBasic(conversation);
|
||||||
|
filesDiscussed = extractFilesBasic(conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Basic extraction without AI
|
||||||
|
topics = extractTopicsBasic(conversation);
|
||||||
|
filesDiscussed = extractFilesBasic(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
topics: sanitizeTags(topics),
|
||||||
|
decisions,
|
||||||
|
filesDiscussed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackSummary(conversation: string): string {
|
||||||
|
// Take first meaningful line as summary
|
||||||
|
const lines = conversation.split('\n').filter(l => l.trim().length > 20);
|
||||||
|
if (lines.length === 0) return 'Conversation captured';
|
||||||
|
|
||||||
|
const first = lines[0].trim();
|
||||||
|
return first.length > 150 ? first.slice(0, 147) + '...' : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTopicsBasic(conversation: string): string[] {
|
||||||
|
const topics: string[] = [];
|
||||||
|
const lower = conversation.toLowerCase();
|
||||||
|
|
||||||
|
// Common programming topics
|
||||||
|
const keywords = [
|
||||||
|
'typescript', 'javascript', 'python', 'rust', 'go',
|
||||||
|
'react', 'vue', 'angular', 'node', 'express',
|
||||||
|
'database', 'sql', 'api', 'auth', 'authentication',
|
||||||
|
'bug', 'fix', 'error', 'refactor', 'test', 'deploy',
|
||||||
|
'git', 'docker', 'kubernetes', 'aws', 'cloud',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (lower.includes(kw) && topics.length < 5) {
|
||||||
|
topics.push(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFilesBasic(conversation: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
// Match file paths
|
||||||
|
const filePatterns = [
|
||||||
|
/[\w\-\/]+\.(ts|js|tsx|jsx|py|rs|go|md|json|yaml|yml|toml|sql)/gi,
|
||||||
|
/src\/[\w\-\/]+/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of filePatterns) {
|
||||||
|
const matches = conversation.match(pattern);
|
||||||
|
if (matches) {
|
||||||
|
for (const m of matches) {
|
||||||
|
if (!files.includes(m) && files.length < 10) {
|
||||||
|
files.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTags(tags: string[]): string[] {
|
||||||
|
return tags
|
||||||
|
.map(t => t.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, ''))
|
||||||
|
.filter(t => t.length > 0 && t.length < 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCapture(conversation: string, minLength: number): boolean {
|
||||||
|
// Skip very short conversations
|
||||||
|
if (conversation.length < minLength) return false;
|
||||||
|
|
||||||
|
// Skip if mostly greetings/pleasantries
|
||||||
|
const lower = conversation.toLowerCase();
|
||||||
|
const greetings = ['hello', 'hi ', 'hey', 'thanks', 'thank you', 'goodbye', 'bye'];
|
||||||
|
const greetingCount = greetings.filter(g => lower.includes(g)).length;
|
||||||
|
|
||||||
|
// If more than half the "content" is greetings, skip
|
||||||
|
const words = conversation.split(/\s+/).length;
|
||||||
|
if (words < 20 && greetingCount > 2) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -367,37 +367,6 @@ 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 ---
|
// --- memory_summary ---
|
||||||
import { getCachedSummary, generateSummary } from '../core/summary';
|
import { getCachedSummary, generateSummary } from '../core/summary';
|
||||||
|
|
||||||
@@ -416,6 +385,69 @@ server.tool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- memory_capture ---
|
||||||
|
import { captureConversation, captureText, getCaptureConfig, setCaptureConfig, CaptureMode } from '../core/capture';
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'memory_capture',
|
||||||
|
'Capture a conversation or context as a memory node. Uses AI to summarize and extract key information.',
|
||||||
|
{
|
||||||
|
conversation: z.string().describe('The conversation or context to capture'),
|
||||||
|
sessionId: z.string().optional().describe('Session identifier'),
|
||||||
|
filesChanged: z.array(z.string()).optional().describe('List of files that were changed'),
|
||||||
|
source: z.string().optional().describe('Source identifier (default: claude-code)'),
|
||||||
|
},
|
||||||
|
async ({ conversation, sessionId, filesChanged, source }) => {
|
||||||
|
const result = await captureConversation({
|
||||||
|
conversation,
|
||||||
|
sessionId,
|
||||||
|
filesChanged,
|
||||||
|
source: source || 'claude-code',
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text' as const, text: serialize(result) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'memory_remember',
|
||||||
|
'Remember a piece of text for later. Simpler than memory_capture - for quick notes and facts.',
|
||||||
|
{
|
||||||
|
text: z.string().describe('The text to remember'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Tags to apply'),
|
||||||
|
},
|
||||||
|
async ({ text, tags }) => {
|
||||||
|
const result = await captureText(text, { tags, source: 'remember' });
|
||||||
|
return { content: [{ type: 'text' as const, text: serialize(result) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'memory_capture_config',
|
||||||
|
'Get or set auto-capture configuration',
|
||||||
|
{
|
||||||
|
action: z.enum(['get', 'set']).describe('Action to perform'),
|
||||||
|
mode: z.enum(['always', 'manual', 'decisions', 'off']).optional().describe('Capture mode (for set)'),
|
||||||
|
minLength: z.number().optional().describe('Minimum conversation length (for set)'),
|
||||||
|
autoTag: z.boolean().optional().describe('Auto-generate tags (for set)'),
|
||||||
|
linkRelated: z.boolean().optional().describe('Auto-link related nodes (for set)'),
|
||||||
|
},
|
||||||
|
async ({ action, mode, minLength, autoTag, linkRelated }) => {
|
||||||
|
if (action === 'get') {
|
||||||
|
const config = getCaptureConfig();
|
||||||
|
return { content: [{ type: 'text' as const, text: serialize(config) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<{ mode: CaptureMode; minLength: number; autoTag: boolean; linkRelated: boolean }> = {};
|
||||||
|
if (mode !== undefined) updates.mode = mode;
|
||||||
|
if (minLength !== undefined) updates.minLength = minLength;
|
||||||
|
if (autoTag !== undefined) updates.autoTag = autoTag;
|
||||||
|
if (linkRelated !== undefined) updates.linkRelated = linkRelated;
|
||||||
|
|
||||||
|
const config = setCaptureConfig(updates);
|
||||||
|
return { content: [{ type: 'text' as const, text: serialize({ updated: true, config }) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// --- memory_prompt ---
|
// --- memory_prompt ---
|
||||||
import { interpretAndExecute } from '../core/prompt/interpreter';
|
import { interpretAndExecute } from '../core/prompt/interpreter';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 { gatherContext } from '../core/context';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -167,42 +166,6 @@ 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
|
// Prompt — AI-driven natural language instruction
|
||||||
router.post('/prompt', async (req: Request, res: Response) => {
|
router.post('/prompt', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user