Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ venv.bak/

# Environment variables
.env
.env.bak-*
.env.local
.env.*.local

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).

## [7.2.5] - 2026-07-03 - Clear-water lifecycle repair

- Updated the default NVIDIA extraction/generation model to
`moonshotai/kimi-k2.6` and added a live provider model ping in Dhee doctor.
- Enabled scene summarization by default and wired lifecycle enrichment to fill
missing scene summaries with deterministic fallback summaries.
- Added scene-noise audit and repair for operational/test prompt scenes,
tombstoning active noise while preserving it as episodic events.
- Expanded `DHEE_DATA_DIR` tildes consistently and hardened pytest data-dir
isolation so fixture users do not write into the live vault by accident.

## [7.2.4] - 2026-07-02 - Clear-water memory enrichment

- Deferred enrichment now runs structured engram extraction and marks honest
`failed` and `degraded` states instead of silently completing factless rows.
- Operational tool, screen, and trajectory noise is stored as episodic events
instead of polluting semantic memory recall.
- Added re-extraction support for complete memories that missed structured
facts, plus deterministic preference and plan extraction paths.

## [7.2.2] - 2026-06-06 - Split provider memory profiles

- Added provider runtime profiles that let agent LLMs use OpenAI, Anthropic,
Expand Down
2 changes: 1 addition & 1 deletion dhee/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
# Default import remains model-free for backwards compatibility.
Memory = CoreMemory

__version__ = "7.2.3"
__version__ = "7.2.5"
__all__ = [
# Memory classes
"Engram",
Expand Down
12 changes: 9 additions & 3 deletions dhee/configs/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field, field_validator
Expand All @@ -12,12 +13,17 @@
)


def resolve_dhee_data_dir(path: str | os.PathLike[str]) -> str:
"""Return an absolute Dhee data directory with user markers expanded."""
return os.path.abspath(os.path.expanduser(os.fspath(path)))


def _dhee_data_dir() -> str:
"""Resolve data directory: DHEE_DATA_DIR > ~/.dhee."""
env = os.environ.get("DHEE_DATA_DIR")
if env:
return env
return os.path.join(os.path.expanduser("~"), ".dhee")
return resolve_dhee_data_dir(env)
return resolve_dhee_data_dir(Path.home() / ".dhee")


_VALID_VECTOR_PROVIDERS = {"memory", "sqlite_vec", "zvec"}
Expand Down Expand Up @@ -180,7 +186,7 @@ class SceneConfig(BaseModel):
scene_topic_threshold: float = 0.55 # cosine sim below this = topic shift
auto_close_inactive_minutes: int = 120
max_scene_memories: int = 50
use_llm_summarization: bool = False
use_llm_summarization: bool = True
summary_regenerate_threshold: int = 5


