Documentation
¶
Overview ¶
Package session manages persistent conversation sessions.
Sessions are stored as JSON files in ~/.config/moa/sessions/. Each session contains conversation messages, metadata, and a unique ID. The Store provides CRUD operations with atomic writes (temp + rename) to prevent corruption on crash.
Index ¶
- Constants
- Variables
- func ApplyPreservedMetadata(meta, preserved map[string]any) map[string]any
- func DeepCopyMessage(msg core.AgentMessage) core.AgentMessage
- func DeleteByID(baseDir, id string) error
- func FindSession(baseDir, id string) (*Session, *FileStore, error)
- func FindSessionReadOnly(baseDir, id string) (*Session, *FileStore, error)
- func MigrateV1ToV2(sess *Session) error
- func PreservedMetadata(meta map[string]any) map[string]any
- func ValidateEntries(entries []Entry, leafID string) error
- func ValidateID(id string) error
- type CompactionData
- type ConfigChangeData
- type Entry
- type EntryType
- type FileStore
- func (s *FileStore) Create() *Session
- func (s *FileStore) Delete(id string) error
- func (s *FileStore) Dir() string
- func (s *FileStore) Latest() (*Session, error)
- func (s *FileStore) List() ([]Summary, error)
- func (s *FileStore) Load(id string) (*Session, error)
- func (s *FileStore) LoadReadOnly(id string) (*Session, error)
- func (s *FileStore) Save(sess *Session) error
- func (s *FileStore) SetArchived(id string, archived bool) error
- type Session
- func (s *Session) CompactAtMeta() int
- func (s *Session) Origin() string
- func (s *Session) PathMeta() (scope string, allowedPaths []string)
- func (s *Session) Rename(title string, maxLen int)
- func (s *Session) RuntimeMeta() (model, cwd, permissionMode, thinking string)
- func (s *Session) SetAutoTitle(title string, maxLen int)
- func (s *Session) SetIdempotencyKey(key string)
- func (s *Session) SetOrigin(origin string)
- func (s *Session) SetPathMetadata(scope string, allowedPaths []string)
- func (s *Session) SetRuntimeMetadata(model, cwd, permissionMode, thinking string)
- func (s *Session) SetTitle(text string, maxLen int)
- func (s *Session) TitleIsManual() bool
- type SessionStore
- type SubagentStore
- type SubagentTranscript
- type Summary
- type Tree
- func (t *Tree) AllMessages() []core.AgentMessage
- func (t *Tree) Append(e Entry) string
- func (t *Tree) Branch(entryID string) error
- func (t *Tree) BuildContext() ([]core.AgentMessage, int)
- func (t *Tree) Children(entryID string) []Entry
- func (t *Tree) Clear()
- func (t *Tree) Entries() []Entry
- func (t *Tree) Entry(id string) (Entry, bool)
- func (t *Tree) HasMsgID(msgID string) bool
- func (t *Tree) LeafID() string
- func (t *Tree) Len() int
- func (t *Tree) Path() []Entry
- func (t *Tree) Snapshot() ([]Entry, string)
- func (t *Tree) ValidBranchTarget(entryID string) error
Constants ¶
const ( MetaModel = "model" MetaCWD = "cwd" MetaPermissionMode = "permission_mode" MetaThinking = "thinking" MetaPathScope = "path_scope" MetaAllowedPaths = "allowed_paths" MetaCompactAt = "compact_at" MetaOrigin = "origin" // Automation bookkeeping written by the Automation API. The callback fields // are set at creation; the idempotency key is only written once the run's // first prompt was accepted (see SetIdempotencyKey). MetaIdempotencyKey = "idempotency_key" MetaCallbackURL = "callback_url" MetaCallbackSecret = "callback_secret" // MetaMCPServers holds the per-run MCP servers an automation caller attached // to the session (a list of {name, url, headers}). They are session-scoped — // never written to any config file — and are replayed on resume so the // session reconnects them. MetaMCPServers = "mcp_servers" // MetaAutomationCreated marks a session the Automation API created itself. // Unlike MetaOrigin — a free-form label any creator may pass — it is written // on exactly one code path, so it is the authority check for the scoped // automation interaction endpoints. MetaAutomationCreated = "automation_created" )
RuntimeMetadata keys used for persisting session configuration.
const ( TitleSourceAuto = "auto" TitleSourceManual = "manual" )
Title source values. Empty (legacy) is treated as auto.
const OriginUser = "user"
OriginUser is the implicit origin of a session created by a human through the TUI or the web client. Sessions persisted before origins existed carry no key and are treated as user-originated.
const SessionVersion = 2
SessionVersion is the current session format version. V1 (implicit 0): flat Messages array. V2: entry-based tree with branching support.
Variables ¶
var ErrNotFound = errors.New("session: not found")
ErrNotFound is returned by Load when the session ID does not exist.
Functions ¶
func ApplyPreservedMetadata ¶ added in v0.20.0
ApplyPreservedMetadata copies preserved creation-time keys into a freshly built metadata map, without overwriting values the runtime already set.
func DeepCopyMessage ¶
func DeepCopyMessage(msg core.AgentMessage) core.AgentMessage
DeepCopyMessage creates a deep copy of an AgentMessage, including Custom map and Content slices with their nested maps.
func DeleteByID ¶
DeleteByID searches all project stores under baseDir and deletes the session.
func FindSession ¶
FindSession searches all project stores under baseDir for a session by ID. Returns the session, the store it was found in, and any error.
func FindSessionReadOnly ¶
FindSessionReadOnly searches all project stores without migrating or writing the matching session. It is the read-only counterpart to FindSession.
func MigrateV1ToV2 ¶
MigrateV1ToV2 converts a v1 session (flat Messages) to v2 (entry-based tree). Idempotent: returns nil immediately if already v2+. Each message becomes an Entry with a sequential parent chain. If the first message is a compaction_summary, it becomes a CompactionEntry followed by the remaining messages (recording that compaction already happened).
func PreservedMetadata ¶ added in v0.20.0
PreservedMetadata extracts the creation-time keys that survive snapshots. Returns nil when none are present.
func ValidateEntries ¶
ValidateEntries checks the integrity of a session's entry tree. Returns an error if:
- duplicate entry IDs
- missing parent references
- leaf ID not found
- cycle detected (from leaf to root)
func ValidateID ¶
ValidateID accepts the opaque identifier generated for persisted sessions. Keeping IDs filename-safe prevents external API inputs from escaping a store.
Types ¶
type CompactionData ¶
type CompactionData struct {
Summary string `json:"summary"`
FirstKeptEntryID string `json:"first_kept_entry_id"`
TokensBefore int `json:"tokens_before"`
ReadFiles []string `json:"read_files,omitempty"`
ModifiedFiles []string `json:"modified_files,omitempty"`
}
CompactionData records a non-destructive compaction event.
func (CompactionData) IsEmpty ¶
func (c CompactionData) IsEmpty() bool
IsEmpty returns true if the CompactionData has no summary (zero value).
type ConfigChangeData ¶
type ConfigChangeData struct {
Model string `json:"model,omitempty"`
Thinking string `json:"thinking,omitempty"`
}
ConfigChangeData records a configuration change (model, thinking, etc.).
func (ConfigChangeData) IsEmpty ¶
func (c ConfigChangeData) IsEmpty() bool
IsEmpty returns true if the ConfigChangeData is zero-valued.
type Entry ¶
type Entry struct {
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
Timestamp time.Time `json:"ts"`
Type EntryType `json:"type"`
// Type-specific data (only one populated per entry):
Message core.AgentMessage `json:"message,omitempty"`
Compaction CompactionData `json:"compaction,omitempty"`
Config ConfigChangeData `json:"config,omitempty"`
Label string `json:"label,omitempty"`
}
Entry is a single immutable unit in the session log. All fields are stored by value to enforce immutability.
func DeepCopyEntry ¶
DeepCopyEntry creates a deep copy of an Entry, including all mutable nested data.
type FileStore ¶
type FileStore struct {
// contains filtered or unexported fields
}
FileStore manages session persistence on disk. Sessions are stored as individual JSON files in a directory. Writes are atomic (temp file + rename) to prevent corruption.
func NewFileStore ¶
NewFileStore creates a FileStore for sessions scoped to the given CWD. baseDir is the root sessions directory (empty = ~/.config/moa/sessions/). cwd determines the project subdirectory. Empty cwd uses baseDir directly (legacy/tests).
func (*FileStore) Latest ¶
Latest returns the most recently updated session. Returns nil, nil if no sessions exist.
func (*FileStore) List ¶
List returns summaries of all sessions, sorted by Updated descending (newest first). Uses a streaming json.Decoder that stops as soon as it reaches the entries/messages field, so it never reads (or allocates) the — potentially multi-megabyte — conversation history just to show a session list.
func (*FileStore) Load ¶
Load reads a session by ID. Returns ErrNotFound (wrapped) if the session does not exist.
func (*FileStore) LoadReadOnly ¶
LoadReadOnly reads a session without performing the legacy v1 migration. Consumers which promise a read-only operation (for example transcript export) must use this method: Load may write a migrated copy to disk.
type Session ¶
type Session struct {
// Header fields — read by partial-read list optimization
ID string `json:"id"`
Version int `json:"version"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Title string `json:"title"`
// TitleSource records how Title was set: "manual" (user renamed) or "auto"
// (derived / LLM-generated). Empty is legacy and treated as auto.
TitleSource string `json:"title_source,omitempty"`
// Archived marks a session as closed-but-kept (presentation-only; does
// not unload it from memory). Must stay top-level (not in Metadata,
// which is rebuilt from scratch on every snapshot via collectMetadata).
Archived bool `json:"archived,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
// V2: entry-based tree log
LeafID string `json:"leaf_id,omitempty"`
Entries []Entry `json:"entries,omitempty"`
// V1 legacy (only present in old sessions, cleared after migration)
Messages []core.AgentMessage `json:"messages,omitempty"`
CompactionEpoch int `json:"compaction_epoch,omitempty"`
}
Session represents a persistent conversation.
Field ordering matters: summary fields (ID, Version, Title, Metadata) come first so the readSummary partial-read optimization (4KB prefix) still works.
func (*Session) CompactAtMeta ¶
CompactAtMeta returns the persisted soft compaction threshold in tokens, or 0 when none was set (the default window-based behavior). Metadata round-trips through JSON, so a number read back from disk arrives as float64 while one set in this process is still an int — both are accepted.
func (*Session) Origin ¶ added in v0.20.0
Origin returns the persisted origin, defaulting to OriginUser when absent.
func (*Session) Rename ¶
Rename sets a user-chosen title and marks it manual so auto-titling never overwrites it.
func (*Session) RuntimeMeta ¶
RuntimeMeta returns the persisted runtime configuration from Metadata. Missing keys return empty strings.
func (*Session) SetAutoTitle ¶
SetAutoTitle applies an auto-generated title, unless the user has manually renamed the session. Empty/whitespace titles are ignored.
func (*Session) SetIdempotencyKey ¶ added in v0.20.0
SetIdempotencyKey records the Automation API key this session answers. It is written only after the run's first prompt was accepted, so a session that never received one stays unreachable by key. An empty key is not stored.
func (*Session) SetOrigin ¶ added in v0.20.0
SetOrigin records who created the session (e.g. "user", "automation", or a caller-chosen label such as "linear-webhook"). An empty origin is not stored: missing means user.
func (*Session) SetPathMetadata ¶
SetPathMetadata persists path scope and allowed paths to session metadata.
func (*Session) SetRuntimeMetadata ¶
SetRuntimeMetadata persists the core session configuration (model, cwd, permission mode, thinking level) into Metadata. Called on every state change and at session creation. Centralizes what gets persisted so all frontends (TUI, serve, headless CLI) stay consistent.
func (*Session) SetTitle ¶
SetTitle sets the session title from a user message. Only sets if title is empty (first message). Truncates to maxLen.
func (*Session) TitleIsManual ¶
TitleIsManual reports whether the user explicitly renamed the session. A legacy empty source counts as auto.
type SessionStore ¶
type SessionStore interface {
Create() *Session
Save(sess *Session) error
Load(id string) (*Session, error)
Latest() (*Session, error)
List() ([]Summary, error)
Delete(id string) error
SetArchived(id string, archived bool) error
}
SessionStore abstracts session persistence. FileStore implements this for disk-based storage. External consumers (e.g., HTTP servers) implement it for database storage.
Contract:
- Create returns a new Session with a unique ID and timestamps set. It does NOT persist — call Save.
- Save persists the session. It MUST set Updated to the current time before writing.
- Load returns the session or ErrNotFound (wrapped or direct — use errors.Is).
- Latest returns the most recently updated session, or (nil, nil) if the store is empty.
- List returns summaries sorted by Updated descending. Empty store returns (nil, nil).
- Delete is idempotent — deleting a non-existent session returns nil.
- SetArchived toggles the archived flag and persists it WITHOUT touching Updated (archiving is presentation-only and must not reorder lists).
type SubagentStore ¶
type SubagentStore struct {
// contains filtered or unexported fields
}
SubagentStore persists subagent transcripts for one parent session in a side directory: <session dir>/<sessionID>.subagents/<jobID>.json.
func NewSubagentStore ¶
func NewSubagentStore(sessionDir, sessionID string) *SubagentStore
NewSubagentStore returns a store rooted at "<sessionDir>/<sessionID>.subagents". sessionDir is the directory holding the parent session's <id>.json (e.g. FileStore.Dir()). The directory is created lazily on first Save.
func (*SubagentStore) Dir ¶
func (s *SubagentStore) Dir() string
Dir returns the side-directory path (may not exist yet).
func (*SubagentStore) List ¶
func (s *SubagentStore) List() ([]SubagentTranscript, error)
List returns all persisted transcripts for the session, newest-finished first. Missing directory yields an empty slice (not an error).
func (*SubagentStore) Load ¶
func (s *SubagentStore) Load(jobID string) (*SubagentTranscript, error)
Load reads one transcript by jobID. Returns ErrNotFound (wrapped) if absent.
func (*SubagentStore) Remove ¶
func (s *SubagentStore) Remove() error
Remove deletes the entire side directory (used when the parent session is deleted). No-op if it doesn't exist.
func (*SubagentStore) Save ¶
func (s *SubagentStore) Save(t SubagentTranscript) error
Save atomically writes one transcript to <jobID>.json.
type SubagentTranscript ¶
type SubagentTranscript struct {
JobID string `json:"job_id"`
Task string `json:"task"`
Model string `json:"model"`
Status string `json:"status"`
Thinking string `json:"thinking,omitempty"`
Async bool `json:"async"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Usage *core.Usage `json:"usage,omitempty"`
CostUSD float64 `json:"cost_usd,omitempty"`
// ContextPercent is how full the child's own window was when it finished
// (0-100). Stored alongside usage so reopening a finished subagent
// restores the same reading it had while it ran. A POINTER because 0 is a
// real reading (a child that barely used its window) and has to stay
// distinguishable from a transcript written before this was recorded:
// nil means unknown, and unknown hides the ring instead of drawing an
// empty one.
ContextPercent *int `json:"context_percent,omitempty"`
Messages []core.AgentMessage `json:"messages"`
}
SubagentTranscript is the persisted record of one subagent's sub-conversation. Stored in a side directory next to the parent session so a subagent's transcript survives restarts and can be reopened after it finished, without bloating the parent session's own tree/history.
type Summary ¶
type Summary struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Title string `json:"title"`
TitleSource string `json:"title_source,omitempty"`
Archived bool `json:"archived,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
Summary is a lightweight session descriptor without messages. Used for listing sessions without loading full conversation data.
type Tree ¶
type Tree struct {
// contains filtered or unexported fields
}
Tree is an append-only entry log that supports branching. It maintains an index for O(1) lookup and a leaf pointer for the current branch tip. All public methods are safe for concurrent use.
Key methods:
- BuildContext() returns messages for the LLM (handles compaction)
- AllMessages() returns ALL messages along the current path (for display)
- Branch() moves the leaf to enable forking
- Snapshot() returns a deep-copied (entries, leafID) pair for persistence
func NewTreeFromEntries ¶
NewTreeFromEntries reconstructs a tree from persisted entries. Returns an error if the entries are invalid (see ValidateEntries).
func (*Tree) AllMessages ¶
func (t *Tree) AllMessages() []core.AgentMessage
AllMessages returns ALL messages along the current path (for display). Includes pre-compaction messages. Compaction entries become synthetic status messages.
func (*Tree) Append ¶
Append adds an entry as a child of the current leaf and advances the leaf. The entry's ID and Timestamp are set automatically. ParentID is set to the current leaf. The Message field is deep-copied to enforce immutability. Returns the assigned entry ID.
func (*Tree) Branch ¶
Branch moves the leaf pointer to the given entry ID. Next Append creates a child of that entry (starting a new branch). Returns an error if:
- the entry ID doesn't exist
- the target is a tool_result (would leave dangling tool_call)
- the resulting path (as BuildContext would see it) leaves any assistant tool_call without a matching tool_result
func (*Tree) BuildContext ¶
func (t *Tree) BuildContext() ([]core.AgentMessage, int)
BuildContext returns messages for the LLM provider and the compaction epoch.
Algorithm:
- Walk the current path (root → leaf)
- Find the LAST compaction entry (most recent wins for multi-compaction)
- If compaction found: emit compaction_summary + messages from firstKeptEntryID → leaf
- If no compaction: emit all message entries
- Only include LLM-relevant roles (user, assistant, tool_result, compaction_summary)
func (*Tree) Children ¶
Children returns direct children of the given entry. Returned entries are shallow copies; do not mutate.
func (*Tree) Entries ¶
Entries returns a shallow copy of all entries. For persistence, prefer Snapshot() which deep-copies messages.
func (*Tree) HasMsgID ¶ added in v0.21.0
HasMsgID reports whether any entry in the tree — on ANY branch, not just the current path — carries a message with this ID. Uniqueness of a message identity is a property of the whole tree: branching away from a message does not delete it, and a client can navigate back to that branch, so an ID reused on another branch would collide there.
func (*Tree) Path ¶
Path returns entries from root to the current leaf, in order. Returned entries are shallow copies; do not mutate.
func (*Tree) Snapshot ¶
Snapshot returns a deep copy of all entries and the current leaf ID atomically. Entries are deep-copied (including messages) so the caller can serialize without races. Use this instead of separate Entries()+LeafID() calls for persistence.
func (*Tree) ValidBranchTarget ¶
ValidBranchTarget reports whether branching to entryID would be accepted by Branch, without mutating the tree. Callers building branch-picker UIs should use this to filter candidates so they never offer a target that Branch would reject (e.g. an assistant turn with unresolved tool calls).