Documentation
¶
Overview ¶
Package memory provides persistent user memory backed by pgvector.
Memories are facts extracted from conversations via LLM, deduplicated by cosine similarity on embeddings, and injected into chat prompts. All logic runs in-process — no external services required.
Index ¶
- Constants
- Variables
- func ContainsSecrets(text string) bool
- func FormatConversation(userInput, assistantResponse string) string
- func FormatMemories(identity, preference, project, contextual []*Memory, maxTokens int) string
- func SanitizeLines(text string) string
- type AddOpts
- type ArbitrationResult
- type Arbitrator
- type Category
- type ExtractedFact
- type Memory
- type Operation
- type Scheduler
- type SessionCleaner
- type Store
- func (s *Store) ActiveCount(ctx context.Context, ownerID string) (int, error)
- func (s *Store) Add(ctx context.Context, content string, category Category, ownerID string, ...) (retErr error)
- func (s *Store) All(ctx context.Context, ownerID string, category Category) ([]*Memory, error)
- func (s *Store) Delete(ctx context.Context, id uuid.UUID, ownerID string) error
- func (s *Store) DeleteAll(ctx context.Context, ownerID string) error
- func (s *Store) DeleteStale(ctx context.Context) (int, error)
- func (s *Store) HardDeleteInactive(ctx context.Context, cutoff time.Time) (int, error)
- func (s *Store) HybridSearch(ctx context.Context, query, ownerID string, topK int) ([]*Memory, error)
- func (s *Store) Memories(ctx context.Context, ownerID string, limit, offset int) ([]*Memory, int, error)
- func (s *Store) Memory(ctx context.Context, id uuid.UUID, ownerID string) (*Memory, error)
- func (s *Store) Search(ctx context.Context, query, ownerID string, topK int) (results []*Memory, retErr error)
- func (s *Store) Supersede(ctx context.Context, oldID, newID uuid.UUID) error
- func (s *Store) UpdateAccess(ctx context.Context, ids []uuid.UUID) error
- func (s *Store) UpdateDecayScores(ctx context.Context) (int, error)
Constants ¶
const ( // AutoMergeThreshold: similarity >= this auto-merges (UPDATE in-place). AutoMergeThreshold = 0.95 // ArbitrationThreshold: similarity in [0.85, 0.95) triggers LLM arbitration. ArbitrationThreshold = 0.85 )
Two-threshold dedup constants.
const ArbitrationTimeout = 30 * time.Second
ArbitrationTimeout is the context timeout for LLM arbitration calls.
const DecayInterval = 1 * time.Hour
DecayInterval is how often the scheduler recalculates decay scores.
const EmbedTimeout = 15 * time.Second
EmbedTimeout is the context timeout for embedding API calls. 15s accommodates remote providers (Gemini, OpenAI) with network latency.
const MaxContentLength = 500
MaxContentLength is the maximum length for a single memory fact in bytes.
const MaxFactsPerExtraction = 5
MaxFactsPerExtraction is the maximum number of facts to extract per turn.
const MaxPerUser = 1000
MaxPerUser is the hard cap on active memories per user. Prevents unbounded growth; HNSW handles search efficiently at this scale.
const MaxSearchQueryLen = 1000
MaxSearchQueryLen caps query length for HybridSearch to prevent abuse.
const RedactedPlaceholder = "[REDACTED]"
RedactedPlaceholder replaces lines containing secrets.
const VectorDimension = rag.VectorDimension
VectorDimension matches the embedding column size. Canonical source: rag.VectorDimension. Aliased here to avoid changing 20+ references in memory package tests.
Variables ¶
var ( ErrNotFound = errors.New("memory not found") ErrForbidden = errors.New("forbidden") )
Sentinel errors for memory operations.
Functions ¶
func ContainsSecrets ¶
ContainsSecrets reports whether text contains any known secret pattern.
func FormatConversation ¶
FormatConversation formats a user/assistant exchange for extraction. Inputs are sanitized to prevent delimiter injection into nonce-bounded prompts.
func FormatMemories ¶
FormatMemories renders memories into a prompt-ready string using greedy priority. Categories are rendered in order: identity > preference > project > contextual. Each section only appears if it has memories. Budget flows from higher to lower priority categories — remaining tokens from identity flow to preference, etc.
Memory content is sanitized to prevent prompt injection via XML-like tags.
func SanitizeLines ¶
SanitizeLines processes text line by line, replacing lines that contain secrets with "[REDACTED]". Lines without secrets pass through unchanged.
Types ¶
type AddOpts ¶
type AddOpts struct {
Importance int // 1-10, default 5 if 0
ExpiresIn string // "7d", "30d", "90d", or "" for category default
}
AddOpts carries optional parameters for Add(). Zero value gives safe defaults: importance=5, no expiry override.
type ArbitrationResult ¶
type ArbitrationResult struct {
Operation Operation `json:"operation"`
Content string `json:"content,omitempty"` // merged content (for UPDATE)
Reasoning string `json:"reasoning,omitempty"` // explanation (for logging)
}
ArbitrationResult is the LLM's decision for a memory conflict.
type Arbitrator ¶
type Arbitrator interface {
Arbitrate(ctx context.Context, existing, candidate string) (*ArbitrationResult, error)
}
Arbitrator resolves conflicts between existing and candidate memories. Defined here for Store.Add() parameter; implemented in chat package.
type Category ¶
type Category string
Category classifies a memory fact.
const ( // CategoryIdentity represents persistent user traits (name, location, role). CategoryIdentity Category = "identity" // CategoryContextual represents situational facts (recent decisions, temporary state). CategoryContextual Category = "contextual" // CategoryPreference represents opinions and choices (tools, frameworks, coding style). CategoryPreference Category = "preference" // CategoryProject represents current work context (project name, tech stack, deadlines). CategoryProject Category = "project" )
func AllCategories ¶
func AllCategories() []Category
AllCategories returns all valid categories in priority order (identity first).
func (Category) DecayLambda ¶
DecayLambda returns the exponential decay rate (per hour) for this category. Lambda = ln(2) / half-life, where half-life = TTL/2. Returns 0 for categories that never expire.
func (Category) DefaultTTL ¶
DefaultTTL returns the default time-to-live for memories in this category. Returns 0 for categories that never expire (identity).
type ExtractedFact ¶
type ExtractedFact struct {
Content string `json:"content"`
Category Category `json:"category"`
Importance int `json:"importance,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"` // "7d", "30d", "90d", "" (never)
}
ExtractedFact is a fact extracted from a conversation by the LLM.
type Memory ¶
type Memory struct {
ID uuid.UUID
OwnerID string
Content string
Category Category
SourceSessionID uuid.UUID // zero value if source session was deleted
Active bool
CreatedAt time.Time
UpdatedAt time.Time
Importance int // 1-10 scale
AccessCount int // times returned in search results
LastAccessedAt *time.Time // nil if never accessed
DecayScore float64 // 0.0-1.0, recalculated periodically
SupersededBy *uuid.UUID // nil if not superseded
ExpiresAt *time.Time // nil = never expires
Score float64 // populated by HybridSearch only
}
Memory represents a single extracted fact about a user.
type Scheduler ¶
type Scheduler struct {
// contains filtered or unexported fields
}
Scheduler periodically recalculates decay scores and expires stale memories.
func NewScheduler ¶
NewScheduler creates a decay scheduler with the default interval.
func (*Scheduler) Run ¶
Run blocks until ctx is canceled. Runs UpdateDecayScores() and DeleteStale() on each tick. Callers must track the goroutine with a WaitGroup.
func (*Scheduler) SetRetention ¶
func (s *Scheduler) SetRetention(retentionDays int, cleaner SessionCleaner)
SetRetention configures data retention cleanup. retentionDays <= 0 disables retention cleanup.
type SessionCleaner ¶
type SessionCleaner interface {
DeleteOldSessions(ctx context.Context, cutoff time.Time) (int, error)
}
SessionCleaner is an interface for deleting old sessions. Implemented by session.Store. Defined here to avoid a circular import.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store manages persistent memory backed by PostgreSQL + pgvector.
Store is safe for concurrent use by multiple goroutines.
func (*Store) ActiveCount ¶
ActiveCount returns the count of active memories for a user.
func (*Store) Add ¶
func (s *Store) Add(ctx context.Context, content string, category Category, ownerID string, sessionID uuid.UUID, opts AddOpts, arb Arbitrator) (retErr error)
Add inserts a new memory or updates an existing near-duplicate.
Add is not a pure CREATE — it includes dedup check, merge, arbitration, and potential reactivation of soft-deleted duplicates.
Two-threshold dedup algorithm:
- Validate inputs, embed content (outside transaction)
- Begin transaction with per-owner advisory lock
- Find nearest neighbor across all memories (active + inactive) for the owner
- Similarity >= 0.95 (AutoMerge): UPDATE existing in-place
- Similarity in [0.85, 0.95) (Arbitration): call arb.Arbitrate() if non-nil - ADD: insert new row - UPDATE: update existing with merged content - DELETE: soft-delete existing, insert new - NOOP: discard candidate
- Similarity < 0.85: always INSERT new row
- Commit, then evict if over cap (best-effort)
The transaction + advisory lock prevents TOCTOU races where concurrent Add() calls for the same owner could find the same nearest neighbor and produce a lost update.
NOTE: The arbitration LLM call and OpUpdate re-embedding happen inside the transaction. This is acceptable because the advisory lock is per-owner (not global) and memory extraction is a low-throughput background operation. Named return retErr is used by the deferred timing log.
func (*Store) All ¶
All returns all active memories for a user, optionally filtered by category. When category is empty, returns all categories. Excludes superseded and expired memories.
func (*Store) Delete ¶
Delete soft-deletes a memory by setting active = false. Returns ErrNotFound if the memory doesn't exist. Returns ErrForbidden if the memory belongs to a different owner.
func (*Store) DeleteStale ¶
DeleteStale soft-deletes memories past their expires_at timestamp. Operates globally (all owners). Returns number of memories expired.
func (*Store) HardDeleteInactive ¶
HardDeleteInactive permanently deletes inactive memories older than cutoff. Returns the number of deleted rows.
PRIVILEGED: This is a cross-tenant operation intended only for the background retention scheduler (Scheduler.runOnce). It must NOT be exposed via any API endpoint.
func (*Store) HybridSearch ¶
func (s *Store) HybridSearch(ctx context.Context, query, ownerID string, topK int) ([]*Memory, error)
HybridSearch combines vector similarity, full-text search, and decay score. Results are ranked by composite score: 0.6*vector + 0.2*text + 0.2*decay. Populates Memory.Score with the composite relevance value. Calls UpdateAccess on returned results (log-and-continue on error).
func (*Store) Memories ¶
func (s *Store) Memories(ctx context.Context, ownerID string, limit, offset int) ([]*Memory, int, error)
Memories returns paginated active memories for a user. Returns memories + total count. Excludes superseded and expired entries.
NOTE: When offset >= total matching memories, returns ([], 0, nil). The zero total indicates no rows were scanned, not that zero memories exist.
func (*Store) Memory ¶
Memory retrieves a single memory by ID with ownership check. Returns ErrNotFound if the memory doesn't exist. Returns ErrForbidden if the memory belongs to a different owner.
func (*Store) Search ¶
func (s *Store) Search(ctx context.Context, query, ownerID string, topK int) (results []*Memory, retErr error)
Search finds memories similar to the query, filtered by owner. Returns up to topK results ordered by cosine similarity descending. Excludes superseded and expired memories.
Named returns are used by the deferred timing log.
func (*Store) Supersede ¶
Supersede marks an old memory as superseded by a new one. Validation:
- Self-reference check: oldID == newID → error
- Owner match: atomic UPDATE ensures same owner_id
- Double-supersede guard: WHERE superseded_by IS NULL
- Cycle detection: walks chain up to 10 levels (within transaction)
The cycle detection read and supersede UPDATE are wrapped in a single transaction to prevent TOCTOU races where concurrent Supersede calls could create a cycle between the read and write.
func (*Store) UpdateAccess ¶
UpdateAccess increments access_count and sets last_accessed_at for the given IDs. Called from HybridSearch with log-and-continue pattern.
Best-effort: runs outside a transaction. A partial update (some rows updated, some not) is acceptable — access tracking is advisory, not authoritative.
func (*Store) UpdateDecayScores ¶
UpdateDecayScores recalculates decay_score for all active memories in a single query. Uses a VALUES join to apply per-category lambda rates atomically. Does NOT update updated_at to preserve the decay index. Returns total number of rows updated.
The Go-side formula must stay in sync with the SQL expression:
Go: math.Exp(-lambda * hours) SQL: exp(-v.lambda * extract(epoch from (now() - m.updated_at)) / 3600.0)
NOTE: The explicit ::float8 cast is required because pgx v5 sends Go float64 as an untyped parameter. When PostgreSQL sees `$1 = 0`, it infers the parameter as integer, silently truncating 0.001925 → 0. The cast forces float8 inference. See: github.com/jackc/pgx/issues/2125