NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Memory lifecycle and delivery
Developer reference

Memory lifecycle and delivery

Owning code and boundaries

  • Retention policy, vitality and eviction forecast: src/brain/memoryVitality.ts. Vitality history: src/brain/memoryVitalityHistory.ts.
  • Retention sweep and its daily schedule: src/daemon/retentionSweeps.ts and src/daemon/maintenance.ts:325-344.
  • Guarded soft delete with the per-account daily budget: MemoryStore.autoEvict in src/store/memoryStore.ts:196-216.
  • Retention config clamping and REST validation: src/store/configStore.ts:454-473 and src/api/schemas/config.ts:96-111.
  • Embedding vector space identity: src/embeddings/embeddingService.ts:119-131. Indexing queue: src/embeddings/embedQueue.ts.
  • Categorization: src/brain/memoryCategorizer.ts. Retrieval and ranking: src/brain/memoryService.ts. Recall scope: src/brain/memoryRecallScope.ts.
  • Turn-start recall: src/brain/session/memoryBlock.ts, resolved in src/brain/service/turnContextBuilder.ts:144-171. Mid-turn recall: src/brain/session/liveRecall.ts.
  • Delivery ledger shared by all recall and read paths: src/brain/session/deliveredMemories.ts.
  • Model-facing rendering of one memory: src/brain/session/memoryPrompt.ts. Curator: src/brain/memoryCurator.ts.
  • Schema v44 importance migration: src/store/db.ts:1018-1076.

Boundaries: retrieval never records delivery or usage. Delivery is counted only by the recall path after the block reaches the provider, through markRecalled and DeliveredMemories. The web reads the retention policy and vitality from the daemon. It never computes them itself.

Memory importance, vitality and retention

src/brain/memoryVitality.ts is the one authority for the ten-level importance scale (MIN_IMPORTANCE, MAX_IMPORTANCE), the default retention policy (half-lives of 1, 3, 7, 14, 21, 30, 45, 60, 90 and 180 days, floor 10, grace 14 days, daily deletion limit 5), the accepted bounds (RETENTION_BOUNDS: half-life 0.1 to 365 days, floor 1 to 90, grace 0 to 365, daily deletion limit 1 to 200) and the verbal scale shared by the tool descriptions and the curator prompt. Vitality is a pure function of importance, use count, last use and the clock; there is no pin and no zero half-life. vitality refuses a policy without a positive half-life for the level. isEvictable checks the floor and the half-life only for a memory past its grace period, because the grace check runs first (src/brain/memoryVitality.ts:104-110). A malformed value therefore fails loudly instead of exempting a level, but only once a memory reaches that check. evictableAt is the closed-form eviction forecast on the daily check grid, confirmed against isEvictable; buildVitalityHistory in src/brain/memoryVitalityHistory.ts uses it for the forecast, the route GET /memory/:id/vitality-history serves the result (src/api/routes/memory.ts:397-410), and the web only displays it (web/modules/memory/MemoryVitalityChart.tsx). Retrieval scores importance as (importance - 1) / 9 through the existing MemoryService weights (importanceWeightOf in src/brain/memoryService.ts:516-519; the weight split is in weights(), lines 194-207).

ConfigStore clamps a positive runtime.memoryRetention value into RETENTION_BOUNDS and keeps the current value for a zero, negative or non-finite half-life, floor or daily deletion limit, and rounds the limit to a whole count; the REST schema refuses a half-life, floor or limit outside the bounds, and a fractional limit, with a 400. The web editor mirrors defaults and bounds as text pinned by web/tests/modules/settings/memoryRetentionParity.test.ts, because the web cannot import the daemon. The web mirror is web/modules/settings/MemoryRetentionModal.tsx. graceDays has no REST refusal; ConfigStore clamps it into its bound (src/api/schemas/config.ts:96-100).

The retention sweep (runMemoryEvictionSweep in src/daemon/retentionSweeps.ts, run at boot and every 24 hours from src/daemon/maintenance.ts:325-344) pages through every account's active memories with a keyset cursor (MemoryStore.listActiveForEviction) and sends each eligible candidate through MemoryStore.autoEvict. That method runs in one immediate transaction: it re-reads the row, re-checks eligibility, counts the account's audited auto-evict deletes in the rolling 24 hours before the sweep's own clock, refuses past retention.dailyDeletionLimit (default 5, read once per sweep), and otherwise soft-deletes and audits. Lowering the limit counts the deletes already in the window. The scan page size and the delete budget are independent. The ledger is memory_events itself, so a restart or a second process cannot reset the budget. Eviction stays a soft delete; restore is unchanged.

