Guide

Agent memory: where it lives, when it expires, how it is deleted

Most writing about agent memory argues about retrieval quality. The questions that decide whether you can operate the system are duller: which store does the memory live in, what writes to it, when does it expire, and what is left after a delete. This page answers those four for two implementations we ship.

Paths are repo-relative: OpenClaw-Replicate is the agent workbench, GBrain-Plus is the knowledge engine. Every citation is a line you can open.

Three things are called memory. They have different stores.

A context window, a session log and a knowledge base get the same word and almost nothing else in common. Conflating them is how retention questions go unanswered.

The context window: assembled per turn, discarded after

What the model sees is built fresh for each request. OpenClaw concatenates six Markdown components into the system prompt, and each one is cut off at 20,000 characters (OpenClaw-Replicate/backend/graph/prompt_builder.py:7, :13-19). Nothing in this store survives the turn, so it needs no expiry policy: there is nothing to expire.

The practical consequence is that raising the cap is a latency and cost decision, not a memory feature.

Session history: one JSON file per conversation

The transcript is a file. OpenClaw writes it under sessions/ next to the code, one file per session id (OpenClaw-Replicate/backend/graph/session_manager.py:27-28, :33), and the delete endpoint unlinks that file (OpenClaw-Replicate/backend/api/sessions.py:51-55, OpenClaw-Replicate/backend/graph/session_manager.py:118-122).

Deletion here is complete and immediate on the local disk. That says nothing about copies: backups, snapshots and a provider's logs live outside the code path.

Long-term knowledge: a file plus a derived index, or rows in a database

OpenClaw keeps long-term memory as memory/MEMORY.md, injected into the prompt in order (OpenClaw-Replicate/backend/graph/prompt_builder.py:49) or served through a vector index built from it into backend/storage/memory_index (OpenClaw-Replicate/backend/graph/memory_indexer.py:21-22).

GBrain-Plus puts the same idea in SQL. A page row carries the compiled text and a content hash (GBrain-Plus/backend/src/schema.sql:65-80); each page's chunks and embeddings are rows in content_chunks with an HNSW index over the vector (GBrain-Plus/backend/src/schema.sql:108-140); typed links are rows in links (GBrain-Plus/backend/src/schema.sql:229-245); and each rewrite can leave a version row (GBrain-Plus/backend/src/schema.sql:304-310).

When it is written, and what rebuilds it

Saving the memory file rebuilds the index in OpenClaw: the file API calls rebuild_index() when the path is memory/MEMORY.md (OpenClaw-Replicate/backend/api/files.py:57-64). The rebuild re-reads the file, splits it at 256 tokens with 32 overlap, and persists the index (OpenClaw-Replicate/backend/graph/memory_indexer.py:76-81). The same rebuild runs at startup (OpenClaw-Replicate/backend/app.py:27-28).

In GBrain-Plus writes go through operations, and chunks are inserted as rows (GBrain-Plus/backend/src/core/postgres-engine.ts:946). Because chunks and links hang off the page row by foreign key, removing the page row is what removes the derived data.

What the code says

Six excerpts, one per store and per deletion step.

OpenClaw-Replicate/backend/graph/prompt_builder.py7-19
MAX_COMPONENT_LENGTH = 20000


def _read_component(path: Path) -> str:
    """Read a file, truncating if it exceeds MAX_COMPONENT_LENGTH."""
    if not path.exists():
        return ""
    content = safe_read_text(path)
    if len(content) > MAX_COMPONENT_LENGTH:
        content = content[:MAX_COMPONENT_LENGTH] + "\n...[truncated]"
    return content

The context window is assembled from files and bounded by a constant. Nothing here is stored, so there is no deletion step: the turn ends and the buffer is gone.

OpenClaw-Replicate/backend/graph/session_manager.py27-28
        self._sessions_dir = base_dir / "sessions"
        self._sessions_dir.mkdir(exist_ok=True)

Session history is a directory of JSON files named after sanitized ids (:33). Deleting a conversation is unlinking one file (:118-122), which is complete for this store and says nothing about backups.

OpenClaw-Replicate/backend/graph/memory_indexer.py43-49
    def _maybe_rebuild(self) -> None:
        """Rebuild index if MEMORY.md has changed."""
        current_hash = self._get_file_hash()
        stored_hash = self._get_stored_hash()
        if current_hash and current_hash != stored_hash:
            self.rebuild_index()

The rebuild only fires when the current hash is non-empty. Edit or truncate MEMORY.md and the index is rebuilt from the remaining text; delete the file and this branch is skipped.

