diff --git a/ouroboros/memory.py b/ouroboros/memory.py index dd75fbc3b..4a107745f 100644 --- a/ouroboros/memory.py +++ b/ouroboros/memory.py @@ -116,6 +116,13 @@ def _chat_history_snapshot_id( "schema_version": 1, "query": _normalized_chat_history_query(filters, search), "generations": generations, + # Existing consolidator gap blocks are the durable truth that an older + # span is unknowable after its cursor is rebased. Their small stable IDs + # participate in the stateless token; no block-content/full-file hashing + # or new continuation state is introduced. + "durable_gap_ids": [ + str(value) for value in (coverage.get("durable_gap_ids") or []) + ], } encoded = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), @@ -338,6 +345,31 @@ class Memory: path = self.drive_root / "memory" / "dialogue_blocks.json" return self._load_json_blocks(path) + def _durable_dialogue_gaps(self) -> Tuple[List[Dict[str, Any]], List[str]]: + """Project consolidator-owned durable discontinuities into raw coverage.""" + + gaps: List[Dict[str, Any]] = [] + identities: List[str] = [] + for index, block in enumerate(self.load_dialogue_blocks()): + if not isinstance(block, dict): + continue + gap_id = str(block.get("gap_id") or "").strip() + content = str(block.get("content") or "") + if not gap_id and "[MEMORY GAP]" not in content: + continue + identity = gap_id or ( + "legacy-memory-gap:" + f"{index}:{str(block.get('ts') or '')}:{str(block.get('range') or '')}" + ) + identities.append(identity) + gaps.append({ + "kind": "durable_consolidation_gap", + "gap_id": gap_id, + "block_index": index, + "detail": "A durable dialogue block records a known history discontinuity.", + }) + return gaps, identities + def load_dialogue_meta(self) -> Dict[str, Any]: path = self.drive_root / "memory" / "dialogue_meta.json" return read_json_dict(path) or {} @@ -568,6 +600,9 @@ class Memory: "kind": "consolidation_cursor_state_unreadable", "error": type(exc).__name__, }) + durable_gaps, durable_gap_ids = self._durable_dialogue_gaps() + coverage["gaps"].extend(durable_gaps) + coverage["durable_gap_ids"] = durable_gap_ids last_entries, last_coverage = entries, coverage if stable_generations: return entries, coverage diff --git a/tests/test_chat_history_archive_filters.py b/tests/test_chat_history_archive_filters.py index e4d775757..b6d167a22 100644 --- a/tests/test_chat_history_archive_filters.py +++ b/tests/test_chat_history_archive_filters.py @@ -202,6 +202,65 @@ def test_explicit_chat_history_surfaces_missing_consolidation_generation(tmp_pat assert "completeness unknown" in no_survivors +def test_chat_history_keeps_durable_gap_after_consolidator_rebases_cursor(tmp_path): + from ouroboros.consolidator import consolidate, should_consolidate + + _write(tmp_path / "logs" / "chat.jsonl", [_row("2026-08-21T09:00:00Z", "survivor")]) + memory_dir = tmp_path / "memory" + memory_dir.mkdir(parents=True) + blocks_path = memory_dir / "dialogue_blocks.json" + meta_path = memory_dir / "dialogue_meta.json" + meta_path.write_text(json.dumps({ + "last_consolidated_offset": 50, + "chat_log_signature": {"first_line_sha256": "f" * 64, "size": 999}, + }), encoding="utf-8") + memory = Memory(tmp_path) + + first = memory.chat_history(count=20) + old_snapshot = re.search(r"snapshot=([0-9a-f]{64})", first) + assert old_snapshot is not None + assert "consolidation_cursor_generation_missing" in first + assert should_consolidate(meta_path, tmp_path / "logs" / "chat.jsonl") is True + + class NoLlmExpected: + def chat(self, **_kwargs): + raise AssertionError("gap rebasing below BLOCK_SIZE must not call the LLM") + + assert consolidate( + chat_path=tmp_path / "logs" / "chat.jsonl", + blocks_path=blocks_path, + meta_path=meta_path, + llm_client=NoLlmExpected(), + ) is None + blocks = json.loads(blocks_path.read_text(encoding="utf-8")) + assert blocks[0]["gap_id"].startswith("gap:") + assert "[MEMORY GAP]" in blocks[0]["content"] + + second = memory.chat_history(count=20) + stale_page = memory.chat_history(count=20, snapshot=old_snapshot.group(1)) + assert "durable_consolidation_gap" in second + assert "completeness unknown" in second + assert stale_page.startswith("CHAT_HISTORY_SNAPSHOT_CHANGED:") + + +def test_chat_history_recognizes_legacy_memory_gap_block_without_gap_id(tmp_path): + _write(tmp_path / "logs" / "chat.jsonl", [_row("2026-08-21T09:00:00Z", "survivor")]) + memory_dir = tmp_path / "memory" + memory_dir.mkdir(parents=True) + (memory_dir / "dialogue_blocks.json").write_text(json.dumps([{ + "ts": "2026-08-21T10:00:00Z", + "type": "summary", + "range": "unknown", + "message_count": 0, + "content": "[MEMORY GAP] Legacy durable discontinuity.", + }]), encoding="utf-8") + + result = Memory(tmp_path).chat_history(count=20) + + assert "durable_consolidation_gap" in result + assert "completeness unknown" in result + + def test_chat_history_tool_exposes_only_exact_filter_fields(): tool = next(entry for entry in get_tools() if entry.name == "chat_history") assert set(tool.schema["parameters"]["properties"]) == {