Skip to main content
explanation

Why does a dashboard see four layers of memory instead of one?

Context​

An Ethos agent has multiple layers of state that a human would loosely call "memory." A dashboard builder needs to know which layers exist, which the SDK exposes, and which are safe to touch from outside the agent loop.

The four layers​

1. MEMORY.md — rolling project context​

MEMORY.md is a Markdown file that the agent updates across sessions. It contains accumulated knowledge: project conventions, discovered facts, decisions made. MarkdownFileMemoryProvider reads it at the start of every turn via prefetch() and injects the content into the system prompt.

The SDK exposes it through the memory namespace. memory.get({ store: 'memory', personalityId }) returns the current content. memory.write({ store: 'memory', content, personalityId }) overwrites it entirely. personalityId is required on both: every personality has its own MEMORY.md. A dashboard can display it, let users edit it, and save changes.

The write is safe because the agent loop re-reads the file at prefetch time. A dashboard write between turns is picked up on the next turn. A dashboard write during an active turn has no effect on the in-flight system prompt — the agent already read the file.

2. USER.md — persistent user profile​

USER.md is a Markdown file describing who the user is — preferences, background, communication style. It persists across sessions. Like MEMORY.md, the default copy belongs to one personality — personalities/<id>/USER.md under the markdown backend — so switching personality switches profile. A person the gateway has resolved to a userId also has a profile of their own at users/<userId>/USER.md.

The SDK exposes it identically to MEMORY.md: memory.get({ store: 'user', personalityId }) and memory.write({ store: 'user', content, personalityId }). Add userId to either call to read or write that person's profile instead. The same safety properties apply — writes are picked up at the next prefetch().

A dashboard designed as a "profile editor" reads USER.md, presents it in a textarea, and saves changes. The agent sees the updated profile on its next turn.

3. Session messages — the conversation​

Each session has a sequence of messages stored in the SessionStore (the default implementation is SQLiteSessionStore using WAL mode with FTS5 for full-text search). Messages are the turn-by-turn conversation: user inputs, assistant responses, tool calls, tool results.

The SDK exposes session messages read-only through sessions.get({ id }), which returns the Session metadata plus an array of StoredMessage objects. A dashboard renders these as the chat transcript. For a long session, sessions.messages({ id, before?, turns? }) returns the history one page of whole turns at a time, newest first, with a nextCursor for the next-older page.

A dashboard does not write messages directly. Messages are created by the agent loop during a turn: the user sends text via chat.send, the loop processes it, and messages are persisted as side effects. Injecting messages outside the loop would corrupt the conversation history — the LLM expects a strict alternation of user/assistant/tool_result blocks, and violations cause API errors.

4. SQLite session store — the persistence layer​

SQLiteSessionStore in @ethosagent/session-sqlite is the backing store for sessions and messages. It uses @ethosagent/sqlite (a shim over Node 24's built-in node:sqlite) with STRICT tables, WAL journaling, and FTS5 for search.

The SDK does not expose the store directly. The sessions namespace provides list/get/fork/delete/update operations that go through the web API service layer, which in turn calls the store. A dashboard never connects to the SQLite file.

This is intentional. The store's internal schema (rowid ordering, FTS triggers, WAL checkpointing) is an implementation detail. A dashboard that opened the file directly would risk WAL conflicts with the running server process and would bypass the auth layer entirely.

What is safe to read​

LayerSDK endpointSafe to readNotes
MEMORY.mdmemory.get({ store: 'memory', personalityId })YesReturns current Markdown content
USER.mdmemory.get({ store: 'user', personalityId, userId? })YesThe personality's copy, or the person's when userId is passed
Session messagessessions.get({ id }), sessions.messages({ id, before? })YesWhole transcript, or one page of turns at a time
Session listsessions.list({ q?, limit?, cursor? })YesPaginated, supports FTS5 search

What is safe to write​

LayerSDK endpointSafe to writeNotes
MEMORY.mdmemory.write({ store: 'memory', content, personalityId })YesFull overwrite; picked up at next prefetch
USER.mdmemory.write({ store: 'user', content, personalityId, userId? })YesFull overwrite; picked up at next prefetch
Session titlesessions.update({ id, title })YesMetadata only, does not affect messages
Session messagesNoneNoCreated by the agent loop only

The memory scope dimension​

There is no memory scope setting on a personality. Every memory read and write carries a scope id, and the web API derives it from the call: personality:<personalityId> for both stores, or user:<userId> when store is 'user' and a userId is passed (scopeIdFor in apps/web-api/src/services/memory.service.ts). The markdown backend maps those to personalities/<id>/ and users/<userId>/ under ~/.ethos/ and throws on any other prefix (resolveScopeDir in extensions/memory-markdown/src/index.ts). No setting makes two personalities share a file; memory that crosses personalities is team memory, reached only through the team_memory_* tools.

A dashboard therefore names the personality on every memory.get and memory.write, and re-fetches after the user switches personality. To show what the agent sees about a person, pass that person's userId: a turn carrying one reads USER.md from user:<userId> and, when that file is non-empty, drops the personality's copy from the prompt (packages/core/src/agent-loop/stages/context-assembly.ts). The gateway passes a userId for every sender its identity map resolves; ethos chat passes none. The endpoint builds every path server-side — the dashboard never constructs one.

Vector mode​

The memory config setting in ~/.ethos/config.yaml can be 'markdown', 'vector' or 'vault'. The current SDK surface is markdown-shaped: content is a string, writes are full overwrites. Vector-mode chunk CRUD is deferred to a later version.

A dashboard built today should treat memory content as Markdown text. When vector mode lands, the memory namespace will gain additional endpoints — but the existing get and write operations will remain stable (they are in the @stable v1 tier).