Schema version 44 maps stored ranks 1, 2, 3, 4, 5 to 1, 3, 5, 7, 9 for every status, replaces the stored half-life table with the ten-level defaults in the same transaction (clamping the kept enabled flag, grace and floor; a floor of 0 becomes 10 with retention switched off, which keeps the old pause), and appends one update audit event per changed row, with actor migration (src/store/db.ts:1044-1052) without touching historical audit JSON. The column default is 5 on fresh databases; every INSERT in MemoryStore names the importance because an upgraded database keeps its old column default. The same transaction bumps the settings revision when it rewrites the retention block, so an open editor that still holds the old five-level policy cannot write it back (src/store/db.ts:1036-1038, 1074).

Memory indexing and automatic categorization

EmbeddingService.memorySpace(config) resolves the actual endpoint through embeddingEndpoint and returns a secret-free provider/endpoint/requested-width identity plus the model. MemoryStore requires that active space for vector reads, queue selection and writes (src/store/memoryStore.ts:498-506, delegating to src/store/memoryEmbeddingStore.ts); all three use the same identity match beside body freshness. EmbeddingService.snapshotConfig freezes the resolved endpoint and credentials without losing provider usage attribution. Queue, owner reindex and retrieval derive both request and space from that one snapshot, preventing endpoint A-B-A changes from mislabelling vectors. The worker uses the same embeddingEndpoint snapshot. Writers resolve the active space again at the synchronous write. A provider, endpoint, model or requested-width change rejects late vectors, even at equal vector dimensions. Existing cached vectors carrying only the old provider id are obsolete and replaced by the normal bounded queue, without accepting a legacy vector space. Retrieval and duplicate detection fall back to their existing keyword/no-match behavior until indexing catches up. No embedding configuration means no indexing work. One real consumer is MemoryService.findSimilar, which can never compare a new endpoint's query against an old endpoint's equal-width memory vector.

Project MemoryAdd passes its shared-pool options and project fallback into MemoryCategorizer.classifyRow (src/brain/tools/memoryTools.ts:240-244). That seam preserves the source row revision/body/category and the target category fingerprint, applies fallback only when classification has no result, and retains inference-model audit attribution. Manual edits and revoked shared access always win over late classification. Without a project fallback, unavailable inference keeps the existing category. Both dedicated memory settings routes (src/api/routes/memory.ts:97-99 and 276-278) pass connectedOAuthAccounts into ConfigStore.update, just like general settings; stored provider aliases cannot hide canonical OAuth validation.

Memory delivery in a conversation

memoryRecallScope infers host projects from canonical real paths and picks the longest containing project root. Containment rejects a relative .., a ../ path and an absolute relative result; real child names such as ..cache remain inside the project. A validated selected project id takes precedence for managed conversations. The resulting categories include the account's global categories and that project's permitted personal and shared categories; without a project, only global personal categories are recalled.

MemoryService.retrieve(userId, query, opts) returns only { memories } (RetrieveResult, src/brain/memoryService.ts:87-89). It selects by semantic relevance, importance and vitality, deduplicates and packs within the count and UTF-8 body-byte budget. A successful embedding in which no candidate clears the relevance floor falls back to keyword matches only, without recency (src/brain/memoryService.ts:255-256, 434); unavailable embeddings use keyword matches plus recency. An empty query makes no embedding request. Retrieval never records delivery or increments usage itself. memoryBlock, liveRecall and MemorySearch consume the selected rows; delivery accounting remains in markRecalled.

MemoryCurator reads its configured operation budget once at the start of each post-turn run (src/brain/memoryCurator.ts:87-88). A zero budget disables the whole pass before semantic search, inference resolution or writes, so disabled automatic curation incurs no embedding or extraction-model cost. Positive budgets retain the existing capped mutations and configured near-duplicate threshold. Categorization audit attribution comes from the model captured in MemoryCategorizer.classifyDecision, not a separately resolved model.

src/brain/session/memoryPrompt.ts owns the model-facing memory element used by both turn-start memoryBlock and mid-turn liveRecall. Pass renderMemoryForModel(memory, now) the typed id, body, kind, importance and optional update timestamp. Stored rows adapt updated_at at the turn-start boundary; the live retrieval already projects updatedAt. Each element carries the same age and staleness warning, escapes attribute text and neutralizes its own closing tag before the outer untrusted frame is applied. Rendering performs no retrieval, selection, byte-budget adjustment or delivery accounting; an empty selection still emits nothing. Live recall charges the complete framed bytes to its existing turn budget, while retrieval retains its body-byte selection budget. Frozen historical blocks are not rewritten. Memory tool results keep their separate id/prose format and structured delivery annotations.

