agentmemory

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 14 Imported by: 2

README

agentmemory

Memory for Go agents over Open Responses: what the agent keeps between sessions about the user, the project and its own past work, in a scoped store with an append-only journal, changed only through tools built with agenttool, and rendered into the instructions with a manifest of what the model was shown. A product wires the block and the tools into its agentturn config; the loop is never imported here.

  • The root module depends on openresponses, agenttool and the standard library.
  • filestore is the reference store: one Markdown file per entry, which a person can open, edit, diff and commit. It imports the root package and the standard library.
  • sqlite is a nested module on modernc.org/sqlite with full-text search.
  • storetest is the conformance suite every store passes.

Install

go get github.com/ChristopherDavenport/agentmemory
go get github.com/ChristopherDavenport/agentmemory/sqlite   # optional

Go 1.25 or later.

The shape

An Entry is one remembered thing: a kebab-case Name unique within a Scope (products define the scopes; user, project and session are the conventional three), Markdown Content bounded by the store's limit (4 KiB by default), and Meta such as a description. A Store holds entries and a journal of every change:

type Store interface {
	Get(ctx context.Context, scope Scope, name string) (*Entry, error)
	List(ctx context.Context, scope Scope) ([]Entry, error)
	Put(ctx context.Context, e Entry, opts ...PutOption) error
	Forget(ctx context.Context, scope Scope, name string) error
	Search(ctx context.Context, scopes []Scope, query string, limit int) ([]Entry, error)
	Journal(ctx context.Context, after uint64) iter.Seq2[Change, error]
	MaxEntryBytes() int
}

Put creates or replaces the whole entry and is the store's only write, so the journal is a list of full states, each with its hash. IfHash(h) makes a Put conditional on the stored hash, "" meaning the entry must not exist, so a write built on a stale read fails with ErrConflict instead of clobbering. Forget writes a tombstone that keeps the last content. Every Change carries a Seq that orders the journal within the store, the Prev hash it replaced, and the Session that wrote it, from WithSession(ctx, id), so a reader can chain records and see a fork. Content over the bound is refused with a SizeError naming the attempted size, the limit and what the entry holds now.

Wiring

mem, err := filestore.Open(filepath.Join(home, "memory"))
scopes := []agentmemory.Scope{"user", "project"}

block, manifest, err := agentmemory.Render(ctx, mem, scopes)
cfg := agentturn.Config{
	Model:        client,
	Instructions: prompt + "\n\n" + block + "\n\n" + agentmemory.Usage(),
	Tools:        append(tools, agentmemory.Tools(mem, scopes)...),
	// The freshest state each turn. Not a Transform: a Transform
	// cannot reach the instructions and what it injects is not
	// recorded.
	BeforeModelCall: func(ctx context.Context, req *openresponses.Request) error {
		b, m, err := agentmemory.Render(ctx, mem, scopes)
		if err != nil {
			return err
		}
		req.Instructions = prompt + "\n\n" + b + "\n\n" + agentmemory.Usage()
		record(m) // into a custom session entry under agentmemory:render
		return nil
	},
}
ctx = agentmemory.WithSession(ctx, sessionID) // attributes the journal

Render produces the block: a header with the counts and the budget, one section per scope, one heading per entry with its size and the limit and its description, then the content verbatim. Entries are included in list order until the next would take the content total over MaxTotalBytes (32 KiB by default); the rest are listed under their scope so the model knows what memory_search can fetch. The output is determined by the store's state and the bounds alone, so an unchanged store renders the same bytes, and the Manifest lists what the block held and omitted, by scope, name, hash and size, for the session's provenance.

# Memory

Entries: 2 shown, 0 omitted. Used: 41 of 32768 bytes (32727 free). Entry limit: 4096 bytes.

## user

### style (28 of 4096 bytes) — How the user likes answers

Short answers.

Code in Go.

### timezone (13 of 4096 bytes)

Europe/London

The tools

Tools(store, scopes) returns four tools restricted to the scopes the product allows; a scope outside the list is an error the model sees, and a call that omits the scope uses the first.

tool arguments does
memory_save scope, name, content, meta create, or replace whole
memory_patch scope, name, old_text, new_text replace one exact occurrence
memory_forget scope, name write a tombstone
memory_search query, scopes, limit find entries the block omits

