Navigation
ELOWEN · Documentation

Last updated: October 20, 2026

Browse documentation · Memory dreaming
Developer reference

Memory dreaming

Memory dreaming is the automatic consolidation of one account's own memories: a pass that merges similar notes, corrects outdated facts and shortens bodies so the memories stay useful. It is modelled on Claude Code's autoDream mechanism and keeps its shape: an end-of-turn gate, a lock over a watermark, and a background fresh delegated child for each batch that makes real writes with real memory tools, under a permission adapter that lets it change only that account's own memories.

src/brain/dreaming/service.ts (MemoryDreamingService) owns the WHOLE life of a run: the gate, the scan throttle, the lock and watermark, the child it mints, the completion marking and every terminal state. It is deliberately the only place a dream starts. The settled turn only says a check MAY be due; every decision after that is here, in one order.

Owning code and boundaries

  • Gate, throttle, lock, child, terminal states, status and Run now: src/brain/dreaming/service.ts.
  • Prompt rendering and the prompt-version hash: src/brain/dreaming/prompt.ts over the shipped template prompts/memory/dreaming.md.
  • Lock, watermark, run rows, session count, unreviewed batch, reviewed mark and change log: packages/db/src/memory/dreaming.ts, exposed as repositories.memory.dreaming.
  • The account preference blob: packages/db/src/config/codecs/store/memoryDreamingSettings.ts and the memoryDreaming resource of repositories.config.userSettings in packages/db/src/config/userSettings.ts.
  • The name-level tool boundary: dreamToolDenial in src/brain/session/capabilities.ts, a sibling of planWriteDenial.
  • The memory tools' dream behavior: src/brain/tools/memoryTools.ts (personal-only, active-only writes, actor dreaming).
  • The memory identity and the durable scope: src/brain/identity.ts (forDelegatedTurn) and src/brain/delegatedScope.ts (scope.dream).
  • Arming: the dream effect of settleTurn (src/brain/session/turnSettled.ts), decided in src/brain/service/turnRunner.ts.
  • Daemon wiring: construction inside BrainService, the boot sweep and beforePause in src/daemon/bootstrap.ts, the handles in src/daemon/brainCore.ts.
  • REST: src/api/routes/memory.ts (the dream routes), src/api/routes/selfSettings.ts (GET/PATCH /auth/me/memory-dreaming) and src/api/routes/users.ts (the account-deletion abort).
  • The notice in the parent conversation: the dream chat event in src/brain/session/chatEvents.ts.
  • No recall usage from a dream read: src/brain/session/deliveredMemories.ts.

Two parts of the memory store serve dreaming and are described below: the reviewed column in every memory repository writer, and the per-source audit of every merge (X15).

Claude Code mapping

Claude Code Elowen
Runner called at main turn end The optional dream effect of settleTurn, armed by TurnRunner for the account's own web/CLI turns only
autoDreamEnabled feature flag The per-account memoryDreaming.enabled blob, default OFF
minHours / minSessions server config The RuntimeLimits sliders memoryDreamingMinHours / memoryDreamingMinSessions
.consolidate-lock file with a holder PID and mtime The table memory_dream_lock(user_id, holder_boot_id, last_consolidated_at), one row per account
In-process 10-minute scan throttle The in-process lastScan map on MemoryDreamingService
Sessions with a transcript mtime since the watermark The account's own conversations with a settled user turn after the watermark
Permission-rule pre-check before firing The account grant and rule pre-check for the four Memory* write tools
In-memory task registry with an abort controller The active map plus a durable memory_dream_runs row
runForkedAgent sharing the parent's cache A fresh host-minted child run through BrainService.runDelegatedTurn; no parent transcript is inherited
canUseTool = Y6n dreamToolDenial in the execution gate chain, plus the memory identity bound to the owning account
The four-phase prompt with tool constraints and a session list prompts/memory/dreaming.md with its four placeholders filled by renderDreamPrompt
filesTouched and the "Improved N memory files" notice Memory events stamped with the run id, and one dream chat event in the triggering conversation
Fork failure rolls the watermark back; abort keeps it Identical

Deviations from Claude Code

