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.tsover the shipped templateprompts/memory/dreaming.md. - Lock, watermark, run rows, session count, unreviewed batch, reviewed mark and change log:
packages/db/src/memory/dreaming.ts, exposed asrepositories.memory.dreaming. - The account preference blob:
packages/db/src/config/codecs/store/memoryDreamingSettings.tsand thememoryDreamingresource ofrepositories.config.userSettingsinpackages/db/src/config/userSettings.ts. - The name-level tool boundary:
dreamToolDenialinsrc/brain/session/capabilities.ts, a sibling ofplanWriteDenial. - The memory tools' dream behavior:
src/brain/tools/memoryTools.ts(personal-only, active-only writes, actordreaming). - The memory identity and the durable scope:
src/brain/identity.ts(forDelegatedTurn) andsrc/brain/delegatedScope.ts(scope.dream). - Arming: the
dreameffect ofsettleTurn(src/brain/session/turnSettled.ts), decided insrc/brain/service/turnRunner.ts. - Daemon wiring: construction inside
BrainService, the boot sweep andbeforePauseinsrc/daemon/bootstrap.ts, the handles insrc/daemon/brainCore.ts. - REST:
src/api/routes/memory.ts(the dream routes),src/api/routes/selfSettings.ts(GET/PATCH /auth/me/memory-dreaming) andsrc/api/routes/users.ts(the account-deletion abort). - The notice in the parent conversation: the
dreamchat event insrc/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,MemoryMergeandMemoryDelete, 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 existingMemoryServiceretrieval andlistRecentmethods; ordinary turns retain their captured conversation scope. - X3. Deletion is
MemoryDelete, a soft delete that is restorable. There is normand 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
deleteaudit 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:
- The toggle is on; otherwise return.
- Read the watermark
A = last_consolidated_at ?? 0and compute the elapsed hours. Ifhours < memoryDreamingMinHours, return. A watermark of 0 means "never consolidated", so the gate passes exactly as CC's absent lock mtime does. - 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.
- Count the account's other conversations with a settled user turn after the watermark. If fewer than
memoryDreamingMinSessions, return. The candidate filter isbrain_sessions.activity_atpast the watermark; a journalEXISTSthen 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) andidx_brain_session_entries_dream_user_time(settled non-meta user entries by session and creation time), both inpackages/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. - 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 anaskfollows the account'sunattendedAskssetting like every other unattended turn: it refuses only in strict mode (deny). The status endpoint recomputesblockedReasonfrom the live grant, rules and stored model instead of retaining a past refusal. - Resolve the model: the stored pick, else the conversation's own model. It must be offered and allowed, otherwise return without a fallback (X5).
- 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. - Acquire the lock. If it returns null (held by this boot for less than an hour), return.
- 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:
channelIdminted in the sub-agent namespace,subagent-dream-<runId>, and…-<batchNumber>for each later batch of the same run. Its session id isbrain-ch-subagent-dream-<runId>, soisSubagentSessionand the ephemeral-run rules hide it from every conversation list and nothing is delivered to the parent.ownerUserIdfor the account, andparentSessionIdas the triggering conversation (automatic) or the account's newest own conversation (Run now).delegatedAccessnarrowed to{readOnlyOrigin: 'imposed', dream: {runId, accountUserId}}, withpermissionBoundary: buildReadOnlyBoundary(accountRules)whoseunattendedAsksis then replaced by the account's own setting (it staysdenywhen 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 underallow(the setting's default) and is refused only in strict mode (deny).dreamToolDenialpermits memory writes.- A non-admin child opens in the first usable Project in the account's own order, through the same
defaultProjectRefresolver used bypreparePersonalProjectfor 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-onlyProjecttool 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 recordsfailedwithproject_unavailable, releases its lock while keeping the automatic watermark, and logs locally without reportingmemory.dreaming_failedto the licence server. accountAllowfrom the account's current grant, the pinned origin, and the resolved model. The automatic path marks the requestscheduled.
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
setCategoryclears the flag; the row becomes reviewable again. - A dream's own write SETS the flag instead, because the writer is passed
opts.dreamRunIdundercurrentDreamRun(). Under a dream the audit actor isdreamingand the event carries the model. - Deliberately NOT clearing: the automatic categorizer (
setCategoryIfUnchanged), bulkcategory_id = NULLfrom a category or project deletion,markUsedand 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 toaskwith strict unattended asks): the automatic check declines with ablockedReason; 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.