diff --git a/anton/core/memory/base.py b/anton/core/memory/base.py index aa5b11c..4a5cbf4 100644 --- a/anton/core/memory/base.py +++ b/anton/core/memory/base.py @@ -104,6 +104,10 @@ def rewrite_identity(self, entries: list[str]) -> None: """Merge new entries into the identity snapshot and rewrite.""" ... + def clear_identity(self) -> None: + """Delete the identity snapshot file if present.""" + ... + # --- update / delete --- def del_rule(self, id: str) -> None: diff --git a/anton/core/memory/cortex.py b/anton/core/memory/cortex.py index 329a7c6..a3e7550 100644 --- a/anton/core/memory/cortex.py +++ b/anton/core/memory/cortex.py @@ -129,6 +129,30 @@ def __init__( self._llm = llm_client self._turn_count = 0 + # One-time migration: identity is singular and global. Any entries that + # landed in project scope from the old encode() bug are merged upward. + # Global wins on key conflicts — orphaned entries are likely stale + # (the bug wrote them; the user may have since corrected to global), + # so we only import keys that don't already exist globally. + orphaned = [e.text for e in self.project_hc.get_identities()] + if orphaned: + existing_global_keys = { + e.text.split(":", 1)[0].strip().lower() + for e in self.global_hc.get_identities() + if ":" in e.text + } + to_migrate = [ + fact + for fact in orphaned + if not ( + ":" in fact + and fact.split(":", 1)[0].strip().lower() in existing_global_keys + ) + ] + if to_migrate: + self.global_hc.rewrite_identity(to_migrate) + self.project_hc.clear_identity() + # ~6000 chars ≈ ~1500 tokens — above this, use LLM to filter rules _RULES_BUDGET_CHARS = 6000 @@ -297,7 +321,10 @@ async def encode(self, engrams: list[Engram]) -> list[str]: actions: list[str] = [] for engram in engrams: - hc = self.global_hc if engram.scope == "global" else self.project_hc + if engram.kind == "profile": + hc = self.global_hc + else: + hc = self.global_hc if engram.scope == "global" else self.project_hc if engram.kind == "profile": hc.rewrite_identity([engram.text]) diff --git a/anton/core/memory/hippocampus.py b/anton/core/memory/hippocampus.py index f2873fe..13790aa 100644 --- a/anton/core/memory/hippocampus.py +++ b/anton/core/memory/hippocampus.py @@ -184,6 +184,10 @@ def save_identities(self, entries: list[Engram]) -> None: content = "# Profile\n" + "\n".join(f"- {e.text}" for e in entries) + "\n" self._encode_with_lock(self._profile_path, content, mode="write") + def clear_identity(self) -> None: + if self._profile_path.is_file(): + self._profile_path.unlink() + # --------- lessons -------------- diff --git a/tests/test_cortex.py b/tests/test_cortex.py index 1728805..69facbe 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -96,6 +96,14 @@ async def test_encode_profile(self, cortex, dirs): assert (g / "profile.md").exists() assert "Name: Jorge" in (g / "profile.md").read_text() + async def test_encode_profile_with_project_scope_routes_to_global(self, cortex, dirs): + g, p = dirs + engram = Engram(text="Name: Jorge", kind="profile", scope="project") + await cortex.encode([engram]) + assert (g / "profile.md").exists() + assert "Name: Jorge" in (g / "profile.md").read_text() + assert not (p / "profile.md").exists() + async def test_off_mode_returns_disabled(self, dirs): g, p = dirs cortex = Cortex(global_hc=Hippocampus(g), project_hc=Hippocampus(p), mode="off") @@ -104,6 +112,43 @@ async def test_off_mode_returns_disabled(self, dirs): assert any("disabled" in a.lower() for a in actions) +class TestOrphanedIdentityMigration: + def test_migrates_project_identity_to_global_on_init(self, dirs): + g, p = dirs + # Simulate orphaned state from the old bug: identity entries in project scope. + Hippocampus(p).rewrite_identity(["Name: Jorge", "TZ: PST"]) + assert (p / "profile.md").exists() + + Cortex(global_hc=Hippocampus(g), project_hc=Hippocampus(p), mode="copilot") + + assert (g / "profile.md").exists() + merged = (g / "profile.md").read_text() + assert "Name: Jorge" in merged + assert "TZ: PST" in merged + assert not (p / "profile.md").exists() + + def test_migration_does_not_overwrite_fresh_global_entries(self, dirs): + # Orphaned project data is likely stale (old bug wrote it, user may + # have since corrected to global). Global must win on key conflicts. + g, p = dirs + Hippocampus(g).rewrite_identity(["Name: Alejandro"]) + Hippocampus(p).rewrite_identity(["Name: Alec", "TZ: PST"]) + + Cortex(global_hc=Hippocampus(g), project_hc=Hippocampus(p), mode="copilot") + + merged = (g / "profile.md").read_text() + assert "Name: Alejandro" in merged + assert "Name: Alec" not in merged + assert "TZ: PST" in merged # non-conflicting keys still migrate + assert not (p / "profile.md").exists() + + def test_migration_noop_when_project_identity_empty(self, dirs): + g, p = dirs + Cortex(global_hc=Hippocampus(g), project_hc=Hippocampus(p), mode="copilot") + assert not (g / "profile.md").exists() + assert not (p / "profile.md").exists() + + class TestEncodingGate: def test_autopilot_never_confirms(self, dirs): g, p = dirs