memory_patch is the tool the model is told to prefer for an edit: the call is the size of the change, and the edit is anchored in the stored text, so an edit whose anchor another session removed fails out loud and two sessions patching one entry keep both edits; the tool reads, patches and puts with IfHash, retrying on a conflict. memory_search returns five entries and 16 KiB by default, because a tool result stays in the transcript where the block is replaced each turn.

Stores

filestore.Open(dir) keeps one directory per scope, one <name>.md per entry with a frontmatter of name, updated and the meta, an INDEX.md per scope, and one journal.jsonl at the root:

memory/
  journal.jsonl
  user/
    INDEX.md
    style.md

Writes are atomic and serialised by a lock file that names its holder, is taken over when the holder is dead on the same host, and is waited for only so long (ErrLocked, LockHolder, BreakLock). Reconcile journals what a person changed by hand. sqlite.Open(path) keeps the same rows and the same journal lines in one database and searches an FTS5 index in relevance order. NewMemStore() is the in-memory store. All three pass storetest.Run.

Development

make check    # gofmt, tidy, vet, deps, staticcheck, govulncheck, race tests, every module

Render has golden fixtures under testdata/render/; regenerate with go test . -update and review the diff. See CONTRIBUTING.md.

License

MIT. See LICENSE.

Documentation

Overview

Package agentmemory is memory for Go agents over Open Responses: what the agent keeps between sessions about the user, the project and its own past work, in a scoped store with an append-only journal, changed only through tools and rendered into the instructions with a manifest for the session's provenance.

An Entry is one remembered thing: a kebab-case name unique within a Scope, Markdown content bounded by the store's limit, and metadata such as a description. A Store holds entries and a journal of every change; NewMemStore is the in-memory one, filestore the reference one a person can open, edit and commit, and sqlite the one with full-text search. Tools returns the four tools through which the model reaches a store, so every write is a function call in the transcript. Render turns the entries of the scopes a product names into the block it puts in agentturn's Config.Instructions, bounded in total, and the Manifest of what the block held.

The package depends on openresponses, agenttool and the standard library. The agent loop is never imported; a product wires the block and the tools into its configuration.

Index

Constants

View Source
const (
	// MaxNameBytes bounds a scope or a name.
	MaxNameBytes = 64
	// MaxMetaBytes bounds the meta of one entry, keys and values
	// together, separately from the content bound.
	MaxMetaBytes = 1 << 10
)

Limits on names and metadata that every store enforces.

View Source
const (
	SaveTool   = "memory_save"
	PatchTool  = "memory_patch"
	ForgetTool = "memory_forget"
	SearchTool = "memory_search"
)

The tools' names.

View Source
const (
	// DefaultSearchLimit is how many entries a search returns when the
	// call names no limit.
	DefaultSearchLimit = 5
	// DefaultSearchBytes bounds the content one search result shows;
	// matches past it are listed by name.
	DefaultSearchBytes = 16 << 10
)

Defaults for memory_search. A search result is appended to the transcript once and stays for the rest of the session, unlike the rendered block, so both are low against the render budget.

View Source
const DefaultMaxEntryBytes = 4 << 10

DefaultMaxEntryBytes is the per-entry bound every store starts with.

View Source
const DefaultMaxTotalBytes = 32 << 10

DefaultMaxTotalBytes is the bound Render starts with: the content of the included entries together.

View Source
const MetaDescription = "description"

MetaDescription is the meta key the index and the rendered block show beside an entry.

Variables

View Source
var (
	// ErrNotFound is returned for an entry that is not live.
	ErrNotFound = errors.New("agentmemory: no such entry")
	// ErrInvalid is returned for a scope, name, content or meta that
	// breaks the rules in [CheckEntry].
	ErrInvalid = errors.New("agentmemory: invalid entry")
	// ErrTooLarge is returned for content over the store's bound; the
	// error is a [SizeError].
	ErrTooLarge = errors.New("agentmemory: entry over the size limit")
	// ErrConflict is returned by a conditional Put whose precondition
	// fails; the error is a [ConflictError].
	ErrConflict = errors.New("agentmemory: stored entry differs from the one expected")
)

