diff --git a/src/codex_memory/__init__.py b/src/codex_memory/__init__.py index c635b89..3005461 100644 --- a/src/codex_memory/__init__.py +++ b/src/codex_memory/__init__.py @@ -12,6 +12,7 @@ from .delivery import CodexDeliverer from .local_security import secure_windows_tree from .queue import DurableEventQueue +from .scope import capture_scope from .state import HookState PROVIDER_ID = "codex_memory" @@ -44,7 +45,7 @@ def _capture_parts( secrets = tuple(dict.fromkeys((*secrets, token))) queue = DurableEventQueue(root / "queue") builder = CaptureEventBuilder( - {"platform": "codex", "agent_id": "codex-cli"}, + capture_scope(), provider_id=PROVIDER_ID, secrets=secrets, ) @@ -80,7 +81,7 @@ def runtime() -> tuple[ root = plugin_data() token = credential_store(root).get() queue, builder, _state = _capture_parts(root, token) - client = SubstrateClient(token, timeout=10.0) if token else None + client = SubstrateClient(token, timeout=60.0) if token else None return client, queue, CodexDeliverer(queue, client), builder diff --git a/src/codex_memory/client.py b/src/codex_memory/client.py index 45a227b..8eb9632 100644 --- a/src/codex_memory/client.py +++ b/src/codex_memory/client.py @@ -108,7 +108,7 @@ def validate_connection(self) -> dict[str, Any]: class CredentialBoundClient: """Mutable hosted client that follows onboarding credential rotation.""" - def __init__(self, store: CredentialStore, *, timeout: float = 10.0) -> None: + def __init__(self, store: CredentialStore, *, timeout: float = 60.0) -> None: self.store = store self.timeout = timeout self._mutex = threading.RLock() diff --git a/src/codex_memory/history.py b/src/codex_memory/history.py index d5a9c20..8673526 100644 --- a/src/codex_memory/history.py +++ b/src/codex_memory/history.py @@ -14,6 +14,7 @@ from .app_server import CodexAppServer, CodexAppServerError from .import_checkpoint import HistoryCheckpoint +from .scope import capture_scope _MAX_MESSAGES_PER_THREAD = 1000 _MAX_VISIBLE_CHARS_PER_THREAD = 2_000_000 @@ -222,7 +223,7 @@ def _deliver(self, event: dict[str, Any]) -> tuple[dict[str, Any], int]: def _builder(self) -> CaptureEventBuilder: return CaptureEventBuilder( - {"platform": "codex", "agent_id": "codex-cli"}, + capture_scope(), provider_id="codex_memory", secrets=self.secrets, ) diff --git a/src/codex_memory/hook.py b/src/codex_memory/hook.py index f7af477..16afa8a 100644 --- a/src/codex_memory/hook.py +++ b/src/codex_memory/hook.py @@ -12,6 +12,7 @@ from substrate_capture.events import content_digest from . import capture_runtime, hook_runtime +from .scope import recall_scope _MAX_STDIN_BYTES = 4 * 1024 * 1024 _MAX_CAPTURE_CHARS = 200_000 @@ -172,7 +173,9 @@ def _recall_result( return callback(prompt[:_MAX_RECALL_QUERY_CHARS], 5) if client is None: return None - return client.memory_search(prompt[:_MAX_RECALL_QUERY_CHARS], limit=5) + return client.memory_search( + prompt[:_MAX_RECALL_QUERY_CHARS], limit=5, scope=recall_scope() + ) def _canonical_entity_path(item: dict[str, Any]) -> str | None: diff --git a/src/codex_memory/scope.py b/src/codex_memory/scope.py new file mode 100644 index 0000000..20864c1 --- /dev/null +++ b/src/codex_memory/scope.py @@ -0,0 +1,24 @@ +"""Stable single-user identity scope inside each authenticated Substrate tenant.""" +from __future__ import annotations + +CODEX_PLATFORM = "codex" +CODEX_USER_ID = "owner" +CODEX_AGENT_ID = "codex-cli" + + +def capture_scope() -> dict[str, str]: + """Return a fresh scope shared by live hooks, tools, and history replay.""" + return { + "platform": CODEX_PLATFORM, + "user_id": CODEX_USER_ID, + "agent_id": CODEX_AGENT_ID, + } + + +def recall_scope() -> dict[str, str]: + """Return the matching canonical-recall identity scope.""" + return { + "platform": CODEX_PLATFORM, + "user_id": CODEX_USER_ID, + "agent_id": CODEX_AGENT_ID, + } diff --git a/src/codex_memory/tools.py b/src/codex_memory/tools.py index b858c81..1909690 100644 --- a/src/codex_memory/tools.py +++ b/src/codex_memory/tools.py @@ -13,6 +13,7 @@ from .client import SubstrateClient from .onboarding import OnboardingError, OnboardingManager +from .scope import recall_scope _MAX_QUERY = 4096 _MAX_CONTENT = 200_000 @@ -34,7 +35,7 @@ def refresh_client() -> SubstrateClient | None: nonlocal client token = onboarding.store.get() if token and (client is None or client.api_key != token): - client = SubstrateClient(token, timeout=10.0) + client = SubstrateClient(token, timeout=60.0) deliverer.client = client return client @@ -68,7 +69,7 @@ def search(args: dict[str, Any]) -> dict[str, Any]: return guard( lambda: { "results": require_client().memory_search( - text(args, "query"), limit=limit(args), scope={"platform": "codex"} + text(args, "query"), limit=limit(args), scope=recall_scope() ).get("results", []) } ) diff --git a/tests/test_codex_tools.py b/tests/test_codex_tools.py index 32dbe90..97fee83 100644 --- a/tests/test_codex_tools.py +++ b/tests/test_codex_tools.py @@ -15,6 +15,7 @@ from substrate_capture.spool import DurableSpool # noqa: E402 from substrate_capture.client import SubstrateAPIError # noqa: E402 from codex_memory.queue import DurableEventQueue # noqa: E402 +from codex_memory.scope import capture_scope, recall_scope # noqa: E402 class EmptyStore(CredentialStore): @@ -30,6 +31,24 @@ def delete(self, slot: str = "access-token") -> None: pass +def test_codex_owner_scope_matches_capture_and_canonical_recall() -> None: + assert capture_scope() == { + "platform": "codex", + "user_id": "owner", + "agent_id": "codex-cli", + } + assert recall_scope() == { + "platform": "codex", + "user_id": "owner", + "agent_id": "codex-cli", + } + event = CaptureEventBuilder(capture_scope(), provider_id="codex_memory").payload_event( + "turn", "session", {"content": "safe"} + ) + assert event["scope"]["subject_id"] + assert event["scope"]["user_id"] == recall_scope()["user_id"] + + class WikiClient: api_key = "test-placeholder-key" @@ -39,7 +58,11 @@ def search(self, query: str, *, limit: int) -> dict[str, Any]: def memory_search( self, query: str, *, limit: int, scope: dict[str, Any] | None = None ) -> dict[str, Any]: - return {"results": [{"surface": "canonical", "query": query, "limit": limit}]} + return { + "results": [ + {"surface": "canonical", "query": query, "limit": limit, "scope": scope} + ] + } def job_status(self, job_id: str) -> dict[str, Any]: return {"job_id": job_id, "status": "queued"} @@ -66,7 +89,18 @@ def test_codex_tools_use_wiki_search_job_status_and_truthful_queue_status( } assert tools["substrate_search"].handler({"query": "decision", "limit": 3}) == { "ok": True, - "results": [{"surface": "canonical", "query": "decision", "limit": 3}], + "results": [ + { + "surface": "canonical", + "query": "decision", + "limit": 3, + "scope": { + "platform": "codex", + "user_id": "owner", + "agent_id": "codex-cli", + }, + } + ], } assert tools["substrate_wiki_search"].handler({"query": "pages", "limit": 2}) == { "ok": True, diff --git a/tests/test_history_import.py b/tests/test_history_import.py index 16a47c3..fb2502d 100644 --- a/tests/test_history_import.py +++ b/tests/test_history_import.py @@ -78,6 +78,8 @@ def test_import_is_deterministic_resumable_and_checkpoint_content_free(tmp_path: first_ids = [event["event_id"] for event in client.events] assert len(first_ids) == len(set(first_ids)) assert {event["capture_origin"] for event in client.events} == {"history_replay"} + assert {event["scope"]["user_id"] for event in client.events} == {"owner"} + assert {event["scope"]["platform"] for event in client.events} == {"codex"} importer.close() raw = b"".join(path.read_bytes() for path in (tmp_path / "imports").glob("checkpoint.sqlite3*")) diff --git a/tests/test_hosted_onboarding.py b/tests/test_hosted_onboarding.py index 3432633..f435a99 100644 --- a/tests/test_hosted_onboarding.py +++ b/tests/test_hosted_onboarding.py @@ -141,6 +141,7 @@ def test_running_mcp_client_refreshes_after_setup_and_credential_repair(tmp_path store = MemoryStore() oauth = OAuth() client = CredentialBoundClient(store) + assert client.timeout == 60.0 manager = OnboardingManager( tmp_path, api=oauth, store=store, capability_check=lambda _token: {}, credential_changed=client.refresh, diff --git a/tests/test_native_hooks.py b/tests/test_native_hooks.py index e9eadf2..67750b7 100644 --- a/tests/test_native_hooks.py +++ b/tests/test_native_hooks.py @@ -13,11 +13,31 @@ sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from codex_memory import capture_runtime # noqa: E402 -from codex_memory.hook import handle, main # noqa: E402 +from codex_memory.hook import _recall_result, handle, main # noqa: E402 from codex_memory.state import HookState # noqa: E402 from substrate_capture.client import SubstrateClient # noqa: E402 +def test_native_recall_uses_the_same_owner_and_agent_scope_as_capture() -> None: + observed: list[tuple[str, int, dict[str, object]]] = [] + + class Client: + def memory_search( + self, query: str, *, limit: int, scope: dict[str, object] + ) -> dict[str, object]: + observed.append((query, limit, scope)) + return {"results": []} + + assert _recall_result(None, "decision", Client()) == {"results": []} + assert observed == [ + ( + "decision", + 5, + {"platform": "codex", "user_id": "owner", "agent_id": "codex-cli"}, + ) + ] + + def _events() -> list[dict[str, object]]: queue, _builder, _state = capture_runtime() values = [] @@ -87,6 +107,8 @@ def test_native_hooks_capture_exact_turn_lineage_and_dedupe( ] assert all(event["payload"]["turn_id"] == "turn-1" for event in turns) assert all(event["payload"]["session_id"] == "session-1" for event in turns) + assert all(event["scope"]["user_id"] == "owner" for event in turns) + assert all(event["scope"]["platform"] == "codex" for event in turns)