diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..4e28729c --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "cortex-codex-plugins", + "interface": { + "displayName": "Cortex for Codex" + }, + "plugins": [ + { + "name": "hypermnesia-mcp-codex", + "source": { + "source": "local", + "path": "./plugins/hypermnesia-mcp-codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0e50afb..aee76b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,6 +396,11 @@ jobs: # the repository's existing release-toolchain pin. node-version: "24" + - name: Install uv (pinned) + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.3" + # Vendor parsers catch configuration drift that a generic MCP client # cannot: Claude's custom plugin component path, Gemini's extension # schema, and Codex's config.toml transport/timeouts. Versions and bytes @@ -418,6 +423,30 @@ jobs: "$cli_bin/codex" mcp list --json \ -c 'mcp_servers={cortex={command="hypermnesia-mcp",startup_timeout_sec=30,tool_timeout_sec=600,env={CORTEX_MEMORY_STORE_BACKEND="sqlite"}}}' \ | python -c 'import json,sys; servers={s["name"]:s for s in json.load(sys.stdin)}; c=servers["cortex"]; assert c["transport"]["type"]=="stdio"; assert c["transport"]["command"]=="hypermnesia-mcp"; assert c["transport"]["env"]["CORTEX_MEMORY_STORE_BACKEND"]=="sqlite"; assert c["startup_timeout_sec"]==30.0; assert c["tool_timeout_sec"]==600.0' + marketplace_ref="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + "$cli_bin/codex" plugin marketplace add cdeust/Cortex \ + --ref "$marketplace_ref" --json + "$cli_bin/codex" plugin list --available --json \ + | python -c 'import json,sys; d=json.load(sys.stdin); expected=json.load(open("package.json"))["version"]; ps=d.get("available",[])+d.get("installed",[]); p=[x for x in ps if x.get("pluginId")=="hypermnesia-mcp-codex@cortex-codex-plugins"]; assert len(p)==1, p; assert p[0]["version"]==expected' + "$cli_bin/codex" plugin add hypermnesia-mcp-codex@cortex-codex-plugins --json + "$cli_bin/codex" plugin list --json \ + | python -c 'import json,sys; d=json.load(sys.stdin); p=[x for x in d.get("installed",[]) if x.get("pluginId")=="hypermnesia-mcp-codex@cortex-codex-plugins"]; assert len(p)==1, p; assert p[0]["installed"] and p[0]["enabled"]' + "$cli_bin/codex" mcp list --json \ + | python -c 'import json,sys; ss=[s for s in json.load(sys.stdin) if s.get("name")=="cortex"]; assert len(ss)==1, ss; s=ss[0]; assert s["startup_timeout_sec"]==180.0, s; assert s["transport"]["command"]=="uvx", s' + mapfile -t plugin_command < <( + python -c 'import json; s=json.load(open("plugins/hypermnesia-mcp-codex/.mcp.json"))["mcpServers"]["cortex"]; print(s["command"]); print(*s["args"], sep="\n")' + ) + plugin_timeout="$( + python -c 'import json; print(json.load(open("plugins/hypermnesia-mcp-codex/.mcp.json"))["mcpServers"]["cortex"]["startup_timeout_sec"])' + )" + UV_CACHE_DIR="$RUNNER_TEMP/cortex-codex-cold-uv-cache" \ + UV_TOOL_DIR="$RUNNER_TEMP/cortex-codex-cold-uv-tools" \ + PYTHONPATH="$GITHUB_WORKSPACE" \ + python scripts/verify_mcp_hosts.py \ + --timeout "$plugin_timeout" --clients codex-cli --profiles lean \ + --command-includes-profile \ + --allow-bootstrap-network \ + -- "${plugin_command[@]}" test-windows: name: Test (Windows, SQLite backend) diff --git a/.mcpbignore b/.mcpbignore index 70961690..09a59304 100644 --- a/.mcpbignore +++ b/.mcpbignore @@ -47,11 +47,14 @@ deps/ # these files (scripts/, docker/) is already excluded above. requirements/ -# Claude Code plugin assets (not used by the MCPB / uv server runtime) +# Host plugin assets (not used by the MCPB / uv server runtime) .claude-plugin/ +.agents/ agents/ commands/ skills/ +plugins/hypermnesia-mcp-codex/ +plugins/cortex-deprecated/ scripts/ docker/ Dockerfile diff --git a/CHANGELOG.md b/CHANGELOG.md index e84d1ea0..d6b0b02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Native Codex local plugin packaging.** A dedicated + `cortex-codex-plugins` repository marketplace now exposes an isolated, + MCP-only Codex package backed by the published PyPI stdio server on the + exact 10-tool `lean` profile. Its 180-second startup ceiling is backed by a + clean-cache `uvx` lifecycle measured at 110.46 seconds locally (macOS 26.5.1 + arm64, uv 0.8.19) and 23.87 seconds on `ubuntu-latest` CI. This is additive: + Claude Code remains the primary integration and keeps its complete profile, + lifecycle hooks, custom agent, marketplace, and installation path unchanged. - **Hook-free MCP protocol and host-configuration gates.** CI now starts the installed production stdio entry point under representative Claude, Gemini, and Codex client identities, completes the MCP lifecycle for both the full @@ -19,6 +27,8 @@ adheres to [Semantic Versioning](https://semver.org/). ### Changed +- Benchmark provenance now requires the date, environment, exact command, code + revision, and experimental conditions alongside before/after measurements. - Claude's wiki-groomer agent now lives in a Claude-specific manifest path, preventing Gemini CLI from auto-loading Claude-only agent frontmatter while preserving the Claude plugin behavior and tool list. diff --git a/CLAUDE.md b/CLAUDE.md index ff0dd661..34d3526d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,8 +85,8 @@ separate **cortex-viz** MCP (reads this same store read-only). are tracked as a follow-up (found 2026-07-14 during #114), not a standard to add to. - No invented constants: every hardcoded number carries a `# source:` - comment (paper, committed benchmark, or dated measurement). A number - without one blocks the diff in review. + comment (paper, committed benchmark, or dated measurement naming the + environment and conditions). A number without one blocks the diff in review. ## What NOT to do @@ -103,13 +103,16 @@ separate **cortex-viz** MCP (reads this same store read-only). Every change to the retrieval or memory system: 1. **No source, no implementation.** Every algorithm/constant/threshold - traces to a published paper, a committed benchmark, or a dated - measurement. No source → say "I don't know" and stop. + traces to a published paper, a committed benchmark, or a dated measurement + that names the environment and experimental conditions. No source → say + "I don't know" and stop. 2. **Verify sources, don't guess.** Read the actual paper; confirm its experimental conditions match ours (small corpus, conversational content, 384-dim embeddings) before reusing an equation or constant. -3. **Benchmark before commit.** Re-run the affected benchmarks; no - regression accepted. Results must be reproducible on a clean DB. +3. **Benchmark before commit.** Re-run the affected benchmarks; no regression + accepted. Record the before/after values, exact command, code revision, + environment, and experimental conditions. Results must be reproducible on + a clean DB. 4. **Audit trail.** Every module docstring cites its paper and equations; `docs/provenance/paper-implementation-audit.md` stays current. diff --git a/README.md b/README.md index 4609cd93..137b0698 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,29 @@ Or add it to `~/.gemini/settings.json` directly: } ``` -**OpenAI Codex CLI** — install the executable first so the first MCP handshake is not spent downloading the Python environment, then register it: +**OpenAI Codex and ChatGPT desktop — native local plugin (recommended).** The +repository now carries an isolated Codex marketplace and an exact 10-tool +lean MCP surface. Claude Code remains the primary integration and retains its +automatic hooks, custom agent, full tool profile, and unchanged marketplace. +Pre-install the same published package once so the plugin's first `uvx` +handshake can reuse the local uv cache instead of spending its startup budget +downloading the Python environment. The bundled server also declares a +180-second startup ceiling, backed by a 110.46-second clean-cache launch +measured on 2026-08-02 on local macOS 26.5.1 arm64 with uv 0.8.19 (the clean +`ubuntu-latest` CI run completed in 23.87 seconds): + +```bash +uv tool install "hypermnesia-mcp[sqlite]" +codex plugin marketplace add cdeust/Cortex +codex plugin add hypermnesia-mcp-codex@cortex-codex-plugins +``` + +Restart the ChatGPT desktop app and start a new task after installation. See +[the Codex plugin guide](docs/codex-plugin.md) for the host boundary and the +public-directory requirements Cortex deliberately does not claim. + +**Direct Codex MCP configuration (fallback).** After the same pre-installation, +register the executable directly: ```bash uv tool install "hypermnesia-mcp[sqlite]" diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md new file mode 100644 index 00000000..2f465866 --- /dev/null +++ b/docs/codex-plugin.md @@ -0,0 +1,69 @@ +# Cortex plugin for Codex + +Cortex ships a native Codex package in an isolated repository subdirectory. +It points at the same Cortex product as the existing Claude Code plugin, but +the two packages deliberately offer different host integrations: + +- **Claude Code is primary.** Its marketplace package keeps automatic hooks, + custom agents, the complete MCP tool profile, and the existing installation + flow. +- **Codex is additive.** Its MCP-only package starts the published PyPI server + over local stdio with the exact 10-tool `lean` profile. It installs no + lifecycle hooks, skills, apps, or agents and does not alter the Claude + package. + +The Codex `.mcp.json` lives under `plugins/hypermnesia-mcp-codex/`, never at +the repository root. This preserves Cortex's Claude contract: Claude Code +must not discover a second project-scoped MCP server when this repository is +the active working directory. + +## Install from the repository marketplace + +Add the Cortex repository marketplace and install the plugin: + +```bash +uv tool install "hypermnesia-mcp[sqlite]" +codex plugin marketplace add cdeust/Cortex +codex plugin add hypermnesia-mcp-codex@cortex-codex-plugins +``` + +Restart the ChatGPT desktop app and start a new task so Codex loads the new +plugin components. The plugin uses `uvx`, so `uv` must be available on `PATH`. +The preliminary `uv tool install` is deliberate: it downloads the published +package before Codex's startup window, allowing the first plugin handshake to +reuse uv's local artifact cache. + +The bundled server declares `startup_timeout_sec: 180`. This is a bounded +startup ceiling, not a delay. On 2026-08-02, a local macOS 26.5.1 arm64 run +with uv 0.8.19 and clean `UV_CACHE_DIR` and `UV_TOOL_DIR` completed +`initialize`, `tools/list`, and `memory_stats` in 110.46 seconds with exactly +ten lean tools. A follow-up on the same machine after the tool installation +completed the same contract in 28.19 seconds. The clean `ubuntu-latest` CI +runner completed it in 23.87 seconds; CI reads the command and timeout from the +manifest itself. + +The bundled MCP command is equivalent to: + +```bash +uvx --from "hypermnesia-mcp[sqlite]" \ + hypermnesia-mcp --profile lean +``` + +This is a local plugin. It does not make Cortex available to ChatGPT web and +does not expose the local memory database over the internet. + +The repository-marketplace schema requires both `policy.installation` and +`policy.authentication`. Cortex uses the documented `ON_INSTALL` value. This +is marketplace timing metadata, not an added authentication mechanism: the +local stdio server declares no credentials or remote endpoint, and the +non-interactive install is exercised in CI. See OpenAI's +[marketplace metadata contract](https://developers.openai.com/plugins/build/plugins#marketplace-metadata). + +## Public directory boundary + +A future hosted Cortex integration is a separate security and product scope. +Public submission requires a stable HTTPS MCP Streamable HTTP endpoint, +authentication and per-user or per-organization isolation, reviewable tool +metadata, domain verification, operational monitoring, and the applicable +privacy and legal material. None of those remote-deployment claims are made by +this local package. diff --git a/plugins/hypermnesia-mcp-codex/.codex-plugin/plugin.json b/plugins/hypermnesia-mcp-codex/.codex-plugin/plugin.json new file mode 100644 index 00000000..5cb63ab9 --- /dev/null +++ b/plugins/hypermnesia-mcp-codex/.codex-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "hypermnesia-mcp-codex", + "version": "4.17.2", + "description": "Cortex persistent local-first memory for Codex, with Claude Code remaining the primary automatic integration.", + "author": { + "name": "Clement Deust", + "email": "admin@ai-architect.tools", + "url": "https://github.com/cdeust" + }, + "homepage": "https://github.com/cdeust/Cortex/blob/main/docs/codex-plugin.md", + "repository": "https://github.com/cdeust/Cortex", + "license": "MIT", + "keywords": [ + "memory", + "mcp", + "codex", + "claude-code", + "local-first" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Cortex Memory", + "shortDescription": "Persistent local memory for Codex", + "longDescription": "Use Cortex's focused local-memory tools from Codex. Claude Code remains the primary integration, with automatic lifecycle hooks, custom agents, and the complete tool profile.", + "developerName": "Clement Deust", + "category": "Developer Tools", + "capabilities": [ + "Persistent project memory", + "Decision and correction recall", + "Local-first storage", + "Memory inspection" + ], + "websiteURL": "https://github.com/cdeust/Cortex", + "defaultPrompt": [ + "Recall the decisions relevant to this repository.", + "Show what Cortex remembers about this project.", + "Store the durable lesson from this task." + ] + } +} diff --git a/plugins/hypermnesia-mcp-codex/.mcp.json b/plugins/hypermnesia-mcp-codex/.mcp.json new file mode 100644 index 00000000..dd9f8bd0 --- /dev/null +++ b/plugins/hypermnesia-mcp-codex/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "cortex": { + "command": "uvx", + "args": [ + "--from", + "hypermnesia-mcp[sqlite]", + "hypermnesia-mcp", + "--profile", + "lean" + ], + "startup_timeout_sec": 180 + } + } +} diff --git a/scripts/verify_mcp_hosts.py b/scripts/verify_mcp_hosts.py index b1788d44..4c5d5d5f 100644 --- a/scripts/verify_mcp_hosts.py +++ b/scripts/verify_mcp_hosts.py @@ -18,6 +18,7 @@ import subprocess import sys import tempfile +import time from typing import Literal from mcp_server.tool_profiles import LEAN_TOOL_NAMES @@ -46,6 +47,7 @@ class ContractCase: command: tuple[str, ...] data_root: Path timeout: int + socks_proxy_regression: bool @property def label(self) -> str: @@ -80,20 +82,22 @@ def _frames(client_name: str) -> str: ) -def _environment(data_root: Path) -> dict[str, str]: +def _environment(data_root: Path, *, socks_proxy_regression: bool) -> dict[str, str]: env = os.environ.copy() env.update( { "CORTEX_CLAUDE_DIR": str(data_root), "CORTEX_MEMORY_STORE_BACKEND": "sqlite", "CORTEX_MEMORY_AP_ENABLED": "0", - # Regression environment: FastMCP's banner-time update check used - # to import SOCKS support and abort before initialize. The MCP - # runtime must not make that non-essential network request. - "ALL_PROXY": "socks5://127.0.0.1:9", - "all_proxy": "socks5://127.0.0.1:9", } ) + if socks_proxy_regression: + # Regression environment: FastMCP's banner-time update check used to + # import SOCKS support and abort before initialize. The MCP runtime + # must not make that non-essential network request. Cold uvx bootstrap + # explicitly disables this fixture because it must reach PyPI. + env["ALL_PROXY"] = "socks5://127.0.0.1:9" + env["all_proxy"] = "socks5://127.0.0.1:9" env.pop("FASTMCP_SHOW_SERVER_BANNER", None) env.pop("FASTMCP_CHECK_FOR_UPDATES", None) env.pop("CORTEX_MCP_PROFILE", None) @@ -137,7 +141,10 @@ def _run_client(case: ContractCase) -> dict[int, dict[str, object]]: input=_frames(case.client_name), text=True, capture_output=True, - env=_environment(case.data_root / case.client_name / case.profile), + env=_environment( + case.data_root / case.client_name / case.profile, + socks_proxy_regression=case.socks_proxy_regression, + ), timeout=case.timeout, check=False, ) @@ -223,18 +230,45 @@ def _verify_memory_call( raise ContractError(f"{case.label}: memory_stats failed: {call_result!r}") -def _verify(case: ContractCase) -> int: +def _verify(case: ContractCase) -> tuple[int, float]: + started_at = time.monotonic() responses = _run_client(case) _verify_initialize(case, responses) count = _verify_tool_surface(case, responses) _verify_auxiliary_discovery(case, responses) _verify_memory_call(case, responses) - return count + return count, time.monotonic() - started_at def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--timeout", type=int, default=2 * 60) + parser.add_argument( + "--clients", + nargs="+", + choices=CLIENTS, + default=CLIENTS, + help="client identities to exercise (default: all)", + ) + parser.add_argument( + "--profiles", + nargs="+", + choices=PROFILES, + default=PROFILES, + help="tool profiles to exercise (default: full lean)", + ) + parser.add_argument( + "--command-includes-profile", + action="store_true", + help=( + "run the supplied command unchanged; requires exactly one --profiles value" + ), + ) + parser.add_argument( + "--allow-bootstrap-network", + action="store_true", + help="do not inject the SOCKS regression fixture; intended for cold uvx", + ) parser.add_argument( "command", nargs=argparse.REMAINDER, @@ -246,21 +280,29 @@ def main() -> int: command = command[1:] if not command: parser.error("server command after -- cannot be empty") + if args.command_includes_profile and len(args.profiles) != 1: + parser.error("--command-includes-profile requires exactly one --profiles value") with tempfile.TemporaryDirectory(prefix="cortex_mcp_hosts_") as temp_dir: - for client_name in CLIENTS: - for profile in PROFILES: + for client_name in args.clients: + for profile in args.profiles: + server_command = ( + tuple(command) + if args.command_includes_profile + else (*command, "--profile", profile) + ) case = ContractCase( client_name=client_name, profile=profile, - command=(*command, "--profile", profile), + command=server_command, data_root=Path(temp_dir), timeout=args.timeout, + socks_proxy_regression=not args.allow_bootstrap_network, ) - count = _verify(case) + count, elapsed_seconds = _verify(case) print( f"PASS {case.label}: initialize + discovery + " - f"memory_stats ({count} tools)" + f"memory_stats ({count} tools, {elapsed_seconds:.2f}s)" ) return 0 diff --git a/tests_py/scripts/test_codex_plugin_contract.py b/tests_py/scripts/test_codex_plugin_contract.py new file mode 100644 index 00000000..950925e2 --- /dev/null +++ b/tests_py/scripts/test_codex_plugin_contract.py @@ -0,0 +1,93 @@ +"""Contract tests for the additive, isolated Codex plugin package.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MARKETPLACE_PATH = REPO_ROOT / ".agents/plugins/marketplace.json" +PLUGIN_ROOT = REPO_ROOT / "plugins/hypermnesia-mcp-codex" +PLUGIN_PATH = PLUGIN_ROOT / ".codex-plugin/plugin.json" +MCP_PATH = PLUGIN_ROOT / ".mcp.json" + + +def _json(path: Path) -> dict: + return json.loads(path.read_text()) + + +def test_codex_plugin_is_confined_to_a_dedicated_subdirectory() -> None: + """Never reintroduce the project-scoped MCP collision in Claude Code.""" + assert not (REPO_ROOT / ".mcp.json").exists() + assert not (REPO_ROOT / ".codex-plugin").exists() + assert PLUGIN_PATH.is_file() + assert MCP_PATH.is_file() + + ignored = (REPO_ROOT / ".mcpbignore").read_text().splitlines() + assert ".agents/" in ignored + assert "plugins/hypermnesia-mcp-codex/" in ignored + assert "plugins/cortex-deprecated/" in ignored + + +def test_codex_marketplace_resolves_only_the_dedicated_plugin() -> None: + marketplace = _json(MARKETPLACE_PATH) + assert marketplace["name"] == "cortex-codex-plugins" + assert len(marketplace["plugins"]) == 1 + + entry = marketplace["plugins"][0] + assert entry == { + "name": "hypermnesia-mcp-codex", + "source": { + "source": "local", + "path": "./plugins/hypermnesia-mcp-codex", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Developer Tools", + } + source = (REPO_ROOT / entry["source"]["path"]).resolve() + assert source == PLUGIN_ROOT.resolve() + assert source.is_relative_to(REPO_ROOT.resolve()) + + +def test_codex_plugin_is_mcp_only_and_uses_the_exact_lean_profile() -> None: + plugin = _json(PLUGIN_PATH) + server = _json(MCP_PATH)["mcpServers"]["cortex"] + + assert plugin["name"] == "hypermnesia-mcp-codex" + assert plugin["mcpServers"] == "./.mcp.json" + for unsupported in ("hooks", "skills", "apps", "agents", "postInstall"): + assert unsupported not in plugin + + assert server == { + "command": "uvx", + "args": [ + "--from", + "hypermnesia-mcp[sqlite]", + "hypermnesia-mcp", + "--profile", + "lean", + ], + # Measured clean-cache startup on 2026-08-02: 110.46s on macOS 26.5.1 + # arm64 with uv 0.8.19. This bounded ceiling leaves startup headroom + # without inventing a sleep or retry. + "startup_timeout_sec": 180, + } + + +def test_codex_package_does_not_weaken_the_primary_claude_plugin() -> None: + claude = _json(REPO_ROOT / ".claude-plugin/plugin.json") + claude_server = claude["mcpServers"]["cortex"] + + assert claude["name"] == "hypermnesia-mcp" + assert claude["hooks"] + assert claude["agents"] == ["./claude-agents/cortex-wiki-groomer.md"] + assert claude_server["command"] == "python3" + assert claude_server["args"] == [ + "${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py", + "mcp_server", + ] + assert "--profile" not in claude_server["args"] diff --git a/tests_py/scripts/test_cross_host_manifests.py b/tests_py/scripts/test_cross_host_manifests.py index f3c43767..fab94bd8 100644 --- a/tests_py/scripts/test_cross_host_manifests.py +++ b/tests_py/scripts/test_cross_host_manifests.py @@ -27,3 +27,7 @@ def test_cross_host_manifest_versions_match_the_release() -> None: assert _json("server.json")["version"] == expected assert _json("manifest.json")["version"] == expected assert _json(".claude-plugin/plugin.json")["version"] == expected + assert ( + _json("plugins/hypermnesia-mcp-codex/.codex-plugin/plugin.json")["version"] + == expected + )