Errors a Store returns, each wrapped with the detail of the case.

Functions

func CheckEntry

func CheckEntry(e Entry, limit int, stored *Entry) error

CheckEntry reports what a store refuses: an invalid scope or name, empty content or content that is not UTF-8, a meta key that is not a name or is reserved ("name", "updated"), a meta value with a line break or surrounding space, meta over MaxMetaBytes together, each as ErrInvalid; and content over limit as a SizeError, with stored's size when the entry exists. Every store calls it in Put, so the rules are the same everywhere.

func Hash

func Hash(content string) string

Hash returns "sha256:" and the hex digest of content, the form Entry.Hash and Change.Prev carry.

func Match

func Match(e Entry, query string) bool

Match reports whether e matches query as the reference stores search: every whitespace-separated word of query appears, ignoring case, in the entry's name, one of its meta values or its content. An empty query matches every entry.

func SessionFrom

func SessionFrom(ctx context.Context) string

SessionFrom returns the session ID attached to ctx, or "".

func Tools

func Tools(store Store, scopes []Scope, opts ...ToolOption) []agenttool.Tool

Tools returns the four tools through which the model reaches store, restricted to the scopes the product allows: memory_save, memory_patch, memory_forget and memory_search. A call that names a scope outside the list is an error the model sees; a call that omits the scope uses the first. Every write is a function call in the transcript, and the store's journal records it under the session on the context, see WithSession. Tools panics with no scopes, since a tool set that can reach nothing is a programming error.

func Usage

func Usage() string

Usage is one paragraph telling the model how to use the memory tools over the block Render produced. A product appends it to its instructions when it offers Tools.

func ValidName

func ValidName(s string) bool

ValidName reports whether s is kebab-case: lowercase ASCII letters and digits in groups joined by single hyphens, between 1 and MaxNameBytes bytes. Scopes, names and meta keys all follow it, so a name is a file name, a directory name and a frontmatter key without escaping.

func ValidScope

func ValidScope(s Scope) bool

ValidScope reports whether s is a well-formed scope: see ValidName.

func WithSession

func WithSession(ctx context.Context, id string) context.Context

WithSession attaches the session ID that writes through ctx, so a store records it on each change. One store serves many sessions at once, so the attribution travels on the context rather than the store; a product sets it on the context it runs the agent with.

Types

type Change

type Change struct {
	// Seq is the record's position in the store's journal, monotonic
	// within one store and the cursor [Store.Journal] takes. It
	// promises nothing across stores.
	Seq uint64 `json:"seq"`
	// Entry is the entry after the change; a tombstone carries the
	// last content with Deleted set.
	Entry Entry `json:"entry"`
	// Prev is the hash of the content the change replaced, "" for a
	// create. A reader chains records through it and sees a fork where
	// two writers built on one predecessor.
	Prev string `json:"prev,omitempty"`
	// Session is the session that wrote the change, from
	// [WithSession], or "" for a person or an unattributed caller.
	Session string `json:"session,omitempty"`
	// At is when the change was made, for display and the record. It
	// never orders records; Seq does.
	At time.Time `json:"at"`
}

Change is one journal record: the entry as it was after the change, what it replaced, and who made it.

type ConflictError

type ConflictError struct {
	Scope      Scope
	Name       string
	Want, Have string
}

ConflictError is the ErrConflict a conditional Put returns: the hash the caller expected and the one the store holds, "" for no entry on either side.

func (*ConflictError) Error

func (e *ConflictError) Error() string

func (*ConflictError) Is

func (e *ConflictError) Is(target error) bool

Is reports ErrConflict.

type Entry

type Entry struct {
	Scope Scope `json:"scope"`
	// Name is kebab-case and unique within the scope.
	Name string `json:"name"`
	// Content is Markdown, bounded by the store's limit.
	Content string `json:"content"`
	// Meta is what the product and the model add beside the content:
	// the description the index and the block show, a type, anything
	// else. Keys are kebab-case; values are one line each.
	Meta map[string]string `json:"meta,omitempty"`
	// Hash is "sha256:" and the hex digest of Content. A store sets it.
	Hash string `json:"hash"`
	// Updated is when the store last wrote the entry. A store sets it.
	Updated time.Time `json:"updated"`
	// Deleted marks a tombstone in the journal; Content is then the
	// last content the entry held. A store never returns one from Get
	// or List.
	Deleted bool `json:"deleted,omitempty"`
}

