session

package
v0.27.0 Latest Latest
Warning

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

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
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.

View Source
const (
	TitleSourceAuto   = "auto"
	TitleSourceManual = "manual"
)

Title source values. Empty (legacy) is treated as auto.

View Source
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.

View Source
const SessionVersion = 2

SessionVersion is the current session format version. V1 (implicit 0): flat Messages array. V2: entry-based tree with branching support.

Variables

View Source
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

func ApplyPreservedMetadata(meta, preserved map[string]any) map[string]any

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

func DeleteByID(baseDir, id string) error

DeleteByID searches all project stores under baseDir and deletes the session.

func FindSession

func FindSession(baseDir, id string) (*Session, *FileStore, error)

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

func FindSessionReadOnly(baseDir, id string) (*Session, *FileStore, error)

FindSessionReadOnly searches all project stores without migrating or writing the matching session. It is the read-only counterpart to FindSession.

func MigrateV1ToV2

func MigrateV1ToV2(sess *Session) error

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

func PreservedMetadata(meta map[string]any) map[string]any

PreservedMetadata extracts the creation-time keys that survive snapshots. Returns nil when none are present.

func ValidateEntries

func ValidateEntries(entries []Entry, leafID string) error

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

func ValidateID(id string) error

ValidateID accepts the opaque identifier generated for persisted sessions. Keeping IDs filename-safe prevents external API inputs from escaping a store.

Types

type CWDScan added in v0.25.0

type CWDScan struct {
	// CWDs holds the distinct working directories, newest session first.
	CWDs []string
	// Unreadable counts the session files that could not be decoded at all:
	// truncated or corrupt JSON, a shape the header decoder does not
	// recognize, a file that would not open. It is kept apart from NoCWD
	// because it is the only half that can change: such a file may decode on
	// the next run, so a caller that needs the full picture has a reason to
	// look again.
	Unreadable int
	// NoCWD counts the sessions that decoded fine and simply never recorded a
	// working directory — written by a moa old enough not to store one.
	// Re-reading them produces the same answer forever, so a caller deciding
	// whether to retry must not mistake this for evidence it could recover.
	NoCWD int
}

CWDScan is what ScanCWDs could learn about where sessions ran.

func ScanCWDs added in v0.25.0

func ScanCWDs(baseDir string) (CWDScan, error)

ScanCWDs reports the working directories recorded across every project-scoped store under baseDir, reading only each session's header.

It exists next to ListAll rather than on top of it because it must never fall back to a full read: ListAll's readSummary retries a file it could not stream by loading all of it, which for a damaged multi-megabyte transcript means reading the whole thing to answer a question about its first hundred bytes. A caller that only wants the header treats such a file as unreadable and says so — that is what Unreadable is for. ListAll keeps its fallback, since a session list that silently dropped recoverable sessions would be a regression for every other caller.

func (CWDScan) Unmappable added in v0.25.0

func (s CWDScan) Unmappable() int

Unmappable is the number of sessions that could not say which directory they ran in, for either reason.

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

func DeepCopyEntry(e Entry) Entry

DeepCopyEntry creates a deep copy of an Entry, including all mutable nested data.

type EntryType

type EntryType string

EntryType identifies the kind of entry in the session log.

const (
	EntryMessage    EntryType = "message"
	EntryCompaction EntryType = "compaction"
	EntryConfig     EntryType = "config"
	EntryLabel      EntryType = "label"
)

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

func NewFileStore(baseDir, cwd string) (*FileStore, error)

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) Create

func (s *FileStore) Create() *Session

Create creates a new empty session with a unique ID (v2 format).

func (*FileStore) Delete

func (s *FileStore) Delete(id string) error

Delete removes a session by ID.

func (*FileStore) Dir

func (s *FileStore) Dir() string

Dir returns the session storage directory.

func (*FileStore) Latest

func (s *FileStore) Latest() (*Session, error)

Latest returns the most recently updated session. Returns nil, nil if no sessions exist.

func (*FileStore) List

func (s *FileStore) List() ([]Summary, error)

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

func (s *FileStore) Load(id string) (*Session, error)

Load reads a session by ID. Returns ErrNotFound (wrapped) if the session does not exist.

