Memory write & recall

Memory is not "a summary of the chat log" but its own table: with a scope, an importance value and an access counter. Recall scores each candidate with similarity + importance + recency decay + access frequency + a scope bonus. That formula explains why some memories keep resurfacing and others never return.

Manual create source = manual Conversation extract source = conversation Work session extract source = work Embedding vec_memory_embeddings Scoring 0.4 similarity + 0.3 importance 0.2 recency + 0.1 access freq Scope filter Global always matches Session by TopicId · Project by ProjectId Injected into context top 5 by default hits update time and count
All three write paths share one vector store and one scoring function: writes decide what exists, scoring and scope decide what the model sees this turn.

Memory model

FieldNotes
ScopeGlobal / Session / Project, defaulting to Global
ProjectId / TopicIdScope carriers: Project requires ProjectId, Session requires an existing TopicId
Sourcemanual / conversation / work
CategoryFree-form label passed to the model with the content
ImportanceDefault 0.5, clamped to 0…1 on create and update
AccessCount / LastAccessedAtRecall side effects: a hit increments the counter and refreshes the timestamp; both feed scoring
ScoreReturned only in search results, never persisted

Three write paths

PathBehaviour
POST /api/memoriesManual create; Session scope is not allowed here (session memories come from extraction) and Project scope must carry ProjectId
POST /api/memories/extract/{topicId}Conversation extraction (conversation); the automatic entry point weighs user message count, elapsed time and density
POST /api/memories/extract-work/{sessionId}Work session extraction (work); fires every 10 user messages and only when the session belongs to a managed project
Both constraints are deliberate: work sessions are noisy and expensive, so skipping extraction beats writing a pile of meaningless memories.

Recall and scoring

ComponentWeight / formula
Similarity0.4 × similarity from the vector search
Importance0.3 × importance
Recency0.2 × exp(-0.05 × days since last access)
Access frequency0.1 × log(1 + AccessCount) / log(51), capped at 50 hits
Scope bonusExtra weight per scope; Global always matches, Session matches the same TopicId, Project the same ProjectId

Results are sorted by total score and truncated: topK = 5 when injecting context, topK = 10 on the search page. Every hit immediately writes back LastAccessedAt and AccessCount, so a memory that was used is more likely to be used again. That positive feedback loop is the first thing to check when an irrelevant memory keeps showing up.

Work sessions inject the union of global and current-project memories (passing the project's ManagedProjectId), which keeps project-specific rules inside their project.

Storage backends

  • SQLite uses the sqlite-vec table vec_memory_embeddings; PostgreSQL uses the vector column on MemoryEmbeddings.
  • There is currently no full-text path for memories: recall is vector search plus scoring.
  • Editing a memory re-computes its embedding; deleting soft-deletes the row and removes the embedding and its vector row.
  • There is no version history for memories: edits overwrite.

Constants and observability

ConstantValue
Score weightsAlpha = 0.4 · Beta = 0.3 · Gamma = 0.2 · Delta = 0.1
Decay lambdaDecayLambda = 0.05
Work auto-extract intervalAutoExtractInterval = 10 user messages
Conversation time signalTimeTriggerMinutes = 30

Practice notes

  • Wrong scope is the most common memory problem: a project-specific convention stored as Global pollutes unrelated conversations.
  • Importance is the only long-term manual lever: drop noisy memories towards 0.1 and decay will take care of them.
  • Switching embedding models requires re-indexing memories, otherwise the similarity term is skewed — same rule as note indexing.
  • Automatic extraction and Dream consolidation are separate: the former writes, the latter merges; see Dream consolidation for the merge rules.