Matrix logo

Conversation Store

Package matrix/neo/internal/conversation is Neo's durable chat-thread memory. It persists each turn as append-only JSONL per conversation_id with a retained-turn cap and archive rollover.

Package matrix/neo/internal/conversation is Neo's durable chat-thread memory. It persists each turn as append-only JSONL (one line per turn) per conversation_id, so history survives reloads, new chats, suspend, and redeploy at O(1) write cost per turn.

Source file: neo/internal/conversation/store.go.


Design decisions

Unified history with the daemon. The on-disk shape is byte-compatible with the daemon's own conversation store (executor/cmd/mcl-execute/daemon_conversation.go). A pre-Neo daemon thread written as a single JSON object is still readable (the loader falls back to the legacy single-object parse when the file is not JSONL). New writes are always JSONL. Neo derives the SAME directory (filepath.Dir(cortexRoot)/conversations = /data/conversations in prod), so a user's pre-Neo daemon threads and their new Neo threads list together as one unified history.

Pure side-channel. It never touches cortex, signs anything, or perturbs replay. Conversation continuity and the audit/replay chain are independent storage.

O(1) append writes. Each turn is appended as one JSONL line with fsync. The rename-based atomic write (tmp + rename) is reserved for the archive rollup/compaction step, never for the per-turn append.

Retained-turn cap with archive rollover. The hot set (live .jsonl) is bounded by RetainedTurns (config NEO_CONVERSATION_RETAINED_TURNS, default 1000). Turns that scroll past the cap roll to a sibling archive file (<convID>.archive.jsonl) -- durable, NOT dropped -- and remain retrievable via History/Archived/Recent.

Best-effort. IO errors are logged, never fatal. A blank conversation id or text is ignored.


Store

type Store struct {
    mu  sync.Mutex
    dir string
}
store := conversation.Open(dir) // dir="" yields a disabled store (safe no-op)

Turn

type Turn struct {
    Role     string    `json:"role"` // "user" | "assistant"
    Text     string    `json:"text"`
    IntentID string    `json:"intent_id,omitempty"`
    TS       time.Time `json:"ts"`
}

Record

type Record struct {
    ConversationID string    `json:"conversation_id"`
    Title          string    `json:"title,omitempty"`
    Turns          []Turn    `json:"turns"`
    Updated        time.Time `json:"updated"`
}

Operations

Append

func (s *Store) Append(convID string, turn Turn)
func (s *Store) AppendUser(convID, text string)
func (s *Store) AppendAssistant(convID, intentID, text string)

Records one turn and persists atomically. Zero TS is filled with time.Now().UTC().

Recent

func (s *Store) Recent(convID string, n int) []Turn

Returns the last n turns (oldest-first), or nil when there are none. Used for resume seeding. DefaultRecallTurns = 16.

Get

func (s *Store) Get(convID string) *Record

Returns the full turn log for one conversation, or nil when not found.

List

func (s *Store) List() []Summary

Returns a summary of every persisted conversation, newest-first:

type Summary struct {
    ConversationID string    `json:"conversation_id"`
    Title          string    `json:"title"`
    Preview        string    `json:"preview"`
    TurnCount      int       `json:"turn_count"`
    Updated        time.Time `json:"updated"`
}

Title derives from the first user turn (trimmed to 60 runes). Preview is the most recent turn (trimmed to 100 runes).

History and Archived

func (s *Store) History(convID string, limit int) []Turn  // hot + archive, newest-first
func (s *Store) Archived(convID string) []Turn            // archive file only

History merges the hot set and the archive, returning the most recent limit turns across both. Archived returns only the rolled-off turns.


Directory resolution

func Dir(override, cortexRoot string) string
  • Explicit NEO_CONVERSATIONS_DIR wins
  • Otherwise derives from filepath.Dir(cortexRoot) then sibling of cortex root
  • Returns "" when neither is available (persistence disabled)

This matches how server.MediaDir derives /data/media from /data/cortex.


Daemon compatibility

The JSON tags match the daemon store and the web client's ConversationTurn. A file written by the daemon is readable by Neo, and vice versa. This is tested in TestDaemonFileCompatible.


Modifying the store

What to changeWhere
Default recall turnsconversation/store.go - DefaultRecallTurns (16)
Retained-turn capconfig/config.go - NEO_CONVERSATION_RETAINED_TURNS (1000)
Title/preview lengthconversation/store.go - truncateLabel()
Directory derivationconversation/store.go - Dir()
JSON shapeconversation/store.go - Turn, Record, Summary structs