Memory

Give agents context across turns: recent, summarized, or semantic.

What it is

Memory is how an agent keeps useful context across turns. In Phero, memory is a pluggable component: the agent can retrieve prior messages before each turn and save the new messages after each turn.

Different implementations provide different behavior:

Simple FIFO memory (in-process)

memory/simple stores recent llm.Message values in a fixed-size FIFO ring buffer. When the capacity is reached, older messages roll off.

import memory "github.com/henomis/phero/v2/memory/simple"

conversationMemory := memory.New(20)

a.SetMemory(conversationMemory)

This is the default choice for REPL-style conversational agents.

Snapshots: a summary that stands for the oldest messages

A memory that implements memory.SnapshotStore keeps a log of messages plus a stored memory.Snapshot: a summary (and a few pinned messages, such as the task) that stands for the oldest messages. Retrieve then returns the summary followed by the messages after it, so a long conversation stays short without losing its log. memory/simple, memory/jsonfile, memory/psql and memory/nats implement it; the agent's compaction uses it.

snap, tail, err := store.LoadSnapshot(ctx) // latest snapshot (or nil) and the messages after it, with cursors
// ... summarize, then cover everything up to a message:
err = store.SaveSnapshot(ctx, memory.Snapshot{Summary: text}, tail[len(tail)-5].Cursor, snap)

JSON file memory (persistent local state)

memory/jsonfile stores the full conversation in a JSON file on disk. The file path acts as the session identifier, so restarting the process and pointing at the same file resumes the same history.

import memory "github.com/henomis/phero/v2/memory/jsonfile"

conversationMemory, err := memory.New("memory.json")
if err != nil {
    panic(err)
}

a.SetMemory(conversationMemory)

This is a good fit for local assistants or skill workflows that need persistence without introducing a database. It keeps snapshots too: the file stays a plain JSON array until the first snapshot, then holds the messages and the snapshot (older versions of the file are still read), and jsonfile.WithPruneCompacted(true) rewrites it without the messages a snapshot covers.

For an app that keeps one file per session, SetMeta(key, value) stores metadata in the file, such as the session's title (an empty value removes the key; Meta() reads it back, and Clear removes it). jsonfile.Inspect(path) reads a file without opening it as a memory, to list sessions: the number of messages ever saved, when the file was last written, whether it holds a snapshot, and its metadata. Keep what a list shows, such as a title from the first prompt, in the metadata: it outlives pruning.

_ = mem.SetMeta("title", "fix the failing test")

files, _ := filepath.Glob(filepath.Join(sessionsDir, "*.json"))
for _, f := range files {
    info, err := memory.Inspect(f)
    if err != nil {
        continue // not a memory file
    }
    fmt.Printf("%s  %d messages  %s  %s\n", filepath.Base(f), info.Messages,
        info.UpdatedAt.Format(time.DateTime), info.Meta["title"])
}

Summarization: use the agent's compaction

To keep a long conversation short, let the agent summarize it: agent.SetCompaction compacts by tokens, during the run, and stores the summary as a snapshot in any of these memories, so the log is never rewritten and the next run starts from the summary.

conversationMemory := memory.New(200)

a.SetMemory(conversationMemory)
a.SetCompaction(agent.NewSummaryCompactor(llmClient, agent.WithSummaryKeepRecent(20)), 150_000)

Deprecated: the backends' own WithSummarization(llm, threshold, size) still works as before, but it counts messages rather than tokens, calls the LLM inside Save, and replaces the oldest size messages with a summary system message, rewriting the stored history. It can't be combined with snapshots (memory.ErrSnapshotsUnsupported). The size must be below the threshold: a larger one is lowered to threshold - 1.

Semantic memory (RAG-backed)

For long-term recall, you can back memory with a RAG store: messages are embedded and stored in a vector store, then retrieved by similarity search at query time.

The adapter lives at memory/rag and wraps a rag.RAG instance.

import (
    ragmemory "github.com/henomis/phero/v2/memory/rag"
    "github.com/henomis/phero/v2/rag"
)

ragEngine, _ := rag.New(store, embedder)

conversationMemory := ragmemory.New(ragEngine)

a.SetMemory(conversationMemory)