Expand Down
9 changes: 3 additions & 6 deletions dhee/contract_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,17 +741,14 @@ def guard_router_call(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, An
"repo": str(repo_root),
})
if mode == "deny":
# Even in deny mode a supervisor outage must leave a recovery
# path open: read-only tools cannot violate a contract, and the
# dhee CLI is how the agent repairs the runtime state.
command = str(arguments.get("command") or "").strip()
recoverable = tool_name in _READ_TOOL_NAMES or tool_name in _GREP_TOOL_NAMES or (
tool_name in _BASH_TOOL_NAMES and command.startswith("dhee ")
recoverable = tool_name in {"Read", "Grep"} or (
tool_name == "Bash" and command.startswith("dhee ")
)
if recoverable:
warning = (
"Contract supervisor unavailable; deny mode allowed this "
"read-only/remediation call."
"native read-only/remediation call."
)
_record_enforcement_warning(
repo_root,
Expand Down
20 changes: 19 additions & 1 deletion dhee/core/engram_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def extract(
user_profile: Optional[Dict[str, Any]] = None,
existing_metadata: Optional[Dict[str, Any]] = None,
user_id: str = "default",
strict_llm_failure: bool = False,
) -> UniversalEngram:
"""Extract structured engram with contextual anchoring.

Expand All @@ -129,6 +130,8 @@ def extract(
)
if engram:
return engram
if strict_llm_failure:
raise RuntimeError("Engram LLM extraction produced no structured result")

# Rule-based extraction
return self._extract_rule_based(
Expand Down Expand Up @@ -330,13 +333,17 @@ def _extract_facts_rules(self, content: str) -> List[Fact]:
(r"(?:I|user)\s+(?:prefer|like|love|enjoy|use)\s+(.+?)(?:\.|$|,)", "prefers"),
(r"(?:I|user)\s+(?:switched to|moved to|changed to)\s+(.+?)(?:\.|$|,)", "switched_to"),
(r"(?:I|user)\s+(?:work at|work for|employed at)\s+(.+?)(?:\.|$|,)", "works_at"),
(r"(?:I|user|owner)\s+(?:live|lives|reside|resides)\s+in\s+(.+?)(?:\.|$|,)", "lives_in"),
(r"(?:my|user's|owner's)\s+(?:home|city|location)\s+is\s+(.+?)(?:\.|$|,)", "lives_in"),
(r"(?:my|user's|owner's)\s+(?:plan|goal)\s+is\s+to\s+(.+?)(?:\.|$|,)", "plans_to"),
(r"(?:I|user|owner)\s+(?:plan|plans|intend|intends)\s+to\s+(.+?)(?:\.|$|,)", "plans_to"),
(r"(?:I|user)\s+(?:visited|went to|traveled to)\s+(.+?)(?:\.|$|,)", "visited"),
(r"(?:I|user)\s+(?:bought|purchased)\s+(.+?)(?:\s+for\s+)?([\d,.]+)?\s*(\w+)?", "bought"),
]
for pattern, predicate in preference_patterns:
matches = re.finditer(pattern, content, re.IGNORECASE)
for match in matches:
value = match.group(1).strip().rstrip(".")
value = self._clean_rule_value(match.group(1))
if len(value) > 100:
continue
fact = Fact(
Expand All @@ -360,6 +367,17 @@ def _extract_facts_rules(self, content: str) -> List[Fact]:

return facts

@staticmethod
def _clean_rule_value(value: str) -> str:
text = str(value or "").strip().rstrip(".")
text = re.split(
r"\s+(?:and|but|while)\s+(?=(?:I|user|owner|my|user's|owner's)\b)",
text,
maxsplit=1,
flags=re.IGNORECASE,
)[0]
return text.strip().rstrip(".")

def _extract_entities_rules(self, content: str) -> List[EntityRef]:
"""Rule-based entity extraction."""
entities = []
Expand Down
89 changes: 81 additions & 8 deletions dhee/core/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,10 @@ def close_scene(self, scene_id: str, timestamp: Optional[str] = None) -> None:

# Generate summary (LLM when enabled, otherwise deterministic extractive fallback).
memories = self.db.get_scene_memories(scene_id)
summary = None
if self.use_llm_summarization and self.llm:
summary = self._summarize_scene(scene, memories)
if not summary:
summary = self._extractive_scene_summary(memories)
summary = self._generate_scene_summary(scene, memories)
if summary:
updates["summary"] = summary
# Derive title from summary
title = summary.split(".")[0][:120]
updates["title"] = title
updates["title"] = self._title_from_summary(summary)

if updates:
self.db.update_scene(scene_id, updates)
Expand Down Expand Up @@ -270,6 +264,77 @@ def auto_close_stale(self, user_id: str) -> List[str]:
# Summarization
# ------------------------------------------------------------------

def summarize_scene(self, scene_id: str, *, force: bool = False) -> Optional[str]:
"""Generate and persist a summary for an existing scene."""
scene = self.db.get_scene(scene_id)
if not scene:
return None
existing = str(scene.get("summary") or "").strip()
if existing and not force:
return existing

memories = self.db.get_scene_memories(scene_id)
summary = self._generate_scene_summary(scene, memories)
if not summary:
return None
self.db.update_scene(
scene_id,
{
"summary": summary,
"title": self._title_from_summary(summary),
},
)
return summary

def summarize_unsummarized(
self,
*,
user_id: Optional[str] = None,
limit: int = 100,
) -> Dict[str, Any]:
"""Fill missing scene summaries for a bounded maintenance slice."""
bounded_limit = max(1, min(int(limit), 2_000))
scenes = self.db.get_scenes(user_id=user_id, limit=bounded_limit)
summarized: List[Dict[str, Any]] = []
skipped_existing = 0
skipped_empty = 0

for scene in scenes:
if str(scene.get("summary") or "").strip():
skipped_existing += 1
continue
summary = self.summarize_scene(str(scene["id"]), force=True)
if not summary:
skipped_empty += 1
continue
summarized.append(
{
"id": scene.get("id"),
"title": self._title_from_summary(summary),
}
)

return {
"scanned_count": len(scenes),
"summarized_count": len(summarized),
"skipped_existing": skipped_existing,
"skipped_empty": skipped_empty,
"summaries": summarized[:50],
"truncated_summaries": max(0, len(summarized) - 50),
}

def _generate_scene_summary(
self,
scene: Dict[str, Any],
memories: List[Dict[str, Any]],
) -> Optional[str]:
summary = None
if self.use_llm_summarization and self.llm:
summary = self._summarize_scene(scene, memories)
if not summary:
summary = self._extractive_scene_summary(memories)
return summary

def _summarize_scene(
self, scene: Dict[str, Any], memories: List[Dict[str, Any]]
) -> Optional[str]:
Expand Down Expand Up @@ -319,6 +384,14 @@ def _extractive_scene_summary(memories: List[Dict[str, Any]], max_lines: int = 4
return None
return " | ".join(snippets)

@staticmethod
def _title_from_summary(summary: str) -> str:
cleaned = " ".join(str(summary or "").split())
if not cleaned:
return "Untitled scene"
first_sentence = cleaned.split(".")[0].strip()
return (first_sentence or cleaned)[:120]

# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
Expand Down
88 changes: 82 additions & 6 deletions dhee/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -2192,21 +2192,97 @@ def update_strength_bulk(self, updates: Dict[str, float]) -> None:
)

def get_pending_enrichment(self, user_id: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
"""Return memories with enrichment_status='pending', ordered oldest first."""
"""Return pending and retryable failed enrichment rows, oldest first."""
fetch_limit = max(1, int(limit)) * 4
with self._get_connection() as conn:
if user_id:
rows = conn.execute(
"SELECT * FROM memories WHERE enrichment_status = 'pending' AND user_id = ? "
"SELECT * FROM memories WHERE enrichment_status IN ('pending', 'failed') AND user_id = ? "
"AND tombstone = 0 ORDER BY created_at ASC LIMIT ?",
(user_id, limit),
(user_id, fetch_limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM memories WHERE enrichment_status = 'pending' "
"SELECT * FROM memories WHERE enrichment_status IN ('pending', 'failed') "
"AND tombstone = 0 ORDER BY created_at ASC LIMIT ?",
(limit,),
(fetch_limit,),
).fetchall()
return [self._row_to_dict(row) for row in rows]
retryable: List[Dict[str, Any]] = []
for row in rows:
memory = self._row_to_dict(row)
status = str(memory.get("enrichment_status") or "").lower()
metadata = memory.get("metadata") if isinstance(memory.get("metadata"), dict) else {}
attempts_raw = metadata.get("enrichment_attempts", 0)
try:
attempts = int(attempts_raw or 0)
except (TypeError, ValueError):
attempts = 0
if status == "pending" or attempts < 3:
retryable.append(memory)
if len(retryable) >= max(1, int(limit)):
break
return retryable

def requeue_complete_without_engram_facts(
self,
user_id: Optional[str] = None,
limit: int = 100,
) -> Dict[str, Any]:
"""Reopen complete semantic memories that have no linked engram facts."""
try:
with self._get_connection() as conn:
conn.execute("SELECT 1 FROM engram_facts LIMIT 0")
except Exception:
return {"requeued_count": 0, "reason": "engram_facts_unavailable"}

params: List[Any] = []
query = (
"SELECT m.* FROM memories m "
"LEFT JOIN engram_facts f ON f.memory_id = m.id "
"WHERE m.tombstone = 0 "
"AND m.enrichment_status = 'complete' "
"AND COALESCE(m.memory_type, 'semantic') != 'operational_event' "
"GROUP BY m.id HAVING COUNT(f.id) = 0 "
"ORDER BY m.created_at ASC LIMIT ?"
)
if user_id:
query = (
"SELECT m.* FROM memories m "
"LEFT JOIN engram_facts f ON f.memory_id = m.id "
"WHERE m.tombstone = 0 "
"AND m.enrichment_status = 'complete' "
"AND COALESCE(m.memory_type, 'semantic') != 'operational_event' "
"AND m.user_id = ? "
"GROUP BY m.id HAVING COUNT(f.id) = 0 "
"ORDER BY m.created_at ASC LIMIT ?"
)
params.append(user_id)
params.append(max(1, int(limit)))

rows: List[Dict[str, Any]] = []
with self._get_connection() as conn:
for row in conn.execute(query, params).fetchall():
rows.append(self._row_to_dict(row))

updates: List[Dict[str, Any]] = []
for memory in rows:
metadata = memory.get("metadata") if isinstance(memory.get("metadata"), dict) else {}
metadata = dict(metadata)
metadata["enrichment_attempts"] = 0
metadata.pop("last_enrichment_error", None)
metadata["reextract_queued_at"] = _utcnow_iso()
updates.append(
{
"id": memory["id"],
"metadata": metadata,
"enrichment_status": "pending",
}
)
self.update_enrichment_bulk(updates)
return {
"requeued_count": len(updates),
"memory_ids": [item["id"] for item in updates],
}

def update_enrichment_status(self, memory_id: str, status: str) -> None:
"""Mark a memory's enrichment_status (e.g. 'complete')."""
Expand Down
Loading
Loading