Add file-backed durable memory tools
This commit is contained in:
29
README.md
29
README.md
@@ -7,10 +7,12 @@ keep the core small, make the seams obvious, and add capability through tools.
|
||||
|
||||
- `src/agent.ts` — the agent loop
|
||||
- `src/model/openai.ts` — the OpenAI Responses API adapter
|
||||
- `src/memory.ts` — file-backed durable memory
|
||||
- `src/events.ts` — typed lifecycle events
|
||||
- `src/commands.ts` — slash-command registry
|
||||
- `src/tools/registry.ts` — tool registration and lookup
|
||||
- `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/logger.ts` — ordered lifecycle logging
|
||||
- `extensions/example.ts` — an example extension that registers `echo`
|
||||
@@ -108,6 +110,7 @@ The core provides:
|
||||
- `/reset` — clear the current conversation history
|
||||
- `/save` — persist the current conversation immediately
|
||||
- `/logs [on|off]` — toggle lifecycle traces
|
||||
- `/memory` — inspect durable memory files
|
||||
- `/exit` — leave the interactive session
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
- more providers
|
||||
|
||||
38
src/index.ts
38
src/index.ts
@@ -6,9 +6,11 @@ import { EventBus } from "./events.js";
|
||||
import { loadExtensions } from "./extensions.js";
|
||||
import { ConsoleLogger, ToggleLogger } from "./logger.js";
|
||||
import { OpenAIResponsesClient } from "./model/openai.js";
|
||||
import { MemoryStore } from "./memory.js";
|
||||
import { SessionStore } from "./session.js";
|
||||
import { Tui } from "./tui.js";
|
||||
import { builtinTools } from "./tools/builtins.js";
|
||||
import { memoryTools } from "./tools/memory.js";
|
||||
import { ToolRegistry } from "./tools/registry.js";
|
||||
|
||||
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."
|
||||
];
|
||||
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 [
|
||||
"tool.registered",
|
||||
@@ -51,6 +64,9 @@ for (const event of [
|
||||
for (const tool of builtinTools()) {
|
||||
await registry.register(tool);
|
||||
}
|
||||
for (const tool of memoryTools(memoryStore)) {
|
||||
await registry.register(tool);
|
||||
}
|
||||
|
||||
await commands.register({
|
||||
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);
|
||||
|
||||
const systemPrompt = promptFragments.join("\n\n");
|
||||
|
||||
67
src/memory.ts
Normal file
67
src/memory.ts
Normal 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
src/tools/memory.ts
Normal file
68
src/tools/memory.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user