The reference behavior is kept except where RBAC, account scoping or an explicit product decision requires otherwise. Each deviation is deliberate:

  • X1. Memory is per-account database rows reached through the Memory* tools, not files. The index is a catalog the host generates into the prompt. Owner-scoped PostgreSQL rows replace a memory directory.
  • X2. The permission adapter allows writes only through MemoryAdd, MemoryUpdate, MemoryMerge and MemoryDelete, and only on the owning account's own active personal rows. The account's granted category creation, deletion and recategorization tools are also available. Shared project pools, other accounts and environment writes remain refused. Dream reads pass an explicit personal-only scope to the existing MemoryService retrieval and listRecent methods; ordinary turns retain their captured conversation scope.
  • X3. Deletion is MemoryDelete, a soft delete that is restorable. There is no rm and no purge.
  • X4. The dream child carries a host-minted memory identity for its owner (scope.dream). Ordinary delegated children still get none.
  • X5. The account may pick a dreaming model; null means the parent conversation's model. The pick passes the account allow-list at save, at start and on every request, and is never silently substituted: an unusable pick is a refusal, not a reason to run something else.
  • X6. Only the account's own web/CLI conversations arm the check and count as sessions. Channel rooms do not, because a room's row belongs to the operator and holds other people's messages.
  • X7. The two thresholds are admin RuntimeLimits sliders, not server flags.
  • X8. The account toggle defaults to OFF.
  • X9. A durable run row, memory events stamped with the run id (before/after), and one daemon log line per run replace CC's in-memory task and debug logs.
  • X10. A per-memory reviewed flag; each batch catalog holds at most 200 entries / 120,000 bytes of unreviewed memories. Automatic runs retain one batch per run; Run now continues through fresh batches under the same run until the pending set is empty or a batch makes no progress.
  • X11. No importance protection: an importance-10 memory is editable like any other.
  • X12. Run now starts from the account card and records the account's most recently active own conversation as its parent, without inheriting its messages. Like CC's forced path it skips the thresholds and the throttle and leaves the watermark alone, but it still allows only one dream per account.
  • X13. Memory reads inside the dream child do not count as recall usage (use_count, vitality).
  • X14. Account deletion aborts a running dream and removes its lock and run rows.
  • X15. Merge writes a per-source delete audit row with full before/after, for every caller.

Everything not listed follows Claude Code: the order of the gates, the 10-minute throttle, the 1-hour stale lock, watermark = acquisition time, rollback only on child failure, abort keeps the watermark, no step or mutation budget beyond the existing delegated-turn limits, and last-writer-wins for concurrent edits (except where the memory repository already enforces CAS).

The gate: what arms a check, and in what order

A settled turn arms the check in TurnRunner, beside the curator and only after the prompt succeeded. All of these must hold: the turn is on the account's own conversation (brain-<uid>…, no parent session, no delegated scope), and it is not an internal, automated or interrupt-resume turn. settleTurn then calls maybeRun detached, like the curator, so a dream never fails a turn that already produced its answer. Channel rooms and delegated settlements never arm it (X6).

maybeRun(userId, currentSessionId) runs CC's checks in CC's order:

  1. The toggle is on; otherwise return.
  2. Read the watermark A = last_consolidated_at ?? 0 and compute the elapsed hours. If hours < memoryDreamingMinHours, return. A watermark of 0 means "never consolidated", so the gate passes exactly as CC's absent lock mtime does.
  3. The 10-minute scan throttle. It starts only after the time gate passed, so a turn settling inside the minimum hours does not consume the window the next real check needs.
  4. Count the account's other conversations with a settled user turn after the watermark. If fewer than memoryDreamingMinSessions, return. The candidate filter is brain_sessions.activity_at past the watermark; a journal EXISTS then confirms the candidate holds a settled, non-meta user row newer than the watermark. Channel, sub-agent, cron and delegated sessions are excluded, and the current conversation is excluded. idx_brain_sessions_dream_activity (top-level sessions by account and activity) and idx_brain_session_entries_dream_user_time (settled non-meta user entries by session and creation time), both in packages/db/migrations/0001_baseline.sql, make the two probes cheap: the journal probe reads its partial index rather than loading message JSON, and the activity index also supplies the requested ordering.
  5. The permission pre-check: the account's tool authority must grant all four memory write tools, and the account's permission rules must not resolve any of them to deny. Nobody is attached to a dream run, so an ask follows the account's unattendedAsks setting like every other unattended turn: it refuses only in strict mode (deny). The status endpoint recomputes blockedReason from the live grant, rules and stored model instead of retaining a past refusal.
  6. Resolve the model: the stored pick, else the conversation's own model. It must be offered and allowed, otherwise return without a fallback (X5).
  7. A manual run already in flight is left alone: if runningRun(userId) exists, return immediately before the lock is taken, so an automatic check can never move the watermark for a run that will not fire.
  8. Acquire the lock. If it returns null (held by this boot for less than an hour), return.
  9. Fire: insert the run row and start the child. Batch reads precede acquisition; a failed run claim restores the prior watermark before propagating the error.

Every refusal before the lock is silent and cheap, which is the point: a check runs after every qualifying turn, and only the last step may cost anything.