The turn-start recall in TurnContextBuilder waits for brain.limits.memoryRecallWaitMs (default 5,000 ms, bounded 0–30,000); 0 sends the first model call without waiting. Its single scoped retrieval keeps running after the deadline. Only successful provider preflight queues that same result for liveRecall through the shared typed-record append seam on a subsequent model call; a client fence or refused preflight leaves no queued recall or delivery count behind. If a turn finishes first, its live session retains the result for the next turn. Results never consume a live-search pass, are rechecked against the active-branch delivery set at injection, and count usage only after the anchored block is stored. When that recheck drops memories another path delivered meanwhile, liveRecall does not send the shortened block: it calls the result's optional MemoryBlockResult.again() once (an async function, src/brain/session/memoryBlock.ts:54, called at src/brain/session/liveRecall.ts:366), which runs the same scoped retrieval (one more embedding request) against the current delivery set, and delivers that result on a later model call without awaiting it in the hook. The re-run is not retried again. A disposed session cannot carry a still-pending result across restart; no invisible delivery is counted. Live recall logs each search's issue-to-settle latency. A settled search returns only memories unseen when it ran, so rows dropped at consumption were delivered since by another path; the search then releases its query lock and the next pass may repeat the same query to fill the freed slots, bounded by the configured pass count. With recall disabled or absent, no search starts. For example, a slow embedding for a short first message need not delay the provider, but its original message's memory can still appear after the first tool result.

src/brain/session/deliveredMemories.ts is the shared delivery seam for turn-start recall, nonblocking live recall, MemorySearch and MemoryListRecent. Inject the live session's DeliveredMemories into a recall path and ask has(id, body) before rendering. The two recall paths also hand has to MemoryService.retrieve as RetrieveOpts.exclude, which passes over delivered memories before it fills the count and byte cap; filtering only the capped result left a long conversation with an empty recall once its best matches had been shown. Example: with a cap of 2 and the top two matches delivered, the third and fourth are returned. When every memory that cleared the relevance floor is excluded, the result is empty rather than a keyword fallback. The keyword fallback reads its sources in widening pages until excluded or out-of-scope rows no longer crowd candidates out of its window. After a recall block is actually handed off, call record(id, body); a failed live attachment calls withdraw(id, reason). The read tools use has and show an id-only “already in context” marker for an unchanged result. They attach the exact emitted id/body versions to the provider-visible tool result's details.memoryDeliveries, never infer a new delivery from headings in its rendered text. They do not record execution alone: code mode forwards each structured version whose complete body survives in the provider-visible output, including individually retained bodies after truncation. A nested call whose result is not printed contributes no delivery. With no delivery seam, standalone tool harnesses render their usual complete rows.

DeliveredMemories.installToolDelivery wraps PI's existing afterToolCall hook after delivery transforms. It counts only fresh id/body versions remaining in admitted direct or code-mode output, filters spilled versions out of the persisted annotation, and never marks a nested tool execution itself. Code-mode's existing truncation formatter reports retained contiguous source spans for individual plain/JSON body admission; generated notices cannot masquerade as memories. Core spills use only the original retained preview, not their notice. Journal-read and accounting failures preserve the admitted tool result. Duplicate reports of a retained version do not count twice. The live-recall attachment is admitted before accounting; refusal withdraws its process-local copy without incrementing usage. An accounting failure is logged and does not remove an already durable block from the provider's current context. The dashboard digest similarly reuses the recap request's bounded per-user usage projection rather than repeating its origin aggregate.

