Documentation
¶
Overview ¶
Package conversations is the per-domain Store over aura.conversations + aura.conversation_turns + aura.context_rot_events (PRD 1.8 / 1.8.5). It copies the canonical Store pattern proved in internal/identity and reused by internal/askuser (D-A4-01): Store{pool,q} over the generated sqlc surface, SQLSTATE-based error classification via errors.As + pgErr.Code (never message matching), sentinel errors, pgtype boundary conversion (Pitfall 5), and db.WithTx for atomic multi-statement writes.
Scope is the durable conversation core (SPEC Req#7-13): multi-thread persistence, the atomic per-turn AppendTurn (INSERT turn + UPDATE aggregates in ONE tx — SC-2), byte-identical LoadHistory (Req#8), sidecar spill for oversized turn content, per-conversation token+USD aggregation, status/rename/delete, and the locked cross-slice SearchConversationTurns FTS query (Req#13). The L1/L2/L2.5 context ladder lives in context.go; the auto-title worker body in title.go; the boot reconciliation GC in orphan_scan.go. The Runner (04-05) consumes a narrow interface; no interface is declared here (D-A2-02, "accept interfaces, return structs").
Index ¶
- Constants
- Variables
- func GenerateTitle(ctx context.Context, client llm.Client, model string, history []llm.Message) (string, error)
- func InitEncoder() error
- func ScanOrphans(ctx context.Context, pool *pgxpool.Pool, p ScanParams) error
- type AppendTurnParams
- type Branch
- type Config
- type ContextConfig
- type Conversation
- type ConversationCleaner
- type CreateParams
- type RotEvent
- type ScanParams
- type SearchResult
- type Store
- func (s *Store) AppendAssistantTurnWithCacheMetric(ctx context.Context, p AppendTurnParams, metric sqlc.InsertCacheMetricParams) error
- func (s *Store) AppendTurn(ctx context.Context, p AppendTurnParams) error
- func (s *Store) CanonicalBranchLeaf(ctx context.Context, conversationID string) (int, error)
- func (s *Store) CountTurns(ctx context.Context, conversationID string) (int, error)
- func (s *Store) Create(ctx context.Context, p CreateParams) (Conversation, error)
- func (s *Store) Delete(ctx context.Context, conversationID string) error
- func (s *Store) ForkBranch(ctx context.Context, conversationID string, divergeSeq int, ...) (int, uuid.UUID, error)
- func (s *Store) Get(ctx context.Context, conversationID string) (Conversation, error)
- func (s *Store) List(ctx context.Context, includeArchived bool) ([]Conversation, error)
- func (s *Store) ListBranches(ctx context.Context, conversationID string) ([]Branch, error)
- func (s *Store) ListContextRotEvents(ctx context.Context, conversationID string) ([]RotEvent, error)
- func (s *Store) LoadBranchHistory(ctx context.Context, conversationID string, leafSeq int) ([]llm.Message, error)
- func (s *Store) LoadHistory(ctx context.Context, conversationID string) ([]llm.Message, error)
- func (s *Store) LoadManagedHistory(ctx context.Context, conversationID string, cfg ContextConfig) ([]llm.Message, error)
- func (s *Store) LoadManagedHistoryForBranch(ctx context.Context, conversationID string, leafSeq int, cfg ContextConfig) ([]llm.Message, error)
- func (s *Store) Rename(ctx context.Context, conversationID, title string) error
- func (s *Store) SearchConversationTurns(ctx context.Context, query string, limit int) ([]SearchResult, error)
- func (s *Store) SetBranchPointers(ctx context.Context, conversationID string, seq int, branchID uuid.UUID, ...) error
- func (s *Store) SetTitleIfNull(ctx context.Context, conversationID, title string) error
- func (s *Store) UpdateStatus(ctx context.Context, conversationID, status string) error
- type Sweeper
- type SweeperConfig
- type Turn
Constants ¶
const ( StatusActive = "active" StatusArchived = "archived" StatusDeleted = "deleted" )
Status values mirror the aura.conversations CHECK constraint. archive/unarchive flip between active and archived; UpdateStatus(deleted) is the soft-delete that hides a conversation from List without a row delete.
Variables ¶
var CanonicalBranchID = uuid.UUID{}
CanonicalBranchID is the all-zero sentinel branch every pre-0017 turn is backfilled onto by migration 0017 (D-09 / CHAT-05). A non-branched conversation's whole history lives on this branch, so a path walk from its leaf reconstructs the same linear turn list ListTurnsBySeq returns (the byte-identity contract, store.go:250). New sibling branches (plan 25-07 edit/regenerate) mint a fresh uuid instead.
var ErrContextWindowExceeded = errors.New("conversation context exceeds the model window; start a new chat with `aura chat new`")
ErrContextWindowExceeded is returned by ApplyContextLadder when the history is still over the L2 hard cap after L1 (and L2.5 cannot reduce it — e.g. only the system turn remains). It is a normal-flow error the REPL surfaces (suggesting `aura chat new`), NEVER the iter.Seq2 error slot.
var ErrConversationNotFound = errors.New("conversation not found")
ErrConversationNotFound is a missing conversation lookup — a sentinel so callers classify the failure without string matching.
var ErrTurnNotFound = errors.New("conversations: turn not found")
ErrTurnNotFound is returned when a fork targets a seq that does not exist in the conversation — mapped to a clean 404 at the REST boundary (T-25-26).
Functions ¶
func GenerateTitle ¶
func GenerateTitle(ctx context.Context, client llm.Client, model string, history []llm.Message) (string, error)
GenerateTitle is the exported entry the Runner (04-05) invokes from its WithoutCancel/WithTimeout/WaitGroup auto-title worker (D-A5-01). It delegates to the package-internal generateTitle body; the worker lifecycle (the goroutine, the bounded ctx, the WaitGroup join) is the Runner's, not this package's. Errors are returned for the caller to discard (a NULL title renders "(untitled ...)").
func InitEncoder ¶
func InitEncoder() error
InitEncoder eagerly initializes the cached cl100k_base encoder at boot (the composition root calls it after db.Open, before serving) so the first turn does not pay the one-time vocab-parse latency. It is a thin exported wrapper over the lazy encoder() — idempotent (sync.Once), goleak-safe (no goroutine, no network).
func ScanOrphans ¶
ScanOrphans is the boot reconciliation GC (D-A5-02), run after db.Open and before serving (the 04-05 composition root calls it). It:
- removes $AURA_RUN_DIR/conversations/<id> dirs with NO matching conversations row (session_id == conversation_id, D-26), under an O_NOFOLLOW/Lstat symlink guard so a malicious symlink cannot redirect RemoveAll outside runDir;
- sweeps $AURA_RUN_DIR/tmp/* entries older than 24h;
- logs an audit-only WARN if the run dir exceeds the threshold (NEVER purges).
Individual rm failures are WARN-logged and recovered at the next boot scan; they do not abort the scan. Only a structural failure (cannot read the runDir) returns a wrapped error.
Types ¶
type AppendTurnParams ¶
type AppendTurnParams struct {
ConversationID string
Seq int
Role string
Content string
ToolCallID string
ToolCalls []byte
InputTokens int
OutputTokens int
CachedTokens int
CostUSD float64
}
AppendTurnParams carries one new turn plus its token/cost delta. Cost is the USD figure to fold into the running total_cost_usd aggregate (RESEARCH OQ4 — aggregated in SQL inside the same tx). A Seq <= 0 asks the Store to allocate the next per-conversation seq inside the append transaction.
type Branch ¶ added in v1.0.0
Branch is one navigable branch path of a conversation tree (D-09 / CHAT-05): the LeafSeq tip the BranchPicker selects + the BranchID it belongs to. ParentSeq is the divergence point (NULL → 0 for a root branch). The canonical (all-zero) branch sorts first (ListBranchLeaves ORDER BY branch_id ASC), so a non-branched conversation reports exactly one branch whose leaf is its last turn.
type Config ¶
type Config struct {
RunDir string // $AURA_RUN_DIR — sidecar root
TurnCapBytes int // AURA_CONVERSATION_TURN_CAP_BYTES (65536)
Cleaner ConversationCleaner
}
Config carries the Store's filesystem + spill knobs (from config.Config). Cleaner is the optional os.Root cascade (sandbox.WorkspaceManager) the composition root injects so Delete tears down the per-conversation tree symlink-safely; a nil Cleaner keeps the os.RemoveAll fallback.
type ContextConfig ¶
type ContextConfig struct {
ContextWindow int
MaxOutputTokens int
ToolEvictAfterTurns int
// AlwaysBlock is the rendered messages[1] always-on skill block (D-07). When
// non-empty the ladder injects it as a PROTECTED user-role turn immediately after
// the system L0 turn — counted toward the budget but never evicted by L1/L2.5
// (Pitfall 3). The Runner renders it per turn from current loader state; an empty
// string means no always:true skill is active (the turn is omitted).
AlwaysBlock string
}
ContextConfig carries the L1/L2 inputs. ContextWindow + MaxOutputTokens come from llm.Config (04-01); ToolEvictAfterTurns from config.Config (AURA_CONTEXT_TOOL_EVICT_AFTER_TURNS).
type Conversation ¶
type Conversation struct {
ID string
Title string // empty when the DB title is NULL
TitleSet bool // false when the DB title is NULL
IdentityID string
Status string
Model string
TotalInputTokens int64
TotalOutputTokens int64
TotalCachedTokens int64
TotalCostUSD float64
CreatedAt string // RFC3339; used by DisplayTitle for the untitled fallback
}
Conversation is the domain projection of aura.conversations — plain Go types at the package boundary instead of the sqlc pgtype wrappers. Title is the rendered title (NULL renders "(untitled <created_at>)" via DisplayTitle).
func (Conversation) DisplayTitle ¶
func (c Conversation) DisplayTitle() string
DisplayTitle renders the title for the CLI list: the set title, or the SPEC "(untitled <created_at>)" fallback when the DB title is still NULL (Req#9).
type ConversationCleaner ¶
ConversationCleaner is the narrow purge surface Delete calls to tear down a conversation's on-disk tree (workspace/ AND the .result spillover) with an os.Root no-follow cascade (D-14). It is DECLARED HERE — the consumer — so the concrete impl (sandbox.WorkspaceManager) can be injected by the composition root (main.go / chat boot) WITHOUT conversations importing sandbox: that would be an import cycle (sandbox already declares a matching shape sandbox-side only for a compile-time assertion, never importing us — landmine #4).
type CreateParams ¶
CreateParams carries the inputs for a new conversation.
type RotEvent ¶ added in v1.0.0
type RotEvent struct {
TS string // RFC3339
Action string
PairsDropped int
TokensBefore int
TokensAfter int
}
RotEvent is the domain projection of one aura.context_rot_events row — the microcompact ladder audit trail (L2.5 hard-drop). PairsDropped/TokensBefore/ TokensAfter quantify one drop; TS is the RFC3339 timestamp. The web cockpit's context-budget gauge marks these on the fill bar (D-11, Phase 25).
type ScanParams ¶
ScanParams carries the boot-scan inputs. WarnThresholdBytes <= 0 falls back to the SPEC default. The pool is read-only here (existence lookups only).
type SearchResult ¶
SearchResult is one FTS hit (the app-side excerpt is the CLI/channel's job).
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store wraps a pgx pool and the generated Queries — the canonical shape from internal/identity. Non-tx reads/writes use s.q; the atomic per-turn write (AppendTurn) and delete-then-rm wrap db.WithTx / pool ops. runDir is the $AURA_RUN_DIR root the sidecar spill writes under; turnCapBytes is AURA_CONVERSATION_TURN_CAP_BYTES (content over this spills to a sidecar file). cleaner is the optional os.Root cascade injected at boot; when nil, Delete falls back to os.RemoveAll (the pre-2b behavior).
func New ¶
New builds a Store over an open pool. A zero TurnCapBytes falls back to the SPEC default so a misconfigured caller never disables spillover entirely.
func (*Store) AppendAssistantTurnWithCacheMetric ¶
func (s *Store) AppendAssistantTurnWithCacheMetric(ctx context.Context, p AppendTurnParams, metric sqlc.InsertCacheMetricParams) error
AppendAssistantTurnWithCacheMetric writes a terminal assistant turn, folds its usage into the conversation aggregate, and appends the matching cache_metrics row in one database transaction. The Runner uses this for final assistant Events so a metric failure cannot leave the conversation with an answer the caller never receives.
func (*Store) AppendTurn ¶
func (s *Store) AppendTurn(ctx context.Context, p AppendTurnParams) error
AppendTurn writes one turn AND folds its token/cost delta into the conversation aggregates in a SINGLE db.WithTx transaction (SC-2): a failure between the turn INSERT and the aggregates UPDATE rolls the whole thing back, leaving no partial turn. When Seq <= 0, the Store row-locks the parent conversation and allocates MAX(seq)+1 inside the same tx so concurrent appenders cannot race. Content over turnCapBytes spills to a sidecar file once the final seq is known (the file write is not part of the DB atomicity); cleanupSidecarOnTxError removes a just-spilled file when the tx rolls back (M-04), and a boot scan reconciles any leftover. The row then stores content=NULL + content_sidecar_path.
func (*Store) CanonicalBranchLeaf ¶ added in v1.0.0
CanonicalBranchLeaf returns the deepest seq of a conversation's canonical branch — the default leaf LoadBranchHistory walks from when no specific branch is selected. It is 0 for a conversation with no turns. The walk from this leaf over a properly-chained canonical branch (the 0017 backfill produces parent_seq = seq-1) yields the linear history (Pitfall 3: only body turns differ per branch; the head stays byte-identical).
func (*Store) CountTurns ¶
CountTurns returns the number of persisted turns for a conversation. The Runner uses it for non-fatal bookkeeping such as auto-title eligibility; append sequence allocation happens inside AppendTurn's transaction.
func (*Store) Create ¶
func (s *Store) Create(ctx context.Context, p CreateParams) (Conversation, error)
Create persists a new active conversation and returns its projection.
func (*Store) Delete ¶
Delete removes the conversation row (conversation_turns + paused_states cascade via FK ON DELETE CASCADE), then tears down the per-conversation sidecar dir AFTER the DB delete commits. When a Cleaner is wired (2b) the teardown is the os.Root no-follow cascade (sandbox.WorkspaceManager.PurgeConversationDir) over the WHOLE <id>/ tree — workspace/ AND the .result spillover — so an attacker- planted symlink in the sidecar-writable workspace is unlinked as a LINK, never followed to its target (ROADMAP crit 2, landmine #4). With no Cleaner it falls back to os.RemoveAll. A filesystem failure is a WARN-level degradation (returned as an error the caller may log), NOT a rolled-back delete — the boot orphan scan reconciles a leftover dir at next start (SPEC Req#12).
func (*Store) ForkBranch ¶ added in v1.0.0
func (s *Store) ForkBranch(ctx context.Context, conversationID string, divergeSeq int, role, content string) (int, uuid.UUID, error)
ForkBranch creates a new sibling branch by appending a fresh turn that chains off the SAME parent as the diverging turn at divergeSeq — so the new turn REPLACES divergeSeq on a new branch rather than continuing after it (the edit-a-user-turn / regenerate semantics, RESEARCH OQ3 full tree). The old branch stays queryable (the diverging turn and its descendants are untouched). It allocates the next global seq, inserts the turn, and sets its branch_id (a fresh uuid) + parent_seq in ONE transaction so a partial fork can never leave a turn with default (canonical) pointers. Returns the new leaf seq and its branch id; the caller re-runs over LoadManagedHistoryForBranch(newLeaf). The messages[0] head is unchanged by construction — only body turns differ per branch (Pitfall 3 / the CAP-04 cache invariant). divergeSeq must exist (→ ErrTurnNotFound).
func (*Store) List ¶
List returns conversations ordered by last_active_at DESC. includeArchived adds archived rows; deleted rows are always excluded (the query filters status).
func (*Store) ListBranches ¶ added in v1.0.0
ListBranches returns the conversation's navigable branch set — one Branch per leaf turn (a turn no other turn chains off of). The BranchPicker navigates among sibling leaves; a re-run continues over the selected leaf's path (LoadManagedHistoryForBranch). A non-branched conversation reports exactly one branch (its canonical leaf), so the picker stays hidden until an edit/regenerate forks a sibling. The order is stable (canonical branch first, then by seq).
func (*Store) ListContextRotEvents ¶ added in v1.0.0
func (s *Store) ListContextRotEvents(ctx context.Context, conversationID string) ([]RotEvent, error)
ListContextRotEvents returns the microcompact ladder events for one conversation in ts-ASC order via the existing ListContextRotEvents sqlc query (no new query or migration — read-only projection at the pgtype boundary). A malformed id is a parse error the caller maps to a clean 404.
func (*Store) LoadBranchHistory ¶ added in v1.0.0
func (s *Store) LoadBranchHistory(ctx context.Context, conversationID string, leafSeq int) ([]llm.Message, error)
LoadBranchHistory reconstructs the loop history for the SELECTED branch path, the path-aware analog of LoadHistory (store.go:255). It walks leaf->root from the given leaf seq, projects each turn to an llm.Message, and repairs tool-message pairs along the path exactly like LoadHistory. For the canonical branch leaf of a non-branched conversation the result is byte-identical to LoadHistory (the migration backfilled a complete parent_seq = seq-1 chain — RESEARCH A3). The returned slice is raw; the L1/L2/L2.5 ladder (context.go) is applied by the caller around it.
func (*Store) LoadHistory ¶
LoadHistory reconstructs the loop history from conversation_turns ORDER BY seq. Two consecutive calls return byte-identical slices (Req#8): the reconstruction is a pure function of the persisted rows (sidecar-spilled content is re-read from disk; no nondeterministic source feeds the result). The returned slice is raw — the L1/L2/L2.5 ladder (context.go) is applied by the caller around it.
func (*Store) LoadManagedHistory ¶
func (s *Store) LoadManagedHistory(ctx context.Context, conversationID string, cfg ContextConfig) ([]llm.Message, error)
LoadManagedHistory loads the raw turns and applies the L1/L2/L2.5 ladder, the entry point the Runner calls (D-A2-06: the ladder is applied in/around LoadHistory). It uses the Store as the rot emitter. It loads the FULL seq-ordered history (loadTurns / ListTurnsBySeq), so a non-branched conversation stays byte-identical to the pre-0017 linear case (D-09 foundation: the branch path walk is the explicit-leaf LoadManagedHistoryForBranch; this default path is unchanged).
func (*Store) LoadManagedHistoryForBranch ¶ added in v1.0.0
func (s *Store) LoadManagedHistoryForBranch(ctx context.Context, conversationID string, leafSeq int, cfg ContextConfig) ([]llm.Message, error)
LoadManagedHistoryForBranch is the path-aware variant (D-09 / CHAT-05): it walks the SELECTED branch path (leaf -> root via parent_seq, returned root -> leaf) and feeds that deterministic turn list into the SAME L1/L2/L2.5 ladder. A leafSeq <= 0 selects the conversation's canonical branch leaf, so the default selection reconstructs the linear history (the 0017 backfill chains the canonical branch parent_seq = seq-1). The protected head (system seq=1 + the always-block) is rebuilt by the ladder exactly as in the linear case — only the body turns differ per branch (Pitfall 3), so messages[0] stays byte-identical across branches (the CAP-04 cache invariant).
func (*Store) Rename ¶
Rename sets the conversation title unconditionally (the CLI `aura chat rename`).
func (*Store) SearchConversationTurns ¶
func (s *Store) SearchConversationTurns(ctx context.Context, query string, limit int) ([]SearchResult, error)
SearchConversationTurns wraps the LOCKED cross-slice FTS query (SPEC Req#13 / D-A5-03): content % $1 ORDER BY similarity(content,$1) DESC LIMIT $2. The query SQL is the contract Telegram /search (Phase 13) reuses byte-for-byte; this wrapper only projects pgtype at the boundary. The SQL is never rewritten here.
func (*Store) SetBranchPointers ¶ added in v1.0.0
func (s *Store) SetBranchPointers(ctx context.Context, conversationID string, seq int, branchID uuid.UUID, parentSeq int) error
SetBranchPointers sets a turn's branch/parent pointers — the branch-write seam plan 25-07 uses when an edit/regenerate forks a new sibling branch off an existing parent turn. parentSeq <= 0 writes a NULL parent (a branch root). It validates the ids at the boundary (Pitfall 5) and routes through the SetTurnBranchPointers sqlc query.
func (*Store) SetTitleIfNull ¶
SetTitleIfNull idempotently sets the title only when it is still NULL (the auto-title worker drives this — D-A5-01). A repeat call after the title is set is a no-op (the WHERE title IS NULL filters it).
type Sweeper ¶
type Sweeper struct {
// contains filtered or unexported fields
}
Sweeper is the interval-driven background worker that re-runs the boot sidecar sweep so aged/orphaned sidecars are reclaimed without a daemon restart (M-06). Its lifecycle mirrors the Runner's auto-title WaitGroup: Start launches one goroutine, Stop joins it under a bounded wait (goleak-clean), and a cancelled Start ctx winds it down too.
func NewRunDirSweeper ¶
NewRunDirSweeper builds the production sweeper whose tick re-runs ScanOrphans over the run dir (the boot sweep) on the given interval. The pool is read-only inside the scan (existence lookups only). A non-positive interval yields a sweeper that Start no-ops.
func NewSweeper ¶
func NewSweeper(cfg SweeperConfig) *Sweeper
NewSweeper builds a periodic sweeper from the config. It does NOT start the worker — the caller wires Start into the daemon boot and Stop into its shutdown.
type SweeperConfig ¶
type SweeperConfig struct {
// Interval is the tick period. A non-positive interval DISABLES the periodic
// worker (the boot sweep still runs); Start then launches no goroutine.
Interval time.Duration
// Sweep is the work each tick performs. The serve composition root wires this to
// a closure over ScanOrphans (orphan dirs + tmp TTL + audit size WARN), reusing the
// boot sweep's age/orphan rules — this worker adds NO new reclaim policy.
Sweep func(context.Context)
}
SweeperConfig configures the periodic sidecar-sweep worker (audit M-06 part 2). The boot ScanOrphans is a one-shot at startup; in a long-running `serve` daemon sidecars accumulate between reboots, so this worker re-runs the SAME sweep on an interval.
type Turn ¶
type Turn struct {
Seq int
Role string
Content string
ContentSidecarPath string
ToolCallID string
ToolCalls []byte
InputTokens int
OutputTokens int
CachedTokens int
}
Turn is the domain projection of one aura.conversation_turns row, ready to be re-hydrated into an llm.Message by LoadHistory. ToolCalls is the raw jsonb.