Lock and watermark

memory_dream_lock holds one row per account: holder_boot_id and last_consolidated_at, which is the watermark. All three operations run inside the shared write lock, so the check and the write are atomic and no read-back is needed (unlike CC's file, which another writer could race).

Operation Effect
acquire(userId, bootId, now) Returns the PRIOR watermark, or null when the same boot already holds it and less than one hour has passed. Otherwise upserts the holder and the new watermark, and returns the prior value.
rollback(userId, prior) Undoes an acquisition after a failed child: back to the prior watermark, or deletes the row when there was none.
release(userId) Clears the holder and KEEPS the watermark; this is what completion and abort both do.

Liveness is "the current daemon boot", because only the daemon fires dreams. After a restart every old holder is stale at once and the watermark stays at the old start time, exactly as with a killed CC process. A manual run never calls acquire or rollback, so Run now leaves the watermark untouched; two dreams for one account are prevented by the partial unique index memory_dream_runs_running. When the crash path leaves a run row running, the boot sweep closes it interrupted while the lock keeps the dead boot as its holder, so the next eligible check simply takes the lock over.

The fresh dream child

The run row is inserted first (running, trigger, parent and child, provider/model, prompt version, hours_since, session counts). Then the service builds a DelegatedTurnRequest:

  • channelId minted in the sub-agent namespace, subagent-dream-<runId>, and …-<batchNumber> for each later batch of the same run. Its session id is brain-ch-subagent-dream-<runId>, so isSubagentSession and the ephemeral-run rules hide it from every conversation list and nothing is delivered to the parent.
  • ownerUserId for the account, and parentSessionId as the triggering conversation (automatic) or the account's newest own conversation (Run now).
  • delegatedAccess narrowed to {readOnlyOrigin: 'imposed', dream: {runId, accountUserId}}, with permissionBoundary: buildReadOnlyBoundary(accountRules) whose unattendedAsks is then replaced by the account's own setting (it stays deny when the daemon exposes no permission settings to read). The shared boundary clamps Bash to the non-destructive command set (so a compound read-only command runs) and keeps the account's denies; an ask left over from the account's rules therefore runs under allow (the setting's default) and is refused only in strict mode (deny). dreamToolDenial permits memory writes.
  • A non-admin child opens in the first usable Project in the account's own order, through the same defaultProjectRef resolver used by preparePersonalProject for a new owner conversation. Both managed and host Projects qualify; the live ACL and captured project ceiling must admit the choice. The chosen execution ref is durable. The parent proves memory identity, not placement. Administrators retain their existing host behavior. The read-only Project tool may switch the child's target at the next model-step boundary; subsequent tool calls and rehydration use that stored target. Non-admin runs never bypass the Project invariant. If no usable Project remains when a batch is dispatched, the run records failed with project_unavailable, releases its lock while keeping the automatic watermark, and logs locally without reporting memory.dreaming_failed to the licence server.
  • accountAllow from the account's current grant, the pinned origin, and the resolved model. The automatic path marks the request scheduled.

The turn goes through the in-process BrainService.runDelegatedTurn path. It does not use the runner pool. Every batch uses one fresh delegated child, irrespective of the parent's model. The scope has no fork flag: neither seedForkBranch nor the fork handover runs, and the task is the rendered dream directive. parentSessionId remains mandatory for identity proof, lineage and completion notices; it does not copy messages. Later batches receive only confirmed earlier-batch counts and a bounded summary appended to their own directive, never the previous child's transcript.

The child receives the normal fresh delegated system prompt and policy-filtered tool catalog. Memory tools and read-only verification tools use the existing composition path. Deferred tools are advertised in that catalog and become active through ToolSearch; a short first-request tool list does not mean they are absent. Listed own conversations remain readable through ElowenApi GET /brain/messages?session=<id> with the normal per-call, read-only credential, revoked after each call.

The run receipt reads ProviderRequestStore.dreamUsage(userId, runId), which sums the existing brain_request_session_summary only for this account's children carrying the same host-minted dream run. Parent usage never enters the receipt. This reads existing diagnostics, not another counter; empty or entirely unpriced attempts have a null cost. Spend admission and billing stay in the existing delegated path, with usage_by_origin as the one origin-attributed source. The batch owner awaits runTurn before reading the receipt; that delegated promise owns terminal provider-attempt settlement. maybeRun awaits the completed run. Manual runNow instead returns its running row immediately so the UI can display and cancel it; its return is not a usage-settlement signal. Tests that inspect final usage await the service's completion contract, never one event-loop tick.

