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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user