src / agents / memory.ts
/**
* Persistent memory — JSON file stored in the workspace.
*
* Each memory entry has a key, content, optional tags, and timestamps.
* Search is keyword-based (fast, no external deps, works offline).
*/
import { readFile, writeFile, mkdir } from "fs/promises";
import { join, dirname } from "path";
export interface MemoryEntry {
id: string;
key: string;
content: string;
tags: string[];
created: string;
updated: string;
}
interface MemoryStore {
version: number;
entries: MemoryEntry[];
}
function memoryPath(workspace: string): string {
return join(workspace, ".agent_memory", "memory.json");
}
async function load(workspace: string): Promise<MemoryStore> {
try {
return JSON.parse(await readFile(memoryPath(workspace), "utf-8")) as MemoryStore;
} catch {
return { version: 1, entries: [] };
}
}
async function save(workspace: string, store: MemoryStore): Promise<void> {
const p = memoryPath(workspace);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, JSON.stringify(store, null, 2), "utf-8");
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function score(entry: MemoryEntry, query: string): number {
const q = query.toLowerCase();
const haystack = `${entry.key} ${entry.content} ${entry.tags.join(" ")}`.toLowerCase();
// Count how many query words appear in the haystack
const words = q.split(/\s+/).filter(Boolean);
const hits = words.filter(w => haystack.includes(w)).length;
return words.length > 0 ? hits / words.length : 0;
}
// ── Public API ────────────────────────────────────────────────────────────
export async function memorySave(
workspace: string,
key: string,
content: string,
tags: string[],
): Promise<string> {
const store = await load(workspace);
const existing = store.entries.find(e => e.key.toLowerCase() === key.toLowerCase());
const now = new Date().toISOString();
if (existing) {
existing.content = content;
existing.tags = tags;
existing.updated = now;
await save(workspace, store);
return `Updated memory: "${key}" (id: ${existing.id})`;
}
const entry: MemoryEntry = { id: uid(), key, content, tags, created: now, updated: now };
store.entries.push(entry);
await save(workspace, store);
return `Saved memory: "${key}" (id: ${entry.id})`;
}
export async function memoryRecall(
workspace: string,
query: string,
limit: number,
tags: string[],
): Promise<string> {
const store = await load(workspace);
let results = store.entries;
// Filter by tags if specified
if (tags.length > 0) {
results = results.filter(e => tags.some(t => e.tags.map(x => x.toLowerCase()).includes(t.toLowerCase())));
}
// Score and sort
const scored = results
.map(e => ({ entry: e, score: score(e, query) }))
.filter(x => query.trim() === "" || x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
if (scored.length === 0) return "No memories found matching the query.";
return JSON.stringify(
scored.map(({ entry, score: s }) => ({
id: entry.id,
key: entry.key,
content: entry.content,
tags: entry.tags,
relevance: Math.round(s * 100) + "%",
updated: entry.updated,
})),
null, 2
);
}
export async function memoryList(workspace: string, tags: string[]): Promise<string> {
const store = await load(workspace);
let results = store.entries;
if (tags.length > 0) {
results = results.filter(e => tags.some(t => e.tags.map(x => x.toLowerCase()).includes(t.toLowerCase())));
}
if (results.length === 0) return "Memory is empty.";
return JSON.stringify(
results.map(e => ({ id: e.id, key: e.key, tags: e.tags, updated: e.updated })),
null, 2
);
}
export async function memoryDelete(workspace: string, idOrKey: string): Promise<string> {
const store = await load(workspace);
const before = store.entries.length;
store.entries = store.entries.filter(
e => e.id !== idOrKey && e.key.toLowerCase() !== idOrKey.toLowerCase()
);
if (store.entries.length === before) return `No memory found with id or key: "${idOrKey}"`;
await save(workspace, store);
return `Deleted ${before - store.entries.length} memory entry.`;
}