The run summary accumulates the final replies from its fresh batch children, separated by blank lines and bounded to 2,000 characters while retaining the start. The directive supplies the account's validated UserSettingStore.locale(userId) through BrainDeps.locale, wired in brainCore.ts. The existing setting supports cs, sk and en, defaulting to en; no language is inferred from messages or memories. It asks for two or three plain sentences in that explicit language, without generic handover labels.

The prompt is rendered from prompts/memory/dreaming.md; its four placeholders are the account scope, the explicit language and tool constraints plus the session list and catalog, the shared memory conventions from src/brain/memoryConventions.ts with the importance scale, and the note about project instructions supplied in the child's system prompt, if any. prompt_version is the first 12 characters of the sha256 of the template, its tool constraints and the shared conventions. Changing the tool-constraint text changes the recorded version; a code-only policy change does not.

renderDreamPrompt normalizes conversation titles with collapseWhitespace from src/shared/text.ts before inserting each session line; catalog bodies retain their original multiline text. The pure projection has no preview cap, preserves empty titles as empty and changes neither the prompt-version inputs nor the run's persistence or lifecycle.

The prompt asks the child to verify concrete paths, commands, configuration, services, versions, documentation and external facts with its read-only tools before keeping, updating or deleting a memory. For example, a stored service port can be checked against current configuration and service state, and an external fact against web search/fetch when those tools are granted. Unavailable evidence leaves the record intact with uncertainty reported. Only memories may be changed. The Bash boundary is a command clamp, not a filesystem sandbox: it does not prevent file writes through redirection, sed -i or arbitrary scripts. It also permits network access through curl, wget and ssh. The owner knowingly accepts that the owner's dream runs unsandboxed with these file-write and network capabilities despite the non-destructive boundary. Bash remains available as it is. The prompt still tells the agent it must not change anything except memories and forbids shell redirection and other environment changes; this instruction is not an execution-level write barrier. Updating the template or constraint text changes the recorded prompt_version hash.

The permission adapter

dreamToolDenial(name, params) runs before gatePermissions' early return, like planWriteDenial, and applies only when the current turn carries a dream run. dreamToolAllowed in the same module is its single name classification. The spawner passes the host-minted dream marker as CapabilitySpec.dream and the registry's validated manifest declarations as readOnlyPluginTools. The registry ownership map already supplied in toolDeferral.toolOwner identifies MCP-owned tools regardless of deferral settings or name spelling. dreamToolAllowed excludes all of them even when their manifest declares them read-only; composeSessionTools filters the catalog with that classification before deferral or code-mode composition. No separate dream classification allow-list is captured in DelegatedExecutionScope. The shared captureDelegatedExecutionScope instead captures the account's current tool grant, denials and spawnedBy principal for each fresh batch. The existing delegated policy intersects later live grants with that durable ceiling for visibility and execution; gaining a tool cannot widen an already captured child. ToolSearch, exec and wait remain infrastructure envelopes; their nested calls retain the same restriction. The imposed read-only boundary remains in force, and the required-four-write-tools admission gate is unchanged:

Call Decision
MemorySearch, MemoryListRecent, MemoryCategories Allow, with personal scope only (no shared-pool categories)
MemoryAdd Allow, personal categories only
MemoryUpdate, MemoryMerge, MemoryDelete Allow only when every target id is the account's own active, personal row; otherwise refused. Dream MemoryMerge may use an explicit categoryId naming an existing own personal category to consolidate across personal categories/project bindings without changing definitions or sharing
MemoryCategoryCreate, MemoryCategoryDelete, MemoryRecategorize Allowed within account grants and permission rules. Category CRUD is keyed to the acting account; recategorization uses the existing personal maintenance job and excludes shared pools
ElowenApi Allowed with a read-only credential (GET/HEAD only)
Bash Allowed through the existing buildReadOnlyBoundary command deny-list, with account denies preserved; destructive commands, privilege escalation, service/host control and publishing remain refused. This shared boundary permits network access and arbitrary verification scripts; it is not a filesystem sandbox
DocsSearch Allowed for product documentation verification
Project Reads allowed. A matching host-minted dream may switch only its own execution target within its captured account project ceiling and current ACL; all project, account and environment mutations remain refused. Ordinary delegated children retain their pinned targets
Non-MCP plugin tools declared read-only in their own manifest Allowed, including file reads/searches and web search/fetch; absent declarations grant nothing
Every tool owned by the MCP plugin Not advertised and refused, including all external mcp__* tools, ListMcpResources and ReadMcpResource, even when declared read-only. Explore and plan retain their existing MCP access
ToolSearch, exec, wait Infrastructure envelopes; discovery, the exec description, nested tools and ALL_TOOLS use the same composed dream set intersected with the account grant; nested execution retains the same gates
Plugin write tools and every other undeclared tool Not advertised; refused by the same name classification if a call arrives anyway
An account rule that resolves to ask Follows the account's live unattendedAsks: runs under allow (the default), refused under deny
A shell line the resolver cannot split (for example a newline before an unterminated quote) Always refused: under the clamp it is the only way Bash still resolves to ask, and its later lines never met a deny rule
An account rule that resolves to deny, or a tool outside the account grant Refused
The gate itself throws Refused (fail closed)

