Add file-backed durable memory tools
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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