memory

package
v0.0.0-...-d8a54d3 Latest Latest
Warning

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

Go to latest
Published: Feb 21, 2026 License: MIT Imports: 21 Imported by: 0

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

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

View Source
const ArbitrationTimeout = 30 * time.Second

ArbitrationTimeout is the context timeout for LLM arbitration calls.

View Source
const DecayInterval = 1 * time.Hour

DecayInterval is how often the scheduler recalculates decay scores.

View Source
const EmbedTimeout = 15 * time.Second

EmbedTimeout is the context timeout for embedding API calls. 15s accommodates remote providers (Gemini, OpenAI) with network latency.

View Source
const MaxContentLength = 500

MaxContentLength is the maximum length for a single memory fact in bytes.

View Source
const MaxFactsPerExtraction = 5

MaxFactsPerExtraction is the maximum number of facts to extract per turn.

View Source
const MaxPerUser = 1000

MaxPerUser is the hard cap on active memories per user. Prevents unbounded growth; HNSW handles search efficiently at this scale.

View Source
const MaxSearchQueryLen = 1000

MaxSearchQueryLen caps query length for HybridSearch to prevent abuse.

View Source
const RedactedPlaceholder = "[REDACTED]"

RedactedPlaceholder replaces lines containing secrets.

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

View Source
var (
	ErrNotFound  = errors.New("memory not found")
	ErrForbidden = errors.New("forbidden")
)

Sentinel errors for memory operations.

Functions

func ContainsSecrets

func ContainsSecrets(text string) bool

ContainsSecrets reports whether text contains any known secret pattern.

func FormatConversation

func FormatConversation(userInput, assistantResponse string) string

FormatConversation formats a user/assistant exchange for extraction. Inputs are sanitized to prevent delimiter injection into nonce-bounded prompts.

func FormatMemories

func FormatMemories(identity, preference, project, contextual []*Memory, maxTokens int) string

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

func SanitizeLines(text string) string

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.

func Arbitrate

func Arbitrate(ctx context.Context, g *genkit.Genkit, modelName string,
	existing, candidate string) (*ArbitrationResult, error)

Arbitrate asks the LLM to resolve a conflict between an existing memory and a new candidate fact. Called when cosine similarity is in [0.85, 0.95).

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

func (c Category) DecayLambda() float64

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

func (c Category) DefaultTTL() time.Duration

DefaultTTL returns the default time-to-live for memories in this category. Returns 0 for categories that never expire (identity).

func (Category) ExpiresAt

func (c Category) ExpiresAt() *time.Time

ExpiresAt calculates the expiration timestamp from category TTL. Returns nil for categories that never expire (identity).

func (Category) Valid

func (c Category) Valid() bool

Valid reports whether c is a known category.

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.

func Extract

func Extract(ctx context.Context, g *genkit.Genkit, modelName, conversation string) ([]ExtractedFact, error)

Extract uses an LLM to extract user-specific facts from a conversation. Returns empty slice if no facts found.

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 Operation

type Operation string

Operation is the LLM-decided action for a memory conflict.

const (
	OpAdd    Operation = "ADD"
	OpUpdate Operation = "UPDATE"
	OpDelete Operation = "DELETE"
	OpNoop   Operation = "NOOP"
)

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler periodically recalculates decay scores and expires stale memories.

func NewScheduler

func NewScheduler(store *Store, logger *slog.Logger) *Scheduler

NewScheduler creates a decay scheduler with the default interval.

func (*Scheduler) Run

func (s *Scheduler) Run(ctx context.Context)

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 NewStore

func NewStore(pool *pgxpool.Pool, embedder ai.Embedder, logger *slog.Logger) (*Store, error)

NewStore creates a memory Store.

func (*Store) ActiveCount

func (s *Store) ActiveCount(ctx context.Context, ownerID string) (int, error)

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:

  1. Validate inputs, embed content (outside transaction)
  2. Begin transaction with per-owner advisory lock
  3. Find nearest neighbor across all memories (active + inactive) for the owner
  4. Similarity >= 0.95 (AutoMerge): UPDATE existing in-place
  5. 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
  6. Similarity < 0.85: always INSERT new row
  7. 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

func (s *Store) All(ctx context.Context, ownerID string, category Category) ([]*Memory, error)

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

func (s *Store) Delete(ctx context.Context, id uuid.UUID, ownerID string) error

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

func (s *Store) DeleteAll(ctx context.Context, ownerID string) error

DeleteAll soft-deletes all active memories for a user.

func (*Store) DeleteStale

func (s *Store) DeleteStale(ctx context.Context) (int, error)

DeleteStale soft-deletes memories past their expires_at timestamp. Operates globally (all owners). Returns number of memories expired.

func (*Store) HardDeleteInactive

func (s *Store) HardDeleteInactive(ctx context.Context, cutoff time.Time) (int, error)

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

func (s *Store) Memory(ctx context.Context, id uuid.UUID, ownerID string) (*Memory, error)

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

func (s *Store) Supersede(ctx context.Context, oldID, newID uuid.UUID) error

Supersede marks an old memory as superseded by a new one. Validation:

  1. Self-reference check: oldID == newID → error
  2. Owner match: atomic UPDATE ensures same owner_id
  3. Double-supersede guard: WHERE superseded_by IS NULL
  4. 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

func (s *Store) UpdateAccess(ctx context.Context, ids []uuid.UUID) error

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

func (s *Store) UpdateDecayScores(ctx context.Context) (int, error)

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

Jump to

Keyboard shortcuts

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