Files
flynn/src/memory/store.ts
T
William Valentin 9f81c01603 feat(session): persist model tier overrides per session
Store per-session config in SQLite and route /model and /reset through command fast-paths so channel sessions keep independent model selection across reconnects and restarts.
2026-02-13 01:04:26 -08:00

342 lines
10 KiB
TypeScript

import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { MEMORY_CATEGORIES, categoryNamespace, isMemoryCategory, type MemoryCategory } from './categories.js';
/**
* 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;
}
export interface PromptMemorySection {
title: string;
content: string;
}
/**
* 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;
}
export interface SearchOptions {
categories?: MemoryCategory[];
baseNamespacePrefix?: 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;
private _dirtyNamespaces: Set<string> = new Set();
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');
}
// Mark namespace as needing re-indexing for vector search
this._dirtyNamespaces.add(namespace);
}
/** Read content for a category under a base namespace. */
readCategory(baseNamespace: string, category: MemoryCategory): string {
return this.read(categoryNamespace(baseNamespace, category));
}
/** Write content for a category under a base namespace. */
writeCategory(baseNamespace: string, category: MemoryCategory, content: string, mode: 'append' | 'replace'): void {
this.write(categoryNamespace(baseNamespace, category), content, mode);
}
/** List categories that currently exist under a base namespace. */
listCategories(baseNamespace: string): MemoryCategory[] {
const categorySet = new Set<MemoryCategory>();
const prefix = `${baseNamespace}/`;
for (const namespace of this.listNamespaces()) {
if (!namespace.startsWith(prefix)) {
continue;
}
const suffix = namespace.slice(prefix.length);
if (suffix.includes('/')) {
continue;
}
if (isMemoryCategory(suffix)) {
categorySet.add(suffix);
}
}
return MEMORY_CATEGORIES.filter(category => categorySet.has(category));
}
/** Read all category files under a base namespace. */
readAllCategories(baseNamespace: string): Partial<Record<MemoryCategory, string>> {
const result: Partial<Record<MemoryCategory, string>> = {};
for (const category of this.listCategories(baseNamespace)) {
const content = this.readCategory(baseNamespace, category);
if (content.length > 0) {
result[category] = content;
}
}
return result;
}
/**
* 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, opts?: SearchOptions): SearchResult[] {
const results: SearchResult[] = [];
const namespaces = this.listNamespaces().filter((namespace) => {
if (opts?.baseNamespacePrefix && !namespace.startsWith(opts.baseNamespacePrefix)) {
return false;
}
if (opts?.categories && opts.categories.length > 0) {
const suffix = namespace.split('/').pop() ?? '';
return isMemoryCategory(suffix) && opts.categories.includes(suffix);
}
return true;
});
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);
}
/**
* Return namespaces that have been modified since last call, then clear
* the dirty set. Used by the background indexer to re-embed changed content.
*/
getDirtyNamespaces(): string[] {
const dirty = Array.from(this._dirtyNamespaces);
this._dirtyNamespaces.clear();
return dirty;
}
/**
* Mark all existing namespaces as dirty (e.g. for initial full indexing).
*/
markAllDirty(): void {
for (const ns of this.listNamespaces()) {
this._dirtyNamespaces.add(ns);
}
}
/**
* 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 sections = this.getPromptSections().map((section) => `## ${section.title}\n\n${section.content}`);
// Nothing to inject
if (sections.length === 0) {
return '';
}
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);
}
/** Build memory sections used by prompt injectors. */
getPromptSections(): PromptMemorySection[] {
const userMemory = this.read('user');
const globalMemory = this.read('global');
const userCategoryMemory = this.readAllCategories('user');
const globalCategoryMemory = this.readAllCategories('global');
const sections: PromptMemorySection[] = [];
if (userMemory.length > 0) {
sections.push({ title: 'User Memory', content: userMemory });
}
if (globalMemory.length > 0) {
sections.push({ title: 'Global Memory', content: globalMemory });
}
for (const category of MEMORY_CATEGORIES) {
const content = userCategoryMemory[category];
if (content) {
sections.push({
title: `User ${this._categoryLabel(category)}`,
content,
});
}
}
for (const category of MEMORY_CATEGORIES) {
const content = globalCategoryMemory[category];
if (content) {
sections.push({
title: `Global ${this._categoryLabel(category)}`,
content,
});
}
}
return sections;
}
// ---------------------------------------------------------------------------
// 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;
}
private _categoryLabel(category: MemoryCategory): string {
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
}