mode="apply") executes that plan with bi-temporal soft-delete and a per-op undo snapshot.
The name follows the complementary learning systems framework from neuroscience: ingestion is the fast, episodic path (Khora.remember), and dream is the slow, reorganizing path. Same store, different access pattern. See Research and prior art for the lineage.
Why run it
Reach for dream when one of these is true:- Duplicate entities from independent ingest batches. Near-duplicate variants like
"OpenAI"/"Open AI"or"Marie Skłodowska-Curie"/"M. Curie"that the ingest-time resolver didn’t merge. The dedupe op does a second, namespace-wide pass with all accumulated evidence. - Stale provenance after heavy
forget()/forget_session(). Entity and relationshipsource_chunk_idskeep pointing at deleted chunks, because the forget cascade removes the chunks but not the back-pointers. That leaves provenance inaccurate and can skew entity scoring. The GC op drops the dead references. - You changed your
ExpertiseConfigand want to know whether the data still matches. The schema-drift report diffs the entity and relationship types present in the data against your config and flags what diverged. It reports the drift. It does not re-extract old documents. - You want a reviewable “state of the graph” snapshot before authorizing anything destructive. Dry-run produces exactly that.
How to run it
The master switch isDreamConfig.enabled (env var KHORA_DREAM_ENABLED), default False. Per-op flags are off by default too. Turn on only what you need.
result.metadata["plan_hash"] is unchanged. Planning is deterministic, so a changed hash means the data moved under you.
Run-level history is in khora_dream_runs, readable via kb.dream_history(namespace) whether or not a report sink is on.
Caveats
- Some apply ops are PostgreSQL-only. Most vectorcypher mutation handlers run on both the Postgres and embedded
sqlite_lancestacks. Three that bind raw UUIDs are gated to PostgreSQL:centroid_recompute,source_chunk_ids_gc, andcontradiction_reconcile. On any other dialect the orchestrator’s dialect gate reports those ops asskipped(raisingDreamBackendUnsupportedinternally) rather than letting a driver error leak.dedupe_entitiesandprune_edgesrun onsqlite_lance. - The graph store is kept in sync. When the namespace has a graph backend, apply mirrors each soft-delete or endpoint rewrite to the graph post-commit: Neo4j and Memgraph get an eventual-consistency mirror, so graph recall stays consistent with PostgreSQL. A mirror failure after the PG commit is recorded (counter
khora.dream.graph_mirror.partial_failure) and queued inkhora_dream_runs.graph_mirror_pendingfor the reconciler to retry. (sqlite_lanceis single-store, so no mirror is needed.) - Undo is per-op, not per-run. Every apply handler snapshots pre-state into
undo.json(schemadream-undo/1).kb.dream_undo(op_id)reverses a single applied op from that snapshot inside one transaction. There is no run-levelundo(run_id), so reversing a whole run means undoing its ops one at a time. - Guardrails on the apply path. A
KHORA_DREAM_DISABLE_APPLYkill-switch, a Postgres advisory lock held for the whole run, achunk_id-mutation runtime assertion, and the snapshot-before-mutate undo records. - Concurrency. One run per namespace at a time (advisory lock); a second concurrent run fast-fails with
DreamLockUnavailable. Different namespaces run in parallel. On embedded backends the lock degrades to an in-processasyncio.Lock, so cross-process safety is not promised onsqlite_lance. - It is not autonomous. Dream does not decide when to run (that’s your cron / Temporal / k8s policy), does not judge whether a planned merge makes business sense (it uses cosine / Levenshtein / age heuristics), and does not replace good ingest-time decisions. If dedupe finds thousands of merges in a fresh namespace, the bug is upstream.
centroid_recompute on sqlite_lance, and auto-chaining of dedupe into centroid recompute (today centroid recompute needs clusters fed in from a prior dedupe run).
Phases and what they do
Every op returns aDreamOp with a decision string and a structured outputs dict. The orchestrator routes those through whichever sinks are enabled. An op never mutates state directly. Even Phase 2 planners only describe what they would do until you call apply.
Phase 1: audit ops (read-only)
Pure observation. No LLM calls, no mutations, no risk to production data. Apply mode is a pass-through.- Schema drift vs
ExpertiseConfig. A multiset diff of theentity_type/relationship_typestrings in the data against what your config declares: types present but undeclared, types declared but unused, and frequencies that shifted by 50% or more since the last run. It never renames anything. - PageRank orphan report. Builds the namespace’s entity-relationship graph, down-weights
ASSOCIATED_WITHco-occurrence edges so they don’t dominate, runs PageRank, and flags entities that are bottom-percentile, barely mentioned (mention_count <= 1), and not recently recalled. Each is markedarchive_candidate=true. The op never archives. source_chunk_idsarray-length audit. Reports dead-UUID counts, the array-length distribution, and the worst offenders. Surfaces GC candidates for the Phase 2 GC op without touching a row.
Phase 2: planner ops
Each emits oneDreamOp per work item. In dry-run you get the plan only. In apply the matching apply_<op> handler runs under a per-op transaction, snapshotting pre-state into undo.json first. The plan is checkpointed to khora_dream_runs, so a crashed run resumes via resume_from=<run_id>.
- Cross-batch entity dedupe. Buckets entities by
(name_lower, entity_type), scores pairwise cosine on pre-normalized embeddings, and plans a merge for any pair above the per-type threshold (default0.90, tighter than the online resolver’s0.85). Skip-collisions, where one canonical entity would absorb two clusters, are reported but not auto-resolved. - Centroid recompute. For each proposed merge cluster, decides how the canonical embedding should be produced: a weighted-mean
centroidfor close surface-form variants, are_embedof the canonical name for lexically distant but semantically aligned names, or askip_multimodalfinding when the cluster spans more than one concept (the merge itself is the bug). Needsrapidfuzz(the[accel]extra). source_chunk_idsGC. Plans per-entity rewrites that drop the dead chunk UUIDs the audit found. Idempotent.
Reading the reports
Three sinks consume the sameDreamOp stream. Enable each independently via DreamConfig.report_*_sink_enabled.
File sink
Writes per-run artifacts under{base_dir}/{namespace_id}/{date}/{run_id}.*:
redact_text ("none" / "summary" / "all", default "summary") controls how much raw text appears across all sinks. Old reports are swept by retention_days (default 30) and retention_runs_per_namespace (default 50).
Default location. With report_file_sink_enabled=True, reports write to <system temp dir>/khora-dream-reports (/tmp/... on Linux), which the OS can wipe on reboot. There is no config setting for the path, so copy reports somewhere persistent on a schedule if you need a durable audit trail.
Event sink
Bridges into the existingHookDispatcher via DREAM_* event types (DREAM_RUN_STARTED, DREAM_PHASE_STARTED, DREAM_OP_DECIDED, DREAM_PHASE_COMPLETED, DREAM_RUN_COMPLETED, DREAM_RUN_FAILED). Existing SemanticFilter filters work, including the low-cost fields dream_op_types and dream_decisions. A DREAM_OP_DECIDED payload matches one line of events.jsonl.
Collector sink (OpenTelemetry)
Emits spans and metrics. The operator-facing pieces are stable; the per-op internals may change, so don’t pin dashboards to them.- Public spans:
khora.dream.run,khora.dream.phase. - Public metrics (aggregate-only, never labeled by
namespace_id):khora.dream.runs_total,khora.dream.run.duration,khora.dream.phase.duration,khora.dream.ops_total {phase, op_type, decision}.
Reference
Configuration
DreamConfig is a pydantic-settings model (env prefix KHORA_DREAM_). The knobs you’ll reach for:
Full env-var bindings are in Configuration.
Public API
Bound methods onkhora.Khora, with functional equivalents at khora.dream.api:
dream(namespace, *, mode, scope, ops, config, on_progress, resume_from) -> DreamResultdream_status(run_id) -> dictdream_history(namespace, *, limit=20) -> list[DreamRunInfo]dream_undo(op_id, *, base_dir=None) -> bool: reverse one applied op from itsundo.jsonsnapshot. ReturnsTruewhen a row was restored,Falsefor an unknown or already-undone op.
khora): DreamResult, DreamRunInfo, DreamScope, DreamMode, OpKind, UndoRecord, OpSummary. DreamResult.metadata carries plan_hash and (on dry-run) plan_payload.
Exceptions
All inherit fromKhoraError. Pattern-match these from a job runner:
Storage
khora_dream_runs(PostgreSQL-only): the checkpoint table for crash-resume. Carriesstate,plan_hash,last_committed_op_seq, heartbeat timestamps, and the report path. The embedded path mirrors equivalent state through the file sink.- Bi-temporal columns on
relationshipsandmemory_facts:valid_to,invalidated_at,invalidated_by. They record when a row was superseded and by which op, so a soft-delete can answer “what did the agent believe on date X” without losing history.
Stability
Research and prior art
Dream phase is not a novel invention. It composes patterns the systems and ML communities have used for decades, applied to long-lived agent memory. The naming comes from the complementary learning systems framework (McClelland, McNaughton & O’Reilly, 1995): fast episodic encoding and slow structured consolidation need separate substrates to avoid catastrophic interference. That maps onto online ingest versus offline replay. The same “ingest in one regime, consolidate in another” split shows up in offline RL replay buffers (DQN, prioritized experience replay) and in the OLTP-versus-OLAP separation, which is the cleanest framing: dream is to memory what OLAP is to OLTP. The entity-resolution and bi-temporal pieces sit in established literature (Köpcke & Rahm 2010 on entity matching; Snodgrass 1999 on bi-temporal SQL). Compared with agent-memory frameworks, dream targets the consolidation side they each defer:
It is complementary to all four: same substrate, different cadence, different objective.