// Memory manager for AgenTeam — wraps vectra for local vector search import { LocalIndex } from "vectra" import type { MetadataFilter, IndexItem, MetadataTypes } from "vectra" import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs" import { join, dirname } from "path" import { embed } from "./embeddings.ts" import { debugLog } from "../state-io.ts" import type { MemoryEntry, MemoryConfig, MemorySearchResult, MemoryCategory, } from "../types.ts" // --- Content file helpers --- // Content is stored in .agenteam/memory/{id}.json, not in vectra metadata. function contentPath(worktree: string, id: string): string { return join(worktree, ".agenteam", "memory", `${id}.json`) } function writeContentFile(worktree: string, id: string, content: string): void { const p = contentPath(worktree, id) const dir = dirname(p) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(p, JSON.stringify({ content })) } function readContentFile(worktree: string, id: string): string { const p = contentPath(worktree, id) if (!existsSync(p)) return "" try { return JSON.parse(readFileSync(p, "utf-8")).content } catch { return "" } } /** * Reconstruct a MemoryEntry from vectra index item metadata. * Content is read from the separate content file, not vectra metadata. */ function itemToEntry(item: IndexItem>, worktree: string): MemoryEntry { const meta = item.metadata return { id: item.id, category: meta.category as MemoryCategory, title: meta.title as string, content: readContentFile(worktree, item.id), tags: JSON.parse(meta.tags as string) as string[], project: meta.project as string, created_at: meta.created_at as string, source: meta.source as MemoryEntry["source"], } } export class MemoryManager { private index: LocalIndex> | null = null private worktree: string | null = null private config: MemoryConfig | null = null private initialized = false /** * Initialize the memory manager. * Idempotent — safe to call multiple times for the same worktree. */ async init(worktree: string, config: MemoryConfig): Promise { if (this.initialized && this.worktree === worktree) return this.worktree = worktree this.config = config const indexPath = join(worktree, ".agenteam", "memory") this.index = new LocalIndex(indexPath) await this.ensureIndex() this.initialized = true debugLog("system", "memory.initialized", { path: indexPath }) } /** * Ensure the vectra index exists on disk. */ private async ensureIndex(): Promise { if (!this.index) throw new Error("MemoryManager not initialized") const created = await this.index.isIndexCreated() if (!created) { await this.index.createIndex() } } /** * Get the initialized index or throw. */ private getIndex(): LocalIndex> { if (!this.index || !this.initialized) { throw new Error("MemoryManager not initialized — call init() first") } return this.index } /** * Store a memory entry in the vectra index. * Content is written to a separate file; only scalars go into vectra metadata. */ async store(entry: MemoryEntry): Promise { const index = this.getIndex() const cfg = this.config! const text = `${entry.title}\n${entry.content}` const vector = await embed(text, cfg.embedding_model) // Store content in separate file writeContentFile(this.worktree!, entry.id, entry.content) // Store only scalars in vectra metadata await index.upsertItem({ id: entry.id, vector, metadata: { category: entry.category, title: entry.title, // NO content — stored in file tags: JSON.stringify(entry.tags), project: entry.project, created_at: entry.created_at, source: entry.source, }, }) debugLog("system", "memory.stored", { id: entry.id, category: entry.category, }) } /** * Update an existing memory entry by ID. * Re-embeds if title or content changed. */ async update(id: string, fields: Partial>): Promise { const index = this.getIndex() const existing = await index.listItemsByMetadata({} as any) const item = existing.find(i => i.id === id) if (!item) throw new Error(`Memory entry '${id}' not found`) // Read existing entry const current = itemToEntry(item, this.worktree!) // Merge fields const merged: MemoryEntry = { ...current, ...fields } // Re-embed if title or content changed if (fields.title !== undefined || fields.content !== undefined) { const text = `${merged.title}\n${merged.content}` const vector = await embed(text, this.config!.embedding_model) // Update content file writeContentFile(this.worktree!, id, merged.content) // Re-insert with new vector await index.upsertItem({ id: merged.id, vector, metadata: { category: merged.category, title: merged.title, tags: JSON.stringify(merged.tags), project: merged.project, created_at: merged.created_at, source: merged.source, }, }) } else { // Just update metadata (tags, category) — keep existing vector writeContentFile(this.worktree!, id, merged.content) await index.upsertItem({ id: merged.id, vector: item.vector, metadata: { category: merged.category, title: merged.title, tags: JSON.stringify(merged.tags), project: merged.project, created_at: merged.created_at, source: merged.source, }, }) } debugLog("system", "memory.updated", { id, fields: Object.keys(fields) }) } /** * Semantic search over stored memories. * Returns results sorted by score (highest first), filtered by similarity threshold. */ async query( text: string, maxResults?: number, ): Promise { const index = this.getIndex() const cfg = this.config! const vector = await embed(text, cfg.embedding_model) const limit = maxResults ?? cfg.max_auto_results const results = await index.queryItems( vector, text, limit, ) // Filter by similarity threshold and reconstruct entries const filtered = results .filter((r) => r.score >= cfg.similarity_threshold) .sort((a, b) => b.score - a.score) return filtered.map((r) => ({ entry: itemToEntry(r.item, this.worktree!), score: r.score, })) } /** * List all entries, optionally filtered by category. */ async list(category?: MemoryCategory): Promise { const index = this.getIndex() let items: IndexItem>[] if (category) { const filter: MetadataFilter = { category: { $eq: category } } items = await index.listItemsByMetadata(filter) } else { items = await index.listItems() } return items.map(item => itemToEntry(item, this.worktree!)) } /** * Delete an entry by ID. */ async delete(id: string): Promise { const index = this.getIndex() await index.deleteItem(id) // Clean up content file const cp = contentPath(this.worktree!, id) if (existsSync(cp)) { try { unlinkSync(cp) } catch {} } debugLog("system", "memory.deleted", { id }) } /** * Auto-inject helper: returns a compact summary of relevant memories. * Format: "1. [category] title — tags: tag1, tag2" * Truncated to fit within maxTokens (rough: 1 token ≈ 4 chars). */ async getSummary(query: string, maxTokens: number): Promise { const results = await this.query(query) if (results.length === 0) return "" const maxChars = maxTokens * 4 const lines: string[] = [] let totalChars = 0 for (let i = 0; i < results.length; i++) { const { entry } = results[i] const line = `${i + 1}. [${entry.category}] ${entry.title} — tags: ${entry.tags.join(", ")}` // Check if adding this line would exceed the budget // +1 for the newline character const addedLength = line.length + (lines.length > 0 ? 1 : 0) if (totalChars + addedLength > maxChars) break lines.push(line) totalChars += addedLength } return lines.join("\n") } } // Per-worktree instances — fixes multi-project bug const _instances = new Map() /** * Get the cached MemoryManager instance for a given worktree. * Call init() on the first invocation to configure it. */ export function getMemoryManager(worktree: string): MemoryManager { if (!_instances.has(worktree)) { _instances.set(worktree, new MemoryManager()) } return _instances.get(worktree)! }