/** * AutoMemoryBridge - Bidirectional sync between Claude Code Auto Memory and AgentDB * * Per ADR-048: Bridges Claude Code's auto memory (markdown files at * ~/.claude/projects//memory/) with claude-flow's unified memory * system (AgentDB + HNSW). * * Auto memory files are human-readable markdown that Claude loads into its * system prompt. MEMORY.md (first 200 lines) is the entrypoint; topic files * store detailed notes and are read on demand. * * @module @claude-flow/memory/auto-memory-bridge */ import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; import * as fs from 'node:fs/promises'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import * as path from 'node:path'; import { createDefaultEntry, } from './types.js'; import { LearningBridge } from './learning-bridge.js'; import { MemoryGraph } from './memory-graph.js'; // ===== Constants ===== const DEFAULT_TOPIC_MAPPING = { 'project-patterns': 'patterns.md', 'debugging': 'debugging.md', 'architecture': 'architecture.md', 'performance': 'performance.md', 'security': 'security.md', 'preferences': 'preferences.md', 'swarm-results': 'swarm-results.md', }; const CATEGORY_LABELS = { 'project-patterns': 'Project Patterns', 'debugging': 'Debugging', 'architecture': 'Architecture', 'performance': 'Performance', 'security': 'Security', 'preferences': 'Preferences', 'swarm-results': 'Swarm Results', }; const DEFAULT_CONFIG = { memoryDir: '', workingDir: process.cwd(), maxIndexLines: 180, topicMapping: DEFAULT_TOPIC_MAPPING, syncMode: 'on-session-end', syncIntervalMs: 60_000, minConfidence: 0.7, maxTopicFileLines: 500, pruneStrategy: 'confidence-weighted', }; // ===== AutoMemoryBridge ===== /** * Bidirectional bridge between Claude Code auto memory and AgentDB. * * @example * ```typescript * const bridge = new AutoMemoryBridge(memoryBackend, { * workingDir: '/workspaces/my-project', * }); * * // Record an insight * await bridge.recordInsight({ * category: 'debugging', * summary: 'HNSW index requires initialization before search', * source: 'agent:tester', * confidence: 0.95, * }); * * // Sync to auto memory files * await bridge.syncToAutoMemory(); * * // Import auto memory into AgentDB * await bridge.importFromAutoMemory(); * ``` */ export class AutoMemoryBridge extends EventEmitter { config; backend; lastSyncTime = 0; syncTimer = null; insights = []; /** Track AgentDB keys of insights already written to files during this session */ syncedInsightKeys = new Set(); /** Monotonic counter to prevent key collisions within the same ms */ insightCounter = 0; /** Optional learning bridge (ADR-049) */ learningBridge; /** Optional knowledge graph (ADR-049) */ memoryGraph; constructor(backend, config = {}) { super(); this.backend = backend; this.config = { ...DEFAULT_CONFIG, ...config, topicMapping: { ...DEFAULT_TOPIC_MAPPING, ...(config.topicMapping || {}), }, }; if (!this.config.memoryDir) { this.config.memoryDir = resolveAutoMemoryDir(this.config.workingDir); } if (this.config.syncMode === 'periodic' && this.config.syncIntervalMs > 0) { this.startPeriodicSync(); } // ADR-049: Initialize optional learning bridge and knowledge graph if (config.learning) { this.learningBridge = new LearningBridge(backend, config.learning); } if (config.graph) { this.memoryGraph = new MemoryGraph(config.graph); } } /** Get the resolved auto memory directory path */ getMemoryDir() { return this.config.memoryDir; } /** Get the path to MEMORY.md */ getIndexPath() { return path.join(this.config.memoryDir, 'MEMORY.md'); } /** Get the path to a topic file */ getTopicPath(category) { const filename = this.config.topicMapping[category] || `${category}.md`; return path.join(this.config.memoryDir, filename); } /** * Record a memory insight. * Stores in the in-memory buffer and optionally writes immediately. */ async recordInsight(insight) { this.insights.push(insight); // Store in AgentDB const key = await this.storeInsightInAgentDB(insight); this.syncedInsightKeys.add(key); // If sync-on-write, write immediately to files if (this.config.syncMode === 'on-write') { await this.writeInsightToFiles(insight); } // ADR-049: Notify learning bridge if (this.learningBridge) { await this.learningBridge.onInsightRecorded(insight, key); } this.emit('insight:recorded', insight); } /** * Sync high-confidence AgentDB entries to auto memory files. * Called on session-end or periodically. */ async syncToAutoMemory() { const startTime = Date.now(); const errors = []; const updatedCategories = new Set(); try { // ADR-049: Consolidate learning trajectories before syncing if (this.learningBridge) { await this.learningBridge.consolidate(); } // Ensure directory exists await this.ensureMemoryDir(); // Snapshot and clear the buffer atomically to avoid race conditions const buffered = this.insights.splice(0, this.insights.length); // Flush buffered insights to files for (const insight of buffered) { try { await this.writeInsightToFiles(insight); updatedCategories.add(insight.category); } catch (err) { errors.push(`Failed to write insight: ${err.message}`); } } // Query AgentDB for high-confidence entries since last sync, // skipping entries we already wrote from the buffer above const entries = await this.queryRecentInsights(); for (const entry of entries) { const entryKey = entry.key; if (this.syncedInsightKeys.has(entryKey)) continue; try { const category = this.classifyEntry(entry); await this.appendToTopicFile(category, entry); updatedCategories.add(category); this.syncedInsightKeys.add(entryKey); } catch (err) { errors.push(`Failed to sync entry ${entry.id}: ${err.message}`); } } // Curate MEMORY.md index await this.curateIndex(); const synced = buffered.length + entries.length; this.lastSyncTime = Date.now(); // Prevent unbounded growth of syncedInsightKeys if (this.syncedInsightKeys.size > 10_000) { const keys = [...this.syncedInsightKeys]; this.syncedInsightKeys = new Set(keys.slice(keys.length - 5_000)); } const result = { synced, categories: [...updatedCategories], durationMs: Date.now() - startTime, errors, }; this.emit('sync:completed', result); return result; } catch (err) { errors.push(`Sync failed: ${err.message}`); this.emit('sync:failed', { error: err }); return { synced: 0, categories: [], durationMs: Date.now() - startTime, errors, }; } } /** * Import auto memory files into AgentDB. * Called on session-start to hydrate AgentDB with previous learnings. * Uses bulk insert for efficiency. */ async importFromAutoMemory() { const startTime = Date.now(); const memoryDir = this.config.memoryDir; if (!existsSync(memoryDir)) { return { imported: 0, skipped: 0, files: [], durationMs: 0 }; } let imported = 0; let skipped = 0; const processedFiles = []; const files = readdirSync(memoryDir).filter(f => f.endsWith('.md')); // Pre-fetch existing content hashes to avoid N queries const existingHashes = await this.fetchExistingContentHashes(); // Batch entries for bulk insert const batch = []; for (const file of files) { const filePath = path.join(memoryDir, file); const content = await fs.readFile(filePath, 'utf-8'); const entries = parseMarkdownEntries(content); for (const entry of entries) { const contentHash = hashContent(entry.content); if (existingHashes.has(contentHash)) { skipped++; continue; } const input = { key: `auto-memory:${file}:${entry.heading}`, content: entry.content, namespace: 'auto-memory', type: 'semantic', tags: ['auto-memory', file.replace('.md', '')], metadata: { sourceFile: file, heading: entry.heading, importedAt: new Date().toISOString(), contentHash, }, }; batch.push(createDefaultEntry(input)); existingHashes.add(contentHash); imported++; } processedFiles.push(file); } // Bulk insert all at once if (batch.length > 0) { await this.backend.bulkInsert(batch); } // ADR-049: Build knowledge graph from imported entries if (this.memoryGraph && batch.length > 0) { await this.memoryGraph.buildFromBackend(this.backend, 'auto-memory'); } const result = { imported, skipped, files: processedFiles, durationMs: Date.now() - startTime, }; this.emit('import:completed', result); return result; } /** * Curate MEMORY.md to stay under the line limit. * Groups entries by category and prunes low-confidence items. */ async curateIndex() { await this.ensureMemoryDir(); // Collect summaries from all topic files const sections = {}; for (const [category, filename] of Object.entries(this.config.topicMapping)) { const topicPath = path.join(this.config.memoryDir, filename); if (existsSync(topicPath)) { const content = await fs.readFile(topicPath, 'utf-8'); const summaries = extractSummaries(content); if (summaries.length > 0) { sections[category] = summaries; } } } // ADR-049: Use graph PageRank to prioritize sections let sectionOrder; if (this.memoryGraph) { const topNodes = this.memoryGraph.getTopNodes(20); const categoryCounts = new Map(); for (const node of topNodes) { const cat = node.community || 'general'; categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1); } sectionOrder = [...categoryCounts.entries()] .sort((a, b) => b[1] - a[1]) .map(([cat]) => cat) .filter((cat) => sections[cat]); } // Prune sections before building the index to avoid O(n^2) rebuild loop const budget = this.config.maxIndexLines; pruneSectionsToFit(sections, budget, this.config.pruneStrategy); // Build the final index (with optional graph-aware ordering) const lines = buildIndexLines(sections, this.config.topicMapping, sectionOrder); await fs.writeFile(this.getIndexPath(), lines.join('\n'), 'utf-8'); this.emit('index:curated', { lines: lines.length }); } /** * Get auto memory status: directory info, file count, line counts. */ getStatus() { const memoryDir = this.config.memoryDir; if (!existsSync(memoryDir)) { return { memoryDir, exists: false, files: [], totalLines: 0, indexLines: 0, lastSyncTime: this.lastSyncTime, bufferedInsights: this.insights.length, }; } const fileStats = []; let totalLines = 0; let indexLines = 0; let mdFiles; try { mdFiles = readdirSync(memoryDir).filter(f => f.endsWith('.md')); } catch { return { memoryDir, exists: true, files: [], totalLines: 0, indexLines: 0, lastSyncTime: this.lastSyncTime, bufferedInsights: this.insights.length, }; } for (const file of mdFiles) { try { const content = readFileSync(path.join(memoryDir, file), 'utf-8'); const lineCount = content.split('\n').length; fileStats.push({ name: file, lines: lineCount }); totalLines += lineCount; if (file === 'MEMORY.md') { indexLines = lineCount; } } catch { // Skip unreadable files } } return { memoryDir, exists: true, files: fileStats, totalLines, indexLines, lastSyncTime: this.lastSyncTime, bufferedInsights: this.insights.length, }; } /** Stop periodic sync and clean up */ destroy() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; } // ADR-049: Clean up learning bridge if (this.learningBridge) { this.learningBridge.destroy(); } this.removeAllListeners(); } // ===== Private Methods ===== async ensureMemoryDir() { const dir = this.config.memoryDir; if (!existsSync(dir)) { await fs.mkdir(dir, { recursive: true }); } } async storeInsightInAgentDB(insight) { const content = insight.detail ? `${insight.summary}\n\n${insight.detail}` : insight.summary; const key = `insight:${insight.category}:${Date.now()}:${this.insightCounter++}`; const input = { key, content, namespace: 'learnings', type: 'semantic', tags: ['insight', insight.category, `source:${insight.source}`], metadata: { category: insight.category, summary: insight.summary, source: insight.source, confidence: insight.confidence, contentHash: hashContent(content), ...(insight.agentDbId ? { linkedEntryId: insight.agentDbId } : {}), }, }; const entry = createDefaultEntry(input); await this.backend.store(entry); return key; } async writeInsightToFiles(insight) { await this.ensureMemoryDir(); const topicPath = this.getTopicPath(insight.category); const line = formatInsightLine(insight); if (existsSync(topicPath)) { const existing = await fs.readFile(topicPath, 'utf-8'); // Exact line-based dedup: check if the summary already appears as a bullet if (hasSummaryLine(existing, insight.summary)) return; const lineCount = existing.split('\n').length; if (lineCount >= this.config.maxTopicFileLines) { const pruned = pruneTopicFile(existing, this.config.maxTopicFileLines - 10); await fs.writeFile(topicPath, pruned + '\n' + line, 'utf-8'); } else { await fs.appendFile(topicPath, '\n' + line, 'utf-8'); } } else { const label = CATEGORY_LABELS[insight.category] || insight.category; const header = `# ${label}\n\n`; await fs.writeFile(topicPath, header + line, 'utf-8'); } } async queryRecentInsights() { const query = { type: 'hybrid', namespace: 'learnings', tags: ['insight'], updatedAfter: this.lastSyncTime || 0, limit: 50, }; try { const entries = await this.backend.query(query); return entries.filter(e => { const confidence = e.metadata?.confidence || 0; return confidence >= this.config.minConfidence; }); } catch { return []; } } classifyEntry(entry) { const category = entry.metadata?.category; if (category && category in DEFAULT_TOPIC_MAPPING) { return category; } const tags = entry.tags || []; if (tags.includes('debugging') || tags.includes('bug') || tags.includes('fix')) { return 'debugging'; } if (tags.includes('architecture') || tags.includes('design')) { return 'architecture'; } if (tags.includes('performance') || tags.includes('benchmark')) { return 'performance'; } if (tags.includes('security') || tags.includes('cve')) { return 'security'; } if (tags.includes('swarm') || tags.includes('agent')) { return 'swarm-results'; } return 'project-patterns'; } async appendToTopicFile(category, entry) { const insight = { category, summary: entry.metadata?.summary || entry.content.split('\n')[0], detail: entry.content, source: entry.metadata?.source || 'agentdb', confidence: entry.metadata?.confidence || 0.5, agentDbId: entry.id, }; await this.writeInsightToFiles(insight); } /** Fetch all existing content hashes from the auto-memory namespace in one query */ async fetchExistingContentHashes() { try { const entries = await this.backend.query({ type: 'hybrid', namespace: 'auto-memory', limit: 10_000, }); const hashes = new Set(); for (const entry of entries) { const hash = entry.metadata?.contentHash; if (hash) hashes.add(hash); } return hashes; } catch { return new Set(); } } startPeriodicSync() { this.syncTimer = setInterval(async () => { try { await this.syncToAutoMemory(); } catch (err) { this.emit('sync:error', err); } }, this.config.syncIntervalMs); if (this.syncTimer.unref) { this.syncTimer.unref(); } } } // ===== Utility Functions ===== /** * Resolve the auto memory directory for a given working directory. * Mirrors Claude Code's path derivation from git root. */ export function resolveAutoMemoryDir(workingDir) { const gitRoot = findGitRoot(workingDir); const basePath = gitRoot || workingDir; // Claude Code normalizes to forward slashes then replaces with dashes // The leading dash IS preserved (e.g. /workspaces/foo -> -workspaces-foo) const normalized = basePath.split(path.sep).join('/'); const projectKey = normalized.replace(/\//g, '-'); return path.join(process.env.HOME || process.env.USERPROFILE || '~', '.claude', 'projects', projectKey, 'memory'); } /** * Find the git root directory by walking up from workingDir. */ export function findGitRoot(dir) { let current = path.resolve(dir); const root = path.parse(current).root; while (current !== root) { if (existsSync(path.join(current, '.git'))) { return current; } current = path.dirname(current); } return null; } /** * Parse markdown content into structured entries. * Splits on ## headings and extracts content under each. */ export function parseMarkdownEntries(content) { const entries = []; const lines = content.split('\n'); let currentHeading = ''; let currentLines = []; for (const line of lines) { const headingMatch = line.match(/^##\s+(.+)/); if (headingMatch) { if (currentHeading && currentLines.length > 0) { entries.push({ heading: currentHeading, content: currentLines.join('\n').trim(), metadata: {}, }); } currentHeading = headingMatch[1]; currentLines = []; } else if (currentHeading) { currentLines.push(line); } } if (currentHeading && currentLines.length > 0) { entries.push({ heading: currentHeading, content: currentLines.join('\n').trim(), metadata: {}, }); } return entries; } /** * Extract clean one-line summaries from a topic file. * Returns bullet-point items (lines starting with '- '), stripping * metadata annotations like _(source, date, conf: 0.95)_. */ export function extractSummaries(content) { return content .split('\n') .filter(line => line.startsWith('- ')) .map(line => line.slice(2).trim()) .filter(line => !line.startsWith('See `')) .map(line => line.replace(/\s*_\(.*?\)_\s*$/, '').trim()) .filter(Boolean); } /** * Format an insight as a markdown line for topic files. */ export function formatInsightLine(insight) { const timestamp = new Date().toISOString().split('T')[0]; const prefix = `- ${insight.summary}`; const suffix = ` _(${insight.source}, ${timestamp}, conf: ${insight.confidence.toFixed(2)})_`; if (insight.detail && insight.detail.split('\n').length > 2) { return `${prefix}${suffix}\n ${insight.detail.split('\n').join('\n ')}`; } return `${prefix}${suffix}`; } /** * Hash content for deduplication. */ export function hashContent(content) { return createHash('sha256').update(content).digest('hex').slice(0, 16); } /** * Prune a topic file to stay under the line limit. * Removes oldest entries (those closest to the top after the header). */ export function pruneTopicFile(content, maxLines) { const lines = content.split('\n'); if (lines.length <= maxLines) return content; const header = lines.slice(0, 3); const entries = lines.slice(3); const kept = entries.slice(entries.length - (maxLines - 3)); return [...header, ...kept].join('\n'); } /** * Check if a summary already exists as a bullet line in topic file content. * Uses exact bullet prefix matching (not substring) to avoid false positives. */ export function hasSummaryLine(content, summary) { // Match lines that start with "- " (possibly followed by metadata) return content.split('\n').some(line => line.startsWith(`- ${summary}`)); } /** * Prune sections to fit within a line budget. * Removes entries from the largest sections first. */ function pruneSectionsToFit(sections, budget, strategy) { // Pre-compute total line count: title(1) + blank(1) + per-section(heading + items + "See..." + blank) let totalLines = 2; for (const summaries of Object.values(sections)) { totalLines += 1 + summaries.length + 1 + 1; } while (totalLines > budget) { const sorted = Object.entries(sections) .filter(([, items]) => items.length > 1) .sort((a, b) => b[1].length - a[1].length); if (sorted.length === 0) break; const [targetCat, targetItems] = sorted[0]; if (strategy === 'lru' || strategy === 'fifo') { targetItems.shift(); } else { targetItems.pop(); } totalLines--; // one fewer bullet line if (targetItems.length === 0) { delete sections[targetCat]; totalLines -= 3; // heading + "See..." + blank removed } } } /** * Build MEMORY.md index lines from curated sections. */ function buildIndexLines(sections, topicMapping, sectionOrder) { const lines = ['# Claude Flow V3 Project Memory', '']; // Use provided order, then append any remaining sections const orderedCategories = sectionOrder ? [...sectionOrder, ...Object.keys(sections).filter((k) => !sectionOrder.includes(k))] : Object.keys(sections); for (const category of orderedCategories) { const summaries = sections[category]; if (!summaries || summaries.length === 0) continue; const label = CATEGORY_LABELS[category] || category; const filename = topicMapping[category] || `${category}.md`; lines.push(`## ${label}`); for (const summary of summaries) { lines.push(`- ${summary}`); } lines.push(`- See \`${filename}\` for details`); lines.push(''); } return lines; } export default AutoMemoryBridge; //# sourceMappingURL=auto-memory-bridge.js.map