Entry is one remembered thing.

func (Entry) Description

func (e Entry) Description() string

Description returns the entry's description, or "".

func (Entry) Size

func (e Entry) Size() int

Size returns the content's size in bytes, the number the bound applies to.

type Manifest

type Manifest struct {
	// Entries are the included entries in block order.
	Entries []ManifestEntry `json:"entries"`
	// Omitted are the entries the total bound left out, in the order
	// they would have appeared.
	Omitted []ManifestEntry `json:"omitted,omitempty"`
}

Manifest lists what Render put in the block, for the session's provenance: a product records it beside the turn, in a custom entry under "agentmemory:render", so a later reader knows which memories the model had and which it did not.

func Render

func Render(ctx context.Context, s Store, scopes []Scope, opts ...RenderOption) (string, Manifest, error)

Render returns the in-context block: a header with the counts and the budget, then one section per scope in the order given, one heading per entry in list order carrying its size and the store's limit and its description, then the content verbatim. Entries are included until the next would take the content total over the bound; it and everything after it are listed under their scopes as omitted, so the model knows what memory_search can fetch. The output is determined by the store's state and the bounds alone, so an unchanged store renders the same bytes and costs nothing in the session, and the manifest's hashes are the hashes of the content the block shows.

Content is not transformed. A heading inside an entry's content at level one to three would read as structure, so the tool descriptions ask the model for level four or none.

type ManifestEntry

type ManifestEntry struct {
	Scope Scope  `json:"scope"`
	Name  string `json:"name"`
	Hash  string `json:"hash"`
	Bytes int    `json:"bytes"`
}

ManifestEntry names one entry by scope and name, with the hash and size of the content the block held or left out.

type MemOption

type MemOption func(*MemStore)

MemOption configures NewMemStore.

func WithClock

func WithClock(now func() time.Time) MemOption

WithClock sets the clock that stamps Updated and At, for tests that want a fixed one.

func WithMaxEntryBytes

func WithMaxEntryBytes(n int) MemOption

WithMaxEntryBytes sets the content bound; the default is DefaultMaxEntryBytes.

type MemStore

type MemStore struct {
	// contains filtered or unexported fields
}

MemStore is the in-memory Store: the reference for the contract, the store for tests, and a store for a product whose memory need not outlive the process. It is safe for concurrent use.

func NewMemStore

func NewMemStore(opts ...MemOption) *MemStore

NewMemStore returns an empty in-memory store.

func (*MemStore) Forget

func (m *MemStore) Forget(ctx context.Context, scope Scope, name string) error

Forget implements Store.

func (*MemStore) Get

func (m *MemStore) Get(_ context.Context, scope Scope, name string) (*Entry, error)

Get implements Store.

func (*MemStore) Journal

func (m *MemStore) Journal(_ context.Context, after uint64) iter.Seq2[Change, error]

Journal implements Store.

func (*MemStore) List

func (m *MemStore) List(_ context.Context, scope Scope) ([]Entry, error)

List implements Store.

func (*MemStore) MaxEntryBytes

func (m *MemStore) MaxEntryBytes() int

MaxEntryBytes implements Store.

func (*MemStore) Put

func (m *MemStore) Put(ctx context.Context, e Entry, opts ...PutOption) error

Put implements Store.

func (*MemStore) Search

func (m *MemStore) Search(_ context.Context, scopes []Scope, query string, limit int) ([]Entry, error)

Search implements Store with Match, listing by scope order then name.

type PutOption

type PutOption func(*PutOptions)

PutOption configures one Put.

func IfHash

func IfHash(h string) PutOption

IfHash makes the Put conditional: it fails with ErrConflict when the stored content hash is not h, so a write built on a stale read fails instead of clobbering. "" means the entry must not exist.

type PutOptions

type PutOptions struct {
	// IfHash is the hash the stored entry must have when Conditional
	// is set, "" meaning the entry must not exist.
	IfHash      string
	Conditional bool
}