The active PI journal branch is the durable authority, not a second table or a bare-id ledger. The reader projects the messages retained after the latest compaction, starting at its firstKeptEntryId tail, and ignores stripped memory frames. Turn-start and live recall persist exact id/body versions in hidden native elowen.turn-context records anchored to their PI journal entry. Records outside retained context or retired as elowen.context-cleared contribute no delivery identities. Old direct memory-tool results without structured details use a narrow multiline text fallback. Migration 57 externalizes historical turn framing, and migration 58 removes redundant stored id lists before the strict context reader runs. A per-session derived snapshot is invalidated by the store's journal-change notification on append, branch movement, compaction and in-place cold rewrites; batched lookups read one projection until that event, rather than reloading history for every memory. The session registry unsubscribes at disposal (src/brain/session/liveRegistry.ts:137). A short-lived in-memory pending entry bridges the interval before a delivered frame reaches the journal; it is withdrawn on failed attachment, anchor loss, compaction or an intervening journal append without that output. For example, if memory #7 is already in a retained turn-start frame, live recall and the read tools omit its unchanged body; an edited body is sent once, then recognized on rehydrate and on a fork copied from that branch. Cold stripping may free the earlier delivery only when the existing cache-expiry and no-work-in-flight gates permit retiring that context record without rewriting user words. A respawn does not itself mean compaction. MemoryEmbeddingStore uses one internal embeddingIsFresh decision for vector retrieval and embedding queue selection. MemoryStore.listActiveWithEmbeddings(userId, activeSpace) returns fresh vectors; MemoryStore.needsEmbedding(userId, activeSpace) selects active owned rows that need replacement. Both pass the joined row metadata to the same decision on each read. A missing LEFT JOIN embedding is not fresh; body hash, provider and model must match, while activeSpace.dimensions = null accepts the stored width. Concrete dimensions must match exactly. This adds no provider calls or cache and preserves each query's own versus shared-pool scope. EmbeddingQueue is a real consumer of needsEmbedding.

memoryRecallUserId(sessionId, ownerUserId, turnWriterUserId) in brain/memoryRecallScope.ts is the shared account selector for room turn-start recall and session/liveRecallWiring. Personal conversations use a positive owner id; channel conversations use only a positive verified writer id from the current turn, returning null when that writer is absent. Callers resolve it before retrieval and on each live pass. This pure selector does not authorize session eligibility or memory grants: the existing account, kind and subagent gates remain caller-owned. Rooms still use globalMemoryRecallScope and never infer a project from their policy cwd. With no account, automatic recall makes no memory request. The selector performs no database or network work; ChannelService's recallMemoryBlock call is a real consumer.

Embedding row/input and recall-context shapes belong to the concrete owners; the facade derives its method-signature aliases from those owners without importing back from the facade. MemoryEmbeddingRow and SetEmbeddingInput are private aliases, while the live recall-context contract remains exported.

MemoryService.markRecalled(userId, ids) reaches MemoryStore.markUsed(userId, ids) and MemoryUsageStore on the existing connection. Only delivered memories count; each accessible row's counter and reader-keyed usage event commit together, and inaccessible or absent rows log nothing. Usage history retains memory_id, user_id and used_at without stored session/turn/search correlation. Live recall keeps that correlation only in local logs and invokes onInjected(ids) after admitting exact body versions. MemoryUsageStore owns USAGE_HISTORY_DAYS = 90; purgeUsageEventsOlderThan() has no caller-selected window. Daily maintenance uses the same constant for its log. Variable recallCountsSince(days) is unchanged, and the vitality chart consumes the retained usage timestamps.

Maintenance replies contain id, operation, status, total, processed, succeeded, failed, error, finishedAt, uiInitiated and acknowledgedAt without mode or startedAt. Recategorization still accepts mode as a local all or uncategorized run input. Migration 58 preserves receipt rowid, counts and finish/acknowledgement state while replacing the owner index with user_id and operation; the unique running-job index remains. Bootstrap interruption is separate from opening the database. The latest operation slots and at most twenty visible owner-scoped receipts use the existing 24-hour retention. Without the maintenance service, API routes report unavailable. MemoryMaintenanceControl and the operation dock consume this same projection.

Shared project-memory authorization is implemented once in the private SQL membership expression in src/store/sharedMemoryAccess.ts. Call isSharer(db, userId, projectId) to gate access to one project's pool, or sharedCategoryIds(db, userId) to resolve all existing pools the reader may use. Both read current project sharing settings and memberships on each call, without caching or administrator-wide access. Sharing must be enabled. A non-empty project_memory_members list grants access exactly to its named accounts; an empty list grants access through user_projects. Scalar checks issue one SQL query, and category selection issues one set-based query across projects, without per-pool checks. Invalid account IDs return false or an empty array before querying; a missing project or disabled sharing grants nothing. Category selection includes only project-bound categories owned by SHARED_CATEGORY_USER_ID and does not create missing pools. MemoryCategoryStore.sharedForProject consumes the scalar check before lazily creating a pool, while listShared and listSharedIds consume the category-set reader. No public interface or schema changes.