/**
 * OpenClaw-inspired 2-Step Memory Search System
 *
 * Workflow:
 * 1. Semantic search to find relevant content
 * 2. Targeted retrieval to minimize token usage
 *
 * Features:
 * - Semantic similarity scoring
 * - File-based memory storage
 * - Configurable minScore and maxResults
 * - Context-efficient retrieval
 */
import { EventEmitter } from 'events';
import fs from 'fs-extra';
import path from 'path';
import { logger } from '../utils/logger.js';
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG = {
    memoryDir: '.codebuddy/memory',
    indexFile: '.codebuddy/memory/index.json',
    maxMemorySize: 10000, // Max entries
    embeddingProvider: 'none',
};
const DEFAULT_SEARCH_OPTIONS = {
    maxResults: 10,
    minScore: 0.3,
};
// ============================================================================
// Semantic Memory Search Manager
// ============================================================================
export class SemanticMemorySearch extends EventEmitter {
    config;
    index = new Map();
    invertedIndex = new Map(); // term -> entry IDs
    loaded = false;
    constructor(config = {}) {
        super();
        this.config = { ...DEFAULT_CONFIG, ...config };
    }
    /**
     * Initialize and load the memory index
     */
    async initialize() {
        if (this.loaded)
            return;
        await this.loadIndex();
        await this.scanMemoryFiles();
        this.loaded = true;
        this.emit('initialized', { entryCount: this.index.size });
    }
    /**
     * Step 1: Semantic search across memory
     */
    async search(query, options = {}) {
        if (!this.loaded) {
            await this.initialize();
        }
        const opts = { ...DEFAULT_SEARCH_OPTIONS, ...options };
        const queryTerms = this.tokenize(query);
        this.emit('search:start', { query, options: opts });
        // Find candidate entries using inverted index
        const candidateIds = new Set();
        for (const term of queryTerms) {
            const entries = this.invertedIndex.get(term.toLowerCase());
            if (entries) {
                for (const id of entries) {
                    candidateIds.add(id);
                }
            }
        }
        // Score candidates
        const results = [];
        for (const id of candidateIds) {
            const entry = this.index.get(id);
            if (!entry)
                continue;
            // Apply filters
            if (opts.tags && opts.tags.length > 0) {
                if (!entry.metadata.tags?.some(t => opts.tags.includes(t))) {
                    continue;
                }
            }
            if (opts.sources && opts.sources.length > 0) {
                if (!opts.sources.includes(entry.metadata.source)) {
                    continue;
                }
            }
            if (opts.dateRange) {
                const entryDate = entry.metadata.timestamp;
                if (opts.dateRange.start && entryDate < opts.dateRange.start)
                    continue;
                if (opts.dateRange.end && entryDate > opts.dateRange.end)
                    continue;
            }
            // Calculate score
            const { score, matchedTerms } = this.calculateScore(entry, queryTerms);
            if (score >= (opts.minScore || 0)) {
                results.push({
                    entry,
                    score,
                    matchedTerms,
                    snippet: this.extractSnippet(entry.content, matchedTerms),
                });
            }
        }
        // Sort by score and limit results
        results.sort((a, b) => b.score - a.score);
        const limited = results.slice(0, opts.maxResults);
        this.emit('search:complete', { query, resultCount: limited.length });
        return limited;
    }
    /**
     * Step 2: Targeted retrieval from file
     */
    async retrieve(filePath, options = {}) {
        const { from = 1, lines = 50, context = 3 } = options;
        const fullPath = path.isAbsolute(filePath)
            ? filePath
            : path.join(process.cwd(), this.config.memoryDir, filePath);
        if (!await fs.pathExists(fullPath)) {
            throw new Error(`Memory file not found: ${filePath}`);
        }
        const content = await fs.readFile(fullPath, 'utf-8');
        const allLines = content.split('\n');
        const totalLines = allLines.length;
        // Calculate actual line range with context
        const startLine = Math.max(1, from - context);
        const endLine = Math.min(totalLines, from + lines + context - 1);
        // Extract lines (1-indexed)
        const extractedLines = allLines.slice(startLine - 1, endLine);
        this.emit('retrieve:complete', { path: filePath, lines: extractedLines.length });
        return {
            content: extractedLines.join('\n'),
            path: filePath,
            lineStart: startLine,
            lineEnd: endLine,
            totalLines,
        };
    }
    /**
     * Add a memory entry
     */
    async add(content, metadata) {
        const id = this.generateId();
        const entry = {
            id,
            content,
            metadata: {
                source: metadata.source || 'manual',
                path: metadata.path || '',
                lineStart: metadata.lineStart || 1,
                lineEnd: metadata.lineEnd || content.split('\n').length,
                timestamp: metadata.timestamp || new Date(),
                tags: metadata.tags,
                importance: metadata.importance || 1,
            },
        };
        this.index.set(id, entry);
        this.indexEntry(entry);
        await this.saveIndex();
        this.emit('entry:added', { id, source: entry.metadata.source });
        return id;
    }
    /**
     * Remove a memory entry
     */
    async remove(id) {
        const entry = this.index.get(id);
        if (!entry)
            return false;
        // Remove from inverted index
        const terms = this.tokenize(entry.content);
        for (const term of terms) {
            const entries = this.invertedIndex.get(term.toLowerCase());
            if (entries) {
                entries.delete(id);
                if (entries.size === 0) {
                    this.invertedIndex.delete(term.toLowerCase());
                }
            }
        }
        this.index.delete(id);
        await this.saveIndex();
        this.emit('entry:removed', { id });
        return true;
    }
    /**
     * Update a memory entry
     */
    async update(id, content, metadata) {
        const existing = this.index.get(id);
        if (!existing)
            return false;
        // Remove old index entries
        await this.remove(id);
        // Add updated entry with same ID
        const entry = {
            id,
            content,
            metadata: {
                ...existing.metadata,
                ...metadata,
                timestamp: new Date(),
            },
        };
        this.index.set(id, entry);
        this.indexEntry(entry);
        await this.saveIndex();
        this.emit('entry:updated', { id });
        return true;
    }
    /**
     * Load the index from disk
     */
    async loadIndex() {
        const indexPath = this.config.indexFile;
        if (await fs.pathExists(indexPath)) {
            try {
                const data = await fs.readJson(indexPath);
                for (const entry of data.entries || []) {
                    entry.metadata.timestamp = new Date(entry.metadata.timestamp);
                    this.index.set(entry.id, entry);
                    this.indexEntry(entry);
                }
                logger.debug(`Loaded ${this.index.size} memory entries from index`);
            }
            catch (error) {
                logger.warn('Failed to load memory index, starting fresh');
            }
        }
    }
    /**
     * Save the index to disk
     */
    async saveIndex() {
        const indexPath = this.config.indexFile;
        await fs.ensureDir(path.dirname(indexPath));
        const data = {
            version: 1,
            updatedAt: new Date().toISOString(),
            entries: Array.from(this.index.values()),
        };
        await fs.writeJson(indexPath, data, { spaces: 2 });
    }
    /**
     * Scan memory directory for markdown files
     */
    async scanMemoryFiles() {
        const memoryDir = path.join(process.cwd(), this.config.memoryDir);
        if (!await fs.pathExists(memoryDir)) {
            await fs.ensureDir(memoryDir);
            return;
        }
        const files = await this.findMarkdownFiles(memoryDir);
        for (const file of files) {
            const relativePath = path.relative(memoryDir, file);
            // Skip if already indexed
            if (Array.from(this.index.values()).some(e => e.metadata.path === relativePath)) {
                continue;
            }
            try {
                const content = await fs.readFile(file, 'utf-8');
                const stats = await fs.stat(file);
                await this.add(content, {
                    source: 'file',
                    path: relativePath,
                    lineStart: 1,
                    lineEnd: content.split('\n').length,
                    timestamp: stats.mtime,
                });
            }
            catch (error) {
                logger.warn(`Failed to index memory file: ${file}`);
            }
        }
    }
    /**
     * Find all markdown files recursively
     */
    async findMarkdownFiles(dir) {
        const results = [];
        const entries = await fs.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                results.push(...await this.findMarkdownFiles(fullPath));
            }
            else if (entry.name.endsWith('.md')) {
                results.push(fullPath);
            }
        }
        return results;
    }
    /**
     * Index an entry in the inverted index
     */
    indexEntry(entry) {
        const terms = this.tokenize(entry.content);
        for (const term of terms) {
            const lowered = term.toLowerCase();
            if (!this.invertedIndex.has(lowered)) {
                this.invertedIndex.set(lowered, new Set());
            }
            this.invertedIndex.get(lowered).add(entry.id);
        }
        // Also index tags
        if (entry.metadata.tags) {
            for (const tag of entry.metadata.tags) {
                const lowered = tag.toLowerCase();
                if (!this.invertedIndex.has(lowered)) {
                    this.invertedIndex.set(lowered, new Set());
                }
                this.invertedIndex.get(lowered).add(entry.id);
            }
        }
    }
    /**
     * Tokenize text into terms
     */
    tokenize(text) {
        // Split on whitespace and punctuation, filter short terms
        return text
            .toLowerCase()
            .split(/[\s\p{P}]+/u)
            .filter(term => term.length >= 2)
            .filter(term => !this.isStopWord(term));
    }
    /**
     * Check if a term is a stop word
     */
    isStopWord(term) {
        const stopWords = new Set([
            'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
            'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
            'should', 'may', 'might', 'must', 'can', 'to', 'of', 'in', 'for',
            'on', 'with', 'at', 'by', 'from', 'as', 'or', 'and', 'but', 'if',
            'then', 'else', 'when', 'up', 'down', 'out', 'off', 'over', 'under',
            'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
            'we', 'our', 'you', 'your', 'he', 'she', 'they', 'their',
        ]);
        return stopWords.has(term);
    }
    /**
     * Calculate relevance score
     */
    calculateScore(entry, queryTerms) {
        const entryTerms = new Set(this.tokenize(entry.content));
        const matchedTerms = [];
        let matches = 0;
        for (const queryTerm of queryTerms) {
            if (entryTerms.has(queryTerm.toLowerCase())) {
                matches++;
                matchedTerms.push(queryTerm);
            }
        }
        // TF-IDF inspired scoring
        const termFrequency = matches / Math.max(queryTerms.length, 1);
        const docFrequency = this.invertedIndex.get(queryTerms[0]?.toLowerCase())?.size || 1;
        const idf = Math.log(this.index.size / docFrequency + 1);
        // Boost by importance
        const importanceBoost = entry.metadata.importance || 1;
        // Recency boost (newer = higher)
        const daysSinceUpdate = (Date.now() - entry.metadata.timestamp.getTime()) / (1000 * 60 * 60 * 24);
        const recencyBoost = Math.max(0.5, 1 - daysSinceUpdate / 365);
        const score = termFrequency * idf * importanceBoost * recencyBoost;
        return { score: Math.min(1, score), matchedTerms };
    }
    /**
     * Extract a relevant snippet from content
     */
    extractSnippet(content, matchedTerms, maxLength = 200) {
        if (content.length <= maxLength) {
            return content;
        }
        // Find the first matched term
        const lowerContent = content.toLowerCase();
        let startPos = 0;
        for (const term of matchedTerms) {
            const pos = lowerContent.indexOf(term.toLowerCase());
            if (pos !== -1) {
                startPos = Math.max(0, pos - 50);
                break;
            }
        }
        const snippet = content.slice(startPos, startPos + maxLength);
        const prefix = startPos > 0 ? '...' : '';
        const suffix = startPos + maxLength < content.length ? '...' : '';
        return prefix + snippet.trim() + suffix;
    }
    /**
     * Generate a unique ID
     */
    generateId() {
        return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
    }
    /**
     * Get statistics
     */
    getStats() {
        const sources = {};
        let totalLength = 0;
        for (const entry of this.index.values()) {
            sources[entry.metadata.source] = (sources[entry.metadata.source] || 0) + 1;
            totalLength += entry.content.length;
        }
        return {
            totalEntries: this.index.size,
            totalTerms: this.invertedIndex.size,
            sources,
            avgEntryLength: this.index.size > 0 ? Math.round(totalLength / this.index.size) : 0,
        };
    }
    /**
     * Clear all memory
     */
    async clear() {
        this.index.clear();
        this.invertedIndex.clear();
        await this.saveIndex();
        this.emit('memory:cleared');
    }
}
// ============================================================================
// Singleton & Convenience Functions
// ============================================================================
let memorySearchInstance = null;
export function getSemanticMemorySearch(config) {
    if (!memorySearchInstance) {
        memorySearchInstance = new SemanticMemorySearch(config);
    }
    return memorySearchInstance;
}
export function resetSemanticMemorySearch() {
    memorySearchInstance = null;
}
/**
 * Convenience: Search and retrieve in one call
 */
export async function searchAndRetrieve(query, options = {}) {
    const search = getSemanticMemorySearch();
    await search.initialize();
    const results = await search.search(query, options);
    const detailed = [];
    for (const result of results) {
        if (result.entry.metadata.path) {
            try {
                const retrieved = await search.retrieve(result.entry.metadata.path, {
                    from: result.entry.metadata.lineStart,
                    lines: options.retrieveLines || 50,
                });
                detailed.push({ result, content: retrieved.content });
            }
            catch {
                detailed.push({ result, content: result.entry.content });
            }
        }
        else {
            detailed.push({ result, content: result.entry.content });
        }
    }
    return detailed;
}
//# sourceMappingURL=semantic-memory-search.js.map