Add file-backed durable memory tools

This commit is contained in:
Adolfo Reyna
2026-05-15 15:07:40 -04:00
parent b02ef0b455
commit d791b2495f
4 changed files with 202 additions and 0 deletions
+29
View File
@@ -7,10 +7,12 @@ keep the core small, make the seams obvious, and add capability through tools.
- `src/agent.ts` — the agent loop - `src/agent.ts` — the agent loop
- `src/model/openai.ts` — the OpenAI Responses API adapter - `src/model/openai.ts` — the OpenAI Responses API adapter
- `src/memory.ts` — file-backed durable memory
- `src/events.ts` — typed lifecycle events - `src/events.ts` — typed lifecycle events
- `src/commands.ts` — slash-command registry - `src/commands.ts` — slash-command registry
- `src/tools/registry.ts` — tool registration and lookup - `src/tools/registry.ts` — tool registration and lookup
- `src/tools/builtins.ts` — two read-only built-in tools - `src/tools/builtins.ts` — two read-only built-in tools
- `src/tools/memory.ts` — memory tools backed by markdown files
- `src/extensions.ts` — project-local extension loader - `src/extensions.ts` — project-local extension loader
- `src/logger.ts` — ordered lifecycle logging - `src/logger.ts` — ordered lifecycle logging
- `extensions/example.ts` — an example extension that registers `echo` - `extensions/example.ts` — an example extension that registers `echo`
@@ -108,6 +110,7 @@ The core provides:
- `/reset` — clear the current conversation history - `/reset` — clear the current conversation history
- `/save` — persist the current conversation immediately - `/save` — persist the current conversation immediately
- `/logs [on|off]` — toggle lifecycle traces - `/logs [on|off]` — toggle lifecycle traces
- `/memory` — inspect durable memory files
- `/exit` — leave the interactive session - `/exit` — leave the interactive session
Extensions can register their own commands. Extensions can register their own commands.
@@ -123,6 +126,32 @@ You are a helpful agent. Use tools when they are useful, and answer clearly.
Override it with the `SYSTEM_PROMPT` environment variable. Extensions can also Override it with the `SYSTEM_PROMPT` environment variable. Extensions can also
append fragments to the final system prompt with `addSystemPrompt(...)`. append fragments to the final system prompt with `addSystemPrompt(...)`.
## Durable memory
The harness keeps long-lived memory in simple markdown files:
```text
.harness/memory/
MEMORY.md
USER.md
daily/
YYYY-MM-DD.md
```
The model can choose to use:
- `memory_read` — read `MEMORY.md` or `USER.md`
- `memory_write` — append durable facts or user preferences
- `memory_log` — append chronological notes to today's daily file
The split is intentional:
- `MEMORY.md` — durable project or world facts
- `USER.md` — user preferences and profile
- `daily/` — episodic notes that are useful as chronology rather than permanent truth
These files are plain markdown so they remain inspectable and easy to edit by hand.
That gives us a clean path to later add: That gives us a clean path to later add:
- more providers - more providers
+38
View File
@@ -6,9 +6,11 @@ import { EventBus } from "./events.js";
import { loadExtensions } from "./extensions.js"; import { loadExtensions } from "./extensions.js";
import { ConsoleLogger, ToggleLogger } from "./logger.js"; import { ConsoleLogger, ToggleLogger } from "./logger.js";
import { OpenAIResponsesClient } from "./model/openai.js"; import { OpenAIResponsesClient } from "./model/openai.js";
import { MemoryStore } from "./memory.js";
import { SessionStore } from "./session.js"; import { SessionStore } from "./session.js";
import { Tui } from "./tui.js"; import { Tui } from "./tui.js";
import { builtinTools } from "./tools/builtins.js"; import { builtinTools } from "./tools/builtins.js";
import { memoryTools } from "./tools/memory.js";
import { ToolRegistry } from "./tools/registry.js"; import { ToolRegistry } from "./tools/registry.js";
const initialPrompt = process.argv.slice(2).join(" ").trim(); const initialPrompt = process.argv.slice(2).join(" ").trim();
@@ -23,6 +25,17 @@ const promptFragments = [
"You are a helpful agent. Use tools when they are useful, and answer clearly." "You are a helpful agent. Use tools when they are useful, and answer clearly."
]; ];
const sessionStore = new SessionStore(resolve(cwd, ".harness", "session.json")); const sessionStore = new SessionStore(resolve(cwd, ".harness", "session.json"));
const memoryStore = new MemoryStore(resolve(cwd, ".harness", "memory"));
await memoryStore.ensure();
promptFragments.push(
[
"You have access to durable memory tools.",
"Use memory when a fact is likely to matter across future sessions.",
"Store durable facts in MEMORY.md, user preferences/profile in USER.md,",
"and chronological notes in the daily log.",
"Do not store transient details unless they are likely to be useful later."
].join(" ")
);
for (const event of [ for (const event of [
"tool.registered", "tool.registered",
@@ -51,6 +64,9 @@ for (const event of [
for (const tool of builtinTools()) { for (const tool of builtinTools()) {
await registry.register(tool); await registry.register(tool);
} }
for (const tool of memoryTools(memoryStore)) {
await registry.register(tool);
}
await commands.register({ await commands.register({
name: "help", name: "help",
@@ -119,6 +135,28 @@ await commands.register({
} }
}); });
await commands.register({
name: "memory",
description: "Show memory files and their current contents.",
async execute() {
const paths = memoryStore.getSummaryPaths();
const [memory, user] = await Promise.all([
memoryStore.read("memory"),
memoryStore.read("user")
]);
return [
`MEMORY.md: ${paths.memory}`,
memory.trim(),
"",
`USER.md: ${paths.user}`,
user.trim(),
"",
`daily/: ${paths.dailyDir}`
].join("\n");
}
});
await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments); await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments);
const systemPrompt = promptFragments.join("\n\n"); const systemPrompt = promptFragments.join("\n\n");
+67
View File
@@ -0,0 +1,67 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export type MemoryFile = "memory" | "user";
export class MemoryStore {
private readonly root: string;
private readonly dailyDir: string;
constructor(rootDir: string) {
this.root = rootDir;
this.dailyDir = join(rootDir, "daily");
}
async ensure(): Promise<void> {
await mkdir(this.dailyDir, { recursive: true });
await this.ensureFile(this.pathFor("memory"), "# Memory\n\n");
await this.ensureFile(this.pathFor("user"), "# User\n\n");
}
async read(file: MemoryFile): Promise<string> {
await this.ensure();
return await readFile(this.pathFor(file), "utf8");
}
async append(file: MemoryFile, content: string): Promise<void> {
await this.ensure();
await appendFile(this.pathFor(file), `${content.trim()}\n\n`, "utf8");
}
async appendDaily(content: string, date = new Date()): Promise<string> {
await this.ensure();
const filePath = this.dailyPath(date);
await this.ensureFile(filePath, `# ${this.dayStamp(date)}\n\n`);
await appendFile(filePath, `${content.trim()}\n\n`, "utf8");
return resolve(filePath);
}
getSummaryPaths(): { memory: string; user: string; dailyDir: string } {
return {
memory: resolve(this.pathFor("memory")),
user: resolve(this.pathFor("user")),
dailyDir: resolve(this.dailyDir)
};
}
private pathFor(file: MemoryFile): string {
return join(this.root, file === "memory" ? "MEMORY.md" : "USER.md");
}
private dailyPath(date: Date): string {
return join(this.dailyDir, `${this.dayStamp(date)}.md`);
}
private dayStamp(date: Date): string {
return date.toISOString().slice(0, 10);
}
private async ensureFile(filePath: string, initialContent: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
try {
await readFile(filePath, "utf8");
} catch {
await writeFile(filePath, initialContent, "utf8");
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { ToolDefinition } from "../types.js";
import { MemoryStore } from "../memory.js";
export function memoryTools(store: MemoryStore): ToolDefinition<any>[] {
return [
{
name: "memory_read",
description:
"Read durable memory. Use 'memory' for general durable facts and 'user' for user preferences/profile.",
parameters: {
type: "object",
properties: {
file: {
type: "string",
enum: ["memory", "user"]
}
},
required: ["file"],
additionalProperties: false
},
async execute(input: { file: "memory" | "user" }) {
return await store.read(input.file);
}
},
{
name: "memory_write",
description:
"Append a durable note to memory. Use only for facts likely to matter across future sessions.",
parameters: {
type: "object",
properties: {
file: {
type: "string",
enum: ["memory", "user"]
},
content: {
type: "string"
}
},
required: ["file", "content"],
additionalProperties: false
},
async execute(input: { file: "memory" | "user"; content: string }) {
await store.append(input.file, input.content);
return { ok: true };
}
},
{
name: "memory_log",
description:
"Append an episodic note to today's daily memory log for things useful as chronology rather than durable facts.",
parameters: {
type: "object",
properties: {
content: {
type: "string"
}
},
required: ["content"],
additionalProperties: false
},
async execute(input: { content: string }) {
const path = await store.appendDaily(input.content);
return { ok: true, path };
}
}
];
}