Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/agent-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ The full model story — dialects, keys, local endpoints, retries — is [Models

```yaml
memory:
scope: thread # default: none
scope: thread # default: none
persistent: true # default: false
```

`none` (the default) starts every run fresh. `thread` persists conversation history per conversation key — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session — so follow-ups work ("make it weekly instead"). History lives in the agent's own SQLite file, nowhere else.
`none` (the default) starts every run fresh. `thread` persists conversation history per conversation key — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session — so follow-ups work ("make it weekly instead"). `persistent: true` gives the agent `remember`/`recall`/`list_memories`/`forget` tools — facts that survive across conversation keys and container restarts, not just one thread's transcript. Both live in the agent's own SQLite file, nowhere else, and compose freely. The full story, including what the model sees in its system prompt and how it's audited, is in [Memory](memory.md).

## Limits

Expand Down Expand Up @@ -115,6 +116,7 @@ The `env:` block grants environment variables to tools and MCP servers — and o
- **`skills:`** — markdown files that teach the agent how to use something well; capability stays with the config. → [Skills](skills.md)
- **`tools:`** — capability beyond the natives: MCP servers, and tool search to keep their schemas out of context. → [Tools](tools.md)
- **`permissions:`** — deny-by-default allowlists for hosts, executables, and paths. Omit the block and the agent can touch nothing. → [Permissions](permissions.md)
- **`memory:`** — conversation history (`scope`) and facts that survive across conversations and restarts (`persistent`). → [Memory](memory.md)

## Validating

Expand Down
3 changes: 2 additions & 1 deletion docs/docker-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ curl -s 127.0.0.1:55031/healthz | jq # the addr af ps printed
Each agent owns one SQLite file: `/data/<handle>.db` in the container, or wherever `AF_DATA_DIR` points (locally it defaults to `.looped/`). It holds:

- **sessions/messages** - the conversation history per conversation key (when `memory.scope: thread`)
- **memories** - facts the agent chose to remember, keyed by name, visible across every conversation key (when `memory.persistent: true`) — see [Memory](memory.md)
- **runs** - every run, with its trigger, input, status, steps, tokens and timestamps
- **audit** - every permission decision, allowed and denied
- **audit** - every permission decision, allowed and denied, plus every memory write and delete
- **identity** - the name the agent chose on first boot

This means the agent's full history sits in one file you can query: everything the agent did, including the actions its permissions denied. Persist the volume; with a fresh one the agent starts over and names itself again.
Expand Down
67 changes: 67 additions & 0 deletions docs/memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
title: "Memory"
description: "Two independent kinds of memory: thread history that replays a conversation, and persistent memory that survives across conversations and restarts."
---

The `memory:` block controls two independent things: whether a conversation's history replays on the next message in that thread, and whether the agent can save facts that outlive any single conversation.

```yaml
memory:
scope: thread # default: none
persistent: true # default: false
```

Both default off. An agent with no `memory:` block starts every run with a blank slate — no history, no remembered facts — which is the right choice for a stateless webhook handler that shouldn't accumulate anything between calls.

## Thread history: `scope`

`scope: thread` persists the message transcript per *conversation key* — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session. On the next event in the same conversation, the full prior transcript loads back in, so follow-ups work ("make it weekly instead" refers to what was just discussed). `scope: none` (the default) starts every run fresh, even within what a human would call the same conversation.

This is the entire transcript, replayed verbatim — expensive in context, but complete. It answers "what did we just say to each other," not "what do you know about me."

## Persistent memory: `persistent`

`persistent: true` gives the agent four tools, backed by its own SQLite file:

| Tool | Effect |
| --- | --- |
| `remember` | Save or update a fact under a key. Overwrites any existing value for that key. |
| `recall` | Read one fact back by key. |
| `list_memories` | List every key and value currently held. |
| `forget` | Delete a fact by key. |

Unlike thread history, these facts are keyed by nothing but the agent itself — they're visible from *every* conversation key, and they survive a run that starts with `scope: none`. This is where an agent puts a user's stated preference ("always deploy to us-east"), a fact it was told once and shouldn't need repeating ("the on-call rotation is in #incidents"), or a note to its future self about long-running work ("waiting on PR #204 to merge before continuing the migration"). Thread history can't do this: it's scoped to one conversation key, and it's a full transcript rather than a distilled fact.

The agent decides what's worth remembering — there's no automatic extraction from the conversation. A `purpose` that expects the agent to retain user preferences should say so explicitly, the same way it would spell out any other expected behavior.

### What the model sees

Reading every remembered fact into every system prompt would burn context as memories accumulate, so persistent memory follows the same progressive-disclosure shape as [skills](skills.md#progressive-disclosure): the system prompt carries only the *keys* currently held —

```
You have persistent memory — facts and preferences that survive across
conversations and restarts. Use recall to read one, list_memories to browse,
remember to save or update one, forget to delete one. Keys you already have:
- deploy_region
- oncall_channel
```

— and the agent spends a `recall` or `list_memories` call to pull the value into context only when a turn actually needs it. An agent with fifty memories costs fifty short lines until it reads one.

### Where it lives, and its boundaries

Memories live in the `memories` table of the agent's own SQLite file, alongside sessions, runs, audit and identity — the same file described in [Persistence: the data volume](docker-run.md#persistence-the-data-volume). This keeps memory agent-local, consistent with one agent doing one job: there is no mechanism for one agent to read another's memories, and a fresh data volume clears memory exactly the way it clears identity and history.

Every `remember` and `forget` call lands in the [audit trail](docker-run.md#persistence-the-data-volume) as a `memory` event (`{ action: "remember" | "forget", key }`), visible at `GET /audit` alongside permission decisions. `recall` and `list_memories` are read-only and aren't audited, the same way an allowed `read_file` isn't.

## Combining both

`scope` and `persistent` compose freely — they answer different questions:

```yaml
memory:
scope: thread # replay this conversation's transcript
persistent: true # and carry facts across every conversation
```

A support-bot agent might run `scope: thread` alone (each ticket thread needs its own context, nothing more), while a personal-assistant agent typically wants both: thread history for the back-and-forth of the current request, persistent memory for "she prefers window seats" to still be true next month, in a different channel.
1 change: 1 addition & 0 deletions docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"skills",
"tools",
"permissions",
"memory",
"---Models---",
"models"
]
Expand Down
6 changes: 6 additions & 0 deletions packages/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ const MemoryConfigSchema = z.strictObject({
scope: z.enum(["thread", "none"]).default("none").describe(
"thread: conversation history persists per conversation key (chat channel or thread, webhook conversation_id). none: every run starts fresh.",
),
persistent: z.boolean().default(false).describe(
"Give the agent remember/recall/list_memories/forget tools backed by its own SQLite file — " +
"facts and preferences that survive across conversation keys and restarts, not just one thread's history.",
),
}).describe("What the agent remembers between events.");

const LimitsSchema = z.strictObject({
Expand Down Expand Up @@ -352,6 +356,8 @@ export interface Permissions {
export interface MemoryConfig {
/** thread: conversation history persists per conversation key. none: every run starts fresh. */
scope: "thread" | "none";
/** Give the agent remember/recall/list_memories/forget tools, backed by its own SQLite file. */
persistent: boolean;
}

/** Per-run budgets. */
Expand Down
17 changes: 15 additions & 2 deletions packages/core/runtime/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { runAgent, type RunResult } from "../loop/loop.ts";
import { Store } from "../store/store.ts";
import { type AgentIdentity, ensureIdentity, identityNote } from "./identity.ts";
import { createSkillTool, loadSkills, type Skill, skillsPromptSection } from "../skills/skills.ts";
import { createMemoryTools, type MemoryEvent, memoryPromptSection } from "../tools/memory.ts";
import { connectMcpServers, type McpConnections } from "../tools/mcp.ts";
import { SEARCH_AUTO_THRESHOLD, ToolRegistry } from "../tools/registry.ts";

Expand Down Expand Up @@ -88,9 +89,15 @@ export class AgentService {
* Tools follow the permissions (minimalism: a tool the agent can't use
* doesn't exist for it — no dead schemas burning a small model's context).
*/
#buildTools(engine: PermissionEngine): () => NativeTool[] {
#buildTools(
engine: PermissionEngine,
onMemoryEvent: (event: MemoryEvent) => void,
): () => NativeTool[] {
const always: NativeTool[] = [currentTimeTool, ...this.#extraTools];
if (this.#skills?.length) always.push(createSkillTool(this.#skills));
if (this.config.memory?.persistent) {
always.push(...createMemoryTools(this.store, onMemoryEvent));
}
if (this.config.permissions?.run?.length) {
always.push(createRunBashTool({ permissions: engine, env: this.#env }));
}
Expand Down Expand Up @@ -139,20 +146,23 @@ export class AgentService {
const engine = new PermissionEngine(this.config.permissions, (e) => {
decisions.push(e.decision);
});
const memoryEvents: MemoryEvent[] = [];

const useSessions = this.config.memory?.scope === "thread" && event.conversationKey;
const sessionId = useSessions ? this.store.sessionFor(event.conversationKey!) : undefined;
const history = sessionId !== undefined ? this.store.loadMessages(sessionId) : [];
const memories = this.config.memory?.persistent ? this.store.listMemories() : [];

const result = await runAgent({
config: {
...this.config,
purpose: this.config.purpose +
skillsPromptSection(this.#skills ?? []) +
memoryPromptSection(memories) +
identityNote(this.config, identity.name),
},
provider: this.#provider,
tools: this.#buildTools(engine),
tools: this.#buildTools(engine, (e) => memoryEvents.push(e)),
input: event.input,
history,
});
Expand All @@ -171,6 +181,9 @@ export class AgentService {
for (const decision of decisions) {
this.store.recordAudit({ runId, kind: "permission", detail: decision });
}
for (const memoryEvent of memoryEvents) {
this.store.recordAudit({ runId, kind: "memory", detail: memoryEvent });
}
return result;
}

Expand Down
62 changes: 62 additions & 0 deletions packages/core/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,26 @@ CREATE TABLE IF NOT EXISTS identity (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`;

/** One remembered fact, as written to the memories table. */
export interface MemoryRecord {
/** The key the agent chose to file this fact under. */
key: string;
/** The fact itself. */
value: string;
/** ISO timestamp of first write. */
createdAt: string;
/** ISO timestamp of the most recent write. */
updatedAt: string;
}

/** One completed run, as written to the runs table. */
export interface RunRecord {
/** Session the run belonged to; absent for sessionless runs. */
Expand Down Expand Up @@ -186,4 +204,48 @@ export class Store {
)
.run(key, value);
}

/**
* Persist a memory, overwriting any previous value under the same key.
* This is the agent's cross-conversation, cross-restart memory (M37) —
* distinct from thread history, which lives in sessions/messages.
*/
rememberMemory(key: string, value: string) {
this.#db
.prepare(
`INSERT INTO memories (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
)
.run(key, value);
}

/** Recall one memory by key. */
recallMemory(key: string): MemoryRecord | undefined {
const row = this.#db
.prepare("SELECT key, value, created_at, updated_at FROM memories WHERE key = ?")
.get(key) as
| { key: string; value: string; created_at: string; updated_at: string }
| undefined;
if (!row) return undefined;
return { key: row.key, value: row.value, createdAt: row.created_at, updatedAt: row.updated_at };
}

/** All memories, most recently updated first. */
listMemories(): MemoryRecord[] {
const rows = this.#db
.prepare("SELECT key, value, created_at, updated_at FROM memories ORDER BY updated_at DESC")
.all() as { key: string; value: string; created_at: string; updated_at: string }[];
return rows.map((r) => ({
key: r.key,
value: r.value,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}

/** Delete a memory; returns true if a row was removed. */
forgetMemory(key: string): boolean {
const result = this.#db.prepare("DELETE FROM memories WHERE key = ?").run(key);
return result.changes > 0;
}
}
15 changes: 15 additions & 0 deletions packages/core/store/store_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ Deno.test("identity persists (the agent's self-chosen name lives here)", () => {
assertEquals(store.getIdentity("name"), "Ada II");
store.close();
});

Deno.test("memories persist across keys, independent of session history", () => {
const store = tempStore();
assertEquals(store.recallMemory("favorite_color"), undefined);
store.rememberMemory("favorite_color", "blue");
assertEquals(store.recallMemory("favorite_color")?.value, "blue");
store.rememberMemory("favorite_color", "green");
assertEquals(store.recallMemory("favorite_color")?.value, "green");
store.rememberMemory("timezone", "UTC");
assertEquals(store.listMemories().length, 2);
assert(store.forgetMemory("timezone"));
assertEquals(store.forgetMemory("timezone"), false);
assertEquals(store.listMemories().length, 1);
store.close();
});
92 changes: 92 additions & 0 deletions packages/core/tools/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { z } from "zod";
import type { MemoryRecord, Store } from "../store/store.ts";
import { defineTool, type NativeTool } from "./types.ts";

const MAX_LIST_CHARS = 4_000;

/**
* Progressive disclosure, mirroring skills: the system prompt carries a
* cheap index of what the agent already remembers, so a small model knows
* to check before asking the operator to repeat themselves.
*/
export function memoryPromptSection(memories: MemoryRecord[]): string {
if (!memories.length) return "";
const lines = memories.map((m) => `- ${m.key}`).join("\n");
return `\n\nYou have persistent memory — facts and preferences that survive across ` +
`conversations and restarts. Use recall to read one, list_memories to browse, remember ` +
`to save or update one, forget to delete one. Keys you already have:\n${lines}`;
}

/** A memory write or delete, for the audit trail. */
export interface MemoryEvent {
/** Which tool caused the event. */
action: "remember" | "forget";
/** The key affected. */
key: string;
}

/**
* Build the remember/recall/list_memories/forget native tools over an
* agent's own store. `onEvent`, when given, fires on every write/delete so
* the caller can land it in the audit trail (writes only — recall and
* list_memories are read-only and don't need auditing).
*/
export function createMemoryTools(
store: Store,
onEvent?: (event: MemoryEvent) => void,
): NativeTool[] {
const remember = defineTool({
name: "remember",
description:
"Save or update a fact under a key, persisted across conversations and restarts. " +
"Overwrites any existing value for the same key.",
schema: z.strictObject({ key: z.string().min(1), value: z.string().min(1) }),
readOnly: false,
execute({ key, value }) {
store.rememberMemory(key, value);
onEvent?.({ action: "remember", key });
return `remembered ${key}`;
},
});

const recall = defineTool({
name: "recall",
description: "Read a previously remembered fact by key.",
schema: z.strictObject({ key: z.string().min(1) }),
readOnly: true,
execute({ key }) {
const memory = store.recallMemory(key);
if (!memory) return `no memory under key: ${key}`;
return memory.value;
},
});

const listMemories = defineTool({
name: "list_memories",
description: "List all remembered keys and values.",
schema: z.strictObject({}),
readOnly: true,
execute() {
const memories = store.listMemories();
if (!memories.length) return "no memories yet";
const text = memories.map((m) => `${m.key}: ${m.value}`).join("\n");
return text.length > MAX_LIST_CHARS
? text.slice(0, MAX_LIST_CHARS) + `\n[truncated at ${MAX_LIST_CHARS} chars]`
: text;
},
});

const forget = defineTool({
name: "forget",
description: "Delete a remembered fact by key.",
schema: z.strictObject({ key: z.string().min(1) }),
readOnly: false,
execute({ key }) {
const removed = store.forgetMemory(key);
if (removed) onEvent?.({ action: "forget", key });
return removed ? `forgot ${key}` : `no memory under key: ${key}`;
},
});

return [remember, recall, listMemories, forget];
}
Loading
Loading