CapabilitySpec.dreamAuthority supplies the spawner's live account grant and permission settings to DreamToolSet.authority. Every dream call rechecks them before executing, including nested code-mode calls. Missing optional account settings mean no additional rule restriction; a thrown read refuses the call. The captured boundary still applies and a restored grant cannot widen it. Completion rechecks the four required write grants and rules, closing a revoked run as failed without marking its batch.

The refusal is a normal tool result marked refusedByPolicy, so the model learns the call was refused rather than reading a provider error as a glitch. The independent dream gate also refuses delegation and workflow writes, including DelegateContinue; it does not depend on forkToolDenial. AskUserQuestion remains refused by the normal unattended delegated boundary.

The identity that makes the memory tools act for the account is host-minted and re-derived, never trusted from the scope alone. IdentityResolver.forDelegatedTurn returns elowenUserId = scope.dream.accountUserId only when the scope carries a dream AND the child's parent session proves it is that account's own conversation: it belongs to the named account, it is a top-level conversation, it is not itself delegated, and it is not a brain-ch-* room. Without that proof a dream scope yields no memory identity, and the memory tools refuse the call. Only the host mints scope.dream; the Delegate tool and every plugin path build a child's scope from an access descriptor with no such input, so no model input can ever name a dream.

Project verification and cross-category consolidation

Dreaming uses the existing Project tool, createProjectExecutionBoundary and BrainService.selectProjectExecution seam. switch selects only the child's own execution target; it creates no Project and requests no environment start. The request applies after the current tool batch, before the next provider step, so a script must return before reading the new project's files. For example, list accessible projects, switch to the managed project named by a memory, then use the existing file tools to read its guest path. A missing host path says nothing about that guest path.

maySelectSessionExecution is the shared row-ownership decision used by selection and persistence. The service permits a dream only inside that same child's active turn with a matching captured run; ordinary HTTP callers cannot retarget it. BrainStore.setProjectExecution writes execution_ref (plus work_dir when the new target is the host), leaving the immutable delegated scope and the parent untouched. ChannelSessionService resolves a dream's current target from this stored selection on every tool call; ordinary children keep the captured target and placement. preparePersonalProject restores the selected target after eviction. delegatedPolicy retains finite project ceilings for non-admin dreams, and an admin dream resolves each managed project's live account ACL through the existing policyForProjects resolver. Revocation, unavailable projects and missing environment providers fail closed. Permission boundaries, account tool grants and memory scope do not change on a switch.

Plain host execution has no registered Project row. Project list therefore returns a separate hostTarget: { kind: 'host' } only when the acting identity is an administrator and its live policy allows host execution. Project switch checks that same boundary instead of searching the registered project list for an impossible id-less row. The pending commit still rechecks live authority through effectiveTurnWorkDir. A dream can enter a managed project and return using hostTarget; a non-admin or revoked account cannot. Registered host projects keep their existing membership and path checks.

The existing category tools organize only the account's own categories. Category deletion leaves records uncategorized; MemoryRecategorize can restore their classification through the existing account-scoped maintenance job, which requires a configured categorization model. The prompt tells the child to reuse suitable categories and arrange a replacement before removing useful ones. Category definitions and recategorization retain their existing store/maintenance semantics and are not included in the dream's four content-change counters. A started maintenance job is not a completed consolidation: its durable job receipt remains authoritative and may outlive the dream.

MemoryMerge.categoryId is a dream-only explicit destination. It reaches repositories.memory.merge as MemoryMergeOpts.categoryId with the host-owned dreamRunId. Under the same write lock as source retirement and result insertion, the store requires an existing category owned by that account and every unique source to be its own active personal row. Shared pools, foreign rows and missing/inactive sources refuse the entire operation without source retirement or audit changes. The result keeps the chosen category, explicit/common kind and highest source importance, and records the existing per-source deletes and merge audit with dreaming attribution. No category is created, updated or deleted. Without this explicit destination, the existing same-category contract still applies to every caller.

For example, two private restart records filed under separate personal categories can be folded into one record with the category id returned by MemoryCategories. A refused merge must not prevent a separate MemoryUpdate of a verified stale statement. The prompt distinguishes labelled historical behavior from current claims and asks the child to correct confirmed drift even if consolidation cannot finish.

