709 lines
25 KiB
JavaScript
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
|