OpenClaw-Replicate/backend/graph/memory_indexer.py95-101
    def _load_index(self) -> Any:
        """Load persisted index from storage."""
        if self._index is not None:
            return self._index

        if not self._storage_dir.exists() or not any(self._storage_dir.iterdir()):
            return None

When the in-memory index is missing, retrieval loads whatever is persisted, provided the directory is not empty (:113). That is why deleting MEMORY.md without deleting storage leaves the old content answerable.

GBrain-Plus/backend/src/core/operations.ts656-666
const delete_page: Operation = {
  name: 'delete_page',
  description: 'Soft-delete a page. The row is hidden from search and from get_page/list_pages, but is recoverable via restore_page within 72h. The autopilot purge phase hard-deletes after the recovery window. Pass include_deleted: true to get_page to verify the soft-delete landed.',
  params: {
    slug: { type: 'string', required: true },
  },
  mutating: true,
  scope: 'write',
  handler: async (ctx, p) => {
    const slug = p.slug as string;
    if (ctx.dryRun) return { dry_run: true, action: 'soft_delete_page', slug };

Deleting a page is a state change, not a removal. The row stays, search hides it, and the documented recovery window is 72 hours.

GBrain-Plus/backend/src/core/postgres-engine.ts483-497
  async purgeDeletedPages(olderThanHours: number): Promise<{ slugs: string[]; count: number }> {
    const sql = this.sql;
    // Clamp to non-negative integer; runaway purge protection. The DELETE
    // cascades through content_chunks, page_links, chunk_relations via FKs.
    const hours = Math.max(0, Math.floor(olderThanHours));
    const rows = await sql`
      DELETE FROM pages
      WHERE deleted_at IS NOT NULL
        AND deleted_at < now() - (${hours} || ' hours')::interval
      RETURNING slug
    `;
    const slugs = rows.map((r) => r.slug as string);
    return { slugs, count: slugs.length };
  }

The real removal is a separate sweep with a cutoff, and the foreign keys carry the chunks and links with it. Until that sweep runs, the derived rows still exist.

When you delete, what is actually gone

Editing the memory file works. Deleting it does not.

The rebuild is conditional on a non-empty hash of the current file (OpenClaw-Replicate/backend/graph/memory_indexer.py:43-49). Truncate MEMORY.md and the hash changes, so the index is rebuilt from what remains. Delete the file and the hash is empty, the rebuild is skipped, and retrieval falls back to the index already persisted on disk (OpenClaw-Replicate/backend/graph/memory_indexer.py:95-113, :118-125). The old content remains answerable until backend/storage/memory_index is removed as well.

GBrain-Plus deletes in two steps, and the first one is reversible.

delete_page is a soft delete: the operation documents a 72-hour recovery window (GBrain-Plus/backend/src/core/operations.ts:656-666), and search filters on the column (GBrain-Plus/backend/src/schema.sql:82-88). The hard delete is a separate purge that removes rows past the cutoff and cascades into chunks and links through the foreign keys (GBrain-Plus/backend/src/core/postgres-engine.ts:483-497, GBrain-Plus/backend/src/core/operations.ts:713-721). If your retention policy promises 24 hours, this one is 72 hours plus whenever the purge actually runs.

Sessions are the simple case.

Unlinking the JSON file removes the conversation from the application's own store (OpenClaw-Replicate/backend/graph/session_manager.py:118-122). Anything you copied elsewhere is outside that promise, which is the normal shape of a deletion guarantee.

Memory is personal data, so decide what not to keep

  • Do not persist what you cannot delete on request. The file-versus-index gap above is the general shape of the problem: a derived store outlives its source unless the deletion procedure names it. Write the procedure down before you write the memory.
  • Chat history is the riskiest store. Sessions are plain JSON on disk, with no encryption in the code path (OpenClaw-Replicate/backend/graph/session_manager.py:12-20, :55-62). File permissions and full-disk encryption are the controls.
  • Long-term memory is reachable by the agent's own tools. The same process can run a shell and read files, with a command blacklist rather than a sandbox (OpenClaw-Replicate/backend/tools/terminal_tool.py:12-24). Anything in memory is readable by a prompt that can drive those tools, so keep credentials out of memory files entirely.
  • Set a retention window per store, not for the product. The three stores have three lifetimes: one turn, until you delete, and until you delete plus the recovery window. One retention sentence cannot cover all three.

If you cannot name the store and the deletion procedure for each, the memory design is not finished.

See the implementations

Both stores described here ship as full source: the agent workbench and the knowledge engine.