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:
Kenneth Zhao
2026-07-06 12:12:55 +08:00
committed by GitHub
parent 70505bd1fc
commit f0c989ba2d
8 changed files with 435 additions and 40 deletions
+79 -1
View File
@@ -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")