func (*FileStore) LoadReadOnly

func (s *FileStore) LoadReadOnly(id string) (*Session, error)

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.

func (*FileStore) Save

func (s *FileStore) Save(sess *Session) error

Save writes a session to disk atomically. Updates the session's Updated timestamp before writing.

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"`
	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

func (s *Session) CompactAtMeta() int

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

func (s *Session) Origin() string

Origin returns the persisted origin, defaulting to OriginUser when absent.

func (*Session) PathMeta

func (s *Session) PathMeta() (scope string, allowedPaths []string)

PathMeta returns the persisted path configuration from Metadata.

func (*Session) Rename

func (s *Session) Rename(title string, maxLen int)

Rename sets a user-chosen title and marks it manual so auto-titling never overwrites it.

func (*Session) RuntimeMeta

func (s *Session) RuntimeMeta() (model, cwd, permissionMode, thinking string)

RuntimeMeta returns the persisted runtime configuration from Metadata. Missing keys return empty strings.

func (*Session) SetAutoTitle

func (s *Session) SetAutoTitle(title string, maxLen int)

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

func (s *Session) SetIdempotencyKey(key string)

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

func (s *Session) SetOrigin(origin string)

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

func (s *Session) SetPathMetadata(scope string, allowedPaths []string)

SetPathMetadata persists path scope and allowed paths to session metadata.

func (*Session) SetRuntimeMetadata

func (s *Session) SetRuntimeMetadata(model, cwd, permissionMode, thinking string)

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

func (s *Session) SetTitle(text string, maxLen int)

SetTitle sets the session title from a user message. Only sets if title is empty (first message). Truncates to maxLen.

func (*Session) TitleIsManual

func (s *Session) TitleIsManual() bool

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
}

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.

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

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"`
	// Result and Error retain the terminal outcome separately from the child
	// transcript so a parent timeline can restore the correct terminal card.
	Result     string      `json:"result,omitempty"`
	Error      string      `json:"error,omitempty"`
	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"`
	Metadata    map[string]any `json:"metadata,omitempty"`
}

Summary is a lightweight session descriptor without messages. Used for listing sessions without loading full conversation data.

func ListAll

func ListAll(baseDir string) ([]Summary, error)

ListAll returns summaries from all project-scoped stores under baseDir. If baseDir is empty, uses the default. Returns partial results on errors.

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 NewTree

func NewTree() *Tree

NewTree creates an empty tree.

func NewTreeFromEntries

func NewTreeFromEntries(entries []Entry, leafID string) (*Tree, error)

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

func (t *Tree) Append(e Entry) string

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

func (t *Tree) Branch(entryID string) error

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:

  1. Walk the current path (root → leaf)
  2. Find the LAST compaction entry (most recent wins for multi-compaction)
  3. If compaction found: emit compaction_summary + messages from firstKeptEntryID → leaf
  4. If no compaction: emit all message entries
  5. Only include LLM-relevant roles (user, assistant, tool_result, compaction_summary)

func (*Tree) Children

func (t *Tree) Children(entryID string) []Entry

Children returns direct children of the given entry. Returned entries are shallow copies; do not mutate.

func (*Tree) Clear

func (t *Tree) Clear()

Clear resets the tree to empty state.

func (*Tree) Entries

func (t *Tree) Entries() []Entry

Entries returns a shallow copy of all entries. For persistence, prefer Snapshot() which deep-copies messages.

func (*Tree) Entry

func (t *Tree) Entry(id string) (Entry, bool)

Entry returns an entry by ID.

func (*Tree) HasMsgID added in v0.21.0

func (t *Tree) HasMsgID(msgID string) bool

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) LeafID

func (t *Tree) LeafID() string

LeafID returns the current branch tip entry ID.

func (*Tree) Len

func (t *Tree) Len() int

Len returns the total number of entries across all branches.

func (*Tree) Path

func (t *Tree) Path() []Entry

Path returns entries from root to the current leaf, in order. Returned entries are shallow copies; do not mutate.

func (*Tree) Snapshot

func (t *Tree) Snapshot() ([]Entry, string)

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

func (t *Tree) ValidBranchTarget(entryID string) error

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).

Jump to

Keyboard shortcuts

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