feat: add persistent memory system (Phase 2)

Implement file-based persistent memory with read/write/search tools:
- MemoryStore with namespace-scoped JSON storage
- memory-read, memory-write, memory-search builtin tools
- Auto-extraction of facts during context compaction
- Configurable via memory.enabled, memory.dir, memory.max_context_tokens
This commit is contained in:
William Valentin
2026-02-06 14:23:59 -08:00
parent 0180d4fb8f
commit 2e1071230a
6 changed files with 580 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
/**
* Configuration for the MemoryStore.
*/
export interface MemoryStoreConfig {
/** Base directory for memory files. */
dir: string;
/** Maximum tokens to inject into system prompt per turn. */
maxContextTokens: number;
}
/**
* A single search result from scanning memory files.
*/
export interface SearchResult {
/** Namespace the match was found in (e.g. 'user', 'sessions/abc123'). */
namespace: string;
/** 1-based line number of the match. */
line: number;
/** The matched line content. */
content: string;
/** Lines of context around the match (1 line above + match + 1 line below). */
context: string;
}
/**
* Manages persistent markdown memory files on disk.
*
* Directory layout:
* {dir}/
* ├── global.md # Cross-session knowledge
* ├── user.md # User preferences and facts
* └── sessions/
* └── {session_id}.md # Per-session notes
*
* Namespaces map directly to filenames: 'user' -> user.md,
* 'sessions/abc123' -> sessions/abc123.md.
*/
export class MemoryStore {
private _config: MemoryStoreConfig;
constructor(config: MemoryStoreConfig) {
this._config = config;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Read the contents of a memory file by namespace.
* Returns an empty string if the file does not exist.
*/
read(namespace: string): string {
const filePath = this._namespacePath(namespace);
if (!existsSync(filePath)) {
return '';
}
return readFileSync(filePath, 'utf-8');
}
/**
* Write content to a memory file.
*
* @param namespace - Target namespace (e.g. 'user', 'sessions/abc123').
* @param content - Markdown content to write.
* @param mode - 'append' adds content after a newline separator;
* 'replace' overwrites the file entirely.
*/
write(namespace: string, content: string, mode: 'append' | 'replace'): void {
const filePath = this._namespacePath(namespace);
// Ensure parent directories exist on first write
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (mode === 'replace') {
writeFileSync(filePath, content, 'utf-8');
} else {
// Append: read existing content, add a newline separator, then the new content
const existing = existsSync(filePath)
? readFileSync(filePath, 'utf-8')
: '';
const separator = existing.length > 0 ? '\n' : '';
writeFileSync(filePath, existing + separator + content, 'utf-8');
}
}
/**
* Search across all memory files for a keyword or phrase.
* Performs case-insensitive line-by-line matching.
* Returns matching lines with 1 line of context above and below.
*/
search(query: string): SearchResult[] {
const results: SearchResult[] = [];
const namespaces = this.listNamespaces();
const lowerQuery = query.toLowerCase();
for (const namespace of namespaces) {
const content = this.read(namespace);
if (content.length === 0) {
continue;
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(lowerQuery)) {
// Gather 1 line of context above and below
const contextLines: string[] = [];
if (i > 0) {
contextLines.push(lines[i - 1]);
}
contextLines.push(lines[i]);
if (i < lines.length - 1) {
contextLines.push(lines[i + 1]);
}
results.push({
namespace,
line: i + 1, // 1-based
content: lines[i],
context: contextLines.join('\n'),
});
}
}
}
return results;
}
/**
* List all available namespaces by scanning the memory directory
* recursively for `.md` files.
*
* Returns namespace strings (file path relative to the base dir, without
* the `.md` extension). E.g. ['global', 'user', 'sessions/abc123'].
*/
listNamespaces(): string[] {
if (!existsSync(this._config.dir)) {
return [];
}
return this._scanDir(this._config.dir);
}
/**
* Build memory context suitable for injection into a system prompt.
*
* Reads `user.md` and `global.md`, formats them under markdown headings,
* and truncates to stay within {@link MemoryStoreConfig.maxContextTokens}
* (estimated at 4 characters per token).
*/
getContextForPrompt(): string {
const userMemory = this.read('user');
const globalMemory = this.read('global');
// Nothing to inject
if (userMemory.length === 0 && globalMemory.length === 0) {
return '';
}
const sections: string[] = [];
if (userMemory.length > 0) {
sections.push(`## User Memory\n\n${userMemory}`);
}
if (globalMemory.length > 0) {
sections.push(`## Global Memory\n\n${globalMemory}`);
}
const full = sections.join('\n\n');
// Truncate to fit within the token budget (estimate: 4 chars ≈ 1 token)
const maxChars = this._config.maxContextTokens * 4;
if (full.length <= maxChars) {
return full;
}
return full.slice(0, maxChars);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/** Resolve a namespace string to an absolute file path. */
private _namespacePath(namespace: string): string {
return join(this._config.dir, `${namespace}.md`);
}
/** Recursively scan a directory for .md files, returning namespace strings. */
private _scanDir(dir: string): string[] {
const namespaces: string[] = [];
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// Recurse into subdirectories
namespaces.push(...this._scanDir(fullPath));
} else if (stat.isFile() && entry.endsWith('.md')) {
// Convert absolute path back to namespace: strip base dir and .md extension
const rel = relative(this._config.dir, fullPath);
const namespace = rel.slice(0, -3); // remove '.md'
namespaces.push(namespace);
}
}
return namespaces;
}
}