tasq/node_modules/@claude-flow/memory/dist/auto-memory-bridge.js

709 lines
25 KiB
JavaScript

/**
* AutoMemoryBridge - Bidirectional sync between Claude Code Auto Memory and AgentDB
*
* Per ADR-048: Bridges Claude Code's auto memory (markdown files at
* ~/.claude/projects/<project>/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 "- <summary>" (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