diff --git a/README.md b/README.md index 5488f31..ec8ad73 100644 --- a/README.md +++ b/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 diff --git a/src/index.ts b/src/index.ts index af5b087..3ee74d9 100644 --- a/src/index.ts +++ b/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"); diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..3186ccb --- /dev/null +++ b/src/memory.ts @@ -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 { + 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 { + await this.ensure(); + return await readFile(this.pathFor(file), "utf8"); + } + + async append(file: MemoryFile, content: string): Promise { + await this.ensure(); + await appendFile(this.pathFor(file), `${content.trim()}\n\n`, "utf8"); + } + + async appendDaily(content: string, date = new Date()): Promise { + 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 { + await mkdir(dirname(filePath), { recursive: true }); + try { + await readFile(filePath, "utf8"); + } catch { + await writeFile(filePath, initialContent, "utf8"); + } + } +} diff --git a/src/tools/memory.ts b/src/tools/memory.ts new file mode 100644 index 0000000..2168cab --- /dev/null +++ b/src/tools/memory.ts @@ -0,0 +1,68 @@ +import type { ToolDefinition } from "../types.js"; +import { MemoryStore } from "../memory.js"; + +export function memoryTools(store: MemoryStore): ToolDefinition[] { + 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 }; + } + } + ]; +}