fix(dream): ground memory audit records in the real git diff (#4673)
* fix(dream): ground commit messages and cursor advance in the real git diff Dream consolidation could emit a /dream-log audit record that did not match the actual file changes: build_dream_commit_message appended the LLM's unverified resp.content, dream_run_completed only checked the stop reason, and file contents were deliberately omitted from the prompt. The combination let a single-turn self-report become the durable audit record. - gitstore: add summarize_working_tree() — a structured, machine-derived summary (per-file +N/-M, totals, capped unified diff) of working-tree changes vs HEAD. Pure filesystem/git ground truth, never LLM narrative. - memory: build_dream_commit_message now takes the diff body instead of resp; dream_content_diff() exposes the real delta over SOUL/USER/MEMORY.md only (excludes .dream_cursor so cursor writes aren't mistaken for edits); build_dream_prompt embeds current file contents so the model edits reality, not a stale mental model. - builtin/cli: both Dream paths now compute the diff, gate cursor advance on a non-empty delta (no-op runs no longer swallow history), and commit with the diff-grounded message. Non-git workspaces fall back to the completion check. - dream.md: document that contents are embedded, and add a chain-of- verification guardrail so the model's summary cannot claim unmade edits. A regression test proves a lying resp.content never reaches the audit log while the real diff does. * fix(dream): mark non-UTF-8 memory files as binary in diff summary Address review feedback (Q1 on PR #4673): summarize_working_tree read working-tree files with errors="replace", which would emit U+FFFD replacement chars into the audit record if a memory file ever held invalid UTF-8 — misrepresenting the diff it is meant to make truthful. Switch to errors="strict" and catch UnicodeDecodeError: a non-UTF-8 (or binary/corrupt) file is now recorded as "{path}: binary or non-UTF-8 file changed" and omitted from the unified diff, so the audit record stays honest. An empty diff block is also suppressed when all changes are binary. Adds a defensive regression test asserting no replacement char leaks.
This commit is contained in:
@@ -20,10 +20,17 @@ from nanobot.utils.gitstore import CommitInfo
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self, git, last_dream_cursor: int = 1, dream_prompt_result=None):
|
||||
def __init__(
|
||||
self,
|
||||
git,
|
||||
last_dream_cursor: int = 1,
|
||||
dream_prompt_result=None,
|
||||
content_diff: str = "",
|
||||
):
|
||||
self.git = git
|
||||
self._last_dream_cursor = last_dream_cursor
|
||||
self._dream_prompt_result = dream_prompt_result
|
||||
self._content_diff = content_diff
|
||||
self.compact_history_called = False
|
||||
|
||||
def get_last_dream_cursor(self) -> int:
|
||||
@@ -38,6 +45,9 @@ class _FakeStore:
|
||||
def set_last_dream_cursor(self, value: int) -> None:
|
||||
self._last_dream_cursor = value
|
||||
|
||||
def dream_content_diff(self) -> str:
|
||||
return self._content_diff
|
||||
|
||||
def compact_history(self) -> None:
|
||||
self.compact_history_called = True
|
||||
|
||||
@@ -159,6 +169,74 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
|
||||
assert callable(calls[0][1]["on_progress"])
|
||||
|
||||
|
||||
def _build_runnable_dream(
|
||||
tmp_path,
|
||||
*,
|
||||
initialized: bool,
|
||||
content_diff: str,
|
||||
stop_reason: str = "completed",
|
||||
) -> tuple[CommandContext, _FakeStore]:
|
||||
"""Build a /dream ctx whose run is driven by a canned stop reason + diff."""
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||
store = _FakeStore(
|
||||
_FakeGit(initialized=initialized),
|
||||
last_dream_cursor=5,
|
||||
dream_prompt_result=("dream prompt", 42),
|
||||
content_diff=content_diff,
|
||||
)
|
||||
|
||||
async def process_direct(*args, **kwargs):
|
||||
return OutboundMessage(
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
content="done",
|
||||
metadata={"_stop_reason": stop_reason},
|
||||
)
|
||||
|
||||
bus = _FakeBus()
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
process_direct=process_direct,
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||
return ctx, store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None:
|
||||
"""A real file delta => productive run => cursor advances (Tier 3)."""
|
||||
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0")
|
||||
await cmd_dream(ctx)
|
||||
await asyncio.sleep(0)
|
||||
assert store._last_dream_cursor == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_keeps_cursor_on_completed_noop(tmp_path) -> None:
|
||||
"""Completed run with no file changes must NOT advance the cursor, so the
|
||||
history batch is reconsidered next run instead of silently swallowed."""
|
||||
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="")
|
||||
await cmd_dream(ctx)
|
||||
await asyncio.sleep(0)
|
||||
assert store._last_dream_cursor == 5 # unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_non_git_falls_back_to_completion_gate(tmp_path) -> None:
|
||||
"""Without git there is no diff signal; productivity falls back to the
|
||||
completion check so non-git workspaces keep working."""
|
||||
ctx, store = _build_runnable_dream(
|
||||
tmp_path, initialized=False, content_diff="", stop_reason="completed",
|
||||
)
|
||||
await cmd_dream(ctx)
|
||||
await asyncio.sleep(0)
|
||||
assert store._last_dream_cursor == 42 # advanced via completion fallback
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_log_latest_is_more_user_friendly() -> None:
|
||||
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
|
||||
|
||||
Reference in New Issue
Block a user