The reviewed flag and the change log

Every memory a run processes is marked reviewed, through the column memories.dream_reviewed_at. The flag is set and cleared inside the memory repository writers, in one place, so it cannot drift:

  • A content write (add, update, merge, restore, archive) or a manual setCategory clears the flag; the row becomes reviewable again.
  • A dream's own write SETS the flag instead, because the writer is passed opts.dreamRunId under currentDreamRun(). Under a dream the audit actor is dreaming and the event carries the model.
  • Deliberately NOT clearing: the automatic categorizer (setCategoryIfUnchanged), bulk category_id = NULL from a category or project deletion, markUsed and pool re-attribution. Those are system scope changes or usage, not edits of what a memory says.

When a run fires, pendingBatch selects the account's own, active, categorized personal rows with dream_reviewed_at IS NULL, oldest id first, capped at 200 entries and 120,000 bytes of body. Both the batch and pendingTotal retain the categorized-personal scope, excluding uncategorized rows, including rows left behind by category deletion. Shared project pools remain excluded. The oldest row is always included even when its body alone exceeds the byte budget, so a large memory can never wedge the queue. The directive includes every batch record in full, with its id, category, kind, importance and updated date; it instructs the child to consider every listed record and leave correct concise records unchanged. Catalog metadata and instructions are additional to the body-byte budget. The directive is not a fixed size ceiling: account instructions and tool contributions vary the request around it. When a batch child completes successfully and the existing completion authority checks still pass, all offered batch ids go through markReviewed. The dream checks the child's settled journal messages with the shared lastTurnErrorText reader. ChannelSessionService rejects every assistant stopReason: 'error', including a non-empty partial answer, using that same stored error text. The existing child-failure path adds no completion marks and rolls back an automatic run's watermark. Only still-active rows with dream_reviewed_at IS NULL AND updated_at < run start are marked. The full catalog is the read; no memory-tool read or journal proof is required. Catalog delivery and dream memory-tool reads add no recall/vitality usage (X13). A row the agent wrote is marked by that write, and a row a person edited during the run is left for the next pass. The run's reviewed count includes distinct offered ids that are marked reviewed or no longer active. Merge sources count as progress even though merging clears their reviewed flags; soft-deleted rows likewise leave the pending set. A failed or aborted current batch adds no completion marks; completed earlier batches keep their marks and usage. The same run row checkpoints distinct reviewed ids, the bounded summary and the receipt through repositories.memory.dreaming.progress, which updates only a still-running own run. Change counts continue to come from the same run's audit events, across all children.

Run now processes successive bounded batches under one run id and one running-account slot until the run-scoped pendingTotal(userId, startedAt) is zero. The remaining count and next pendingBatch use the same strict updated_at < startedAt cutoff as markReviewed; rows edited or created during the run stay pending for a later run and never trigger an unproductive extra child. The next child id is fresh and the run row always points at the current child, so Stop and pause abort the whole run and prevent another batch. If a batch removes no additional offered id from the pending set while eligible memories remain, the run ends as failed with no_progress (automatic runs included); it never repeats that batch forever. Disabling a run that started enabled prevents the next batch and records aborted: disabled. Run now remains available while automatic maintenance is initially off and can still process all batches.

Automatic runs retain the one-batch per-run cap (X10), up to 200 entries and the current 120,000-byte body budget. Their next scheduled run resumes the remaining pending set. Both paths use the same gates, fresh live grants, delegated spend admission and provider request accounting; batches introduce no new paid-request counter. The initial pending total is fixed in batch_size and the wire batchSize, while reviewed_count advances after each completed batch. Records created or edited during a run remain outside its continuation set and are offered to the next run.

The change log is two halves. The run row (memory_dream_runs) holds time, trigger, model, outcome, summary and the usage receipt. memory_events WHERE dream_run_id = run.id holds the before/after of every memory that was added, updated, merged or soft-deleted, with actor dreaming, the model and the agent's reason. Counts shown in the card are derived from those events when read, so they cannot drift from the trail that explains them. Restore uses the existing paths POST /memory/:id/restore and the event's before_json. Merge is the one writer outside the flag: it records a per-source delete event with full before/after for every caller (X15), so the merged-away memories are visible in the log instead of only as a list of ids.

Lifecycle and terminal states

Completed (the current child returned normally, was not aborted and had no provider error, and Run now exhausted the pending set or an automatic run reached its batch cap): checkpoint the batch, set the run completed, release the lock (keeping the watermark), and, when the run produced at least one memory event, record the dream notice in the parent conversation. The existing owner-scoped memory SSE event announces the start, each completed-batch checkpoint and every terminal state, including zero-write completion, failure, Stop and pause. The account card therefore refreshes its status without polling even when no memory body changed. One info log line per run reports the counts, the reviewed total and the usage receipt.