This is used in the Long-Term Memory example (RAG + Qdrant).

NATS JetStream KV memory (persistent, network-accessible)

memory/nats stores conversation history in a NATS JetStream Key-Value bucket. Each session is a single KV key; the value is a JSON-encoded []llm.Message. Because JetStream persists bucket data to disk, memory survives process restarts automatically.

Multiple sessions can coexist in the same bucket by using different session IDs — useful for running independent conversations side-by-side without separate databases.

Several processes can also share one session, for example replicas of the same agent. Save writes only if the key has not changed since it was read; if another process wrote first, it reloads the history and tries again, so no messages are overwritten. If the session keeps changing on every attempt, Save gives up with ErrConcurrentUpdate and stores nothing.

Size limit. A session is stored as one value, so it must fit in one NATS message: the server's max_payload (1MB by default), or the bucket's MaxValueSize if that is smaller. Images are stored base64-encoded, so a few screenshots can be enough. Past the limit, Save returns ErrSessionTooLarge (a *SessionTooLargeError with the session and its size). Through an agent, this shows up as agent.ErrSessionSaveFailed: the answer is still returned, but the turn is not stored, and later turns will not be either. To stay under it, use the agent's compaction, whose snapshots drop the messages they cover (and their images); start a new session or Clear this one; raise max_payload on the NATS server; or, for long sessions with many images, use memory/psql, which stores each message as its own row.

import (
    "os"

    "github.com/nats-io/nats.go"

    natsmemory "github.com/henomis/phero/v2/memory/nats"
)

nc, _ := nats.Connect(os.Getenv("NATS_URL"))
js, _ := nc.JetStream()
kv, _ := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "phero_memory"})

conversationMemory, err := natsmemory.New(kv, "session-123")
if err != nil {
    // handle error
}

a.SetMemory(conversationMemory)

Start a local NATS server with JetStream enabled:

docker run --rm -p 4222:4222 nats -js

The full walkthrough is in the NATS Memory example, which demonstrates session resumption across two process runs.

A session is one KV value, so it must fit in one NATS message (1 MB by default). It keeps snapshots, written with the same compare-and-swap as Save, and saving one drops the messages it covers by default, so a long session stays under the limit; natsmemory.WithPruneCompacted(false) keeps them.

PostgreSQL conversation memory (persistent)

If you want conversation history to survive process restarts (or be shared across replicas), use memory/psql. It's a session-scoped store backed by PostgreSQL (JSONB rows) that keeps snapshots, as memory/simple does.

import (
    "database/sql"
    "os"

    _ "github.com/jackc/pgx/v5/stdlib"

    "github.com/henomis/phero/v2/memory/psql"
)

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
    // handle error
}

conversationMemory, err := psql.New(db, "session-123")
if err != nil {
    // handle error
}

a.SetMemory(conversationMemory)

By default the store auto-creates its table/index (you can disable that with psql.WithEnsureSchema(false)).

It keeps snapshots in a second table named after the first, with _snapshots appended: one row per session, whose cursor is the seq of the last message it covers. SaveSnapshot compares and swaps in SQL, so two processes on the same session cannot overwrite each other's snapshot, and psql.WithPruneCompacted(true) deletes the covered rows in the same transaction. With WithEnsureSchema(false), create it yourself:

CREATE TABLE IF NOT EXISTS conversation_memory_snapshots (
    session_id text        PRIMARY KEY,
    upto_seq   bigint      NOT NULL,
    snapshot   jsonb       NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

Run an example

The conversational agent example uses simple FIFO memory, with an option to enable the agent's compaction.

# from repo root

go run ./examples/conversational-agent

# with summarization enabled

go run ./examples/conversational-agent -summarize -compact-at 2000 -keep-recent 6 -max-messages 20

The skills example uses local JSON-backed memory:

cd ./examples/skills
go run .

For NATS-backed persistent memory (start NATS first):

docker run --rm -p 4222:4222 nats -js

go run ./examples/nats-memory -session my-session

For semantic recall, try the long-term memory example (requires Qdrant):

go run ./examples/long-term-memory -qdrant-host localhost -qdrant-collection long_term_memory

Picking the right memory

Related packages