Dreaming Enhancements
Status: Draft | Owner: vineeth | Last updated: 2026-03-25
1. Problem Statement
Honcho’s dreamer currently runs a single dream type (omni) that executes a fixed two-phase cycle: deduction specialist followed by induction specialist. While this produces useful observations, it has several limitations:
-
No semantic organization of conclusions. Observations (documents) accumulate without any categorical structure. There is no mechanism for grouping related conclusions, tracking thematic areas, or enabling developers to query conclusions by topic. The only filtering axes today are
level(explicit/deductive/inductive/contradiction),observer,observed, and free-text semantic search. -
No developer-defined investigations. The dreamer only runs its built-in deduction+induction cycle. Developers cannot define custom recurring analyses (e.g., “summarize this user’s communication preferences weekly” or “maintain a running personality profile”). There is no way to produce scoped, structured outputs beyond individual observations.
-
Weak consolidation. The deduction specialist can delete outdated observations and the induction specialist creates patterns, but there is no systematic approach to deduplication, staleness detection, or capacity management. As observation counts grow, the system accumulates redundant and stale entries without pressure to clean them up.
These gaps prevent Honcho from being a truly agentic, self-evolving memory environment. The dreamer should not just create observations — it should organize, curate, and produce structured outputs that grow more useful over time.
2. Goals / Non-Goals
Goals
-
G1: Agent-generated tagging. The dreaming agent autonomously discovers and applies semantic tags to conclusions based on patterns it observes. Tags are stored in the new public
metadataJSONB column on thedocumentstable (from the conclusion-tagging spec). Limits on per-conclusion tag count and total tag vocabulary prevent unbounded growth. -
G2: Materialized dreams. Developers can define recurring or one-time investigations (dream definitions) that produce scoped documents — prose summaries, conclusion lists, structured analyses. A new
dream_definitionstable tracks definitions, cadence, and latest results. Results are stored as conclusions with metadata linking them to their definition. -
G3: Improved consolidation. Smarter deduplication via embedding similarity, staleness detection based on age and derivation activity, and capacity-based pressure that triggers cleanup when observation counts exceed thresholds.
-
G4: New DreamType variants. Extend the
DreamTypeenum beyondomnito supporttag,materialize, andconsolidateas independently schedulable dream phases.
Non-Goals
- NG1: File system primitives. Materialized dream results are stored as conclusions or in metadata, not as files. File-based output is a future concern.
- NG2: User-facing tag management UI. Tags are created and managed by the dreaming agent, not by end users directly through the API (though they are readable via the public metadata field).
- NG3: Real-time tagging during message ingestion. Tags are applied during dream cycles, not during the deriver’s representation pipeline.
- NG4: Cross-workspace tag taxonomies. Tags are scoped to a single workspace and observer/observed pair.
3. Design
3.1 Agent-Generated Tagging
3.1.1 Overview
A new TaggingSpecialist runs as an optional third phase of the dream cycle. It examines existing observations and applies semantic tags based on patterns it discovers. Tags are arbitrary strings chosen by the agent — Honcho does not impose a fixed taxonomy. The agent organically evolves the tag vocabulary as it learns more about a peer.
3.1.2 Tag Schema and Limits
Tags are stored in the metadata JSONB column on the documents table (the public, read-write column being added by the conclusion-tagging spec, distinct from internal_metadata).
Storage format:
{
"tags": ["communication-style", "career", "family"],
"tagged_at": "2026-03-25T12:00:00Z",
"tagged_by_run": "a1b2c3d4"
}Limits (enforced in the tool executor, not by DB constraints):
| Limit | Value | Rationale |
|---|---|---|
| Max tags per conclusion | 5 | Prevents over-tagging; forces prioritization |
| Max distinct tags per collection | 50 | Bounds vocabulary growth per observer/observed pair |
| Max tag length | 64 chars | Prevents verbose tag names |
| Tag format | lowercase alphanumeric + hyphens | Normalizes for consistent matching |
When the agent attempts to exceed the collection-level tag limit (50), the tool executor returns an error instructing the agent to reuse existing tags or consolidate similar ones.
Config additions to DreamSettings:
# In DreamSettings
TAGGING_MODEL: str = "claude-haiku-4-5"
MAX_TAGS_PER_CONCLUSION: Annotated[int, Field(default=5, gt=0, le=20)] = 5
MAX_TAGS_PER_COLLECTION: Annotated[int, Field(default=50, gt=0, le=200)] = 50
MAX_TAG_LENGTH: Annotated[int, Field(default=64, gt=0, le=128)] = 643.1.3 TaggingSpecialist
The TaggingSpecialist extends BaseSpecialist and is registered in the SPECIALISTS dict.
File: src/dreamer/specialists.py
class TaggingSpecialist(BaseSpecialist):
"""
Applies semantic tags to observations based on discovered patterns.
This specialist:
1. Reviews recent and high-value observations
2. Identifies thematic categories across the observation space
3. Applies tags to observations via the tag_observations tool
4. Manages the tag vocabulary (merges, renames)
"""
name: str = "tagging"
peer_card_update_instruction: str = "" # Tagging specialist does not update peer card
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
return TAGGING_SPECIALIST_TOOLS
def get_model(self) -> str:
return settings.DREAM.TAGGING_MODEL
def get_max_tokens(self) -> int:
return 8192
def get_max_iterations(self) -> int:
return 10
def build_system_prompt(self, observed: str, *, peer_card_enabled: bool = True) -> str:
# See section 3.1.4 for full prompt
...
def build_user_prompt(self, hints: list[str] | None, peer_card: list[str] | None = None) -> str:
...Registration:
SPECIALISTS: dict[str, BaseSpecialist] = {
"deduction": DeductionSpecialist(),
"induction": InductionSpecialist(),
"tagging": TaggingSpecialist(),
}3.1.4 TaggingSpecialist System Prompt (Sketch)
You are a semantic tagging agent organizing observations about {observed}.
## YOUR JOB
Apply semantic tags to observations to create a navigable knowledge structure.
Tags should reflect the CONTENT THEMES you discover, not the observation level.
## GUIDELINES
- Tags are lowercase, hyphenated strings (e.g., "career-goals", "family-dynamics", "communication-style")
- Each observation can have at most {max_tags_per_conclusion} tags
- The total vocabulary is capped at {max_tags_per_collection} distinct tags per collection
- Reuse existing tags when possible -- consistency matters more than precision
- Merge near-duplicate tags (e.g., "work" and "career" should be one tag)
## PHASE 1: DISCOVERY
Explore the observation space to understand what themes exist:
- `get_recent_observations` - See recent observations
- `search_memory` - Search by topic
- `get_existing_tags` - See what tags already exist and their counts
## PHASE 2: TAGGING
Apply tags to untagged or under-tagged observations:
- `tag_observations` - Apply tags to one or more observations
- `rename_tag` - Rename a tag across all observations that have it
- `merge_tags` - Merge two tags into one
## RULES
1. Focus on untagged observations first
2. Use broad, reusable categories -- not observation-specific labels
3. Every tag should apply to at least 2 observations
4. Do not create tags that duplicate the observation level (no "deductive" tag)
5. Prefer fewer, well-applied tags over many sparse ones
3.1.5 Tagging Tools
New tools added to src/utils/agent_tools.py:
tag_observations — Apply tags to observations by ID.
TOOLS["tag_observations"] = {
"name": "tag_observations",
"description": "Apply semantic tags to one or more observations. Tags are lowercase hyphenated strings.",
"input_schema": {
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"observation_id": {
"type": "string",
"description": "Document ID of the observation to tag",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags to apply (lowercase, hyphenated, max 64 chars each)",
},
},
"required": ["observation_id", "tags"],
},
},
},
"required": ["operations"],
},
}get_existing_tags — Retrieve the current tag vocabulary with counts.
TOOLS["get_existing_tags"] = {
"name": "get_existing_tags",
"description": "Get all existing tags and their usage counts for this collection.",
"input_schema": {
"type": "object",
"properties": {},
},
}rename_tag — Rename a tag across all observations.
TOOLS["rename_tag"] = {
"name": "rename_tag",
"description": "Rename a tag across all observations that have it.",
"input_schema": {
"type": "object",
"properties": {
"old_name": {"type": "string", "description": "Current tag name"},
"new_name": {"type": "string", "description": "New tag name"},
},
"required": ["old_name", "new_name"],
},
}merge_tags — Merge two tags into one.
TOOLS["merge_tags"] = {
"name": "merge_tags",
"description": "Merge two tags into one. All observations with either tag will have the target tag.",
"input_schema": {
"type": "object",
"properties": {
"source_tag": {"type": "string", "description": "Tag to merge from (will be removed)"},
"target_tag": {"type": "string", "description": "Tag to merge into (will be kept)"},
},
"required": ["source_tag", "target_tag"],
},
}Tool list constant:
TAGGING_SPECIALIST_TOOLS: list[dict[str, Any]] = [
TOOLS["get_recent_observations"],
TOOLS["search_memory"],
TOOLS["get_existing_tags"],
TOOLS["tag_observations"],
TOOLS["rename_tag"],
TOOLS["merge_tags"],
]3.1.6 Tool Executor Implementation
The tag_observations handler in create_tool_executor:
- Validates each tag against the format regex
^[a-z0-9]+(-[a-z0-9]+)*$and length limit. - Checks that the observation exists and belongs to the correct workspace/observer/observed.
- Checks that applying the tags would not exceed
MAX_TAGS_PER_CONCLUSIONfor any individual observation. - Queries the current distinct tag count for the collection. If adding new tags would exceed
MAX_TAGS_PER_COLLECTION, returns an error with the current vocabulary so the agent can reuse existing tags. - Updates the
metadataJSONB column:metadata = jsonb_set(metadata, '{tags}', new_tags_array). Also setsmetadata.tagged_atandmetadata.tagged_by_run. - Returns a summary of applied tags.
The get_existing_tags handler:
- Queries:
SELECT DISTINCT jsonb_array_elements_text(metadata->'tags') AS tag, COUNT(*) FROM documents WHERE workspace_name = :ws AND observer = :obs AND observed = :obd AND deleted_at IS NULL AND metadata->'tags' IS NOT NULL GROUP BY tag ORDER BY count DESC. - Returns the tag vocabulary with counts.
The rename_tag and merge_tags handlers perform bulk updates using JSONB array manipulation:
-- rename_tag: replace old_name with new_name in all documents that have old_name
UPDATE documents
SET metadata = jsonb_set(
metadata,
'{tags}',
(
SELECT jsonb_agg(DISTINCT CASE WHEN elem = :old_name THEN :new_name ELSE elem END)
FROM jsonb_array_elements_text(metadata->'tags') AS elem
)
)
WHERE workspace_name = :ws
AND observer = :obs AND observed = :obd
AND deleted_at IS NULL
AND metadata->'tags' @> to_jsonb(:old_name::text);3.1.7 How Tags Influence Future Reasoning
Tags flow into the existing specialist workflows via two mechanisms:
-
Enriched observation display. When
get_recent_observationsorsearch_memoryreturns results, observations that have tags include them in the formatted output:[id:abc123] [tags: career, goals] Content of the observation.... This gives deduction and induction specialists thematic context without additional tool calls. -
Tag-filtered search. A new optional
tagparameter is added tosearch_memory:
"tag": {
"type": "string",
"description": "(Optional) Filter results to observations with this tag",
}The search_memory handler filters results by checking metadata->'tags' @> to_jsonb(:tag::text) before or after the vector similarity search.
- Materialized dream scoping. Dream definitions (section 3.2) can specify tags to scope their investigation, allowing focused analyses of specific thematic areas.
3.2 Materialized Dreams
3.2.1 Overview
Materialized dreams are developer-defined investigations that produce scoped output documents. Unlike the built-in omni dream cycle, materialized dreams are custom tasks with:
- A definition (what to investigate, what output to produce)
- A cadence (how often to run)
- A scope (which observer/observed pair, optional tag filter)
- A result (stored as conclusions with metadata linking to the definition)
3.2.2 dream_definitions Table
DDL:
CREATE TABLE dream_definitions (
id TEXT PRIMARY KEY DEFAULT nanoid(),
name TEXT NOT NULL,
description TEXT,
-- Scope
workspace_name TEXT NOT NULL REFERENCES workspaces(name),
observer TEXT, -- NULL = all observers
observed TEXT, -- NULL = all observed peers
-- Investigation parameters
instructions TEXT NOT NULL, -- System prompt for the investigation agent
output_type TEXT NOT NULL DEFAULT 'conclusions', -- 'conclusions' | 'metadata' | 'prose'
tag_filter TEXT[], -- Optional: only examine observations with these tags
max_output_items INT DEFAULT 10, -- Max conclusions/items to produce per run
-- Scheduling
trigger_type TEXT NOT NULL DEFAULT 'on_demand', -- 'cron' | 'on_change' | 'on_demand'
cron_expression TEXT, -- Required if trigger_type = 'cron' (e.g., '0 0 * * 0' = weekly)
min_interval_hours INT DEFAULT 24, -- Minimum hours between runs (prevents spam)
-- State
enabled BOOLEAN NOT NULL DEFAULT true,
last_run_at TIMESTAMPTZ,
last_run_id TEXT, -- Correlates with DreamRunEvent.run_id
last_run_status TEXT, -- 'success' | 'failure' | 'running'
run_count INT NOT NULL DEFAULT 0,
-- Metadata
h_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
internal_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Constraints
CONSTRAINT dream_definitions_id_length CHECK (length(id) = 21),
CONSTRAINT dream_definitions_id_format CHECK (id ~ '^[A-Za-z0-9_-]+$'),
CONSTRAINT dream_definitions_name_length CHECK (length(name) <= 256),
CONSTRAINT dream_definitions_instructions_length CHECK (length(instructions) <= 65535),
CONSTRAINT dream_definitions_trigger_type_valid CHECK (trigger_type IN ('cron', 'on_change', 'on_demand')),
CONSTRAINT dream_definitions_output_type_valid CHECK (output_type IN ('conclusions', 'metadata', 'prose')),
CONSTRAINT dream_definitions_cron_required CHECK (
trigger_type != 'cron' OR cron_expression IS NOT NULL
),
-- Foreign keys for observer/observed (when specified)
FOREIGN KEY (observer, workspace_name) REFERENCES peers(name, workspace_name),
FOREIGN KEY (observed, workspace_name) REFERENCES peers(name, workspace_name)
);
-- Index for finding definitions that need to run
CREATE INDEX ix_dream_definitions_trigger_lookup
ON dream_definitions (workspace_name, trigger_type, enabled, last_run_at);
-- Unique name per workspace
CREATE UNIQUE INDEX uq_dream_definitions_name_workspace
ON dream_definitions (workspace_name, name);SQLAlchemy model (added to src/models.py):
@final
class DreamDefinition(Base):
__tablename__: str = "dream_definitions"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
name: Mapped[str] = mapped_column(TEXT, nullable=False)
description: Mapped[str | None] = mapped_column(TEXT, nullable=True)
workspace_name: Mapped[str] = mapped_column(
ForeignKey("workspaces.name"), nullable=False, index=True
)
observer: Mapped[str | None] = mapped_column(TEXT, nullable=True)
observed: Mapped[str | None] = mapped_column(TEXT, nullable=True)
instructions: Mapped[str] = mapped_column(TEXT, nullable=False)
output_type: Mapped[str] = mapped_column(
TEXT, nullable=False, server_default="conclusions"
)
tag_filter: Mapped[list[str] | None] = mapped_column(
ARRAY(TEXT), nullable=True
)
max_output_items: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("10")
)
trigger_type: Mapped[str] = mapped_column(
TEXT, nullable=False, server_default="on_demand"
)
cron_expression: Mapped[str | None] = mapped_column(TEXT, nullable=True)
min_interval_hours: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("24")
)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("true")
)
last_run_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_run_id: Mapped[str | None] = mapped_column(TEXT, nullable=True)
last_run_status: Mapped[str | None] = mapped_column(TEXT, nullable=True)
run_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
h_metadata: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSONB, default=dict, server_default=text("'{}'::jsonb")
)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict, server_default=text("'{}'::jsonb")
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("workspace_name", "name"),
CheckConstraint("length(id) = 21", name="dream_def_id_length"),
CheckConstraint("id ~ '^[A-Za-z0-9_-]+$'", name="dream_def_id_format"),
CheckConstraint("length(name) <= 256", name="dream_def_name_length"),
CheckConstraint("length(instructions) <= 65535", name="dream_def_instructions_length"),
CheckConstraint(
"trigger_type IN ('cron', 'on_change', 'on_demand')",
name="dream_def_trigger_type_valid",
),
CheckConstraint(
"output_type IN ('conclusions', 'metadata', 'prose')",
name="dream_def_output_type_valid",
),
CheckConstraint(
"trigger_type != 'cron' OR cron_expression IS NOT NULL",
name="dream_def_cron_required",
),
ForeignKeyConstraint(
["observer", "workspace_name"],
["peers.name", "peers.workspace_name"],
),
ForeignKeyConstraint(
["observed", "workspace_name"],
["peers.name", "peers.workspace_name"],
),
Index(
"ix_dream_definitions_trigger_lookup",
"workspace_name", "trigger_type", "enabled", "last_run_at",
),
)3.2.3 Trigger Types
| Type | Description | Scheduling mechanism |
|---|---|---|
cron | Runs on a cron schedule | DreamScheduler polls dream_definitions on a configurable interval (default 60s). When a cron-triggered definition is due (based on cron_expression and last_run_at), it enqueues a materialize dream task. |
on_change | Runs when new observations are created for the scoped observer/observed pair | After the deriver creates observations, it checks for on_change definitions matching the scope and enqueues a materialize dream if min_interval_hours has elapsed since last_run_at. |
on_demand | Runs only when triggered via API | No automatic scheduling. Triggered by POST /workspaces/{id}/dream_definitions/{def_id}/trigger. |
3.2.4 Output Types
| Type | Storage | Description |
|---|---|---|
conclusions | New Document rows with level='inductive' | The investigation agent creates observations via create_observations. Each observation’s metadata includes {"dream_definition_id": "<def_id>", "dream_run_id": "<run_id>"}. |
metadata | Written to dream_definitions.internal_metadata.last_result | The investigation agent returns structured JSON that is stored on the definition row itself. Useful for compact results that don’t need vector search. |
prose | New Document row with level='inductive' and metadata.output_type='prose' | A single long-form document summarizing the investigation. Stored as one observation with the full prose content. |
3.2.5 API Surface
New CRUD endpoints under /workspaces/{workspace_id}/dream_definitions:
Schemas (added to src/schemas/api.py):
class DreamDefinitionCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=256)
description: str | None = None
observer: str | None = Field(None, description="Observer peer name (null = all)")
observed: str | None = Field(None, description="Observed peer name (null = all)")
instructions: str = Field(..., min_length=1, max_length=65535)
output_type: Literal["conclusions", "metadata", "prose"] = "conclusions"
tag_filter: list[str] | None = None
max_output_items: int = Field(default=10, ge=1, le=100)
trigger_type: Literal["cron", "on_change", "on_demand"] = "on_demand"
cron_expression: str | None = None
min_interval_hours: int = Field(default=24, ge=1, le=720)
class DreamDefinitionUpdate(BaseModel):
name: str | None = None
description: str | None = None
instructions: str | None = None
output_type: Literal["conclusions", "metadata", "prose"] | None = None
tag_filter: list[str] | None = None
max_output_items: int | None = None
trigger_type: Literal["cron", "on_change", "on_demand"] | None = None
cron_expression: str | None = None
min_interval_hours: int | None = None
enabled: bool | None = None
class DreamDefinitionResponse(BaseModel):
id: str
name: str
description: str | None
workspace_name: str
observer: str | None
observed: str | None
instructions: str
output_type: str
tag_filter: list[str] | None
max_output_items: int
trigger_type: str
cron_expression: str | None
min_interval_hours: int
enabled: bool
last_run_at: datetime.datetime | None
last_run_id: str | None
last_run_status: str | None
run_count: int
created_at: datetime.datetime
updated_at: datetime.datetime
metadata: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True, populate_by_name=True)Endpoints (new router file src/routers/dream_definitions.py):
| Method | Path | Description |
|---|---|---|
POST | /workspaces/{workspace_id}/dream_definitions | Create a dream definition |
GET | /workspaces/{workspace_id}/dream_definitions | List dream definitions (paginated) |
GET | /workspaces/{workspace_id}/dream_definitions/{definition_id} | Get a single definition |
PUT | /workspaces/{workspace_id}/dream_definitions/{definition_id} | Update a definition |
DELETE | /workspaces/{workspace_id}/dream_definitions/{definition_id} | Delete a definition |
POST | /workspaces/{workspace_id}/dream_definitions/{definition_id}/trigger | Manually trigger a materialized dream run |
The trigger endpoint:
@router.post(
"/{definition_id}/trigger",
status_code=202,
response_model=DreamTriggerResponse,
)
async def trigger_dream_definition(
workspace_id: str = Path(...),
definition_id: str = Path(...),
db: AsyncSession = db,
) -> DreamTriggerResponse:
"""
Manually trigger a materialized dream for this definition.
Returns 202 Accepted with a run_id for tracking.
Respects min_interval_hours -- returns 429 if too soon.
"""
definition = await crud.get_dream_definition(db, workspace_id, definition_id)
if definition is None:
raise ResourceNotFoundException("Dream definition not found")
if not definition.enabled:
raise ValidationException("Dream definition is disabled")
# Check min_interval_hours
if definition.last_run_at:
hours_since = (datetime.now(timezone.utc) - definition.last_run_at).total_seconds() / 3600
if hours_since < definition.min_interval_hours:
raise HTTPException(
status_code=429,
detail=f"Too soon. {definition.min_interval_hours - hours_since:.1f} hours remaining."
)
run_id = await enqueue_materialized_dream(workspace_id, definition)
return DreamTriggerResponse(run_id=run_id, status="accepted")3.2.6 DreamRunner Execution Flow
When a materialize dream task is dequeued, process_dream in orchestrator.py routes to a new run_materialized_dream function:
process_dream(payload)
|
+--> match payload.dream_type:
case DreamType.OMNI: run_dream(...) # existing
case DreamType.TAG: run_tagging(...) # new (section 3.1)
case DreamType.MATERIALIZE: run_materialized_dream(...) # new
case DreamType.CONSOLIDATE: run_consolidation(...) # new (section 3.3)
run_materialized_dream flow:
- Load the
DreamDefinitionfrom the database using thedefinition_idin the payload. - Mark
last_run_status = 'running'andlast_run_at = now(). - Build a
MaterializationSpecialistdynamically:- System prompt is constructed from
definition.instructionsplus a standard preamble that explains the output format requirements. - Tools are a subset:
get_recent_observations,search_memory,search_messages,create_observations(if output_type isconclusionsorprose). - If
tag_filteris set, the search tools are pre-filtered to only return observations with matching tags.
- System prompt is constructed from
- Run the specialist via
BaseSpecialist.run(...). - If
output_type == 'metadata', parse the agent’s final response as JSON and write todefinition.internal_metadata['last_result']. - Update
last_run_status = 'success', incrementrun_count. - On failure, set
last_run_status = 'failure'and store error ininternal_metadata['last_error'].
Payload extension:
class MaterializeDreamPayload(DreamPayload):
"""Extended payload for materialized dreams."""
dream_type: Literal["materialize"] = "materialize"
definition_id: str # References dream_definitions.idThe DreamPayload model in src/utils/queue_payload.py gains an optional definition_id field:
class DreamPayload(BasePayload):
task_type: Literal["dream"] = "dream"
dream_type: DreamType
observer: str
observed: str
session_name: str | None = None
definition_id: str | None = None # NEW: for materialized dreams3.2.7 Cron Polling
The DreamScheduler gains a new periodic task that polls for due cron definitions:
async def _poll_cron_definitions(self) -> None:
"""Check for cron-triggered dream definitions that are due to run."""
while True:
try:
async with tracked_db("dream_cron_poll") as db:
due_definitions = await crud.get_due_cron_definitions(db)
for defn in due_definitions:
await enqueue_materialized_dream(
defn.workspace_name, defn
)
except Exception as e:
logger.error("Cron poll failed: %s", e)
await asyncio.sleep(self.cron_poll_interval_seconds)Config addition:
# In DreamSettings
CRON_POLL_INTERVAL_SECONDS: Annotated[int, Field(default=60, gt=10, le=3600)] = 60The crud.get_due_cron_definitions query:
SELECT * FROM dream_definitions
WHERE enabled = true
AND trigger_type = 'cron'
AND (last_run_at IS NULL
OR last_run_at + (min_interval_hours || ' hours')::interval < now())
AND last_run_status != 'running'
ORDER BY last_run_at ASC NULLS FIRST
LIMIT 10;Cron expression evaluation uses the croniter library (add to pyproject.toml dependencies) to check if the definition is due based on cron_expression and last_run_at.
3.3 Consolidation Improvements
3.3.1 Overview
A new ConsolidationSpecialist (or enhanced consolidation mode) systematically addresses three problems: duplicate observations, stale observations, and unbounded growth.
3.3.2 Smarter Deduplication
Current behavior: The deduction specialist can manually identify and delete duplicates during its normal run, but has no systematic mechanism for finding them.
New behavior: A dedicated deduplication phase uses embedding similarity to find near-duplicate observations.
Implementation — new tool find_near_duplicates:
TOOLS["find_near_duplicates"] = {
"name": "find_near_duplicates",
"description": "Find observations that are semantically very similar to each other (potential duplicates). Returns pairs of observations with their similarity scores.",
"input_schema": {
"type": "object",
"properties": {
"similarity_threshold": {
"type": "number",
"description": "Minimum cosine similarity to consider as near-duplicate (default: 0.92, range: 0.80-0.99)",
"default": 0.92,
},
"limit": {
"type": "integer",
"description": "Maximum number of duplicate pairs to return (default: 20)",
"default": 20,
},
},
},
}Tool executor implementation:
The handler executes a self-join query on the documents table using pgvector’s cosine distance:
SELECT
d1.id AS id_a, d1.content AS content_a, d1.level AS level_a, d1.created_at AS created_a,
d2.id AS id_b, d2.content AS content_b, d2.level AS level_b, d2.created_at AS created_b,
1 - (d1.embedding <=> d2.embedding) AS similarity
FROM documents d1
JOIN documents d2
ON d1.workspace_name = d2.workspace_name
AND d1.observer = d2.observer
AND d1.observed = d2.observed
AND d1.id < d2.id -- avoid self-pairs and duplicates
WHERE d1.workspace_name = :ws
AND d1.observer = :obs
AND d1.observed = :obd
AND d1.deleted_at IS NULL
AND d2.deleted_at IS NULL
AND 1 - (d1.embedding <=> d2.embedding) >= :threshold
ORDER BY similarity DESC
LIMIT :limit;The consolidation specialist receives these pairs and decides which to merge (keeping the higher-level or more-derived observation) and which to delete.
3.3.3 Staleness Detection
New tool find_stale_observations:
TOOLS["find_stale_observations"] = {
"name": "find_stale_observations",
"description": "Find observations that may be stale based on age and lack of recent derivation activity. Returns observations ordered by staleness score (most stale first).",
"input_schema": {
"type": "object",
"properties": {
"max_age_days": {
"type": "integer",
"description": "Observations older than this are candidates for staleness review (default: 90)",
"default": 90,
},
"limit": {
"type": "integer",
"description": "Maximum number of stale observations to return (default: 20)",
"default": 20,
},
},
},
}Staleness scoring:
An observation’s staleness score is computed as:
staleness = age_days / max_age_days * (1 / max(times_derived, 1))
Observations that are old AND have low times_derived counts are the most stale. The tool returns these for the consolidation specialist to review and decide whether to soft-delete.
The query:
SELECT id, content, level, created_at, times_derived,
EXTRACT(EPOCH FROM (now() - created_at)) / 86400.0 AS age_days,
(EXTRACT(EPOCH FROM (now() - created_at)) / 86400.0 / :max_age_days)
* (1.0 / GREATEST(times_derived, 1)) AS staleness_score
FROM documents
WHERE workspace_name = :ws
AND observer = :obs AND observed = :obd
AND deleted_at IS NULL
AND created_at < now() - (:max_age_days || ' days')::interval
ORDER BY staleness_score DESC
LIMIT :limit;The consolidation specialist reviews stale observations and can:
- Delete those that are clearly outdated or superseded
- Bump
times_derivedfor those that are still relevant (re-confirming them) - Create updated replacement observations when the information has changed
3.3.4 Capacity-Based Pressure
When the total observation count for a collection exceeds a configurable threshold, the consolidation specialist receives pressure signals to clean up.
Config additions:
# In DreamSettings
CONSOLIDATION_MODEL: str = "claude-haiku-4-5"
CONSOLIDATION_SOFT_LIMIT: Annotated[int, Field(default=500, gt=50, le=10000)] = 500
CONSOLIDATION_HARD_LIMIT: Annotated[int, Field(default=1000, gt=100, le=50000)] = 1000Behavior:
- Below
CONSOLIDATION_SOFT_LIMIT: Consolidation is optional. The specialist runs normally withfind_near_duplicatesandfind_stale_observationsas discovery tools. - Between soft and hard limits: Consolidation is pressured. The system prompt includes a directive: “The observation space is growing large ({count}/{hard_limit}). Prioritize deduplication and staleness cleanup.”
- At or above
CONSOLIDATION_HARD_LIMIT: Consolidation is urgent. The specialist receives a strong directive to reduce the observation count. Thefind_near_duplicatesthreshold is lowered automatically (e.g., 0.85 instead of 0.92) to catch more potential merges.
The observation count is fetched at the start of the consolidation run:
count_stmt = select(func.count(Document.id)).where(
Document.workspace_name == workspace_name,
Document.observer == observer,
Document.observed == observed,
Document.deleted_at.is_(None),
)
observation_count = await db.scalar(count_stmt)3.3.5 ConsolidationSpecialist
class ConsolidationSpecialist(BaseSpecialist):
"""
Consolidates the observation space by deduplicating, removing stale
observations, and managing capacity.
"""
name: str = "consolidation"
peer_card_update_instruction: str = ""
def get_tools(self, *, peer_card_enabled: bool = True) -> list[dict[str, Any]]:
return CONSOLIDATION_SPECIALIST_TOOLS
def get_model(self) -> str:
return settings.DREAM.CONSOLIDATION_MODEL
def get_max_tokens(self) -> int:
return 8192
def get_max_iterations(self) -> int:
return 15
def build_system_prompt(self, observed: str, *, peer_card_enabled: bool = True) -> str:
... # See below
def build_user_prompt(
self,
hints: list[str] | None,
peer_card: list[str] | None = None,
) -> str:
...System prompt sketch:
You are a memory consolidation agent for observations about {observed}.
## YOUR JOB
Clean up and optimize the observation space by:
1. Finding and merging near-duplicate observations
2. Identifying and removing stale, outdated observations
3. Ensuring the observation space stays within capacity limits
## TOOLS
- `find_near_duplicates` - Find pairs of very similar observations
- `find_stale_observations` - Find old, unreinforced observations
- `get_recent_observations` - See recent observations for context
- `search_memory` - Search for specific topics
- `delete_observations` - Remove observations (soft delete)
- `create_observations` - Create merged/updated observations when consolidating
## DEDUPLICATION STRATEGY
When you find near-duplicates:
1. Keep the observation with MORE source_ids (better provenance)
2. If equal, keep the NEWER one (more likely to reflect current state)
3. If one is higher level (inductive > deductive > explicit), prefer it
4. Delete the other one
5. If both contain unique information, create a merged observation and delete both originals
## STALENESS STRATEGY
When reviewing stale observations:
1. Check if the observation is still likely true (use search_memory for context)
2. If clearly outdated (e.g., refers to a past event as upcoming), delete it
3. If still relevant but old, leave it -- age alone is not grounds for deletion
4. If superseded by a newer observation with the same information, delete the old one
{capacity_pressure_directive}
## RULES
1. Never delete an observation without checking if other observations depend on it (via source_ids)
2. When in doubt, keep the observation -- false deletion is worse than clutter
3. Log what you delete and why in your final summary
4. Prefer merging over deletion when both observations add value
Tool list:
CONSOLIDATION_SPECIALIST_TOOLS: list[dict[str, Any]] = [
TOOLS["find_near_duplicates"],
TOOLS["find_stale_observations"],
TOOLS["get_recent_observations"],
TOOLS["search_memory"],
TOOLS["delete_observations"],
TOOLS["create_observations"],
TOOLS["get_reasoning_chain"],
]4. Migration Plan
4.1 Database Migration (Alembic)
Migration 1: Add metadata column to documents table.
This migration is owned by the conclusion-tagging spec and is a prerequisite. It adds the public metadata JSONB column (mapped as h_metadata in SQLAlchemy, serialized as metadata in the DB) to the documents table alongside the existing internal_metadata.
Migration 2: Create dream_definitions table.
def upgrade() -> None:
op.create_table(
"dream_definitions",
sa.Column("id", sa.TEXT(), primary_key=True),
sa.Column("name", sa.TEXT(), nullable=False),
sa.Column("description", sa.TEXT(), nullable=True),
sa.Column("workspace_name", sa.TEXT(), sa.ForeignKey("workspaces.name"), nullable=False),
sa.Column("observer", sa.TEXT(), nullable=True),
sa.Column("observed", sa.TEXT(), nullable=True),
sa.Column("instructions", sa.TEXT(), nullable=False),
sa.Column("output_type", sa.TEXT(), nullable=False, server_default="conclusions"),
sa.Column("tag_filter", sa.ARRAY(sa.TEXT()), nullable=True),
sa.Column("max_output_items", sa.Integer(), nullable=False, server_default="10"),
sa.Column("trigger_type", sa.TEXT(), nullable=False, server_default="on_demand"),
sa.Column("cron_expression", sa.TEXT(), nullable=True),
sa.Column("min_interval_hours", sa.Integer(), nullable=False, server_default="24"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_run_id", sa.TEXT(), nullable=True),
sa.Column("last_run_status", sa.TEXT(), nullable=True),
sa.Column("run_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("metadata", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("internal_metadata", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# Constraints
op.create_check_constraint("dream_def_id_length", "dream_definitions", "length(id) = 21")
op.create_check_constraint("dream_def_id_format", "dream_definitions", "id ~ '^[A-Za-z0-9_-]+$'")
op.create_check_constraint("dream_def_name_length", "dream_definitions", "length(name) <= 256")
op.create_check_constraint("dream_def_instructions_length", "dream_definitions", "length(instructions) <= 65535")
op.create_check_constraint("dream_def_trigger_type_valid", "dream_definitions", "trigger_type IN ('cron', 'on_change', 'on_demand')")
op.create_check_constraint("dream_def_output_type_valid", "dream_definitions", "output_type IN ('conclusions', 'metadata', 'prose')")
op.create_check_constraint("dream_def_cron_required", "dream_definitions", "trigger_type != 'cron' OR cron_expression IS NOT NULL")
# Foreign key constraints for observer/observed
op.create_foreign_key(
"fk_dream_def_observer", "dream_definitions",
"peers", ["observer", "workspace_name"], ["name", "workspace_name"],
)
op.create_foreign_key(
"fk_dream_def_observed", "dream_definitions",
"peers", ["observed", "workspace_name"], ["name", "workspace_name"],
)
# Indexes
op.create_index(
"ix_dream_definitions_trigger_lookup", "dream_definitions",
["workspace_name", "trigger_type", "enabled", "last_run_at"],
)
op.create_unique_constraint(
"uq_dream_definitions_name_workspace", "dream_definitions",
["workspace_name", "name"],
)
def downgrade() -> None:
op.drop_table("dream_definitions")4.2 DreamType Enum Extension
The DreamType enum in src/schemas/configuration.py is extended:
class DreamType(str, Enum):
OMNI = "omni"
TAG = "tag"
MATERIALIZE = "materialize"
CONSOLIDATE = "consolidate"The DreamSettings.ENABLED_TYPES default is updated:
ENABLED_TYPES: list[str] = ["omni"] # unchanged default; new types opt-in4.3 Backward Compatibility
- The default
ENABLED_TYPESremains["omni"]. New dream types must be explicitly enabled. - Existing observations without
metadata.tagsare treated as untagged. The tagging specialist targets these first. - The
dream_definitionstable is new and has no impact on existing data. - All new tools are added to new tool lists only (
TAGGING_SPECIALIST_TOOLS,CONSOLIDATION_SPECIALIST_TOOLS). ExistingDEDUCTION_SPECIALIST_TOOLSandINDUCTION_SPECIALIST_TOOLSare unchanged.
5. Implementation Phases
Phase 1: Foundation (prerequisite: conclusion-tagging spec lands first)
- Extend
DreamTypeenum withTAG,MATERIALIZE,CONSOLIDATE. - Add new config fields to
DreamSettings:TAGGING_MODEL,CONSOLIDATION_MODEL, tag limits, consolidation limits,CRON_POLL_INTERVAL_SECONDS. - Create Alembic migration for
dream_definitionstable. - Add
DreamDefinitionmodel tosrc/models.py. - Update
DreamPayloadwith optionaldefinition_id. - Update
process_dreaminorchestrator.pyto route new dream types.
Phase 2: Agent-Generated Tagging
- Implement tagging tools in
src/utils/agent_tools.py:tag_observations,get_existing_tags,rename_tag,merge_tags. - Implement tool executors for tagging tools in
create_tool_executor. - Create
TaggingSpecialistinsrc/dreamer/specialists.py. - Add
tagdream type routing inorchestrator.pywithrun_taggingfunction. - Enrich observation display in
get_recent_observationsandsearch_memoryhandlers to include tags. - Add optional
tagfilter parameter tosearch_memorytool. - Add telemetry event
DreamTaggingEvent.
Phase 3: Consolidation Improvements
- Implement
find_near_duplicatestool and its SQL-based executor. - Implement
find_stale_observationstool and executor. - Create
ConsolidationSpecialistinsrc/dreamer/specialists.py. - Add
consolidatedream type routing inorchestrator.py. - Implement capacity pressure detection and dynamic prompt injection.
- Add telemetry event
DreamConsolidationEvent.
Phase 4: Materialized Dreams
- Implement CRUD operations for
dream_definitionsinsrc/crud/dream_definition.py. - Create API router
src/routers/dream_definitions.pywith all endpoints. - Add schemas to
src/schemas/api.py. - Implement
MaterializationSpecialist(dynamic specialist built from definition instructions). - Implement
run_materialized_dreaminorchestrator.py. - Implement
enqueue_materialized_dreaminsrc/deriver/enqueue.py. - Add cron polling to
DreamScheduler. - Add
on_changetrigger hook in the deriver pipeline. - Add telemetry event
DreamMaterializeEvent. - Register new router in
src/main.py.
Phase 5: Integration and Polish
- Update the
omnidream type to optionally include tagging as a third phase (controlled by config). - Wire
on_changetriggers into the deriver observation creation path. - Add
croniterdependency topyproject.toml. - Write comprehensive tests for all new specialists, tools, and API endpoints.
- Update CLAUDE.md with new architecture documentation.
6. Files to Modify (exact paths relative to repos/honcho/)
New Files
| Path | Description |
|---|---|
src/routers/dream_definitions.py | API router for dream definition CRUD + trigger |
src/crud/dream_definition.py | CRUD operations for dream_definitions table |
alembic/versions/xxxx_add_dream_definitions_table.py | Alembic migration for dream_definitions |
Modified Files
| Path | Change |
|---|---|
src/models.py | Add DreamDefinition model |
src/schemas/configuration.py | Extend DreamType enum; add ResolvedDreamConfiguration fields |
src/schemas/api.py | Add DreamDefinitionCreate, DreamDefinitionUpdate, DreamDefinitionResponse, DreamTriggerResponse schemas |
src/schemas/__init__.py | Re-export new schemas |
src/config.py | Add TAGGING_MODEL, CONSOLIDATION_MODEL, tag limits, consolidation limits, CRON_POLL_INTERVAL_SECONDS to DreamSettings |
src/dreamer/specialists.py | Add TaggingSpecialist, ConsolidationSpecialist; register in SPECIALISTS dict |
src/dreamer/orchestrator.py | Add run_tagging, run_materialized_dream, run_consolidation functions; update process_dream match statement |
src/dreamer/dream_scheduler.py | Add _poll_cron_definitions periodic task; update __init__ to start cron poller |
src/utils/agent_tools.py | Add tag_observations, get_existing_tags, rename_tag, merge_tags, find_near_duplicates, find_stale_observations tools; add TAGGING_SPECIALIST_TOOLS, CONSOLIDATION_SPECIALIST_TOOLS lists; add tag parameter to search_memory; update observation display to include tags |
src/utils/queue_payload.py | Add definition_id to DreamPayload; add create_materialized_dream_payload helper |
src/utils/work_unit.py | Handle materialize dream type in work unit key construction (include definition_id) |
src/utils/types.py | No changes needed (DocumentLevel already covers all levels) |
src/deriver/enqueue.py | Add enqueue_materialized_dream function; add on_change trigger check after observation creation |
src/main.py | Register dream_definitions router |
src/telemetry/events/dream.py | Add DreamTaggingEvent, DreamConsolidationEvent, DreamMaterializeEvent |
src/telemetry/events/__init__.py | Re-export new events |
pyproject.toml | Add croniter dependency |
7. Risk Assessment
| Risk | Severity | Likelihood | Mitigation |
|---|---|---|---|
| Tag vocabulary explosion despite limits | Medium | Low | Hard cap at 50 tags per collection; agent instructed to reuse; merge_tags tool provides cleanup |
find_near_duplicates query is expensive on large collections | High | Medium | Limit to collections under CONSOLIDATION_HARD_LIMIT; add query timeout; consider sampling for very large collections; the self-join is bounded by the HNSW index |
| Cron polling creates excessive dream tasks | Medium | Low | min_interval_hours prevents spam; LIMIT 10 on the poll query caps throughput; existing dream deduplication index prevents duplicate pending tasks |
| Materialized dream agent produces low-quality output | Medium | Medium | max_output_items caps volume; results are stored as regular observations and subject to the same consolidation pressure; developers can disable/delete definitions |
Breaking change to DreamPayload with definition_id | Low | Low | Field is Optional[str] with None default; existing payloads deserialize without issue |
| Tagging specialist conflicts with manual metadata writes via API | Medium | Low | Tags live under metadata.tags key; document that this key is managed by the dreamer; API writes to other metadata keys are unaffected |
croniter dependency adds supply chain risk | Low | Low | croniter is a well-maintained, minimal-dependency library widely used in the Python ecosystem |
8. Verification Plan
Unit Tests
- Tag validation. Test tag format regex, length limits, per-conclusion limits, per-collection limits.
- Tag tool executors. Test
tag_observations,get_existing_tags,rename_tag,merge_tagsagainst a test database. - Near-duplicate finder. Insert documents with known embeddings at varying cosine distances; verify the query returns correct pairs above threshold.
- Staleness scorer. Insert documents with known ages and
times_derivedvalues; verify scoring and ordering. - DreamDefinition CRUD. Test create, read, update, delete, and list with filtering.
- DreamPayload serialization. Verify
definition_idserializes/deserializes correctly; verify backward compatibility with payloads lacking the field. - Work unit key construction. Verify
materializedream type produces correct keys includingdefinition_id. - Cron expression evaluation. Test
get_due_cron_definitionswith variouscron_expressionvalues andlast_run_attimestamps.
Integration Tests
- Full tagging cycle. Create observations, run
TaggingSpecialist, verify tags are applied tometadata.tags. - Full consolidation cycle. Create near-duplicate observations, run
ConsolidationSpecialist, verify duplicates are merged/deleted. - Full materialized dream cycle. Create a dream definition, trigger it, verify output observations are created with correct
metadata.dream_definition_id. - Omni + tag combined run. Run
omnidream with tagging enabled, verify deduction, induction, and tagging all execute. - Cron trigger. Create a cron definition, advance time past the cron interval, verify the cron poller enqueues the dream.
- On-change trigger. Create an
on_changedefinition, create new observations via deriver, verify the materialized dream is enqueued. - Capacity pressure. Create observations up to the hard limit, run consolidation, verify the specialist receives pressure directives and reduces count.
API Tests
- Dream definitions CRUD endpoints. Full lifecycle: create, get, list, update, delete.
- Trigger endpoint. Verify 202 response, verify 429 when under
min_interval_hours. - Trigger with disabled definition. Verify 400/422 response.
- Conclusion list with tag filter. Verify
metadata.tagsfiltering works inlist_conclusionsandquery_conclusions.
Alembic Tests
- Migration up/down. Verify
dream_definitionstable is created with all constraints and indexes, and cleanly drops on downgrade. - Data preservation. Verify existing
documentsrows are unaffected by the migration.
9. Open Questions
-
Should the
omnidream type automatically include tagging? Option A:omnialways runs deduction + induction + tagging (as phase 3). Option B:omniremains deduction + induction only; tagging is a separate dream type that must be explicitly enabled inENABLED_TYPES. Current recommendation: Option B for backward compatibility, with a config flagDREAM.OMNI_INCLUDES_TAGGING: bool = Falseto opt in. -
Should tags be visible in the public
ConclusionAPI schema? Themetadatafield would naturally expose tags. Should we add a dedicatedtagsfield to theConclusionresponse schema for convenience, or rely on clients readingmetadata.tags? Current recommendation: Expose throughmetadataonly; add convenience field later if demanded. -
How should
on_changetriggers handle high-throughput workspaces? If a workspace creates hundreds of observations per hour,on_changedefinitions could be triggered very frequently even withmin_interval_hours. Should there be an additional rate limit beyondmin_interval_hours, such as a minimum observation count threshold? Current recommendation: Add an optionalmin_observations_since_last_run: intfield todream_definitions(default: 10) that must be met in addition to the time interval. -
Should materialized dream results replace previous results or accumulate? When a cron dream runs weekly, should it delete the previous week’s conclusions (tagged with its
definition_id) before creating new ones, or let them accumulate? Current recommendation: Add areplace_previous: bool = Truefield todream_definitions. When true, soft-delete previous results before creating new ones. -
What is the interaction between consolidation and the existing deduction specialist’s deletion capability? The deduction specialist already deletes outdated observations. Should the consolidation specialist be prevented from touching observations created in the current dream cycle? Current recommendation: Yes. The consolidation specialist should have a
created_beforefilter that excludes observations created within the last N hours (configurable, default: 24) to avoid interfering with fresh observations. -
Should the
find_near_duplicatesquery use pgvector’s HNSW index or a brute-force self-join? The HNSW index is optimized for point queries (find K nearest neighbors to a single vector), not for finding all pairs above a threshold. A brute-force approach may be necessary for small-to-medium collections. Current recommendation: Use brute-force self-join with aLIMITand rely onCONSOLIDATION_HARD_LIMITto keep collection sizes manageable. If performance becomes an issue, batch the query by processing N random seed observations and finding their near-neighbors via HNSW. -
Cron expression format — standard 5-field or extended? Standard cron (minute, hour, day-of-month, month, day-of-week) vs. extended cron with seconds. Current recommendation: Standard 5-field cron via
croniter, which is simpler and sufficient for the use cases (hourly, daily, weekly).