PutOptions is the resolved form of a Put's options, for a Store to read through ResolvePutOptions.

func ResolvePutOptions

func ResolvePutOptions(opts ...PutOption) PutOptions

ResolvePutOptions applies the options to a zero PutOptions.

func (PutOptions) Check

func (o PutOptions) Check(scope Scope, name string, stored *Entry) error

Check reports an ErrConflict when the options are conditional and stored, the live entry or nil for none, does not satisfy them. A store calls it under its write lock, after reading the entry.

type RenderOption

type RenderOption func(*renderOptions)

RenderOption configures Render.

func WithMaxTotalBytes

func WithMaxTotalBytes(n int) RenderOption

WithMaxTotalBytes sets the total content bound; the default is DefaultMaxTotalBytes, and a value under one means the default.

type Scope

type Scope string

Scope says whose memory an entry is. Products define the values; "user", "project" and "session" are the conventional three. A scope has the same grammar as a name: see ValidName.

type SizeError

type SizeError struct {
	Scope Scope
	Name  string
	// Size is the attempted content size and Limit the bound.
	Size, Limit int
	// Stored is the size the live entry holds, or -1 when there is
	// none.
	Stored int
}

SizeError is the ErrTooLarge a Put returns, with the numbers the model needs to trim: the attempted size, the limit, and what the entry holds now.

func (*SizeError) Error

func (e *SizeError) Error() string

func (*SizeError) Is

func (e *SizeError) Is(target error) bool

Is reports ErrTooLarge.

type Store

type Store interface {
	// Get returns one live entry, or [ErrNotFound].
	Get(ctx context.Context, scope Scope, name string) (*Entry, error)
	// List returns the live entries of a scope by name. An unknown
	// scope lists nothing.
	List(ctx context.Context, scope Scope) ([]Entry, error)
	// Put creates or replaces the whole entry, content and meta, and
	// appends the new state to the journal. It refuses an invalid
	// entry with [ErrInvalid], content over [Store.MaxEntryBytes] with
	// [ErrTooLarge], and, under [IfHash], a stored hash other than the
	// one expected with [ErrConflict]. Hash and Updated on e are
	// ignored and set by the store.
	Put(ctx context.Context, e Entry, opts ...PutOption) error
	// Forget writes a tombstone: the entry leaves Get and List, and
	// the journal keeps its last content. A missing entry is
	// [ErrNotFound].
	Forget(ctx context.Context, scope Scope, name string) error
	// Search returns the live entries of the scopes that match query,
	// at most limit of them when limit is positive. What matches and
	// in what order is the store's; the reference stores use [Match]
	// and list by scope then name, and sqlite ranks by full-text
	// relevance.
	Search(ctx context.Context, scopes []Scope, query string, limit int) ([]Entry, error)
	// Journal yields every change with Seq greater than after, in
	// order; 0 reads from the beginning. A reader that stops early may
	// resume with the last Seq it saw.
	Journal(ctx context.Context, after uint64) iter.Seq2[Change, error]
	// MaxEntryBytes is the content size Put refuses to exceed.
	MaxEntryBytes() int
}

Store holds entries and the journal of every change to them. Every implementation passes storetest.

type ToolOption

type ToolOption func(*toolOptions)

ToolOption configures Tools.

func WithSearchBytes

func WithSearchBytes(n int) ToolOption

WithSearchBytes sets the most content one memory_search result shows; the default is DefaultSearchBytes, and a value under one means the default.

func WithSearchLimit

func WithSearchLimit(n int) ToolOption

WithSearchLimit sets the number of entries memory_search returns when the call names no limit; the default is DefaultSearchLimit, and a value under one means the default.

Directories

Path Synopsis
Package filestore is the reference agentmemory.Store: one directory per scope, one Markdown file per entry with a frontmatter, an INDEX.md per scope listing every live entry, and one append-only journal.jsonl at the root.
Package filestore is the reference agentmemory.Store: one directory per scope, one Markdown file per entry with a frontmatter, an INDEX.md per scope listing every live entry, and one append-only journal.jsonl at the root.
Package storetest is the conformance suite for agentmemory.Store implementations.
Package storetest is the conformance suite for agentmemory.Store implementations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL