Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Memory lifecycle and delivery

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Memory lifecycle and delivery

Owning code and boundaries

  • Vitality and eviction forecast: src/brain/memoryVitality.ts. Persisted retention defaults and bounds: packages/db/src/config/codecs/brain/memoryVitality.ts. Vitality history: src/brain/memoryVitalityHistory.ts.
  • Retention sweep: packages/db/src/maintenance/index.ts, dispatched by src/daemon/maintenance.ts through repositories.maintenance.maintain. Daily eviction and hourly recall expiry use the owning PostgreSQL repositories; vitality, rolling per-account budget and auditable soft-delete policy are unchanged.
  • Guarded soft delete with the per-account daily budget: repositories.memory.autoEvict in packages/db/src/memory/index.ts.
  • Retention config clamping and REST validation: packages/db/src/config/document.ts and src/api/schemas/config.ts.
  • Embedding vector space identity and per-request account authorization: EmbeddingService.memorySpace and spaceId in src/embeddings/embeddingService.ts. Indexing queue: src/embeddings/embedQueue.ts. Worker batch: src/brain/embedBatchWorker.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 through waitForTurnRecall in src/brain/service/turnContextBuilder.ts. Mid-turn recall: src/brain/session/liveRecall.ts.
  • Delivery identity shared by recall, instruction activation and step reminders: src/brain/session/contextDelivery.ts. Memory journal invalidation and admitted-tool accounting: src/brain/session/deliveredMemories.ts. Frozen mid-turn frame and append driver: src/brain/session/contextRecordDriver.ts.
  • Model-facing rendering of one memory: src/brain/session/memoryPrompt.ts. Curator: src/brain/memoryCurator.ts.
  • Memory dreaming (automatic consolidation, the reviewed flag and the merge audit): src/brain/dreaming/, repositories.memory.dreaming in packages/db/src/memory/dreaming.ts. See Memory dreaming.

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.

Shared memory conventions and merge contract

prompts/memory/conventions.md owns the shared writing policy. The small existing-prompt adapter src/brain/memoryConventions.ts reads it through rawTemplate and appends the existing importance scale. The main persona substitutes it through {{memoryConventions}}; the curator and dreaming consume the same block. Their tool, recall, JSON-output and permission contracts remain role-specific.

The shared policy defines four memory types, examples, exclusions, feedback/project Why/How structures and relevant/stale-memory guidance. Its storage instructions use Elowen's database tools. Retention, categorization, recall and account/project sharing keep their existing owners.

New model-authored records use user, feedback, project or reference; kind remains an open string and old fact, preference, decision and custom labels need no migration. Records stay self-contained and retain conditions, exceptions, identifiers and reasons. The curator run is bounded by its operation budget and by a 2,000-character cap on each side of the exchange it reads (MAX_TEXT_CHARS in src/brain/memoryCurator.ts:16).

All merge callers use repositories.memory.merge: by default active accessible sources must share one exact category, including null. Dreaming alone may provide an explicit categoryId for its own active personal sources; the store validates the existing own personal destination and all sources in the same transaction. See Memory dreaming. Outside that explicit personal exception, refusing different categories prevents crossing project/audience boundaries or losing recallability. The merged row preserves category, common kind, highest importance, per-source audits and dreaming attribution. Mixed kinds require an explicit kind, supported by MemoryMerge, curator JSON, POST /memory/merge and the existing web merge dialog. Duplicate ids are consumed once; inactive/inaccessible sources are skipped. No active accessible source, incompatible categories or missing mixed-source kind cause a conflict before any source is retired. REST returns 409 with the reason; the tool reports it and the curator logs/skips the op.

Successful storage does not guarantee recall: categorization may still be pending and recall remains scope-limited. Kind never selects a private destination. A requested private save in a shared Project must not be promised when the available capability cannot guarantee it.

Memory importance, vitality and retention

The PostgreSQL config codec packages/db/src/config/codecs/brain/memoryVitality.ts defines the persisted 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). src/brain/memoryVitality.ts supplies the verbal scale for tool descriptions and the curator prompt and owns the runtime vitality formula, 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 (isEvictable in src/brain/memoryVitality.ts). 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), and the web only displays it (web/modules/memory/MemoryVitalityChart.tsx). Retrieval scores importance as (importance - 1) / 9 through the existing MemoryService weights (importanceWeightOf and weights() in src/brain/memoryService.ts).

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 (memoryRetentionPatchSchema in src/api/schemas/config.ts).

The memoryEviction maintenance operation, run at boot and every 24 hours from src/daemon/maintenance.ts, pages through every account's active memories with repositories.memory.listActiveForEviction and sends each eligible candidate through repositories.memory.autoEvict. Core supplies the canonical vitality functions once at assembly; the sweep awaits PostgreSQL queries between full scan pages. Each auto-evict call owns a SERIALIZABLE transaction that locks and 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.

repositories.memory.update returns a promise. The PATCH route, MemoryUpdate tool and curator await its committed result before publishing or embedding it. Access checks, update, dreaming flags and audit remain atomic inside the acquired writer transaction; a wait never retains stale access or row data.

Memory indexing and automatic categorization

EmbeddingService.memorySpace(config) returns a secret-free provider/endpoint/requested-width identity plus the model, hashing the configured endpoint through the same URL primitives the request itself composes (src/embeddings/embeddingService.ts). The memory repository requires that active space for vector reads, queue selection and writes in packages/db/src/memory/embeddings.ts; all three use the same identity match beside body freshness. EmbeddingService.snapshotConfig freezes the CONFIGURATION of one indexing run — provider, model and requested width — and deliberately not the account: every request authorizes its own through EmbeddingService.authorizedEndpoint, so a long run follows the operator's pause, grants and routing instead of holding a snapshot of them. Queue, owner reindex and retrieval derive request and space from that one frozen configuration. Queue and owner reindex pass the run's pinned space to every EmbeddingBatch. Its worker implementation captures embeddingSpace(config, authorizedEndpoint) from the exact endpoint sent to the child, then calls assertEmbeddingSpace after paid usage commits and before returning vectors. This refuses vectors from B under A even if live configuration moved A-B-A before the response. EmbeddingSpaceChangedError ends the run, retaining earlier valid batches and leaving the rejected batch pending. Writers also resolve the active space again before the awaited repository write. A provider, endpoint, model or requested-width change rejects late vectors, even at equal vector dimensions. Stored vectors from a different identity are replaced by the normal bounded queue. Retrieval and duplicate detection fall back to their existing keyword/no-match behavior until indexing catches up. No embedding configuration means no indexing work.

An OpenRouter OAuth account can serve embeddings. resolveConnectionCredentials (src/brain/config.ts) is the credentialed-endpoint seam, addressed by an ALREADY AUTHORIZED route: the dispatch seam decided which account may serve the request and this function only reads that account's secret. For OpenRouter it passes on the account's access key, which is a permanent API key. Other OAuth tokens never reach an embedding client.

An explicit local baseUrl has no provider and therefore no account: the request carries the configured key, its usage is reported with a null provider and connection rather than filed under an account nobody chose, and its vector-space identity is that URL. A request naming a provider is refused when no identity was established; a missing account is never read as instance work. The background worker builds its own EmbeddingService that authorizes no account of its own, and the daemon authorizes a whole worker batch through authorizedEndpoint before forking it (src/brain/backgroundWorker.ts, src/brain/embedBatchWorker.ts). Worker EmbeddingJobConfig carries the already authorized explicit baseUrl, model, key and optional dimensions, not a provider identity or another policy source. The explicit-endpoint request returns before retry-policy/provider-series lookup: 200, 429 and 503 each send one batch request, with no account reauthorization or transport retry. Its required provider-only retryPolicy getter fails loudly if reached, like authorizeAttempt/recordLimit, rather than inventing a default or policy IPC. Body-specific batch-to-single narrowing and the daemon's later memory drain business retry keep their existing owners. Actual child IPC/local HTTP tests exercise this boundary; it does not change native chat's separate Retry-After or WebSocket observability limits.

A request that fails is retried only by the BOUNDED attempt series (src/brain/session/attemptSeries.ts): the next account of the same provider serves the same model, inside one small bound per request, and only when the failure's own transport evidence (AttemptEvidence over the real response, classified by classifyProviderFailure) proves the ACCOUNT was the problem. A proven limit with a stated reset is written to provider_connection_state; an open outcome (a timeout, a reset, a request the provider refused) ends the series instead of repeating work that may already have been executed and paid for. The series captures one strict policy, including its revision, for at most min(4, 1 + maxRetries) dispatches. Eligible repeated admission waits through the canonical delay/abort helper; typed transient Retry-After is a not-before minimum, not an account-failover or ambiguous-write permission. Native PI chat hint behavior is separate; embedding's 120000 ms regression proves only this helper request path.

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 (categorizeNewMemoryForProject and its classifyRow call in src/brain/tools/memoryTools.ts). 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 (PUT /memory/embedding and PUT /memory/categorization) resolve the connected accounts through connectionEntries and pass them into ConfigStore.update, just like general settings; stored provider aliases cannot hide canonical OAuth validation.

Embedding failure reporting

Query embedding failures from retrieve, searchSemantic and findSimilar share the service's query failure sequence. Every failure stays in local diagnostics; only the third consecutive failure reports memory.embed_failed. A successful query embedding resets it. This sequence is independent of the background queue, which may have no pending rows while queries fail.

The indexing queue counts transport failures per account, refused vectors per account/memory and write failures per account/memory. Only the third failure of each sequence reports; successful completion of that operation resets it. A successful batch cannot reset a failed row write. Invalid batch replies remain immediate, and stopping a batch does not count as failure.

The corpus phase of the same drain reports its sequences under search.index_embed_failed: a refused document is counted per source and text hash and reported like a refused memory, while a corpus that could not be read or stored, a request that failed as a whole and a reply that is not this request's answer end the corpus phase for that tick with that corpus still incomplete.

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 } using a module-private result type in src/brain/memoryService.ts. 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 (retrieve's cleared check in src/brain/memoryService.ts); unavailable embeddings use keyword matches plus recency. An empty query makes no embedding request. retrieve and searchSemantic embed and keyword-match only the first MEMORY_QUERY_CHARS (2,000) characters of the query (boundedQuery in src/brain/memoryService.ts), the same bound liveRecall uses. This bounds provider input and PostgreSQL keyword-search work; the repository's search method escapes LIKE metacharacters and uses parameterized ILIKE. Retrieval never records delivery or increments usage itself. memoryBlock, liveRecall and MemorySearch consume the selected rows; delivery accounting remains in markRecalled.

settleTurn in src/brain/session/turnSettled.ts schedules MemoryCurator for the verified writer, shared by owner and channel turns. Account auto-save settings and the existing curation gates apply. MemoryCurator reads its configured operation budget once at the start of each post-turn run (src/brain/memoryCurator.ts:77-89). 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. Both recall paths use promptMemoryOf(row) to copy id, body, kind and importance and map stored updated_at to updatedAt before rendering. 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 (declared in src/brain/session/memoryBlock.ts, called by the deferred-result policy in src/brain/session/liveRecall.ts), 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/contextDelivery.ts owns the retained delivery projection and shared id/body selection for turn-start recall, nonblocking live recall, MemorySearch and MemoryListRecent. src/brain/session/deliveredMemories.ts keeps the journal-change subscription, append/rollback invalidation, pending admitted output and usage accounting around that projection. The live policy emits frame data through contextRecordDriver.ts, which owns the complete-frame byte selection and commit. 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. Call record(id, body) only after the native context attachment is admitted; a failed attachment records no delivery or usage. 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.

Constructing DeliveredMemories subscribes to journal changes without reading the retained context; until the first lookup builds a snapshot, a notification does nothing. The first has or record call builds its projection, and the next lookup after invalidation rebuilds it. Sessions that never recall or deliver memories do not pay for this projection.

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 records no delivery and does not increment 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. A per-session derived snapshot is built once from the retained context and then kept current by the store's journal-change notification (repositories.journal.onJournalChanged, with JournalChange exported by elowen-db). An append that continues the branch tip arrives with the rows it wrote, and only those rows are parsed into the snapshot, so a long turn pays per appended entry instead of per retained entry. The live flush uses the existing bound database transaction. Journal notifications are queued through sql.afterCommit, publish once after the outer commit and are discarded on rollback or retry. Every lookup also compares the snapshot's tip with the stored active leaf and rebuilds on a mismatch. A branch move, an insert before an entry, a compaction (which moves the retained cut) and an in-place cold rewrite report rewrite or fail the tip check and drop the snapshot, which the next lookup rebuilds from the journal. A record the strict schema rejects also drops the snapshot rather than throwing into the journal writer, and the rebuild raises the same error at the lookup. Batched lookups read one projection rather than reloading history for every memory. Disposing a session closes its DeliveredMemories, which unsubscribes (src/brain/session/liveRegistry.ts:153, DeliveredMemories.close). A short-lived in-memory pending entry bridges the interval before a delivered frame reaches the journal; it expires on anchor loss, compaction or an intervening journal append without that output. Retained context determines visibility without an imperative withdrawal method. 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.

repositories.memory.add, body updates and merge write the exact UTF-8 SHA-256 body identity into memory_body_hashes in the memory/audit transaction. The separate derived table keeps public, audit and prompt memory rows unchanged. A raw external body edit deletes the hash through a trigger; purge cascades it. Category/status/usage edits keep the body hash. needsEmbedding treats a missing hash as pending work, and setEmbedding locks the active owned row, checks the exact body hash and writes the matching hash/vector together. Until a raw or imported row has a stored hash, PostgreSQL scoring computes its exact UTF-8 hash in the same snapshot; it never accepts a stale vector.

repositories.memory.needsEmbedding(userId, activeSpace) applies the pending condition in SQL, so bodies of fresh hashed rows are never materialized or hashed by the indexing tick. Missing vectors, edited bodies, provider/model changes and concrete-width changes require replacement. A null active width accepts the stored width. Queue selection remains owner-only. EmbeddingQueue is its consumer.

EmbeddingQueue.drain(corpora) fills memories and the shared corpora in ONE tick, memory first and under one overlap guard. The memory phase is unchanged, including its per-drain caps; the corpus phase bounds each REQUEST instead, at most 50 documents and 60,000 characters of their text, and runs its requests back to back, because a corpus has no backlog to spread over ticks, only a request size the endpoint accepts. Before every request after its first, the phase re-checks whether an account has memory work waiting and ends the tick if so: memory keeps precedence, so a corpus never delays the next memory turn by more than one request, and that first request is deliberately not gated, or a document the provider keeps refusing would starve every corpus. That check, the silent EmbedBatchStoppedError and the existing overlap guard are what bound memory's latency; there is no per-drain request cap. Each source is read lazily from its owner, so a drained corpus is the documentation checkout and route registry as they are now. Content addressing makes a restart free: hashText of a document's exact text is its address in semantic_vectors, the process remembers a digest of the corpus (sha256 over its sorted document hashes plus EmbeddingService.spaceId) once it verified that corpus complete in the active space, and a matching digest ends the source after one key lookup of repositories.search.vectors.missing. put stores only what is missing, prune(corpus, space, live) drops the rows no current document reports as soon as a corpus ends a tick complete, and every drain calls pruneSpace(activeSpace), the only path that retires the dead vector spaces of the palette corpus, which has no live set anything could prune against. Corpus requests are the INSTANCE's own work: they authorize null and their usage lands on the instance owner's row.

packages/db/src/memory/embeddings.ts stores memory vectors as PostgreSQL halfvec. repositories.memory.scoreActiveEmbeddings computes exact cosine similarity in SQL for every eligible row, checking current account/project scope, vector space, width and exact body hash in the same statement. It returns scoring metadata, octet_length(body) and SQL cosine scores, with no vectors in the result or sort rows. loadEmbeddingVectors hydrates fresh, accessible vectors only for the next batch in weighted rank order, at most the requested recall count at a time. Rejections, byte limits, paraphrase deduplication and delivery exclusions can advance to later batches, preserving the full-candidate result. Similarity-only top-k cannot preserve that weighted greedy selection, so full recall retains every scoring row; no approximate index or distance LIMIT is used. MemoryService.retrieve owns the importance/vitality blend, relevance floor, deduplication and strict count/body-byte packing; it hydrates fitting candidates through the same bound getAccessible repository. Score reads and body hydration run in one read-only REPEATABLE READ transaction. A delivery exclusion callback can require the body of a fitting candidate that it subsequently excludes.

searchActiveEmbeddings materializes all eligible exact SQL scores before thresholding, ordering and limiting its result, then hydrates only selected bodies in the same statement. Manual semantic search and duplicate detection use this path without transferring candidate vectors. No approximate vector index, process-local memory-vector cache or vector-revision table participates in either path. PostgreSQL evaluates current metadata and freshness on every query; memory embeddings remain separate from the corpus vectors exposed by repositories.search.vectors.

memoryRecallUserId(sessionId, ownerUserId, turnWriterUserId) in src/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.

MemoryRepository, MemoryEmbeddingCandidate, ScoredMemoryEmbedding and embedding input/space shapes belong to packages/db/src/repositories/memory.ts and are exported through elowen-db. Core imports these async repository contracts; MemoryService retains orchestration and the recall-context contract stays with the live session.

MemoryService.markRecalled(userId, ids) delegates to prepareMarkRecalled and repositories.memory.markUsed. Provider preflight calls prepareMarkRecalled with its bound READ COMMITTED repositories and publishes delivery notifications only after commit. packages/db/src/memory/index.ts updates counters and inserts reader-keyed usage events in one conditional UPDATE RETURNING CTE statement. It inherits the caller's transaction or opens its own READ COMMITTED transaction, without requesting stronger isolation. A savepoint rolls back a failed accounting statement before rethrowing, preserving the enclosing admission transaction. Turn-start recall logs that bookkeeping failure and continues preparing the required delivery state; it publishes usage invalidation only after successful accounting and commit. Duplicate ids retain one history event and counter increment per occurrence; inaccessible or absent rows log nothing. Only delivered memories count. 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. repositories.memory.purgeUsageEventsOlderThan() owns the fixed 90-day recall-history window and bounded deletion batches; hourly PostgreSQL maintenance calls it without a caller-selected window. recallCountsSince(days) takes its window from the caller, 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. The receipt row (memory_maintenance_runs) keeps its rowid, counts and finish/acknowledgement state; the owner index covers user_id and operation, and 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 the private SQL membership expression in packages/db/src/memory/access.ts, reused by row reads, writes and category queries. repositories.memory.categories.listSharedIds(userId) resolves existing permitted pool categories; sharedForProject checks current membership before lazily creating a pool. These methods read current project settings and memberships 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 uses user_projects. Selection includes only project-bound categories owned by user 0 and does not create missing pools. An explicit caller scope only narrows these live grants, so a stale shared-category list cannot restore revoked access. Invalid account ids yield no shared ids; a missing project or disabled sharing grants nothing.

Memory dreaming, the reviewed flag and the merge audit

Automatic consolidation ("memory dreaming") is documented in its own module: Memory dreaming. It runs a fresh delegated child from one account's own conversation and changes that account's own personal memories through the memory tools. From the memory store's side it adds two things.

The column memories.dream_reviewed_at is the per-memory "reviewed by dreaming" flag, and every content writer in packages/db/src/memory/index.ts maintains it: a content write (add, update, merge, restore, archive) or a manual setCategory clears it, while a dream's own write sets it instead (the writer is passed opts.dreamRunId under currentDreamRun()). Deliberately left unchanged are the automatic categorizer (setCategoryIfUnchanged), the bulk category_id = NULL of a category or project deletion, markUsed and pool re-attribution. A completed run marks the rows it was offered that are still active, unreviewed and unchanged since it started; nothing is marked on a failed, aborted or interrupted run.

Merge records the before/after of every source: each merged source gets its own delete audit event with full before/after (X15), in addition to the merge event whose after_json carries the merged row plus the source ids. This holds for every caller, not only a dream, and it is what lets a run's change log show what a merge consumed. Status transitions accept MemoryWriteOpts.model for inference attribution; MemoryDelete supplies the dream turn's model, so its delete event carries the same model as the other writes. The wire rows expose nullable dream_reviewed_at and dream_run_id directly.