diff --git a/docs/fumadocs/content/docs/commands.mdx b/docs/fumadocs/content/docs/commands.mdx index 3e05bb20..56e00466 100644 --- a/docs/fumadocs/content/docs/commands.mdx +++ b/docs/fumadocs/content/docs/commands.mdx @@ -170,11 +170,19 @@ owner/repo@v1.2.3 ```bash skillkit memory status # View memory status skillkit memory search # Search learnings -skillkit memory compress # Compress observations -skillkit memory export # Export as skill +skillkit memory compress # Compress observations to learnings +skillkit memory export # Export learnings as skill +skillkit memory sync-claude # Update CLAUDE.md with learnings +skillkit memory index # View memory index (Layer 1) skillkit memory --global # Use global memory ``` +| Option | Purpose | +|--------|---------| +| `--global` | Use global memory instead of project | +| `--limit` | Limit number of results | +| `--tags` | Filter by tags | + ## Translation Commands ```bash diff --git a/docs/fumadocs/content/docs/memory.mdx b/docs/fumadocs/content/docs/memory.mdx index 3ef3bc9c..33c8c1ad 100644 --- a/docs/fumadocs/content/docs/memory.mdx +++ b/docs/fumadocs/content/docs/memory.mdx @@ -5,25 +5,51 @@ description: Persistent learning across AI agent sessions # Memory System -Capture learnings from AI sessions and convert them into reusable skills. +Capture learnings from AI sessions and convert them into reusable skills. SkillKit's memory system provides automatic observation capture, intelligent compression, and token-optimized retrieval. ## Commands ```bash skillkit memory status # View status skillkit memory search # Search learnings -skillkit memory compress # Compress observations +skillkit memory compress # Compress observations to learnings skillkit memory export # Export as skill -skillkit memory --global # Global memory +skillkit memory sync-claude # Update CLAUDE.md with learnings +skillkit memory index # View memory index (Layer 1) +skillkit memory --global # Global memory operations ``` ## How It Works -1. **Observations** - Track patterns during sessions -2. **Compression** - Distill into reusable knowledge -3. **Injection** - Load into new sessions +### Observation → Learning Pipeline + +1. **Observations** - Track patterns during sessions (tool use, errors, solutions) +2. **Compression** - Distill observations into reusable learnings +3. **Injection** - Load relevant learnings into new sessions 4. **Export** - Convert to shareable skills +### Lifecycle Hooks + +SkillKit integrates with Claude Code's lifecycle hooks for automatic memory capture: + +| Hook | Trigger | Action | +|------|---------|--------| +| **SessionStart** | Session begins | Inject relevant learnings | +| **PostToolUse** | Tool completes | Capture outcomes as observations | +| **SessionEnd** | Session closes | Compress observations to learnings | + +### Progressive Disclosure (Token Optimization) + +Memory retrieval uses a 3-layer system to minimize token usage: + +| Layer | Content | ~Tokens | +|-------|---------|---------| +| **Index** | Titles, tags, timestamps | 50-100 | +| **Timeline** | Context, excerpts, activity | ~200 | +| **Details** | Full content, metadata | 500-1000 | + +The system starts with Layer 1 and progressively fetches deeper layers based on relevance and token budget. + ## Storage ``` @@ -31,17 +57,123 @@ skillkit memory --global # Global memory ├── observations/ # Raw session data ├── learnings/ # Compressed knowledge └── index.json # Memory index + +/.skillkit/memory/ +├── observations/ # Project-specific observations +├── learnings/ # Project learnings +└── index.json # Project memory index ``` +## Auto-CLAUDE.md Updates + +Sync your most effective learnings to CLAUDE.md: + +```bash +skillkit memory sync-claude +``` + +This populates the `## LEARNED` section with high-effectiveness insights, giving your agent persistent context across sessions. + ## Programmatic API +### Memory Compression + ```typescript import { MemoryCompressor, LearningStore } from '@skillkit/core' -const compressor = new MemoryCompressor() -const learning = await compressor.compress(observations) +const compressor = new MemoryCompressor(projectPath) +const { learnings } = await compressor.compress(observations) -const store = new LearningStore() +const store = new LearningStore('project', projectPath) await store.add(learning) const results = await store.search('authentication') ``` + +### Lifecycle Hooks + +```typescript +import { MemoryHookManager } from '@skillkit/core' + +const manager = new MemoryHookManager(projectPath) + +// Session start - inject relevant learnings +const startResult = await manager.onSessionStart() + +// After tool use - capture outcomes +await manager.onToolUse({ + tool_name: 'Write', + tool_input: { file_path: '/src/auth.ts' }, + tool_result: 'File written successfully', + duration_ms: 150 +}) + +// Session end - compress to learnings +const endResult = await manager.onSessionEnd() +``` + +### Progressive Disclosure + +```typescript +import { ProgressiveDisclosureManager } from '@skillkit/core' + +const pdm = new ProgressiveDisclosureManager(projectPath) + +// Layer 1: Index (~50 tokens each) +const index = pdm.getIndex({ includeGlobal: true }) + +// Layer 2: Timeline (~200 tokens each) +const timeline = pdm.getTimeline(['id1', 'id2']) + +// Layer 3: Full details (~600 tokens each) +const details = pdm.getDetails(['id1']) + +// Smart retrieval with token budget +const result = pdm.smartRetrieve('authentication patterns', 2000) +// Returns optimal layer based on budget +``` + +### CLAUDE.md Updater + +```typescript +import { ClaudeMdUpdater } from '@skillkit/core' + +const updater = new ClaudeMdUpdater(projectPath) + +// Preview what would be updated +const preview = updater.preview({ minEffectiveness: 70 }) + +// Update CLAUDE.md +const result = updater.update({ + minEffectiveness: 60, + maxLearnings: 20, + preserveManualEntries: true +}) +``` + +## Configuration + +Configure memory behavior in `.skillkit/config.json`: + +```json +{ + "memory": { + "enabled": true, + "autoInjectOnSessionStart": true, + "autoCaptureToolUse": true, + "autoCompressOnSessionEnd": true, + "minRelevanceForCapture": 30, + "maxTokensForInjection": 2000, + "compressionThreshold": 50 + } +} +``` + +## Claude Code Integration + +Generate hooks configuration for Claude Code: + +```typescript +const manager = new MemoryHookManager(projectPath) +const config = manager.generateClaudeCodeHooksConfig() +// Outputs hooks.json format for Claude Code integration +``` diff --git a/packages/cli/src/commands/memory.ts b/packages/cli/src/commands/memory.ts index a8672b02..0da6fca5 100644 --- a/packages/cli/src/commands/memory.ts +++ b/packages/cli/src/commands/memory.ts @@ -8,6 +8,9 @@ import { getMemoryStatus, createMemoryCompressor, createMemoryInjector, + createClaudeMdUpdater, + syncGlobalClaudeMd, + createProgressiveDisclosureManager, type Learning, } from '@skillkit/core'; @@ -24,17 +27,19 @@ export class MemoryCommand extends Command { captured from coding sessions across all AI agents. Subcommands: - - status: Show current memory status - - search: Search memories by query - - list: List all learnings - - show: Show a specific learning - - compress: Compress observations into learnings - - export: Export a learning as a skill - - import: Import memories from another project - - clear: Clear session observations - - add: Manually add a learning - - rate: Rate a learning's effectiveness - - config: Configure memory settings + - status: Show current memory status + - search: Search memories by query + - list: List all learnings + - show: Show a specific learning + - compress: Compress observations into learnings + - export: Export a learning as a skill + - import: Import memories from another project + - clear: Clear session observations + - add: Manually add a learning + - rate: Rate a learning's effectiveness + - sync-claude: Sync learnings to CLAUDE.md + - index: Show memory index (progressive disclosure) + - config: Configure memory settings `, examples: [ ['Show memory status', '$0 memory status'], @@ -48,6 +53,8 @@ export class MemoryCommand extends Command { ['Clear session', '$0 memory clear'], ['Add manual learning', '$0 memory add --title "..." --content "..."'], ['Rate effectiveness', '$0 memory rate 85'], + ['Sync to CLAUDE.md', '$0 memory sync-claude'], + ['Show memory index', '$0 memory index'], ], }); @@ -146,9 +153,13 @@ export class MemoryCommand extends Command { return this.rateLearning(); case 'config': return this.showConfig(); + case 'sync-claude': + return this.syncClaudeMd(); + case 'index': + return this.showIndex(); default: console.error(chalk.red(`Unknown action: ${action}`)); - console.log(chalk.gray('Available actions: status, search, list, show, compress, export, import, clear, add, rate, config')); + console.log(chalk.gray('Available actions: status, search, list, show, compress, export, import, clear, add, rate, sync-claude, index, config')); return 1; } } @@ -761,6 +772,150 @@ export class MemoryCommand extends Command { return 0; } + /** + * Sync learnings to CLAUDE.md + */ + private async syncClaudeMd(): Promise { + const projectPath = process.cwd(); + + if (this.global) { + if (this.dryRun) { + console.log(chalk.gray('(Dry run - previewing global CLAUDE.md sync)\n')); + } + + const result = this.dryRun + ? { updated: false, path: '~/.claude/CLAUDE.md', learningsAdded: 0, learningSummaries: [], previousLearnings: 0 } + : syncGlobalClaudeMd({ minEffectiveness: 60 }); + + if (this.dryRun) { + const globalStore = new LearningStore('global'); + const learnings = globalStore.getAll() + .filter((l) => (l.effectiveness ?? 0) >= 60 || l.useCount >= 3) + .slice(0, 20); + console.log(chalk.cyan(`Would add ${learnings.length} learnings to global CLAUDE.md`)); + for (const l of learnings.slice(0, 5)) { + console.log(` ${chalk.gray('●')} ${l.title}`); + } + if (learnings.length > 5) { + console.log(chalk.gray(` ... and ${learnings.length - 5} more`)); + } + return 0; + } + + if (result.updated) { + console.log(chalk.green(`✓ Updated global CLAUDE.md with ${result.learningsAdded} learnings`)); + console.log(chalk.gray(` Path: ${result.path}`)); + } else { + console.log(chalk.yellow('No learnings to sync to global CLAUDE.md')); + } + + return 0; + } + + const updater = createClaudeMdUpdater(projectPath); + + if (this.dryRun) { + const preview = updater.preview({ minEffectiveness: 60 }); + + console.log(chalk.gray('(Dry run preview)\n')); + + if (!preview.wouldUpdate) { + console.log(chalk.yellow('No learnings to sync to CLAUDE.md')); + return 0; + } + + console.log(chalk.cyan(`Would add ${preview.learnings.length} learnings to CLAUDE.md\n`)); + + for (const learning of preview.learnings.slice(0, 5)) { + console.log(` ${chalk.gray('●')} ${learning.title}`); + } + + if (preview.learnings.length > 5) { + console.log(chalk.gray(` ... and ${preview.learnings.length - 5} more`)); + } + + console.log(chalk.bold('\nFormatted section preview:')); + console.log(chalk.gray('─'.repeat(50))); + console.log(preview.formattedSection.slice(0, 500)); + if (preview.formattedSection.length > 500) { + console.log(chalk.gray('...')); + } + + return 0; + } + + const result = updater.update({ minEffectiveness: 60 }); + + if (result.updated) { + console.log(chalk.green(`✓ Updated CLAUDE.md with ${result.learningsAdded} learnings`)); + console.log(chalk.gray(` Path: ${result.path}`)); + + if (this.verbose && result.learningSummaries.length > 0) { + console.log(chalk.cyan('\nLearnings added:')); + for (const title of result.learningSummaries.slice(0, 10)) { + console.log(` ${chalk.gray('●')} ${title}`); + } + } + } else { + console.log(chalk.yellow('No learnings to sync to CLAUDE.md')); + } + + return 0; + } + + /** + * Show memory index (progressive disclosure Layer 1) + */ + private async showIndex(): Promise { + const projectPath = process.cwd(); + const manager = createProgressiveDisclosureManager(projectPath); + + let maxResults = 50; + if (this.limit) { + const parsed = parseInt(this.limit, 10); + if (isNaN(parsed) || parsed <= 0) { + console.log(chalk.red('Invalid --limit value. Must be a positive number.')); + return 1; + } + maxResults = parsed; + } + + const index = manager.getIndex({ includeGlobal: this.global, maxResults }); + + if (this.json) { + console.log(JSON.stringify(index, null, 2)); + return 0; + } + + console.log(chalk.bold(`\nMemory Index (${index.length} entries)\n`)); + + if (index.length === 0) { + console.log(chalk.gray('No learnings found.')); + return 0; + } + + const displayLimit = this.limit ? parseInt(this.limit, 10) : 20; + const displayed = index.slice(0, displayLimit); + + for (const entry of displayed) { + const effectiveness = entry.effectiveness !== undefined + ? ` [${this.formatScore(entry.effectiveness)}%]` + : ''; + const scope = entry.scope === 'global' ? chalk.magenta('[G]') : chalk.blue('[P]'); + + console.log(`${scope} ${chalk.gray(entry.id.slice(0, 8))} ${entry.title}${chalk.green(effectiveness)}`); + console.log(` Tags: ${entry.tags.join(', ')} | Uses: ${entry.useCount}`); + } + + if (index.length > displayLimit) { + console.log(chalk.gray(`\n... and ${index.length - displayLimit} more (use --limit to show more)`)); + } + + console.log(chalk.gray('\nUse "skillkit memory show " to view full details')); + + return 0; + } + /** * Format relevance/effectiveness score with color */ diff --git a/packages/core/src/memory/claude-md-updater.ts b/packages/core/src/memory/claude-md-updater.ts new file mode 100644 index 00000000..d7026073 --- /dev/null +++ b/packages/core/src/memory/claude-md-updater.ts @@ -0,0 +1,444 @@ +/** + * CLAUDE.md Auto-Updater + * + * Automatically updates CLAUDE.md with learnings from memory. + * Populates the LEARNED section with high-effectiveness insights. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname, basename } from 'node:path'; +import { homedir } from 'node:os'; +import type { Learning } from './types.js'; +import { LearningStore } from './learning-store.js'; + +/** + * CLAUDE.md section markers + */ +const SKILLKIT_MARKER = ''; + +/** + * Update options + */ +export interface ClaudeMdUpdateOptions { + minEffectiveness?: number; + maxLearnings?: number; + includeGlobal?: boolean; + preserveManualEntries?: boolean; + sectionTitle?: string; + addTimestamp?: boolean; +} + +/** + * Default update options + */ +const DEFAULT_UPDATE_OPTIONS: Required = { + minEffectiveness: 60, + maxLearnings: 20, + includeGlobal: false, + preserveManualEntries: true, + sectionTitle: 'LEARNED', + addTimestamp: true, +}; + +/** + * Parsed CLAUDE.md structure + */ +export interface ParsedClaudeMd { + content: string; + sections: Map; + hasLearnedSection: boolean; + learnedSectionContent?: string; + learnedSectionRange?: { start: number; end: number }; +} + +/** + * Update result + */ +export interface ClaudeMdUpdateResult { + updated: boolean; + path: string; + learningsAdded: number; + learningSummaries: string[]; + previousLearnings: number; +} + +/** + * CLAUDE.md Updater + * + * Manages automatic updates to CLAUDE.md with learnings from memory. + */ +export class ClaudeMdUpdater { + private projectPath: string; + private claudeMdPath: string; + + constructor(projectPath: string, claudeMdPath?: string) { + this.projectPath = projectPath; + this.claudeMdPath = claudeMdPath || join(projectPath, 'CLAUDE.md'); + } + + /** + * Parse CLAUDE.md to extract structure + */ + parse(): ParsedClaudeMd { + if (!existsSync(this.claudeMdPath)) { + return { + content: '', + sections: new Map(), + hasLearnedSection: false, + }; + } + + const content = readFileSync(this.claudeMdPath, 'utf-8'); + const sections = new Map(); + + const lines = content.split('\n'); + let currentSection: string | null = null; + let sectionStart = 0; + let sectionContent: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const headerMatch = line.match(/^(#{1,3})\s+(.+)$/); + + if (headerMatch) { + if (currentSection) { + sections.set(currentSection, { + start: sectionStart, + end: i - 1, + content: sectionContent.join('\n'), + }); + } + + currentSection = headerMatch[2].trim(); + sectionStart = i; + sectionContent = [line]; + } else if (currentSection) { + sectionContent.push(line); + } + } + + if (currentSection) { + sections.set(currentSection, { + start: sectionStart, + end: lines.length - 1, + content: sectionContent.join('\n'), + }); + } + + const learnedSection = sections.get('LEARNED') || sections.get('Learned'); + const hasLearnedSection = !!learnedSection; + + return { + content, + sections, + hasLearnedSection, + learnedSectionContent: learnedSection?.content, + learnedSectionRange: learnedSection + ? { start: learnedSection.start, end: learnedSection.end } + : undefined, + }; + } + + /** + * Get learnings to add to CLAUDE.md + */ + getLearningsForClaudeMd(options: ClaudeMdUpdateOptions = {}): Learning[] { + const opts = { ...DEFAULT_UPDATE_OPTIONS, ...options }; + + const projectStore = new LearningStore('project', this.projectPath); + let learnings = projectStore.getAll(); + + if (opts.includeGlobal) { + const globalStore = new LearningStore('global'); + learnings = [...learnings, ...globalStore.getAll()]; + } + + return learnings + .filter((l) => (l.effectiveness ?? 0) >= opts.minEffectiveness || l.useCount >= 3) + .sort((a, b) => { + const scoreA = (a.effectiveness ?? 50) + a.useCount * 5; + const scoreB = (b.effectiveness ?? 50) + b.useCount * 5; + return scoreB - scoreA; + }) + .slice(0, opts.maxLearnings); + } + + /** + * Format learnings as CLAUDE.md LEARNED section + */ + formatLearnedSection(learnings: Learning[], options: ClaudeMdUpdateOptions = {}): string { + const opts = { ...DEFAULT_UPDATE_OPTIONS, ...options }; + const lines: string[] = []; + + lines.push(`## ${opts.sectionTitle}`); + lines.push(''); + lines.push(SKILLKIT_MARKER); + + if (opts.addTimestamp) { + lines.push(``); + } + + lines.push(''); + + const byCategory = this.categorizeLearnings(learnings); + + for (const [category, categoryLearnings] of byCategory) { + if (categoryLearnings.length > 0) { + lines.push(`### ${category}`); + + for (const learning of categoryLearnings) { + const title = this.formatLearningTitle(learning); + const summary = this.extractSummary(learning.content); + lines.push(`${title}`); + lines.push(summary); + lines.push(''); + } + } + } + + return lines.join('\n'); + } + + /** + * Update CLAUDE.md with learnings + */ + update(options: ClaudeMdUpdateOptions = {}): ClaudeMdUpdateResult { + const opts = { ...DEFAULT_UPDATE_OPTIONS, ...options }; + const learnings = this.getLearningsForClaudeMd(opts); + + if (learnings.length === 0) { + return { + updated: false, + path: this.claudeMdPath, + learningsAdded: 0, + learningSummaries: [], + previousLearnings: 0, + }; + } + + const parsed = this.parse(); + const newSection = this.formatLearnedSection(learnings, opts); + + let newContent: string; + let previousLearnings = 0; + + if (parsed.hasLearnedSection && parsed.learnedSectionRange) { + if (opts.preserveManualEntries) { + const existingContent = parsed.learnedSectionContent || ''; + const manualEntries = this.extractManualEntries(existingContent); + previousLearnings = this.countLearnings(existingContent); + + const combinedSection = this.combineWithManualEntries(newSection, manualEntries); + + const lines = parsed.content.split('\n'); + const before = lines.slice(0, parsed.learnedSectionRange.start).join('\n'); + const after = lines.slice(parsed.learnedSectionRange.end + 1).join('\n'); + + newContent = before + (before ? '\n' : '') + combinedSection + (after ? '\n' + after : ''); + } else { + const lines = parsed.content.split('\n'); + const before = lines.slice(0, parsed.learnedSectionRange.start).join('\n'); + const after = lines.slice(parsed.learnedSectionRange.end + 1).join('\n'); + + newContent = before + (before ? '\n' : '') + newSection + (after ? '\n' + after : ''); + } + } else if (parsed.content) { + newContent = parsed.content + '\n\n' + newSection; + } else { + newContent = this.createNewClaudeMd(newSection); + } + + const dir = dirname(this.claudeMdPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(this.claudeMdPath, newContent, 'utf-8'); + + return { + updated: true, + path: this.claudeMdPath, + learningsAdded: learnings.length, + learningSummaries: learnings.map((l) => l.title), + previousLearnings, + }; + } + + /** + * Preview update without writing + */ + preview(options: ClaudeMdUpdateOptions = {}): { + learnings: Learning[]; + formattedSection: string; + wouldUpdate: boolean; + } { + const learnings = this.getLearningsForClaudeMd(options); + const formattedSection = this.formatLearnedSection(learnings, options); + + return { + learnings, + formattedSection, + wouldUpdate: learnings.length > 0, + }; + } + + /** + * Check if CLAUDE.md exists + */ + exists(): boolean { + return existsSync(this.claudeMdPath); + } + + /** + * Get CLAUDE.md path + */ + getPath(): string { + return this.claudeMdPath; + } + + private categorizeLearnings(learnings: Learning[]): Map { + const categories = new Map(); + + for (const learning of learnings) { + let category = 'General'; + + if (learning.patterns?.includes('error-handling')) { + category = 'Error-Handling'; + } else if (learning.patterns?.includes('debugging')) { + category = 'Debugging'; + } else if (learning.patterns?.includes('architecture')) { + category = 'Architecture'; + } else if (learning.patterns?.includes('workflow')) { + category = 'Workflow'; + } else if (learning.tags.some((t) => t.includes('react') || t.includes('typescript'))) { + category = 'Code-Patterns'; + } + + const existing = categories.get(category) || []; + existing.push(learning); + categories.set(category, existing); + } + + return categories; + } + + private formatLearningTitle(learning: Learning): string { + const tags = learning.tags.slice(0, 3).join(', '); + return tags ? `**${tags}**: ${learning.title}` : `**${learning.title}**`; + } + + private extractSummary(content: string): string { + const lines = content.split('\n').filter((l) => l.trim()); + + for (const line of lines) { + const cleaned = line.replace(/^#+\s*/, '').replace(/^\*\*.*?\*\*:?\s*/, ''); + if (cleaned.length > 20 && !cleaned.startsWith('#')) { + return cleaned.slice(0, 200) + (cleaned.length > 200 ? '...' : ''); + } + } + + return content.slice(0, 200) + (content.length > 200 ? '...' : ''); + } + + private extractManualEntries(sectionContent: string): string[] { + const lines = sectionContent.split('\n'); + const manualEntries: string[] = []; + let inManualEntry = false; + let currentEntry: string[] = []; + + for (const line of lines) { + if (line.includes(SKILLKIT_MARKER)) { + inManualEntry = false; + continue; + } + + if (line.startsWith('### ') && !line.includes('Auto-populated')) { + if (currentEntry.length > 0) { + manualEntries.push(currentEntry.join('\n')); + } + inManualEntry = true; + currentEntry = [line]; + } else if (inManualEntry) { + currentEntry.push(line); + } + } + + if (currentEntry.length > 0) { + manualEntries.push(currentEntry.join('\n')); + } + + return manualEntries.filter((e) => e.trim().length > 0); + } + + private combineWithManualEntries(autoSection: string, manualEntries: string[]): string { + if (manualEntries.length === 0) { + return autoSection; + } + + const lines = autoSection.split('\n'); + const combinedLines = [...lines]; + + combinedLines.push(''); + combinedLines.push('### Manual Entries'); + combinedLines.push(''); + combinedLines.push(''); + + for (const entry of manualEntries) { + combinedLines.push(entry); + combinedLines.push(''); + } + + return combinedLines.join('\n'); + } + + private countLearnings(content: string): number { + const matches = content.match(/^\*\*[^*]+\*\*/gm); + return matches ? matches.length : 0; + } + + private createNewClaudeMd(learnedSection: string): string { + const projectName = basename(this.projectPath) || 'Project'; + + return `# ${projectName} + +${learnedSection} +`; + } +} + +/** + * Create a CLAUDE.md updater + */ +export function createClaudeMdUpdater( + projectPath: string, + claudeMdPath?: string +): ClaudeMdUpdater { + return new ClaudeMdUpdater(projectPath, claudeMdPath); +} + +/** + * Update CLAUDE.md with learnings (standalone function) + */ +export function updateClaudeMd( + projectPath: string, + options: ClaudeMdUpdateOptions = {} +): ClaudeMdUpdateResult { + const updater = new ClaudeMdUpdater(projectPath); + return updater.update(options); +} + +/** + * Sync global CLAUDE.md with global learnings + */ +export function syncGlobalClaudeMd(options: ClaudeMdUpdateOptions = {}): ClaudeMdUpdateResult { + const globalClaudeMdPath = join(homedir(), '.claude', 'CLAUDE.md'); + const updater = new ClaudeMdUpdater(homedir(), globalClaudeMdPath); + + const globalOpts: ClaudeMdUpdateOptions = { + ...options, + includeGlobal: true, + sectionTitle: 'Global Learnings', + }; + + return updater.update(globalOpts); +} diff --git a/packages/core/src/memory/compressor.ts b/packages/core/src/memory/compressor.ts index d1726e6a..0806532e 100644 --- a/packages/core/src/memory/compressor.ts +++ b/packages/core/src/memory/compressor.ts @@ -95,7 +95,6 @@ export class RuleBasedCompressor implements CompressionEngine { ): Promise { const opts = { ...DEFAULT_COMPRESSION_OPTIONS, ...options }; - // Filter observations const filtered = opts.includeLowRelevance ? observations : observations.filter((o) => o.relevance >= 50); @@ -116,36 +115,29 @@ export class RuleBasedCompressor implements CompressionEngine { const learnings: CompressedLearning[] = []; const processedIds: string[] = []; - // Group observations by type const byType = this.groupByType(filtered); - // Extract error-solution pairs const errorSolutionLearnings = this.extractErrorSolutionPairs(byType, opts); learnings.push(...errorSolutionLearnings.learnings); processedIds.push(...errorSolutionLearnings.processedIds); - // Extract decision patterns const decisionLearnings = this.extractDecisionPatterns(byType, opts); learnings.push(...decisionLearnings.learnings); processedIds.push(...decisionLearnings.processedIds); - // Extract file change patterns const fileChangeLearnings = this.extractFileChangePatterns(byType, opts); learnings.push(...fileChangeLearnings.learnings); processedIds.push(...fileChangeLearnings.processedIds); - // Extract tool usage patterns const toolUsageLearnings = this.extractToolUsagePatterns(byType, opts); learnings.push(...toolUsageLearnings.learnings); processedIds.push(...toolUsageLearnings.processedIds); - // Filter by importance and limit const finalLearnings = learnings .filter((l) => l.importance >= opts.minImportance) .sort((a, b) => b.importance - a.importance) .slice(0, opts.maxLearnings); - // Add additional tags if (opts.additionalTags.length > 0) { for (const learning of finalLearnings) { learning.tags = [...new Set([...learning.tags, ...opts.additionalTags])]; @@ -187,7 +179,6 @@ export class RuleBasedCompressor implements CompressionEngine { const learnings: CompressedLearning[] = []; const processedIds: string[] = []; - // Match errors with solutions for (const error of errors) { const errorText = error.content.error || error.content.action; const matchingSolution = solutions.find((s) => { @@ -195,21 +186,18 @@ export class RuleBasedCompressor implements CompressionEngine { const solutionAction = s.content.action?.toLowerCase() || ''; const errorLower = errorText.toLowerCase(); - // Require keyword similarity between error and solution const hasKeywordMatch = this.hasSimilarKeywords(errorLower, solutionContext) || this.hasSimilarKeywords(errorLower, solutionAction); if (!hasKeywordMatch) { return false; } - // Solution should indicate it's a fix/resolution, or have strong keyword overlap const isSolutionIndicator = solutionContext.includes('fix') || solutionContext.includes('resolve') || solutionContext.includes('solution') || solutionAction.includes('fix') || solutionAction.includes('resolve'); - // Require both keyword match AND solution indicator return isSolutionIndicator || this.hasStrongKeywordOverlap(errorLower, solutionContext); }); @@ -230,7 +218,6 @@ export class RuleBasedCompressor implements CompressionEngine { processedIds.push(error.id, matchingSolution.id); } else if (error.content.error) { - // Standalone error learning const title = this.generateTitle('Error Pattern', errorText); const content = this.formatStandaloneErrorContent(error); const tags = this.extractTags([error]); @@ -249,7 +236,6 @@ export class RuleBasedCompressor implements CompressionEngine { } } - // Process remaining solutions for (const solution of solutions) { if (!processedIds.includes(solution.id)) { const title = this.generateTitle('Solution', solution.content.action); @@ -310,7 +296,6 @@ export class RuleBasedCompressor implements CompressionEngine { const learnings: CompressedLearning[] = []; const processedIds: string[] = []; - // Group file changes by pattern (same files modified together) const filePatterns = new Map(); for (const change of fileChanges) { const files = change.content.files || []; @@ -320,7 +305,6 @@ export class RuleBasedCompressor implements CompressionEngine { filePatterns.set(pattern, existing); } - // Create learnings for significant patterns (2+ occurrences) for (const [pattern, changes] of filePatterns) { if (changes.length >= 2 || changes.some((c) => (c.content.files?.length || 0) > 3)) { const title = `File Modification Pattern: ${pattern}`; @@ -353,10 +337,8 @@ export class RuleBasedCompressor implements CompressionEngine { const learnings: CompressedLearning[] = []; const processedIds: string[] = []; - // Only extract if there are significant patterns const allObs = [...toolUses, ...checkpoints]; if (allObs.length >= 5) { - // Group by common action patterns const actionPatterns = new Map(); for (const obs of allObs) { const actionType = this.getActionType(obs.content.action); @@ -390,7 +372,6 @@ export class RuleBasedCompressor implements CompressionEngine { } private generateTitle(prefix: string, text: string): string { - // Extract key words from text const cleaned = text .replace(/[^a-zA-Z0-9\s]/g, ' ') .replace(/\s+/g, ' ') @@ -534,7 +515,6 @@ Consider automating or documenting this workflow for future reference. private getFilePattern(files: string[]): string { if (files.length === 0) return 'unknown'; - // Extract common directory const dirs = files.map((f) => { const parts = f.split('/'); return parts.slice(0, -1).join('/') || 'root'; @@ -545,7 +525,6 @@ Consider automating or documenting this workflow for future reference. return uniqueDirs[0]; } - // Extract file types const extensions = files.map((f) => { const ext = f.split('.').pop() || 'unknown'; return ext; @@ -568,7 +547,6 @@ Consider automating or documenting this workflow for future reference. } private hasSimilarKeywords(text1: string, text2: string): boolean { - // Normalize: lowercase and strip punctuation for better matching const normalize = (word: string) => word.toLowerCase().replace(/[^a-z0-9]/g, ''); const words1 = text1.split(/\s+/).map(normalize).filter((w) => w.length > 3); const words2 = new Set(text2.split(/\s+/).map(normalize).filter((w) => w.length > 3)); @@ -580,7 +558,6 @@ Consider automating or documenting this workflow for future reference. * More strict than hasSimilarKeywords - requires at least 2 matching words */ private hasStrongKeywordOverlap(text1: string, text2: string): boolean { - // Normalize: lowercase and strip punctuation for better matching const normalize = (word: string) => word.toLowerCase().replace(/[^a-z0-9]/g, ''); const words1 = text1.split(/\s+/).map(normalize).filter((w) => w.length > 3); const words2 = new Set(text2.split(/\s+/).map(normalize).filter((w) => w.length > 3)); @@ -625,7 +602,6 @@ export class APIBasedCompressor implements CompressionEngine { ): Promise { const opts = { ...DEFAULT_COMPRESSION_OPTIONS, ...options }; - // Filter observations const filtered = opts.includeLowRelevance ? observations : observations.filter((o) => o.relevance >= 50); @@ -643,26 +619,18 @@ export class APIBasedCompressor implements CompressionEngine { }; } - // Format observations for the prompt const observationsText = this.formatObservationsForPrompt(filtered); - - // Build the prompt const prompt = this.buildCompressionPrompt(observationsText, opts); try { - // Call the API const response = await this.callAPI(prompt); - - // Parse the response const learnings = this.parseAPIResponse(response, filtered); - // Filter and limit const finalLearnings = learnings .filter((l) => l.importance >= opts.minImportance) .sort((a, b) => b.importance - a.importance) .slice(0, opts.maxLearnings); - // Add additional tags if (opts.additionalTags.length > 0) { for (const learning of finalLearnings) { learning.tags = [...new Set([...learning.tags, ...opts.additionalTags])]; @@ -686,7 +654,6 @@ export class APIBasedCompressor implements CompressionEngine { }, }; } catch (error) { - // Fall back to rule-based compression on API failure console.warn('API compression failed, falling back to rule-based:', error); const fallback = new RuleBasedCompressor(); return fallback.compress(observations, options); @@ -820,7 +787,6 @@ Generate up to ${opts.maxLearnings} learnings. Only include learnings with impor private parseAPIResponse(response: string, observations: Observation[]): CompressedLearning[] { try { - // Extract JSON from response (handle markdown code blocks) let jsonStr = response; const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/); if (jsonMatch) { @@ -833,7 +799,6 @@ Generate up to ${opts.maxLearnings} learnings. Only include learnings with impor throw new Error('Response is not an array'); } - // Validate and clean up each learning const observationIds = new Set(observations.map((o) => o.id)); return parsed .filter((item): item is CompressedLearning => { @@ -847,7 +812,6 @@ Generate up to ${opts.maxLearnings} learnings. Only include learnings with impor }) .map((item) => ({ ...item, - // Validate source observation IDs sourceObservationIds: (item.sourceObservationIds || []).filter((id: string) => observationIds.has(id) ), @@ -884,7 +848,6 @@ export class MemoryCompressor { projectPath, options?.projectName ); - // For global scope, use home directory; for project scope, use project path const indexBasePath = options?.scope === 'global' ? homedir() : projectPath; this.indexStore = new MemoryIndexStore(indexBasePath, options?.scope === 'global'); this.projectName = options?.projectName; @@ -926,10 +889,8 @@ export class MemoryCompressor { projectName: this.projectName || options?.projectName, }; - // Compress observations const result = await this.engine.compress(observations, compressionOptions); - // Store each learning const storedLearnings: Learning[] = []; for (const compressed of result.learnings) { const learning = this.learningStore.add({ @@ -942,7 +903,6 @@ export class MemoryCompressor { patterns: compressed.patterns, }); - // Index the learning this.indexStore.indexLearning(learning); storedLearnings.push(learning); @@ -1004,12 +964,10 @@ export class LearningConsolidator { * Merge two similar learnings */ merge(learning1: Learning, learning2: Learning): Omit { - // Keep the more effective/used one as base const base = this.getBetterLearning(learning1, learning2); const other = base === learning1 ? learning2 : learning1; return { - // Preserve the source from the base learning instead of hard-coding 'session' source: base.source, sourceObservations: [ ...(base.sourceObservations || []), @@ -1046,14 +1004,11 @@ export class LearningConsolidator { continue; } - // Merge the learnings const merged = this.merge(l1, l2); - // Add the merged learning const newLearning = store.add(merged); index.indexLearning(newLearning); - // Remove the old learnings store.delete(l1.id); store.delete(l2.id); index.removeLearning(l1.id); @@ -1073,14 +1028,12 @@ export class LearningConsolidator { private calculateSimilarity(l1: Learning, l2: Learning): number { let score = 0; - // Title similarity (Jaccard) const title1Words = new Set(l1.title.toLowerCase().split(/\s+/)); const title2Words = new Set(l2.title.toLowerCase().split(/\s+/)); const titleIntersection = [...title1Words].filter((w) => title2Words.has(w)).length; const titleUnion = new Set([...title1Words, ...title2Words]).size; score += (titleIntersection / titleUnion) * 0.3; - // Tag overlap const tags1 = new Set(l1.tags); const tags2 = new Set(l2.tags); const tagIntersection = [...tags1].filter((t) => tags2.has(t)).length; @@ -1089,7 +1042,6 @@ export class LearningConsolidator { score += (tagIntersection / tagUnion) * 0.3; } - // Pattern overlap const patterns1 = new Set(l1.patterns || []); const patterns2 = new Set(l2.patterns || []); const patternIntersection = [...patterns1].filter((p) => patterns2.has(p)).length; @@ -1098,7 +1050,6 @@ export class LearningConsolidator { score += (patternIntersection / patternUnion) * 0.2; } - // Content similarity (simple word overlap) const content1Words = new Set( l1.content .toLowerCase() @@ -1121,20 +1072,16 @@ export class LearningConsolidator { } private getBetterLearning(l1: Learning, l2: Learning): Learning { - // Prefer higher effectiveness if (l1.effectiveness !== l2.effectiveness) { return (l1.effectiveness || 0) > (l2.effectiveness || 0) ? l1 : l2; } - // Prefer higher use count if (l1.useCount !== l2.useCount) { return l1.useCount > l2.useCount ? l1 : l2; } - // Prefer more recent return new Date(l1.updatedAt) > new Date(l2.updatedAt) ? l1 : l2; } private mergeContent(content1: string, content2: string): string { - // Simple merge: keep the longer one and add a note if (content1.length >= content2.length) { return content1; } diff --git a/packages/core/src/memory/engine-integration.ts b/packages/core/src/memory/engine-integration.ts index 65cf0ba4..d9ddae76 100644 --- a/packages/core/src/memory/engine-integration.ts +++ b/packages/core/src/memory/engine-integration.ts @@ -39,30 +39,23 @@ export class MemoryEnabledEngine { private userProgressCallback?: ExecutionProgressCallback; constructor(projectPath: string, options: MemoryEnabledEngineOptions = {}) { - // Create memory observer this.observer = new MemoryObserver(projectPath, options.sessionId, options.memoryConfig); - // Set default agent if provided if (options.defaultAgent) { this.observer.setAgent(options.defaultAgent); } - // Store user's progress callback this.userProgressCallback = options.onProgress; - // Create the combined progress callback const combinedProgressCallback: ExecutionProgressCallback = (event: ExecutionProgressEvent) => { - // Forward to memory observer const observerCallback = this.observer.createProgressCallback(); observerCallback(event); - // Forward to user's callback if provided if (this.userProgressCallback) { this.userProgressCallback(event); } }; - // Create the underlying engine with the combined callback this.engine = createExecutionEngine(projectPath, { checkpointHandler: options.checkpointHandler, onProgress: combinedProgressCallback, @@ -76,22 +69,17 @@ export class MemoryEnabledEngine { skill: ExecutableSkill, options: ExecutionOptions = {} ): ReturnType { - // Set skill name in observer this.observer.setSkillName(skill.name); - // Set agent if provided in options if (options.agent) { this.observer.setAgent(options.agent); } - // Record execution start this.observer.recordExecutionStart(skill.name, options.agent || 'claude-code'); try { - // Execute the skill const result = await this.engine.execute(skill, options); - // Record file modifications from result if (result.filesModified.length > 0) { this.observer.recordFileModification( result.filesModified, @@ -99,14 +87,12 @@ export class MemoryEnabledEngine { ); } - // Record any errors if (result.error) { this.observer.recordError(result.error, `Skill "${skill.name}" failed`); } return result; } catch (error) { - // Record unexpected errors const errorMessage = error instanceof Error ? error.message : String(error); this.observer.recordError(errorMessage, `Unexpected error during skill "${skill.name}" execution`); throw error; diff --git a/packages/core/src/memory/hooks/index.ts b/packages/core/src/memory/hooks/index.ts new file mode 100644 index 00000000..a2d54836 --- /dev/null +++ b/packages/core/src/memory/hooks/index.ts @@ -0,0 +1,27 @@ +/** + * Memory Hooks Module + * + * Provides Claude Code lifecycle hooks for automatic memory capture + * and injection at key session lifecycle events. + */ + +export * from './types.js'; +export { + SessionStartHook, + createSessionStartHook, + executeSessionStartHook, +} from './session-start.js'; +export { + PostToolUseHook, + createPostToolUseHook, + executePostToolUseHook, +} from './post-tool-use.js'; +export { + SessionEndHook, + createSessionEndHook, + executeSessionEndHook, +} from './session-end.js'; +export { + MemoryHookManager, + createMemoryHookManager, +} from './manager.js'; diff --git a/packages/core/src/memory/hooks/manager.ts b/packages/core/src/memory/hooks/manager.ts new file mode 100644 index 00000000..8f4b388f --- /dev/null +++ b/packages/core/src/memory/hooks/manager.ts @@ -0,0 +1,286 @@ +/** + * Memory Hook Manager + * + * Unified manager for all memory lifecycle hooks. + * Provides a single interface for session memory management. + */ + +import type { AgentType } from '../../types.js'; +import type { Learning, Observation } from '../types.js'; +import type { + MemoryHookConfig, + MemoryHookStats, + SessionStartContext, + SessionEndContext, + ToolUseEvent, + ClaudeCodeHookOutput, +} from './types.js'; +import { DEFAULT_MEMORY_HOOK_CONFIG } from './types.js'; +import { SessionStartHook } from './session-start.js'; +import { PostToolUseHook } from './post-tool-use.js'; +import { SessionEndHook } from './session-end.js'; +import { randomUUID } from 'node:crypto'; + +/** + * Memory Hook Manager + * + * Coordinates all memory hooks for a session. + */ +export class MemoryHookManager { + private config: MemoryHookConfig; + private projectPath: string; + private agent: AgentType; + private sessionId: string; + private startedAt: string; + + private sessionStartHook: SessionStartHook; + private postToolUseHook: PostToolUseHook; + private sessionEndHook: SessionEndHook; + + private stats: MemoryHookStats; + + constructor( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {}, + sessionId?: string + ) { + this.projectPath = projectPath; + this.agent = agent; + this.config = { ...DEFAULT_MEMORY_HOOK_CONFIG, ...config }; + this.sessionId = sessionId || randomUUID(); + this.startedAt = new Date().toISOString(); + + this.sessionStartHook = new SessionStartHook(projectPath, agent, this.config); + this.postToolUseHook = new PostToolUseHook(projectPath, agent, this.config, this.sessionId); + this.sessionEndHook = new SessionEndHook(projectPath, agent, this.config); + + this.stats = { + sessionId: this.sessionId, + startedAt: this.startedAt, + observationsCaptured: 0, + learningsInjected: 0, + tokensUsed: 0, + toolCallsCaptured: 0, + errorsRecorded: 0, + solutionsRecorded: 0, + }; + } + + /** + * Handle session start + */ + async onSessionStart(workingDirectory?: string): Promise { + const context: SessionStartContext = { + session_id: this.sessionId, + project_path: this.projectPath, + agent: this.agent, + timestamp: new Date().toISOString(), + working_directory: workingDirectory, + }; + + const result = await this.sessionStartHook.execute(context); + + this.stats.learningsInjected = result.learnings.length; + this.stats.tokensUsed = result.tokenCount; + + return this.sessionStartHook.generateHookOutputFromResult(result); + } + + /** + * Handle tool use + */ + async onToolUse(event: ToolUseEvent): Promise { + const result = await this.postToolUseHook.execute(event); + + if (result.captured) { + this.stats.observationsCaptured++; + this.stats.toolCallsCaptured++; + + if (result.observation?.type === 'error') { + this.stats.errorsRecorded++; + } else if (result.observation?.type === 'solution') { + this.stats.solutionsRecorded++; + } + } + + await this.checkAutoCompression(); + + return this.postToolUseHook.generateHookOutputFromResult(event, result); + } + + /** + * Handle session end + */ + async onSessionEnd(toolCallsCount?: number): Promise { + const context: SessionEndContext = { + session_id: this.sessionId, + project_path: this.projectPath, + agent: this.agent, + timestamp: new Date().toISOString(), + duration_ms: Date.now() - new Date(this.startedAt).getTime(), + tool_calls_count: toolCallsCount, + }; + + const result = await this.sessionEndHook.execute(context); + + return this.sessionEndHook.generateHookOutputFromResult(result); + } + + /** + * Record an error manually + */ + recordError(error: string, context: string): Observation { + const obs = this.postToolUseHook.recordError(error, context); + this.stats.observationsCaptured++; + this.stats.errorsRecorded++; + return obs; + } + + /** + * Record a solution manually + */ + recordSolution(solution: string, context: string, relatedError?: string): Observation { + const obs = this.postToolUseHook.recordSolution(solution, context, relatedError); + this.stats.observationsCaptured++; + this.stats.solutionsRecorded++; + return obs; + } + + /** + * Record a decision manually + */ + recordDecision(decision: string, options: string[], context: string): Observation { + const obs = this.postToolUseHook.recordDecision(decision, options, context); + this.stats.observationsCaptured++; + return obs; + } + + /** + * Record file changes manually + */ + recordFileChange(files: string[], action: string, context: string): Observation { + const obs = this.postToolUseHook.recordFileChange(files, action, context); + this.stats.observationsCaptured++; + return obs; + } + + /** + * Force compression (regardless of threshold) + */ + async forceCompress(): Promise { + const result = await this.sessionEndHook.forceCompress(this.sessionId); + return result.learnings; + } + + /** + * Preview compression without executing + */ + async previewCompression(): Promise<{ + wouldCompress: boolean; + observationCount: number; + estimatedLearnings: number; + observationTypes: Record; + }> { + return this.sessionEndHook.preview(this.sessionId); + } + + /** + * Get current stats + */ + getStats(): MemoryHookStats { + return { ...this.stats }; + } + + /** + * Get session ID + */ + getSessionId(): string { + return this.sessionId; + } + + /** + * Get observation count + */ + getObservationCount(): number { + return this.postToolUseHook.getObservationCount(); + } + + /** + * Get pending errors + */ + getPendingErrors(): Array<{ error: string; timestamp: string }> { + return this.postToolUseHook.getPendingErrors(); + } + + /** + * Get configuration + */ + getConfig(): MemoryHookConfig { + return { ...this.config }; + } + + /** + * Update configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + this.sessionStartHook.setConfig(this.config); + this.postToolUseHook.setConfig(this.config); + this.sessionEndHook.setConfig(this.config); + } + + /** + * Check if auto-compression should trigger + */ + private async checkAutoCompression(): Promise { + if (!this.config.enabled) return; + + const count = this.postToolUseHook.getObservationCount(); + if (count >= this.config.compressionThreshold) { + await this.sessionEndHook.forceCompress(this.sessionId); + } + } + + /** + * Generate Claude Code hooks.json configuration + */ + generateClaudeCodeHooksConfig(): Record { + return { + hooks: [ + { + matcher: '.*', + hooks: [ + { + type: 'command', + command: `npx skillkit memory hook session-start --project "${this.projectPath}"`, + event: 'SessionStart', + }, + { + type: 'command', + command: `npx skillkit memory hook post-tool-use --project "${this.projectPath}"`, + event: 'PostToolUse', + }, + { + type: 'command', + command: `npx skillkit memory hook session-end --project "${this.projectPath}"`, + event: 'SessionEnd', + }, + ], + }, + ], + }; + } +} + +/** + * Create a memory hook manager + */ +export function createMemoryHookManager( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {}, + sessionId?: string +): MemoryHookManager { + return new MemoryHookManager(projectPath, agent, config, sessionId); +} diff --git a/packages/core/src/memory/hooks/post-tool-use.ts b/packages/core/src/memory/hooks/post-tool-use.ts new file mode 100644 index 00000000..ac913b9c --- /dev/null +++ b/packages/core/src/memory/hooks/post-tool-use.ts @@ -0,0 +1,442 @@ +/** + * Post Tool Use Hook + * + * Captures tool outcomes as observations after each tool use in Claude Code. + * This enables automatic memory capture without manual intervention. + */ + +import type { AgentType } from '../../types.js'; +import type { Observation, ObservationType, ObservationContent } from '../types.js'; +import type { + ToolUseEvent, + ToolUseCaptureResult, + MemoryHookConfig, + ClaudeCodeHookOutput, +} from './types.js'; +import { DEFAULT_MEMORY_HOOK_CONFIG } from './types.js'; +import { ObservationStore } from '../observation-store.js'; + +/** + * Tool categories for classification + */ +const TOOL_CATEGORIES: Record = { + Read: 'tool_use', + Write: 'file_change', + Edit: 'file_change', + Bash: 'tool_use', + Glob: 'tool_use', + Grep: 'tool_use', + WebFetch: 'tool_use', + WebSearch: 'tool_use', + Task: 'checkpoint', + AskUserQuestion: 'decision', +}; + +/** + * Relevance scores by tool type + */ +const TOOL_RELEVANCE: Record = { + Write: 70, + Edit: 70, + Bash: 60, + Task: 65, + AskUserQuestion: 75, + WebFetch: 50, + WebSearch: 50, + Read: 30, + Glob: 25, + Grep: 30, +}; + +/** + * Post Tool Use Hook Handler + * + * Captures tool outcomes and stores them as observations. + */ +export class PostToolUseHook { + private config: MemoryHookConfig; + private agent: AgentType; + private store: ObservationStore; + private pendingErrors: Map = new Map(); + + constructor( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {}, + sessionId?: string + ) { + this.agent = agent; + this.config = { ...DEFAULT_MEMORY_HOOK_CONFIG, ...config }; + this.store = new ObservationStore(projectPath, sessionId); + } + + /** + * Execute the post tool use hook + */ + async execute(event: ToolUseEvent): Promise { + this.clearOldPendingErrors(); + + if (!this.config.enabled || !this.config.autoCaptureToolUse) { + return { captured: false, reason: 'Hook disabled' }; + } + + if (this.shouldExcludeTool(event.tool_name)) { + return { captured: false, reason: `Tool ${event.tool_name} is excluded` }; + } + + const relevance = this.calculateRelevance(event); + if (relevance < this.config.minRelevanceForCapture) { + return { captured: false, reason: `Relevance ${relevance} below threshold` }; + } + + const observationType = this.getObservationType(event); + const content = this.extractContent(event); + + if (event.is_error) { + const errorId = this.generateErrorId(content.error || content.action); + this.pendingErrors.set(errorId, { + error: content.error || content.action, + timestamp: new Date().toISOString(), + }); + } + + const matchingSolution = event.is_error ? undefined : this.findMatchingSolution(content); + if (matchingSolution) { + content.solution = content.action; + content.context = `Solution for: ${matchingSolution.error}`; + } + + const observation = this.store.add( + observationType, + content, + this.agent, + matchingSolution ? 95 : relevance + ); + + return { captured: true, observation }; + } + + /** + * Generate Claude Code hook output format + */ + async generateHookOutput(event: ToolUseEvent): Promise { + const result = await this.execute(event); + return this.generateHookOutputFromResult(event, result); + } + + /** + * Generate hook output from pre-computed result (avoids double execution) + */ + generateHookOutputFromResult(event: ToolUseEvent, result: ToolUseCaptureResult): ClaudeCodeHookOutput { + return { + continue: true, + message: result.captured + ? `Observation captured: ${event.tool_name}` + : undefined, + }; + } + + /** + * Get configuration + */ + getConfig(): MemoryHookConfig { + return { ...this.config }; + } + + /** + * Update configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Record an error explicitly + */ + recordError(error: string, context: string): Observation { + const errorId = this.generateErrorId(error); + this.pendingErrors.set(errorId, { + error, + timestamp: new Date().toISOString(), + }); + + return this.store.add( + 'error', + { + action: 'Error encountered', + context, + error, + tags: ['error'], + }, + this.agent, + 80 + ); + } + + /** + * Record a solution explicitly + */ + recordSolution(solution: string, context: string, relatedError?: string): Observation { + let relevance = 70; + let solutionContext = context; + + if (relatedError) { + const errorId = this.generateErrorId(relatedError); + if (this.pendingErrors.has(errorId)) { + this.pendingErrors.delete(errorId); + relevance = 95; + solutionContext = `Solution for: ${relatedError}`; + } + } + + return this.store.add( + 'solution', + { + action: solution, + context: solutionContext, + solution, + tags: ['solution'], + }, + this.agent, + relevance + ); + } + + /** + * Record a decision explicitly + */ + recordDecision(decision: string, options: string[], context: string): Observation { + return this.store.add( + 'decision', + { + action: decision, + context: `Options: ${options.join(', ')}. Context: ${context}`, + tags: ['decision', 'architecture'], + }, + this.agent, + 75 + ); + } + + /** + * Record file modifications + */ + recordFileChange(files: string[], action: string, context: string): Observation { + return this.store.add( + 'file_change', + { + action, + context, + files, + tags: ['file-change'], + }, + this.agent, + files.length > 3 ? 75 : 60 + ); + } + + /** + * Get pending errors that haven't been resolved + */ + getPendingErrors(): Array<{ error: string; timestamp: string }> { + return Array.from(this.pendingErrors.values()); + } + + /** + * Clear old pending errors (older than 30 minutes) + */ + clearOldPendingErrors(): number { + const thirtyMinutesAgo = Date.now() - 30 * 60 * 1000; + let cleared = 0; + + for (const [id, { timestamp }] of this.pendingErrors) { + if (new Date(timestamp).getTime() < thirtyMinutesAgo) { + this.pendingErrors.delete(id); + cleared++; + } + } + + return cleared; + } + + /** + * Get observation store + */ + getStore(): ObservationStore { + return this.store; + } + + /** + * Get observation count + */ + getObservationCount(): number { + return this.store.count(); + } + + private shouldExcludeTool(toolName: string): boolean { + return this.config.excludeTools?.includes(toolName) ?? false; + } + + private calculateRelevance(event: ToolUseEvent): number { + let relevance = TOOL_RELEVANCE[event.tool_name] || 50; + + if (event.is_error) { + relevance = Math.max(relevance, 80); + } + + if (event.duration_ms && event.duration_ms > 10000) { + relevance = Math.min(relevance + 10, 100); + } + + const result = event.tool_result || ''; + if (result.includes('error') || result.includes('Error') || result.includes('failed')) { + relevance = Math.max(relevance, 75); + } + + if (result.includes('success') || result.includes('created') || result.includes('updated')) { + relevance = Math.min(relevance + 5, 100); + } + + return relevance; + } + + private getObservationType(event: ToolUseEvent): ObservationType { + if (event.is_error) { + return 'error'; + } + + return TOOL_CATEGORIES[event.tool_name] || 'tool_use'; + } + + private extractContent(event: ToolUseEvent): ObservationContent { + const content: ObservationContent = { + action: `${event.tool_name}: ${this.summarizeInput(event.tool_input)}`, + context: this.extractContext(event), + tags: [event.tool_name.toLowerCase()], + }; + + if (event.is_error && event.tool_result) { + content.error = event.tool_result.slice(0, 500); + } + + if (event.tool_result && !event.is_error) { + content.result = event.tool_result.slice(0, 200); + } + + const files = this.extractFiles(event); + if (files.length > 0) { + content.files = files; + } + + return content; + } + + private summarizeInput(input: Record): string { + if (input.file_path) return String(input.file_path); + if (input.pattern) return String(input.pattern); + if (input.command) { + const cmd = String(input.command); + return cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd; + } + if (input.query) return String(input.query).slice(0, 100); + + const keys = Object.keys(input); + if (keys.length === 0) return '(no input)'; + return keys.slice(0, 3).join(', '); + } + + private extractContext(event: ToolUseEvent): string { + const parts: string[] = []; + + if (event.duration_ms) { + parts.push(`Duration: ${event.duration_ms}ms`); + } + + if (event.is_error) { + parts.push('Result: Error'); + } else if (event.tool_result) { + const resultPreview = event.tool_result.slice(0, 100); + parts.push(`Result: ${resultPreview}${event.tool_result.length > 100 ? '...' : ''}`); + } + + return parts.join('. ') || 'No additional context'; + } + + private extractFiles(event: ToolUseEvent): string[] { + const files: string[] = []; + + if (event.tool_input.file_path) { + files.push(String(event.tool_input.file_path)); + } + + if (event.tool_input.path) { + files.push(String(event.tool_input.path)); + } + + return files; + } + + private generateErrorId(error: string): string { + const normalized = error + .toLowerCase() + .replace(/[0-9]+/g, 'N') + .replace(/['"`]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 100); + + return normalized; + } + + private findMatchingSolution(content: ObservationContent): { error: string } | undefined { + const actionLower = content.action.toLowerCase(); + const contextLower = content.context.toLowerCase(); + + for (const [errorId, errorData] of this.pendingErrors) { + const hasKeywordMatch = + actionLower.includes('fix') || + actionLower.includes('resolve') || + actionLower.includes('solution') || + contextLower.includes('fix') || + contextLower.includes('resolve'); + + if (hasKeywordMatch) { + const errorWords = errorId.split(' ').filter((w) => w.length > 3); + const textWords = new Set((actionLower + ' ' + contextLower).split(/\s+/)); + const matchCount = errorWords.filter((w) => textWords.has(w)).length; + + if (matchCount >= 2 || (matchCount >= 1 && hasKeywordMatch)) { + this.pendingErrors.delete(errorId); + return errorData; + } + } + } + + return undefined; + } +} + +/** + * Create a post tool use hook handler + */ +export function createPostToolUseHook( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {}, + sessionId?: string +): PostToolUseHook { + return new PostToolUseHook(projectPath, agent, config, sessionId); +} + +/** + * Execute post tool use hook (standalone function for scripts) + */ +export async function executePostToolUseHook( + projectPath: string, + event: ToolUseEvent, + config: Partial = {}, + sessionId?: string +): Promise { + const hook = new PostToolUseHook(projectPath, 'claude-code', config, sessionId); + return hook.execute(event); +} diff --git a/packages/core/src/memory/hooks/session-end.ts b/packages/core/src/memory/hooks/session-end.ts new file mode 100644 index 00000000..df1d5aea --- /dev/null +++ b/packages/core/src/memory/hooks/session-end.ts @@ -0,0 +1,227 @@ +/** + * Session End Hook + * + * Compresses observations to learnings when a Claude Code session ends. + * This enables automatic memory consolidation without manual intervention. + */ + +import { basename } from 'node:path'; +import type { AgentType } from '../../types.js'; +import type { + SessionEndContext, + SessionEndResult, + MemoryHookConfig, + ClaudeCodeHookOutput, +} from './types.js'; +import { DEFAULT_MEMORY_HOOK_CONFIG } from './types.js'; +import { ObservationStore } from '../observation-store.js'; +import { MemoryCompressor } from '../compressor.js'; + +/** + * Session End Hook Handler + * + * Compresses observations collected during the session into learnings. + */ +export class SessionEndHook { + private config: MemoryHookConfig; + private projectPath: string; + private agent: AgentType; + + constructor( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {} + ) { + this.projectPath = projectPath; + this.agent = agent; + this.config = { ...DEFAULT_MEMORY_HOOK_CONFIG, ...config }; + } + + /** + * Execute the session end hook + */ + async execute(context: SessionEndContext): Promise { + if (!this.config.enabled || !this.config.autoCompressOnSessionEnd) { + return { + compressed: false, + observationCount: 0, + learningCount: 0, + learnings: [], + }; + } + + const store = new ObservationStore(this.projectPath, context.session_id); + const observations = store.getAll(); + + if (observations.length < 3) { + return { + compressed: false, + observationCount: observations.length, + learningCount: 0, + learnings: [], + }; + } + + const projectName = context.project_path + ? basename(context.project_path.replace(/\/+$/, '')) || undefined + : undefined; + + const compressor = new MemoryCompressor(this.projectPath, { + scope: 'project', + projectName, + }); + + const { learnings, result } = await compressor.compressAndStore(observations, { + minObservations: 3, + maxLearnings: 10, + minImportance: 4, + includeLowRelevance: false, + additionalTags: ['session-end', this.agent], + }); + + if (result.processedObservationIds.length > 0) { + store.deleteMany(result.processedObservationIds); + } + + return { + compressed: learnings.length > 0, + observationCount: observations.length, + learningCount: learnings.length, + learnings, + }; + } + + /** + * Generate Claude Code hook output format + */ + async generateHookOutput(context: SessionEndContext): Promise { + const result = await this.execute(context); + return this.generateHookOutputFromResult(result); + } + + /** + * Generate hook output from pre-computed result (avoids double execution) + */ + generateHookOutputFromResult(result: SessionEndResult): ClaudeCodeHookOutput { + let message: string | undefined; + if (result.compressed) { + message = `Session memory: ${result.observationCount} observations → ${result.learningCount} learnings`; + } + + return { + continue: true, + message, + }; + } + + /** + * Force compression regardless of settings + */ + async forceCompress(sessionId?: string): Promise { + const store = new ObservationStore(this.projectPath, sessionId); + const observations = store.getAll(); + + if (observations.length === 0) { + return { + compressed: false, + observationCount: 0, + learningCount: 0, + learnings: [], + }; + } + + const compressor = new MemoryCompressor(this.projectPath, { + scope: 'project', + }); + + const { learnings, result } = await compressor.compressAndStore(observations, { + minObservations: 1, + maxLearnings: 20, + minImportance: 3, + includeLowRelevance: true, + }); + + if (result.processedObservationIds.length > 0) { + store.deleteMany(result.processedObservationIds); + } + + return { + compressed: learnings.length > 0, + observationCount: observations.length, + learningCount: learnings.length, + learnings, + }; + } + + /** + * Preview what would be compressed (dry-run) + */ + async preview(sessionId?: string): Promise<{ + wouldCompress: boolean; + observationCount: number; + estimatedLearnings: number; + observationTypes: Record; + }> { + const store = new ObservationStore(this.projectPath, sessionId); + const observations = store.getAll(); + + const types: Record = {}; + for (const obs of observations) { + types[obs.type] = (types[obs.type] || 0) + 1; + } + + const compressor = new MemoryCompressor(this.projectPath, { + scope: 'project', + }); + + const result = await compressor.compress(observations, { + minObservations: 3, + maxLearnings: 10, + minImportance: 4, + }); + + return { + wouldCompress: result.learnings.length > 0, + observationCount: observations.length, + estimatedLearnings: result.learnings.length, + observationTypes: types, + }; + } + + /** + * Get configuration + */ + getConfig(): MemoryHookConfig { + return { ...this.config }; + } + + /** + * Update configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} + +/** + * Create a session end hook handler + */ +export function createSessionEndHook( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {} +): SessionEndHook { + return new SessionEndHook(projectPath, agent, config); +} + +/** + * Execute session end hook (standalone function for scripts) + */ +export async function executeSessionEndHook( + projectPath: string, + context: SessionEndContext, + config: Partial = {} +): Promise { + const hook = new SessionEndHook(projectPath, context.agent || 'claude-code', config); + return hook.execute(context); +} diff --git a/packages/core/src/memory/hooks/session-start.ts b/packages/core/src/memory/hooks/session-start.ts new file mode 100644 index 00000000..0d3b6c08 --- /dev/null +++ b/packages/core/src/memory/hooks/session-start.ts @@ -0,0 +1,177 @@ +/** + * Session Start Hook + * + * Injects relevant memories when a Claude Code session starts. + * This hook runs at the beginning of each session to provide context + * from previous sessions. + */ + +import { basename } from 'node:path'; +import type { AgentType } from '../../types.js'; +import type { + SessionStartContext, + SessionStartResult, + MemoryHookConfig, + ClaudeCodeHookOutput, +} from './types.js'; +import { DEFAULT_MEMORY_HOOK_CONFIG } from './types.js'; +import { MemoryInjector } from '../injector.js'; +import { getMemoryStatus } from '../initializer.js'; + +/** + * Session Start Hook Handler + * + * Retrieves and formats relevant memories for injection at session start. + */ +export class SessionStartHook { + private config: MemoryHookConfig; + private projectPath: string; + private agent: AgentType; + + constructor( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {} + ) { + this.projectPath = projectPath; + this.agent = agent; + this.config = { ...DEFAULT_MEMORY_HOOK_CONFIG, ...config }; + } + + /** + * Execute the session start hook + */ + async execute(context: SessionStartContext): Promise { + if (!this.config.enabled || !this.config.autoInjectOnSessionStart) { + return { + injected: false, + learnings: [], + tokenCount: 0, + formattedContent: '', + }; + } + + const status = getMemoryStatus(this.projectPath); + if (!status.projectMemoryExists && !status.globalMemoryExists) { + return { + injected: false, + learnings: [], + tokenCount: 0, + formattedContent: '', + }; + } + + const projectName = context.project_path + ? basename(context.project_path.replace(/\/+$/, '')) || undefined + : undefined; + + const injector = new MemoryInjector(this.projectPath, projectName); + + const result = await injector.injectForAgent(this.agent, { + maxTokens: this.config.maxTokensForInjection, + minRelevance: this.config.minRelevanceForCapture, + maxLearnings: 10, + includeGlobal: true, + disclosureLevel: 'preview', + }); + + const learnings = result.memories.map((m) => m.learning); + + return { + injected: result.memories.length > 0, + learnings, + tokenCount: result.totalTokens, + formattedContent: result.formattedContent, + }; + } + + /** + * Generate Claude Code hook output format + */ + async generateHookOutput(context: SessionStartContext): Promise { + const result = await this.execute(context); + return this.generateHookOutputFromResult(result); + } + + /** + * Generate hook output from pre-computed result (avoids double execution) + */ + generateHookOutputFromResult(result: SessionStartResult): ClaudeCodeHookOutput { + if (!result.injected || result.learnings.length === 0) { + return { continue: true }; + } + + return { + continue: true, + inject: this.formatInjection(result), + }; + } + + /** + * Format learnings for injection into Claude Code context + */ + private formatInjection(result: SessionStartResult): string { + if (result.learnings.length === 0) { + return ''; + } + + const lines: string[] = [ + '', + ``, + '', + ]; + + for (const learning of result.learnings) { + lines.push(`## ${learning.title}`); + lines.push(`Tags: ${learning.tags.join(', ')}`); + if (learning.frameworks && learning.frameworks.length > 0) { + lines.push(`Frameworks: ${learning.frameworks.join(', ')}`); + } + lines.push(''); + const preview = learning.content.slice(0, 200); + lines.push(preview + (learning.content.length > 200 ? '...' : '')); + lines.push(''); + } + + lines.push(''); + + return lines.join('\n'); + } + + /** + * Get configuration + */ + getConfig(): MemoryHookConfig { + return { ...this.config }; + } + + /** + * Update configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} + +/** + * Create a session start hook handler + */ +export function createSessionStartHook( + projectPath: string, + agent: AgentType = 'claude-code', + config: Partial = {} +): SessionStartHook { + return new SessionStartHook(projectPath, agent, config); +} + +/** + * Execute session start hook (standalone function for scripts) + */ +export async function executeSessionStartHook( + projectPath: string, + context: SessionStartContext, + config: Partial = {} +): Promise { + const hook = new SessionStartHook(projectPath, context.agent || 'claude-code', config); + return hook.execute(context); +} diff --git a/packages/core/src/memory/hooks/types.ts b/packages/core/src/memory/hooks/types.ts new file mode 100644 index 00000000..61a50908 --- /dev/null +++ b/packages/core/src/memory/hooks/types.ts @@ -0,0 +1,139 @@ +/** + * Memory Hooks Types + * + * Types for Claude Code lifecycle hooks that integrate with SkillKit memory. + */ + +import type { AgentType } from '../../types.js'; +import type { Learning, Observation } from '../types.js'; + +/** + * Claude Code hook event types + */ +export type ClaudeCodeHookEvent = + | 'SessionStart' + | 'SessionResume' + | 'SessionEnd' + | 'PreToolUse' + | 'PostToolUse' + | 'UserPromptSubmit' + | 'PreCompact' + | 'Notification' + | 'Stop'; + +/** + * Tool use event for PostToolUse hook + */ +export interface ToolUseEvent { + tool_name: string; + tool_input: Record; + tool_result?: string; + is_error?: boolean; + duration_ms?: number; +} + +/** + * Session start event context + */ +export interface SessionStartContext { + session_id: string; + project_path: string; + agent: AgentType; + timestamp: string; + working_directory?: string; +} + +/** + * Session end event context + */ +export interface SessionEndContext { + session_id: string; + project_path: string; + agent: AgentType; + timestamp: string; + duration_ms?: number; + tool_calls_count?: number; +} + +/** + * Memory hook configuration + */ +export interface MemoryHookConfig { + enabled: boolean; + autoInjectOnSessionStart: boolean; + autoCaptureToolUse: boolean; + autoCompressOnSessionEnd: boolean; + minRelevanceForCapture: number; + maxTokensForInjection: number; + compressionThreshold: number; + capturePatterns?: string[]; + excludeTools?: string[]; +} + +/** + * Default memory hook configuration + */ +export const DEFAULT_MEMORY_HOOK_CONFIG: MemoryHookConfig = { + enabled: true, + autoInjectOnSessionStart: true, + autoCaptureToolUse: true, + autoCompressOnSessionEnd: true, + minRelevanceForCapture: 30, + maxTokensForInjection: 2000, + compressionThreshold: 50, + capturePatterns: ['*'], + excludeTools: ['Read', 'Glob', 'Grep'], +}; + +/** + * Session start hook result + */ +export interface SessionStartResult { + injected: boolean; + learnings: Learning[]; + tokenCount: number; + formattedContent: string; +} + +/** + * Tool use capture result + */ +export interface ToolUseCaptureResult { + captured: boolean; + observation?: Observation; + reason?: string; +} + +/** + * Session end hook result + */ +export interface SessionEndResult { + compressed: boolean; + observationCount: number; + learningCount: number; + learnings: Learning[]; +} + +/** + * Hook script output format for Claude Code + */ +export interface ClaudeCodeHookOutput { + continue: boolean; + message?: string; + inject?: string; + suppress?: boolean; +} + +/** + * Memory hook statistics + */ +export interface MemoryHookStats { + sessionId: string; + startedAt: string; + observationsCaptured: number; + learningsInjected: number; + tokensUsed: number; + toolCallsCaptured: number; + errorsRecorded: number; + solutionsRecorded: number; +} diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index d7071997..36cfdbdd 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -1,8 +1,9 @@ -// Memory Module - Cross-Agent Session Memory System -// Provides persistent memory across all AI coding agents - export * from './types.js'; -export { ObservationStore } from './observation-store.js'; +export { + ObservationStore, + type AutoCompressCallback, + type ObservationStoreOptions, +} from './observation-store.js'; export { LearningStore } from './learning-store.js'; export { MemoryIndexStore } from './memory-index.js'; export { @@ -48,3 +49,25 @@ export { type InjectedMemory, type InjectionResult, } from './injector.js'; + +export * from './hooks/index.js'; + +export { + ClaudeMdUpdater, + createClaudeMdUpdater, + updateClaudeMd, + syncGlobalClaudeMd, + type ClaudeMdUpdateOptions, + type ClaudeMdUpdateResult, + type ParsedClaudeMd, +} from './claude-md-updater.js'; + +export { + ProgressiveDisclosureManager, + createProgressiveDisclosureManager, + type IndexEntry, + type TimelineEntry, + type DetailsEntry, + type ActivityPoint, + type ProgressiveDisclosureOptions, +} from './progressive-disclosure.js'; diff --git a/packages/core/src/memory/initializer.ts b/packages/core/src/memory/initializer.ts index ea5bfa82..ca738724 100644 --- a/packages/core/src/memory/initializer.ts +++ b/packages/core/src/memory/initializer.ts @@ -30,12 +30,10 @@ export function getMemoryPaths(projectPath: string): MemoryPaths { export function initializeMemoryDirectory(projectPath: string): MemoryPaths { const paths = getMemoryPaths(projectPath); - // Create project memory directory if (!existsSync(paths.projectMemoryDir)) { mkdirSync(paths.projectMemoryDir, { recursive: true }); } - // Create global memory directory if (!existsSync(paths.globalMemoryDir)) { mkdirSync(paths.globalMemoryDir, { recursive: true }); } diff --git a/packages/core/src/memory/injector.ts b/packages/core/src/memory/injector.ts index c41023b8..82dcade2 100644 --- a/packages/core/src/memory/injector.ts +++ b/packages/core/src/memory/injector.ts @@ -109,24 +109,19 @@ export class MemoryInjector { async getRelevantMemories(options: InjectionOptions = {}): Promise { const opts = { ...DEFAULT_OPTIONS, ...options }; - // Collect all learnings const projectLearnings = this.projectStore.getAll(); const globalLearnings = opts.includeGlobal ? this.globalStore.getAll() : []; const allLearnings = [...projectLearnings, ...globalLearnings]; - // Score each learning for relevance const scored = allLearnings.map((learning) => ({ learning, ...this.scoreLearning(learning, opts), })); - // Filter by minimum relevance const filtered = scored.filter((s) => s.relevanceScore >= opts.minRelevance); - // Sort by relevance (descending) filtered.sort((a, b) => b.relevanceScore - a.relevanceScore); - // Limit to max learnings return filtered.slice(0, opts.maxLearnings); } @@ -145,7 +140,6 @@ export class MemoryInjector { patterns: [] as string[], }; - // Match by frameworks from project context if (this.projectContext?.stack) { const projectFrameworks = this.extractFrameworkNames(); const learningFrameworks = learning.frameworks || []; @@ -158,7 +152,6 @@ export class MemoryInjector { } } - // Match by explicitly requested tags if (options.tags && options.tags.length > 0) { const requestedTags = new Set(options.tags.map((t) => t.toLowerCase())); for (const tag of learning.tags) { @@ -169,7 +162,6 @@ export class MemoryInjector { } } - // Match by current task keywords if (options.currentTask) { const taskKeywords = this.extractKeywords(options.currentTask); const learningKeywords = [ @@ -189,22 +181,17 @@ export class MemoryInjector { } } - // Match by patterns if (learning.patterns && learning.patterns.length > 0) { - // Patterns are valuable - boost score score += learning.patterns.length * 5; matchedBy.patterns.push(...learning.patterns); } - // Boost by effectiveness if known if (learning.effectiveness !== undefined) { score += (learning.effectiveness / 100) * 20; } - // Boost by use count (popular learnings are likely valuable) score += Math.min(learning.useCount * 2, 15); - // Boost recent learnings const daysSinceUpdate = this.daysSince(learning.updatedAt); if (daysSinceUpdate < 7) { score += 10; @@ -212,10 +199,8 @@ export class MemoryInjector { score += 5; } - // Cap score at 100 score = Math.min(score, 100); - // Estimate token count const tokenEstimate = this.estimateTokens(learning, options.disclosureLevel || 'preview'); return { @@ -304,7 +289,6 @@ export class MemoryInjector { const opts = { ...DEFAULT_OPTIONS, ...options }; const allMemories = await this.getRelevantMemories(opts); - // Select memories within token budget const selected: InjectedMemory[] = []; let totalTokens = 0; let truncated = 0; @@ -314,7 +298,6 @@ export class MemoryInjector { selected.push(memory); totalTokens += memory.tokenEstimate; - // Increment use count if (memory.learning.scope === 'project') { this.projectStore.incrementUseCount(memory.learning.id); } else { @@ -325,7 +308,6 @@ export class MemoryInjector { } } - // Format the content const formattedContent = this.formatMemories(selected, opts.disclosureLevel || 'preview'); return { @@ -350,7 +332,6 @@ export class MemoryInjector { ): Promise { const result = await this.inject(options); - // Reformat for specific agent result.formattedContent = this.formatForAgent(result.memories, agent, options.disclosureLevel); return result; @@ -381,7 +362,6 @@ export class MemoryInjector { switch (level) { case 'summary': - // Just title and tags (already shown above) break; case 'preview': lines.push(learning.content.slice(0, 200) + (learning.content.length > 200 ? '...' : '')); diff --git a/packages/core/src/memory/memory-index.ts b/packages/core/src/memory/memory-index.ts index c4386238..5250d69d 100644 --- a/packages/core/src/memory/memory-index.ts +++ b/packages/core/src/memory/memory-index.ts @@ -8,10 +8,6 @@ export class MemoryIndexStore { private data: MemoryIndex | null = null; constructor(basePath: string, _isGlobal = false) { - // Both global and project use .skillkit subdirectory - // Global: ~/.skillkit/memory/index.yaml (basePath = homedir()) - // Project: /.skillkit/memory/index.yaml (basePath = projectPath) - // Note: _isGlobal kept for API compatibility but basePath determines the actual path this.filePath = join(basePath, '.skillkit', 'memory', 'index.yaml'); } @@ -58,7 +54,6 @@ export class MemoryIndexStore { } private extractKeywords(text: string): string[] { - // Extract meaningful keywords from text const stopWords = new Set([ 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', @@ -79,12 +74,10 @@ export class MemoryIndexStore { indexLearning(learning: Learning): void { const data = this.load(); - // Extract keywords from title and content const titleKeywords = this.extractKeywords(learning.title); const contentKeywords = this.extractKeywords(learning.content); const allKeywords = [...new Set([...titleKeywords, ...contentKeywords])]; - // Index by keywords for (const keyword of allKeywords) { if (!data.entries[keyword]) { data.entries[keyword] = []; @@ -94,7 +87,6 @@ export class MemoryIndexStore { } } - // Index by tags for (const tag of learning.tags) { const normalizedTag = tag.toLowerCase(); if (!data.tags[normalizedTag]) { @@ -105,7 +97,6 @@ export class MemoryIndexStore { } } - // Index by frameworks if present if (learning.frameworks) { for (const framework of learning.frameworks) { const normalizedFw = framework.toLowerCase(); @@ -124,7 +115,6 @@ export class MemoryIndexStore { removeLearning(learningId: string): void { const data = this.load(); - // Remove from keyword entries for (const keyword of Object.keys(data.entries)) { data.entries[keyword] = data.entries[keyword].filter((id) => id !== learningId); if (data.entries[keyword].length === 0) { @@ -132,7 +122,6 @@ export class MemoryIndexStore { } } - // Remove from tags for (const tag of Object.keys(data.tags)) { data.tags[tag] = data.tags[tag].filter((id) => id !== learningId); if (data.tags[tag].length === 0) { @@ -149,20 +138,16 @@ export class MemoryIndexStore { if (keywords.length === 0) return []; - // Find IDs that match any keyword const matchCounts = new Map(); for (const keyword of keywords) { - // Exact match if (data.entries[keyword]) { for (const id of data.entries[keyword]) { matchCounts.set(id, (matchCounts.get(id) || 0) + 2); } } - // Partial match (keyword is substring of indexed word, but not exact match) for (const [indexed, ids] of Object.entries(data.entries)) { - // Skip exact matches - they're already counted above with higher weight if (indexed === keyword) continue; if (indexed.includes(keyword) || keyword.includes(indexed)) { for (const id of ids) { @@ -172,7 +157,6 @@ export class MemoryIndexStore { } } - // Sort by match count (most matches first) return [...matchCounts.entries()] .sort((a, b) => b[1] - a[1]) .map(([id]) => id); @@ -192,7 +176,6 @@ export class MemoryIndexStore { } } - // Sort by match count return [...matchCounts.entries()] .sort((a, b) => b[1] - a[1]) .map(([id]) => id); @@ -210,7 +193,6 @@ export class MemoryIndexStore { return tagResults; } - // Combine results, prioritizing items that match both const keywordSet = new Set(keywordResults); const tagSet = new Set(tagResults); @@ -250,10 +232,8 @@ export class MemoryIndexStore { } rebuildIndex(learnings: Learning[]): void { - // Clear existing index this.data = this.createEmpty(); - // Re-index all learnings for (const learning of learnings) { this.indexLearning(learning); } diff --git a/packages/core/src/memory/observation-store.ts b/packages/core/src/memory/observation-store.ts index 1070e0e3..3d9f7e43 100644 --- a/packages/core/src/memory/observation-store.ts +++ b/packages/core/src/memory/observation-store.ts @@ -10,14 +10,37 @@ import type { } from './types.js'; import type { AgentType } from '../types.js'; +/** + * Auto-compression callback type + */ +export type AutoCompressCallback = (observations: Observation[]) => Promise; + +/** + * Observation store options + */ +export interface ObservationStoreOptions { + compressionThreshold?: number; + autoCompress?: boolean; + onThresholdReached?: AutoCompressCallback; +} + export class ObservationStore { private readonly filePath: string; + private readonly projectPath: string; private data: ObservationStoreData | null = null; private sessionId: string; + private compressionThreshold: number; + private autoCompress: boolean; + private onThresholdReached?: AutoCompressCallback; + private compressionInProgress = false; - constructor(projectPath: string, sessionId?: string) { + constructor(projectPath: string, sessionId?: string, options: ObservationStoreOptions = {}) { + this.projectPath = projectPath; this.filePath = join(projectPath, '.skillkit', 'memory', 'observations.yaml'); this.sessionId = sessionId || randomUUID(); + this.compressionThreshold = options.compressionThreshold ?? 50; + this.autoCompress = options.autoCompress ?? true; + this.onThresholdReached = options.onThresholdReached; } private ensureDir(): void { @@ -83,9 +106,77 @@ export class ObservationStore { data.observations.push(observation); this.save(); + void this.checkAutoCompression().catch(() => { + // Silently ignore auto-compression errors to avoid disrupting add() + }); + return observation; } + /** + * Check if auto-compression should trigger + */ + private async checkAutoCompression(): Promise { + if (!this.autoCompress || this.compressionInProgress) return; + if (!this.onThresholdReached) return; + + const count = this.count(); + if (count >= this.compressionThreshold) { + this.compressionInProgress = true; + try { + const observations = this.getAll(); + await this.onThresholdReached(observations); + } finally { + this.compressionInProgress = false; + } + } + } + + /** + * Set auto-compression callback + */ + setAutoCompressCallback(callback: AutoCompressCallback): void { + this.onThresholdReached = callback; + } + + /** + * Enable/disable auto-compression + */ + setAutoCompress(enabled: boolean): void { + this.autoCompress = enabled; + } + + /** + * Set compression threshold + */ + setCompressionThreshold(threshold: number): void { + if (threshold < 1 || !Number.isInteger(threshold)) { + throw new Error('Compression threshold must be a positive integer'); + } + this.compressionThreshold = threshold; + } + + /** + * Get compression threshold + */ + getCompressionThreshold(): number { + return this.compressionThreshold; + } + + /** + * Check if threshold is reached + */ + isThresholdReached(): boolean { + return this.count() >= this.compressionThreshold; + } + + /** + * Get project path + */ + getProjectPath(): string { + return this.projectPath; + } + getAll(): Observation[] { return this.load().observations; } diff --git a/packages/core/src/memory/observer.ts b/packages/core/src/memory/observer.ts index 4aba714b..8ead4290 100644 --- a/packages/core/src/memory/observer.ts +++ b/packages/core/src/memory/observer.ts @@ -118,26 +118,18 @@ export class MemoryObserver { * Observe an event and potentially store it */ observe(event: ObservableEvent): Observation | null { - // Check if we should capture this event type if (!this.shouldCapture(event)) { return null; } - // Classify the event into an observation type const observationType = this.classifyEvent(event); - - // Extract content from the event const content = this.extractContent(event); - - // Score relevance const relevance = this.scoreRelevance(event); - // Check minimum relevance threshold if (relevance < this.config.minRelevance) { return null; } - // Store the observation return this.store.add(observationType, content, this.currentAgent, relevance); } @@ -243,7 +235,6 @@ export class MemoryObserver { context, }; - // Store pending error for potential solution matching const errorKey = this.generateErrorKey(error); this.pendingErrors.set(errorKey, event); @@ -263,7 +254,6 @@ export class MemoryObserver { error: relatedError, }; - // If we can match this to a pending error, increase relevance if (relatedError) { const errorKey = this.generateErrorKey(relatedError); this.pendingErrors.delete(errorKey); @@ -393,7 +383,6 @@ export class MemoryObserver { content.solution = event.output; } - // Generate tags based on event content.tags = this.generateTags(event); return content; @@ -462,15 +451,12 @@ export class MemoryObserver { private generateTags(event: ObservableEvent): string[] { const tags: string[] = []; - // Add event type as tag tags.push(event.type.replace(/_/g, '-')); - // Add skill name if present if (event.skillName) { tags.push(event.skillName.toLowerCase().replace(/[^a-z0-9-]/g, '-')); } - // Add error-related tags if (event.error) { if (event.error.toLowerCase().includes('type')) tags.push('typescript'); if (event.error.toLowerCase().includes('import')) tags.push('imports'); @@ -480,7 +466,6 @@ export class MemoryObserver { tags.push('async'); } - // Add file-related tags if (event.files) { for (const file of event.files) { if (file.endsWith('.ts') || file.endsWith('.tsx')) tags.push('typescript'); @@ -498,16 +483,13 @@ export class MemoryObserver { * Score relevance of event */ private scoreRelevance(event: ObservableEvent): number { - // Use custom scorer if provided if (this.config.relevanceScorer) { return this.config.relevanceScorer(event); } - // Default relevance scoring - let score = 50; // Base score + let score = 50; switch (event.type) { - // High relevance events case 'error_encountered': case 'task_failed': score = 85; @@ -515,7 +497,6 @@ export class MemoryObserver { case 'solution_applied': score = 90; - // Bonus if it matches a pending error if (event.error) { const errorKey = this.generateErrorKey(event.error); if (this.pendingErrors.has(errorKey)) { @@ -532,10 +513,8 @@ export class MemoryObserver { score = 80; break; - // Medium relevance events case 'file_modified': score = 60; - // More files = higher relevance if (event.files && event.files.length > 3) { score = 70; } @@ -550,7 +529,6 @@ export class MemoryObserver { score = 50; break; - // Lower relevance events case 'task_start': score = 30; break; @@ -568,7 +546,6 @@ export class MemoryObserver { score = 50; } - // Adjust based on content richness if (event.context && event.context.length > 100) { score += 5; } @@ -577,7 +554,6 @@ export class MemoryObserver { score += 5; } - // Cap at 100 return Math.min(score, 100); } @@ -585,7 +561,6 @@ export class MemoryObserver { * Generate a key for matching errors to solutions */ private generateErrorKey(error: string): string { - // Normalize error string for matching return error .toLowerCase() .replace(/[0-9]+/g, 'N') // Replace numbers diff --git a/packages/core/src/memory/progressive-disclosure.ts b/packages/core/src/memory/progressive-disclosure.ts new file mode 100644 index 00000000..d986deac --- /dev/null +++ b/packages/core/src/memory/progressive-disclosure.ts @@ -0,0 +1,458 @@ +/** + * Progressive Disclosure System + * + * Implements 3-layer token-optimized retrieval for efficient context usage: + * - Layer 1: Index (titles, timestamps, IDs) - ~50-100 tokens + * - Layer 2: Timeline (context around observations) - ~200 tokens + * - Layer 3: Details (full content) - ~500-1000 tokens + */ + +import type { Learning } from './types.js'; +import { LearningStore } from './learning-store.js'; + +/** + * Index entry (Layer 1) + * Minimal information for fast scanning + */ +export interface IndexEntry { + id: string; + title: string; + timestamp: string; + tags: string[]; + scope: 'project' | 'global'; + effectiveness?: number; + useCount: number; +} + +/** + * Timeline entry (Layer 2) + * Context around the learning with activity timeline + */ +export interface TimelineEntry extends IndexEntry { + excerpt: string; + frameworks?: string[]; + patterns?: string[]; + sourceCount: number; + lastUsed?: string; + activityTimeline?: ActivityPoint[]; +} + +/** + * Activity point for timeline + */ +export interface ActivityPoint { + timestamp: string; + type: 'created' | 'used' | 'updated' | 'rated'; + description?: string; +} + +/** + * Details entry (Layer 3) + * Full content with all metadata + */ +export interface DetailsEntry extends TimelineEntry { + content: string; + sourceObservations?: string[]; + metadata?: Record; +} + +/** + * Progressive disclosure options + */ +export interface ProgressiveDisclosureOptions { + includeGlobal?: boolean; + minRelevance?: number; + maxResults?: number; +} + +/** + * Token estimates per layer + */ +const TOKEN_ESTIMATES = { + index: 50, + timeline: 200, + details: 600, +}; + +/** + * Progressive Disclosure Manager + * + * Provides 3-layer retrieval for optimal token usage. + */ +export class ProgressiveDisclosureManager { + private projectStore: LearningStore; + private globalStore: LearningStore; + + constructor(projectPath: string, projectName?: string) { + this.projectStore = new LearningStore('project', projectPath, projectName); + this.globalStore = new LearningStore('global'); + } + + /** + * Layer 1: Get index of all learnings + * Minimal tokens (~50-100 per entry) + */ + getIndex(options: ProgressiveDisclosureOptions = {}): IndexEntry[] { + const projectLearnings = this.projectStore.getAll(); + const globalLearnings = options.includeGlobal ? this.globalStore.getAll() : []; + const allLearnings = [...projectLearnings, ...globalLearnings]; + + return allLearnings + .map((learning) => this.toIndexEntry(learning)) + .sort((a, b) => { + const scoreA = (a.effectiveness ?? 50) + a.useCount * 5; + const scoreB = (b.effectiveness ?? 50) + b.useCount * 5; + return scoreB - scoreA; + }) + .slice(0, options.maxResults ?? 50); + } + + /** + * Layer 2: Get timeline entries for specific IDs + * Medium tokens (~200 per entry) + */ + getTimeline(ids: string[], options: ProgressiveDisclosureOptions = {}): TimelineEntry[] { + const entries: TimelineEntry[] = []; + + for (const id of ids) { + let learning = this.projectStore.getById(id); + if (!learning && options.includeGlobal) { + learning = this.globalStore.getById(id); + } + + if (learning) { + entries.push(this.toTimelineEntry(learning)); + } + } + + return entries.slice(0, options.maxResults ?? 20); + } + + /** + * Layer 3: Get full details for specific IDs + * High tokens (~500-1000 per entry) + */ + getDetails(ids: string[], options: ProgressiveDisclosureOptions = {}): DetailsEntry[] { + const entries: DetailsEntry[] = []; + + for (const id of ids) { + let learning = this.projectStore.getById(id); + let store = this.projectStore; + + if (!learning && options.includeGlobal) { + learning = this.globalStore.getById(id); + store = this.globalStore; + } + + if (learning) { + store.incrementUseCount(id); + entries.push(this.toDetailsEntry(learning)); + } + } + + return entries.slice(0, options.maxResults ?? 10); + } + + /** + * Smart retrieval with automatic layer selection + * Uses minimum tokens needed to satisfy the query. + * + * Note: tokensUsed reflects cumulative cost of the retrieval operation + * (index lookup + any deeper layer fetches), not just the returned entries. + * This is intentional since progressive disclosure requires scanning + * the index first before fetching timeline/details. + */ + smartRetrieve( + query: string, + tokenBudget: number = 2000, + options: ProgressiveDisclosureOptions = {} + ): { + layer: 1 | 2 | 3; + entries: IndexEntry[] | TimelineEntry[] | DetailsEntry[]; + tokensUsed: number; + tokensRemaining: number; + } { + if (tokenBudget <= 0) { + return { + layer: 1, + entries: [], + tokensUsed: 0, + tokensRemaining: 0, + }; + } + + const index = this.getIndex(options); + + if (index.length === 0) { + return { + layer: 1, + entries: [], + tokensUsed: 0, + tokensRemaining: tokenBudget, + }; + } + + const indexTokens = index.length * TOKEN_ESTIMATES.index; + + if (tokenBudget < indexTokens) { + const maxEntries = Math.floor(tokenBudget / TOKEN_ESTIMATES.index); + const limitedIndex = index.slice(0, maxEntries); + return { + layer: 1, + entries: limitedIndex, + tokensUsed: limitedIndex.length * TOKEN_ESTIMATES.index, + tokensRemaining: tokenBudget - limitedIndex.length * TOKEN_ESTIMATES.index, + }; + } + + const relevantIds = this.findRelevantIds(index, query, options.minRelevance ?? 0); + + if (relevantIds.length === 0) { + return { + layer: 1, + entries: index, + tokensUsed: indexTokens, + tokensRemaining: tokenBudget - indexTokens, + }; + } + + const remainingBudget = tokenBudget - indexTokens; + const maxTimelineEntries = Math.floor(remainingBudget / TOKEN_ESTIMATES.timeline); + + if (maxTimelineEntries >= 1) { + const timelineIds = relevantIds.slice(0, Math.min(maxTimelineEntries, 10)); + const timeline = this.getTimeline(timelineIds, options); + const timelineTokens = timeline.length * TOKEN_ESTIMATES.timeline; + + const afterTimelineBudget = remainingBudget - timelineTokens; + const maxDetailsEntries = Math.floor(afterTimelineBudget / TOKEN_ESTIMATES.details); + + if (maxDetailsEntries >= 1) { + const detailsIds = timelineIds.slice(0, Math.min(maxDetailsEntries, 5)); + const details = this.getDetails(detailsIds, options); + const detailsTokens = details.length * TOKEN_ESTIMATES.details; + + return { + layer: 3, + entries: details, + tokensUsed: indexTokens + timelineTokens + detailsTokens, + tokensRemaining: tokenBudget - (indexTokens + timelineTokens + detailsTokens), + }; + } + + return { + layer: 2, + entries: timeline, + tokensUsed: indexTokens + timelineTokens, + tokensRemaining: tokenBudget - (indexTokens + timelineTokens), + }; + } + + return { + layer: 1, + entries: index, + tokensUsed: indexTokens, + tokensRemaining: tokenBudget - indexTokens, + }; + } + + /** + * Estimate tokens for a given layer and count + */ + estimateTokens(layer: 1 | 2 | 3, count: number): number { + const estimate = { + 1: TOKEN_ESTIMATES.index, + 2: TOKEN_ESTIMATES.timeline, + 3: TOKEN_ESTIMATES.details, + }; + return estimate[layer] * count; + } + + /** + * Format entries for injection + */ + formatForInjection( + entries: IndexEntry[] | TimelineEntry[] | DetailsEntry[], + layer: 1 | 2 | 3 + ): string { + if (entries.length === 0) return ''; + + const lines: string[] = ['']; + + switch (layer) { + case 1: + lines.push(''); + for (const entry of entries as IndexEntry[]) { + lines.push(`- [${entry.id.slice(0, 8)}] ${entry.title} (${entry.tags.join(', ')})`); + } + break; + + case 2: + lines.push(''); + for (const entry of entries as TimelineEntry[]) { + lines.push(`## ${entry.title}`); + lines.push(`ID: ${entry.id.slice(0, 8)} | Tags: ${entry.tags.join(', ')}`); + if (entry.frameworks && entry.frameworks.length > 0) { + lines.push(`Frameworks: ${entry.frameworks.join(', ')}`); + } + lines.push(''); + lines.push(entry.excerpt); + lines.push(''); + } + break; + + case 3: + lines.push(''); + for (const entry of entries as DetailsEntry[]) { + lines.push(`## ${entry.title}`); + lines.push(`ID: ${entry.id.slice(0, 8)} | Tags: ${entry.tags.join(', ')}`); + if (entry.frameworks && entry.frameworks.length > 0) { + lines.push(`Frameworks: ${entry.frameworks.join(', ')}`); + } + if (entry.patterns && entry.patterns.length > 0) { + lines.push(`Patterns: ${entry.patterns.join(', ')}`); + } + lines.push(''); + lines.push(entry.content); + lines.push(''); + } + break; + } + + lines.push(''); + return lines.join('\n'); + } + + private toIndexEntry(learning: Learning): IndexEntry { + return { + id: learning.id, + title: learning.title, + timestamp: learning.updatedAt, + tags: learning.tags, + scope: learning.scope, + effectiveness: learning.effectiveness, + useCount: learning.useCount, + }; + } + + private toTimelineEntry(learning: Learning): TimelineEntry { + const timeline = this.buildActivityTimeline(learning); + + return { + id: learning.id, + title: learning.title, + timestamp: learning.updatedAt, + tags: learning.tags, + scope: learning.scope, + effectiveness: learning.effectiveness, + useCount: learning.useCount, + excerpt: learning.content.slice(0, 200) + (learning.content.length > 200 ? '...' : ''), + frameworks: learning.frameworks, + patterns: learning.patterns, + sourceCount: learning.sourceObservations?.length ?? 0, + lastUsed: learning.lastUsed, + activityTimeline: timeline, + }; + } + + private toDetailsEntry(learning: Learning): DetailsEntry { + const timeline = this.buildActivityTimeline(learning); + + return { + id: learning.id, + title: learning.title, + timestamp: learning.updatedAt, + tags: learning.tags, + scope: learning.scope, + effectiveness: learning.effectiveness, + useCount: learning.useCount, + excerpt: learning.content.slice(0, 200) + (learning.content.length > 200 ? '...' : ''), + frameworks: learning.frameworks, + patterns: learning.patterns, + sourceCount: learning.sourceObservations?.length ?? 0, + lastUsed: learning.lastUsed, + activityTimeline: timeline, + content: learning.content, + sourceObservations: learning.sourceObservations, + }; + } + + private buildActivityTimeline(learning: Learning): ActivityPoint[] { + const timeline: ActivityPoint[] = []; + + timeline.push({ + timestamp: learning.createdAt, + type: 'created', + description: `Learning created from ${learning.source}`, + }); + + if (learning.updatedAt !== learning.createdAt) { + timeline.push({ + timestamp: learning.updatedAt, + type: 'updated', + }); + } + + if (learning.lastUsed) { + timeline.push({ + timestamp: learning.lastUsed, + type: 'used', + description: `Used ${learning.useCount} times`, + }); + } + + if (learning.effectiveness !== undefined) { + timeline.push({ + timestamp: learning.updatedAt, + type: 'rated', + description: `Effectiveness: ${learning.effectiveness}%`, + }); + } + + return timeline.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + } + + private findRelevantIds(index: IndexEntry[], query: string, minRelevance: number = 0): string[] { + const queryWords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2); + const scored: Array<{ id: string; score: number }> = []; + + for (const entry of index) { + let score = 0; + + const titleWords = entry.title.toLowerCase().split(/\s+/); + for (const qw of queryWords) { + if (titleWords.some((tw) => tw.includes(qw))) { + score += 10; + } + } + + const tags = entry.tags.map((t) => t.toLowerCase()); + for (const qw of queryWords) { + if (tags.includes(qw)) { + score += 20; + } + } + + score += (entry.effectiveness ?? 50) / 10; + score += Math.min(entry.useCount * 2, 20); + + if (score >= minRelevance) { + scored.push({ id: entry.id, score }); + } + } + + return scored + .sort((a, b) => b.score - a.score) + .map((s) => s.id); + } +} + +/** + * Create a progressive disclosure manager + */ +export function createProgressiveDisclosureManager( + projectPath: string, + projectName?: string +): ProgressiveDisclosureManager { + return new ProgressiveDisclosureManager(projectPath, projectName); +}