Failed in the child phase: set the run failed with a short message, roll the watermark back for an automatic run, and add no completion marks for the failed batch. Earlier completed-batch marks and receipts remain. A failure AFTER the child, in the completion bookkeeping, is logged, the run is closed failed with error = 'finish: …' and the lock is released; it is not rolled back, so a bookkeeping error cannot leave the account's one run row stuck running.

Aborted (the user's Stop in the parent conversation, POST /memory/dreaming/runs/:id/cancel, or account deletion): the lock is released and the watermark kept, the run is aborted, and the writes the agent already made stay. The error field records the abort source as aborted: user or aborted: account_deleted; pause records interrupted: daemon_pause. A delegated abort preserves its typed origin and reason as aborted: <origin>: <reason>, including parent teardown, tree abort and recovery. History and the dock translate these host causes through memoryDreamingSummary in operationDockMemory; raw diagnostic reasons remain in the owner-scoped run response. Own provider usage is refreshed once the aborted child unwinds. Interrupted is the pause's own state: beforePause aborts every run in flight and marks it interrupted synchronously, and the boot sweep closes a running row left by a dead process the same way. Terminal lines carry memory.dreaming_completed, memory.dreaming_failed or memory.dreaming_aborted with their code and redacted sample.

OFF stops future automatic runs. Switching an enabled running dream off also prevents its next batch; the current batch is allowed to settle, retaining its completed work. This extends CC's initial-only toggle check for the owner's explicit multi-batch Run now requirement. A revoked grant, a new deny rule, or a new ask rule under strict unattended mode refuses subsequent tool calls; the run fails at settlement if its required memory-write authority is revoked. Model authorization remains on the delegated request path, without a fallback model.

Restart, pause and deletion

A dream child dies with the process. beforePause aborts every running dream child through abortChannel and closes its run as interrupted BEFORE any checkpoint, so a stale child can never resume under the next boot. A crash is covered by the boot sweep (UPDATE memory_dream_runs SET status = 'interrupted' WHERE status = 'running'), which runs beside memoryMaintenance.recoverInterruptedRuns() in bootstrap.ts. Dream children are sub-agent sessions, which is exactly why they are never parked: nothing in the park or resume path owns a dream, and the pause closes their run up front, so there is no resumable dream left. Account deletion calls memoryDreaming.cancelForAccount(id) before the store cleanups, after the plugin cleanup that can still refuse the deletion, so no further provider call is made for the account; repositories.memory.removeForUser then removes the lock and run rows.

The API

The dream routes are literal /memory/dreaming* sub-paths registered above GET /memory/:id, so the id route can never capture them. Identity always comes from the session; no user id is accepted in a body or a query, and a foreign or unknown run id is a 404.

Route Body Responses
GET /memory/dreaming?limit=1..50&before=<runId> none The caller's enabled, model {requested, unavailable}, lastConsolidatedAt, running, blockedReason and a page of runs with nextBefore
GET /memory/dreaming/runs/:id none {run, changes}, the change log read from memory_events for that run; 404 for a foreign or unknown id
POST /memory/dreaming/run empty strict 202 with the new run, or the one already running; 409 {error:'dreaming_unavailable', reason}
POST /memory/dreaming/runs/:id/cancel empty strict 200 with the run; 404 for a foreign or unknown id
DELETE /memory/dreaming/runs/:id none 200 {ok:true} for a settled own run; 409 {error:'dreaming_running'} while running; 404 for a foreign or unknown id

A RunView holds id, trigger, status, startedAt, finishedAt, provider/model, the counts derived from the run's events (added, updated, merged, deleted), reviewedCount, batchSize, the summary, the error and the usage receipt. Refusal reasons are model_not_allowed, model_unavailable, memory_tools_not_granted, memory_writes_denied_by_rule, no_conversation and spend_limit. When the dreaming service is not wired, the routes answer 400 {error:'memory unavailable'}, following the memory-maintenance precedent. blockedReason is computed on each status read from the current grant, rules and explicitly selected model. POST-only checks such as spend admission are returned by Run now itself. The card prioritizes a POST refusal until the status is refreshed.

The account preference is GET/PATCH /auth/me/memory-dreaming with a strict zod schema and a required expectedRevision: an invalid field rejects the whole request with 400 and writes nothing, a stale revision returns 409 with the current value, and a NEW or CHANGED model pair must pass the account's model grant or the request is refused with model not allowed. An absent blob means OFF for old and new accounts alike, and the model-reference cascade deliberately leaves the blob alone: a removed model stays stored and the card shows it as unavailable, never switching silently.

Operation dock and compact history

The existing OperationDockProvider reads GET /memory/dreaming/operations under the memory-dreaming query prefix, scoped to the signed-in account. It includes live automatic and manual runs and the latest 25 unacknowledged outcomes from the last day. Progress shows reviewed memories against the pending total captured at run start, with a percentage when that total is nonzero. Empty totals remain indeterminate. Completed runs map to succeeded; failed, aborted and interrupted runs retain the failure presentation and their translated status. A run the person stopped with Stop is acknowledged in the same call, so it stays in history but never reaches the dock. Inspection reuses PluginOperationProgressDialog's shared presentation contract and links to the account card. POST /memory/dreaming/runs/:id/acknowledge accepts a strict empty body and acknowledges only a settled own run, durably on memory_dream_runs.acknowledged_at. The existing memory SSE/reconnect invalidation refreshes this list; there is no new connection or poll. Empty/loading/error reads invent no operations. This core source works on phones and desktop for every account. The card shows five latest past runs plus live work, with status icons. Its shared Collapsible reveals the rest and cursor paging in place. Summary padding sits outside the two-line clamp, and expanded summaries wrap without clipping. Each settled row offers an icon-only Remove from history button on hover or keyboard focus, always visible on touch devices. The shared ConfirmDialog explains that deletion removes only the run row; running work must first be stopped. The service emits the existing owner-scoped memory invalidation and the web mutation refreshes the memory-dreaming query prefix, covering history and the dock, and discards the deleted run's cached detail. History returns to its first page so no saved cursor points at a removed run. Failure uses the existing error toast.

memory_events.dream_run_id remains an audit correlation value without a foreign-key cascade. Deleting history leaves those events, memory bodies, reviewed marks and the consolidation watermark unchanged. MemoryAuditFeed renders action, actor, reason, model and snapshots directly from each event; it neither fetches nor links to the run. Restoring a deleted memory still uses the existing memory restore route. The removed run's own detail route answers 404 and its history row and dock item disappear.

Settings

The account card's four controls are the on/off toggle, the model, Run now and the overview. The two automatic gates are admin RuntimeLimits: memoryDreamingMinHours (1-168, default 24) and memoryDreamingMinSessions (1-50, default 5), both read live at every check, so a slider change applies to the next check without a restart (the runtime limits in Settings). Nothing is read from the environment.

Absent behavior

  • The toggle is OFF (the default): no automatic check fires. Run now still works.
  • No dreaming service is wired (a minimal daemon or a test): the routes answer 400, and no turn arms a check.
  • The account grants no memory write tool, or a rule resolves one to deny (or to ask with strict unattended asks): the automatic check declines with a blockedReason; Run now returns 409 with that reason.
  • The account has no own conversation to serve as parent: Run now returns 409 no_conversation.
  • The model pick is not offered, or not allowed: the check declines and Run now returns 409; nothing is silently substituted.

Limits and cost

Each full-record batch is bounded to 200 entries and 120,000 body bytes, with the oldest-row exception above. Run now processes a large backlog through fresh children under one run; automatic runs take one batch. The prompt's session list holds the conversations that qualified, and each one is readable through ElowenApi GET. The 10-minute scan throttle bounds how often the session query runs; the whole check is cheap and only the fire costs anything. One dream per account runs at a time (the automatic path through the lock, the manual path through the partial unique index). There is no step, mutation or time budget beyond the existing delegated-turn limits, and the run's cost is visible per run on the card and in usage_by_origin for the owner. Parent history cannot overflow the child's context or incur a repeated cross-model cache write: it is absent from the request. The fresh system prompt, tool definitions and full directive still use context and incur provider input/cache charges, including the oldest-row budget exception. There is no model-specific fork branch or new context-size limit. The run summary is bounded to 2,000 characters. Cost is null when no provider attempt was priced; a known zero cost remains zero. When only some attempts have prices, the receipt carries their known sum.

Real consumer

The account card web/modules/account/MemoryDreamingCard.tsx, folded into AccountMemorySection's save state, is the real consumer of the API: it reads GET /memory/dreaming for the enabled state, the model and available reason, the last consolidation and the run overview; sends PATCH /auth/me/memory-dreaming with expectedRevision for the toggle and the model; calls POST /memory/dreaming/run for Run now and POST …/runs/:id/cancel for Stop; and expands a run through GET /memory/dreaming/runs/:id to show the before/after of each changed memory. The two admin sliders in the runtime limits of Settings are its other half.