From 4247e4c1673cde0b5332ad720f67bd8ffc2fa12c Mon Sep 17 00:00:00 2001 From: kenblaue Date: Wed, 12 Aug 2026 20:45:23 +0200 Subject: [PATCH 1/3] Add HyperMemory AI OpenAI plugin marketplace --- .agents/plugins/marketplace.json | 32 ++ .github/workflows/validate.yml | 27 ++ .gitignore | 7 + AGENTS.md | 32 ++ CONTRIBUTING.md | 36 ++ README.md | 147 ++++++- SECURITY.md | 26 ++ docs/ARCHITECTURE.md | 129 +++++++ docs/INSTALLATION.md | 157 ++++++++ docs/MARKETPLACE.md | 93 +++++ packages/hypercolab-cli/README.md | 21 + .../hypercolab-cli/hypercolab_cli/__init__.py | 3 + .../hypercolab-cli/hypercolab_cli/auth.py | 117 ++++++ .../hypercolab-cli/hypercolab_cli/client.py | 208 ++++++++++ .../hypercolab-cli/hypercolab_cli/config.py | 188 +++++++++ packages/hypercolab-cli/hypercolab_cli/git.py | 55 +++ .../hypercolab-cli/hypercolab_cli/hooks.py | 286 ++++++++++++++ .../hypercolab-cli/hypercolab_cli/main.py | 303 +++++++++++++++ .../hypercolab_cli/mcp_server.py | 131 +++++++ packages/hypercolab-cli/pyproject.toml | 39 ++ packages/hypercolab-cli/tests/test_git.py | 28 ++ packages/hypercolab-cli/tests/test_hooks.py | 53 +++ packages/hypercolab-cli/tests/test_offline.py | 71 ++++ plugins/hypercolab/.codex-plugin/plugin.json | 35 ++ plugins/hypercolab/.mcp.json | 8 + plugins/hypercolab/README.md | 36 ++ .../hypercolab/agents/coordination-writer.md | 21 + plugins/hypercolab/assets/icon.png | Bin 0 -> 3656 bytes plugins/hypercolab/assets/logo.png | Bin 0 -> 3656 bytes plugins/hypercolab/hooks/hooks.json | 53 +++ plugins/hypercolab/scripts/hypercolab_hook.py | 71 ++++ plugins/hypercolab/skills/hypercolab/SKILL.md | 28 ++ .../skills/hypercolab/agents/openai.yaml | 14 + .../references/coordination-agent.md | 19 + plugins/hypermemory/.codex-plugin/plugin.json | 47 +++ plugins/hypermemory/.mcp.json | 8 + plugins/hypermemory/README.md | 50 +++ plugins/hypermemory/agents/memory-writer.md | 22 ++ plugins/hypermemory/assets/icon.png | Bin 0 -> 3631 bytes plugins/hypermemory/assets/logo.png | Bin 0 -> 3631 bytes plugins/hypermemory/hooks/hooks.json | 44 +++ .../scripts/codex_token_listener.py | 360 ++++++++++++++++++ .../hypermemory/scripts/hypermemory_hook.py | 177 +++++++++ .../hypermemory/skills/hypermemory/SKILL.md | 106 ++++++ .../skills/hypermemory/agents/openai.yaml | 15 + .../references/memory-writer-agent.md | 24 ++ .../skills/hypermemory/references/protocol.md | 15 + pyproject.toml | 10 + scripts/build_plugin_archives.py | 56 +++ tests/test_hypercolab_plugin.py | 44 +++ tests/test_hypermemory_plugin.py | 246 ++++++++++++ tests/test_marketplace.py | 38 ++ 52 files changed, 3735 insertions(+), 1 deletion(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/INSTALLATION.md create mode 100644 docs/MARKETPLACE.md create mode 100644 packages/hypercolab-cli/README.md create mode 100644 packages/hypercolab-cli/hypercolab_cli/__init__.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/auth.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/client.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/config.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/git.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/hooks.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/main.py create mode 100644 packages/hypercolab-cli/hypercolab_cli/mcp_server.py create mode 100644 packages/hypercolab-cli/pyproject.toml create mode 100644 packages/hypercolab-cli/tests/test_git.py create mode 100644 packages/hypercolab-cli/tests/test_hooks.py create mode 100644 packages/hypercolab-cli/tests/test_offline.py create mode 100644 plugins/hypercolab/.codex-plugin/plugin.json create mode 100644 plugins/hypercolab/.mcp.json create mode 100644 plugins/hypercolab/README.md create mode 100644 plugins/hypercolab/agents/coordination-writer.md create mode 100644 plugins/hypercolab/assets/icon.png create mode 100644 plugins/hypercolab/assets/logo.png create mode 100644 plugins/hypercolab/hooks/hooks.json create mode 100755 plugins/hypercolab/scripts/hypercolab_hook.py create mode 100644 plugins/hypercolab/skills/hypercolab/SKILL.md create mode 100644 plugins/hypercolab/skills/hypercolab/agents/openai.yaml create mode 100644 plugins/hypercolab/skills/hypercolab/references/coordination-agent.md create mode 100644 plugins/hypermemory/.codex-plugin/plugin.json create mode 100644 plugins/hypermemory/.mcp.json create mode 100644 plugins/hypermemory/README.md create mode 100644 plugins/hypermemory/agents/memory-writer.md create mode 100644 plugins/hypermemory/assets/icon.png create mode 100644 plugins/hypermemory/assets/logo.png create mode 100644 plugins/hypermemory/hooks/hooks.json create mode 100755 plugins/hypermemory/scripts/codex_token_listener.py create mode 100755 plugins/hypermemory/scripts/hypermemory_hook.py create mode 100644 plugins/hypermemory/skills/hypermemory/SKILL.md create mode 100644 plugins/hypermemory/skills/hypermemory/agents/openai.yaml create mode 100644 plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md create mode 100644 plugins/hypermemory/skills/hypermemory/references/protocol.md create mode 100644 pyproject.toml create mode 100755 scripts/build_plugin_archives.py create mode 100644 tests/test_hypercolab_plugin.py create mode 100644 tests/test_hypermemory_plugin.py create mode 100644 tests/test_marketplace.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..35e5616 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,32 @@ +{ + "name": "hypermemory-ai", + "interface": { + "displayName": "HyperMemory AI" + }, + "plugins": [ + { + "name": "hypermemory", + "source": { + "source": "local", + "path": "./plugins/hypermemory" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Memory & Knowledge" + }, + { + "name": "hypercolab", + "source": { + "source": "local", + "path": "./plugins/hypercolab" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..1e444a3 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate marketplace + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install validation dependencies + run: python -m pip install -e "packages/hypercolab-cli[dev]" + - name: Lint + run: ruff check plugins packages tests scripts + - name: Test + run: pytest -q + - name: Build review archives + run: python scripts/build_plugin_archives.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c16f4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +dist/ +.venv/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..267f144 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository guidance + +This repository publishes two OpenAI plugins through one marketplace. Keep the +plugins independently installable and do not introduce dependencies between +their manifests. + +## Invariants + +- Marketplace name: `hypermemory-ai` +- Plugin IDs and folders: `hypermemory`, `hypercolab` +- OpenAI surfaces only; do not add formats or instructions for unrelated agent + ecosystems. +- Keep `.codex-plugin/plugin.json` as the required manifest entry point. +- Keep MCP credentials out of source. Use OAuth or local credential storage. +- Preserve explicit hook trust and safe degraded behavior. +- Keep HyperMemory recall on the main agent and persistence/token reporting on + one awaited memory-writer sub-agent. +- Keep HyperColab join/sync/claims on the main agent. Delegated coordination + writers may record progress but must not bypass ownership conflicts. + +## Validation + +Run before committing: + +```bash +ruff check plugins packages tests scripts +pytest -q +python scripts/build_plugin_archives.py +``` + +When Codex's authoring skills are installed, validate both plugin roots and both +skills with their bundled validators. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..20660ea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Thank you for improving the HyperMemory AI OpenAI plugins. + +## Development setup + +```bash +git clone https://github.com/hypermemory-ai/hm-plugins-openai.git +cd hm-plugins-openai +python -m pip install -e "packages/hypercolab-cli[dev]" +``` + +Create a focused branch, change only the plugin or shared catalog behavior in +scope, and include tests for lifecycle or manifest changes. + +## Quality checks + +```bash +ruff check plugins packages tests scripts +pytest -q +python scripts/build_plugin_archives.py +``` + +Also run the Codex plugin and skill validators listed in the root README when +available. + +## Release checklist + +1. Increment the changed plugin's semantic version. +2. Confirm marketplace name, plugin ID, folder name, and manifest name match. +3. Validate every referenced asset, skill, hook, and MCP path. +4. Review hook commands for safe failure behavior and explicit trust. +5. Confirm no credentials, tokens, test accounts, or private endpoints entered + the package. +6. Build fresh review ZIPs and test installation in a new Codex task. +7. Document user-visible changes. diff --git a/README.md b/README.md index 15cc1bb..77b91c1 100644 --- a/README.md +++ b/README.md @@ -1 +1,146 @@ -# hm-plugins-openai \ No newline at end of file +

+ HyperMemory +      + HyperColab +

+ +

HyperMemory AI plugins for ChatGPT & Codex

+ +

+ One public marketplace. Two focused plugins. Durable memory for every turn, + and coordinated development for every repository. +

+ +

+ Validation + MIT License +

+ +## The marketplace + +| Plugin | What it adds | Best for | +| --- | --- | --- | +| **HyperMemory** | OAuth MCP, relationship-aware recall, delegated memory writing, lifecycle hooks, and privacy-preserving token telemetry | Keeping personal and project context available across chats and sessions | +| **HyperColab** | Project-aware MCP shim, shared graph and timeline, work claims, collision-prevention hooks, Git activity capture, and coordination-agent guidance | Coordinating developers and coding agents working in the same repository | + +The two plugins are independent. Install either one or both from the same +`hypermemory-ai` marketplace. + +## Quick start + +Add this repository as a plugin source once: + +```bash +codex plugin marketplace add hypermemory-ai/hm-plugins-openai +``` + +Install HyperMemory: + +```bash +codex plugin add hypermemory@hypermemory-ai +``` + +Install HyperColab's local CLI/MCP shim, then its plugin: + +```bash +pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli" +hypercolab login +codex plugin add hypercolab@hypermemory-ai +``` + +Finally, start a new task. In Codex CLI, run `/hooks`, review the bundled hook +definitions, and trust the ones you want active. Codex intentionally does not +run new or changed non-managed hooks until you approve their exact definition. + +See [Installation](docs/INSTALLATION.md) for OAuth, verification, updates, +ChatGPT surface notes, and troubleshooting. + +## How the pieces fit + +```mermaid +flowchart LR + Catalog["HyperMemory AI marketplace"] --> Memory["HyperMemory plugin"] + Catalog --> Colab["HyperColab plugin"] + + Memory --> MemoryMCP["OAuth HyperMemory MCP"] + Memory --> MemorySkill["Always-on memory skill"] + Memory --> MemoryHooks["Recall and finalization hooks"] + Memory --> Tokens["Codex token listener"] + + Colab --> ColabMCP["Local HyperColab MCP shim"] + Colab --> ColabSkill["Coordination skill"] + Colab --> ColabHooks["Claims and timeline hooks"] + ColabMCP --> Backend["HyperColab project services"] +``` + +HyperMemory keeps recall on the main agent so remembered context can influence +the answer. Durable writes, timeline updates, and token reporting are delegated +to a bounded memory-writer sub-agent. HyperColab follows the same separation: +join, sync, and claims stay on the main agent, while routine project-timeline +maintenance can be delegated to a coordination writer. + +## Repository layout + +```text +.agents/plugins/marketplace.json Shared marketplace catalog +plugins/hypermemory/ HyperMemory OpenAI plugin +plugins/hypercolab/ HyperColab OpenAI plugin +packages/hypercolab-cli/ HyperColab CLI and local stdio MCP shim +docs/ Installation and architecture guides +scripts/build_plugin_archives.py Reproducible review ZIP builder +tests/ Marketplace and lifecycle tests +``` + +Each plugin contains its required `.codex-plugin/plugin.json`, MCP +configuration, skill, hook definition, assets, and an `agents/` role contract. +The skills reference those roles because the OpenAI plugin manifest does not +currently define a separate auto-installed custom-agent registry. + +## ChatGPT and Codex + +This repository is the Git-backed marketplace and source package used for +development, Codex installation, and workspace testing. Public one-click +installation in both ChatGPT and Codex requires publishing each plugin through +OpenAI's universal Plugins Directory. + +HyperMemory's hosted MCP is ready for the **With MCP** submission path. +HyperColab uses a local stdio shim to resolve the active Git repository, so its +full coordination behavior requires a local coding surface that can launch the +`hypercolab` command. + +## Privacy by design + +- OAuth credentials are handled by the MCP or HyperColab CLI and are never + committed to this repository. +- HyperMemory's Codex listener parses token counters only. It does not return + or upload prompts, responses, tool arguments, tool results, or source code. +- HyperColab records structured summaries, paths, commits, claims, and visible + rationale. It does not send raw source, raw diffs, transcripts, or hidden + reasoning by default. +- Plugin hooks require explicit trust in Codex and can be reviewed or disabled + with `/hooks`. + +Read [Architecture](docs/ARCHITECTURE.md), [Marketplace maintenance](docs/MARKETPLACE.md), +and [Security](SECURITY.md) for the full operational model. + +## Development + +```bash +python -m pip install -e "packages/hypercolab-cli[dev]" +ruff check plugins packages tests scripts +pytest -q +python scripts/build_plugin_archives.py +``` + +Codex's authoring validators are also supported: + +```bash +python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypermemory +python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypercolab +python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypermemory/skills/hypermemory +python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypercolab/skills/hypercolab +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3ba77c5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security policy + +## Reporting a vulnerability + +Do not open a public issue for a vulnerability, credential exposure, or tenant +isolation concern. Email `hello@runstack.ai` with a concise description, +affected component, reproduction steps, and impact. Do not include live access +tokens or personal data. + +## Security boundaries + +- HyperMemory MCP authentication uses OAuth. The plugin contains no API key or + bearer token. +- HyperColab credentials are stored by the local CLI, outside the repository + and plugin package. +- Non-managed plugin hooks require explicit trust in Codex. +- HyperMemory token telemetry parses counters only and must not upload chat or + source content. +- HyperColab timeline events contain structured summaries and repository paths, + not raw code, diffs, transcripts, or hidden reasoning. +- Path claims are coordination controls, not a substitute for operating-system + access control or code review. + +## Supported versions + +Security fixes target the latest version of each plugin on the default branch. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c92880b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,129 @@ +# Architecture + +The repository contains one marketplace catalog and two independently +installable plugins. Shared branding and distribution live at the repository +level; runtime behavior remains isolated by plugin. + +## Package topology + +```mermaid +flowchart TD + Marketplace[".agents/plugins/marketplace.json"] --> HM["plugins/hypermemory"] + Marketplace --> HC["plugins/hypercolab"] + + HM --> HMM[".codex-plugin/plugin.json"] + HM --> HMMCP["Hosted OAuth MCP"] + HM --> HMSkill["Memory skill + writer contract"] + HM --> HMHooks["Session, prompt, and stop hooks"] + HM --> HMToken["Exact Codex token listener"] + + HC --> HCM[".codex-plugin/plugin.json"] + HC --> HCMCP["Local stdio MCP registration"] + HC --> HCSkill["Coordination skill + writer contract"] + HC --> HCHooks["Join, claims, and activity hooks"] + HCMCP --> CLI["packages/hypercolab-cli"] +``` + +## HyperMemory lifecycle + +```mermaid +sequenceDiagram + participant U as User + participant M as Main agent + participant MCP as HyperMemory MCP + participant W as Memory-writer sub-agent + participant L as Codex token listener + + U->>M: Prompt + M->>MCP: overview + recall + MCP-->>M: Relevant graph context + M->>M: Perform the requested work + M->>W: Bounded finalization summary + W->>MCP: recall, store/update, timeline + W->>L: inspect exact counter delta + L-->>W: hm_tokens payload + W->>MCP: one token report + W->>L: acknowledge accepted claim + W-->>M: Brief status + M-->>U: Final response +``` + +Recall remains on the main agent because it changes how the task is understood. +Persistence and telemetry are delegated so they do not crowd the main context. +The stop hook uses a recursion guard to prevent an end-of-turn delegation loop. + +The token listener reads only `session_meta` and `token_count` records from the +logical Codex session's parent and sub-agent rollouts. Its inspect/ack protocol +does not advance the checkpoint until the MCP accepts the report. Tokens that +appear after inspection roll into the next successful report. + +## HyperColab lifecycle + +```mermaid +sequenceDiagram + participant A as Coding agent + participant S as Local MCP shim + participant API as HyperColab API + participant G as Project graph + participant T as Project timeline + + A->>S: colab_join / colab_sync + S->>S: Resolve Git root, remote, branch, worktree + S->>API: Authenticated project request + API-->>A: Active work and path guidance + A->>S: colab_claim before edits + S->>API: Atomic path claim + A->>S: update / activity / finish + API->>T: Append structured event + API->>G: Project durable decisions and outcomes +``` + +The local shim is intentional: it derives repository identity locally so an +agent cannot choose another project's graph or timeline identifier. Redis holds +short-lived sessions, claims, and leases; the project graph and append-only +timeline remain the durable systems of record. + +## Hooks and trust + +Both plugins use the default `hooks/hooks.json` discovery path. Plugin hooks +are non-managed, so Codex requires users to review and trust their exact +definition. Changed hook content receives a new hash and must be reviewed again. + +HyperMemory hooks add recall instructions and create bounded token-listener +jobs. HyperColab hooks load project context, check writes against claims, and +record structured activity. The HyperColab launcher degrades safely when its +CLI is missing: it explains the prerequisite at session start and does not +block writes in an unconfigured environment. + +## Agent packaging + +The current OpenAI plugin manifest supports skills, MCP servers, apps, hooks, +and presentation assets; it does not define a separate auto-installed custom +agent registry. Each plugin therefore ships its agent role as a skill reference: + +- `memory-writer-agent.md` defines HyperMemory finalization. +- `coordination-agent.md` defines delegated HyperColab timeline maintenance. + +The skills instruct the host when to spawn these bounded roles, what context to +provide, and how to prevent recursive delegation. The `agents/openai.yaml` +files alongside each skill provide OpenAI skill interface metadata and MCP +dependencies; they are not custom-agent TOML files. + +## Authentication boundaries + +- HyperMemory uses the hosted MCP's OAuth flow. Codex stores MCP OAuth state; + the plugin contains only the server URL. +- HyperColab authenticates through `hypercolab login`. Credentials remain in + local HyperColab configuration and are not embedded in `.mcp.json`. +- Token provenance and cost provenance are independent. The plugin never + invents provider-actual billing data. + +## Surface behavior + +| Capability | ChatGPT | Codex | +| --- | --- | --- | +| HyperMemory hosted MCP and skill | Supported through MCP/plugin publication | Supported through Git marketplace or public directory | +| HyperMemory exact local token delta | Not exposed by consumer ChatGPT | Supported through local rollout counters | +| HyperColab skill | Supported where installed | Supported | +| HyperColab local MCP shim and Git hooks | Requires a local surface able to launch the CLI | Supported in local CLI/app/IDE workflows | +| Plugin lifecycle hooks | Surface-dependent | Supported after explicit trust | diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..c8f2e5f --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,157 @@ +# Installation + +This repository is a Git-backed Codex marketplace named `hypermemory-ai`. +Registering it makes the catalog available; it does not install either plugin. + +## Prerequisites + +- A current Codex CLI or Codex in the ChatGPT desktop app +- A HyperMemory account +- Python 3.10 or newer and `pipx` for the HyperColab local shim + +## 1. Register the marketplace + +```bash +codex plugin marketplace add hypermemory-ai/hm-plugins-openai +``` + +Confirm the source: + +```bash +codex plugin marketplace list +``` + +The catalog exposes two plugin IDs: + +- `hypermemory@hypermemory-ai` +- `hypercolab@hypermemory-ai` + +## 2. Install HyperMemory + +```bash +codex plugin add hypermemory@hypermemory-ai +``` + +The plugin connects to: + +```text +https://stage.hypermemory.io/mcp +``` + +Complete OAuth when prompted. No API key is stored in the plugin package. The +server handles authorization-code flow, PKCE, refresh tokens, and dynamic +client registration. + +Start a new task after installation so Codex loads the MCP server and bundled +skill. In Codex CLI, run `/hooks`, review the HyperMemory hook definition, and +trust it to enable lifecycle recall enforcement and the exact local token +listener. + +## 3. Install HyperColab + +HyperColab resolves the current Git root, remote, branch, and worktree locally. +Install its CLI and stdio MCP shim before installing the plugin: + +```bash +pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli" +hypercolab login +hypercolab doctor +``` + +Then install the plugin: + +```bash +codex plugin add hypercolab@hypermemory-ai +``` + +Inside a repository enrolled in HyperColab, optionally install the non-blocking +Git activity hooks: + +```bash +hypercolab hooks install +``` + +Start a new task and run `/hooks` to review and trust the HyperColab lifecycle +hooks. Repositories that are not registered with HyperColab remain unaffected. + +## 4. Verify + +```bash +codex plugin list +hypercolab status +``` + +Useful first prompts: + +- `What do you remember about this project?` +- `Join this HyperColab project and show active work.` +- `Claim the files needed for this task before editing.` + +## Updates + +Refresh the Git catalog and reinstall the desired package: + +```bash +codex plugin marketplace upgrade hypermemory-ai +codex plugin add hypermemory@hypermemory-ai +codex plugin add hypercolab@hypermemory-ai +pipx upgrade hypercolab +``` + +Start a new task after updating. If a hook changed, Codex will require review of +the new definition because hook trust is tied to its exact content. + +## Removal + +```bash +codex plugin remove hypermemory --marketplace hypermemory-ai +codex plugin remove hypercolab --marketplace hypermemory-ai +codex plugin marketplace remove hypermemory-ai +pipx uninstall hypercolab +``` + +Removing the marketplace does not delete data already stored in HyperMemory or +HyperColab. Remove local HyperColab Git hooks before uninstalling the CLI if you +installed them: + +```bash +hypercolab hooks uninstall +``` + +## ChatGPT surfaces + +The Git marketplace is intended for Codex installation, local development, and +workspace testing. Public one-click installation for normal ChatGPT and Codex +users requires publishing through the universal Plugins Directory. + +For pre-publication HyperMemory testing in ChatGPT developer mode, register +`https://stage.hypermemory.io/mcp` and use the bundled HyperMemory skill. Since +consumer ChatGPT does not expose Codex rollout JSONL counters, HyperMemory uses +an uncertainty-labelled token estimate there. + +HyperColab's full behavior depends on its local stdio shim and Git context. It +therefore requires a local coding surface that can execute `hypercolab`; a web +chat without access to that process cannot provide repository claims or Git +activity capture. + +## Troubleshooting + +### MCP tools are missing + +Confirm the plugin is installed and enabled, then start a new task. For +HyperColab, also confirm `hypercolab` is on `PATH` with `hypercolab doctor`. + +### Hooks do not run + +Run `/hooks`, inspect the source and current hash, and trust the definition. +Hooks are non-managed and intentionally skipped until trusted. + +### OAuth did not open + +For HyperMemory, invoke a HyperMemory MCP tool and complete the connection flow. +For HyperColab, run `hypercolab login` directly in a terminal. + +### A HyperColab write is blocked + +Run `hypercolab sync` to inspect current ownership. Coordinate a handoff or wait +for the conflicting lease instead of bypassing a live claim. diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md new file mode 100644 index 0000000..8107dd2 --- /dev/null +++ b/docs/MARKETPLACE.md @@ -0,0 +1,93 @@ +# Marketplace maintenance + +## Catalog identity + +The marketplace file lives at `.agents/plugins/marketplace.json` and has the +stable identifier `hypermemory-ai`. Its ordered plugin list is: + +1. `hypermemory` +2. `hypercolab` + +Install identifiers follow `@`: + +```text +hypermemory@hypermemory-ai +hypercolab@hypermemory-ai +``` + +The catalog entries use `source: local` because Codex first checks out the Git +marketplace and then resolves each `./plugins/...` path inside that snapshot. +Registering the repository is separate from installing a plugin. + +## Required entry fields + +Every catalog entry includes: + +- `name`, matching the plugin folder and manifest name +- a repository-relative source path +- `policy.installation` +- `policy.authentication` +- `category` + +Both plugins are `AVAILABLE` and authenticate on installation. Do not add +product gating unless there is an explicit release requirement for it. + +## Plugin package contract + +Each plugin root contains: + +```text +.codex-plugin/plugin.json +.mcp.json +assets/ +hooks/hooks.json +skills//SKILL.md +skills//agents/openai.yaml +skills//references/ +``` + +HyperMemory also includes its token listener and lifecycle bridge under +`scripts/`. HyperColab includes a graceful hook launcher, while its complete CLI +and stdio MCP shim live under `packages/hypercolab-cli/` for `pipx` +installation. + +## Versions and cache behavior + +Use semantic versions in each `.codex-plugin/plugin.json`. Increment only the +plugin that changed. After publishing a Git change: + +```bash +codex plugin marketplace upgrade hypermemory-ai +codex plugin add @hypermemory-ai +``` + +Start a new task to load the updated package. Changed hook definitions require +new trust approval. + +## Archives and `.plugin` files + +Codex marketplace installation consumes the plugin directories referenced by +the catalog; it does not require checked-in `.plugin` archives. OpenAI's public +submission workflow uses the plugin manifest, MCP details, and skill uploads; +skills-only upload artifacts are ZIP files. + +For review or release automation, build deterministic ZIPs locally: + +```bash +python scripts/build_plugin_archives.py +``` + +The command writes one archive per plugin under `dist/`. Generated archives are +ignored by Git so the repository remains source-first and avoids stale binary +packages. + +## Public Plugins Directory + +This Git marketplace supports Codex installation, development, and private or +team distribution. To offer one-click installation to normal ChatGPT and Codex +users, submit each plugin separately through OpenAI's universal Plugins +Directory. + +HyperMemory should use the **With MCP** flow with its hosted MCP URL and final +skill bundle. HyperColab needs a publication plan that preserves repository +identity while satisfying the target surface's MCP transport requirements. diff --git a/packages/hypercolab-cli/README.md b/packages/hypercolab-cli/README.md new file mode 100644 index 0000000..166612f --- /dev/null +++ b/packages/hypercolab-cli/README.md @@ -0,0 +1,21 @@ +# Hypercolab CLI + +`hypercolab` is both the standalone project-coordination CLI and the local +stdio MCP shim used by the HyperColab plugin for ChatGPT and Codex. + +```bash +pipx install . +hypercolab login +hypercolab setup +hypercolab join --goal "Implement project invitations" +hypercolab mcp +``` + +The CLI detects the current Git root, remote, branch, and worktree. Project +graph and timeline identifiers are resolved by the HyperMemory backend and are +never selected directly by an agent. + +The CLI uses the same service methods for direct commands, coding-client hooks, +Git activity capture, and MCP tools. Git events are queued when the API is down. +New claims fail closed while offline; existing cached leases are honored only +until their server-issued expiry. The package configures Codex only. diff --git a/packages/hypercolab-cli/hypercolab_cli/__init__.py b/packages/hypercolab-cli/hypercolab_cli/__init__.py new file mode 100644 index 0000000..7fb977f --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/__init__.py @@ -0,0 +1,3 @@ +"""Hypercolab CLI and MCP shim.""" + +__version__ = "0.1.0" diff --git a/packages/hypercolab-cli/hypercolab_cli/auth.py b/packages/hypercolab-cli/hypercolab_cli/auth.py new file mode 100644 index 0000000..2d4e301 --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/auth.py @@ -0,0 +1,117 @@ +"""OAuth 2.1 PKCE authentication for Hypercolab.""" + +from __future__ import annotations + +import base64 +import hashlib +import http.server +import secrets +import urllib.parse +import webbrowser +from typing import Any + +import httpx + +from .config import Config, load_config, save_config + + +def _pkce() -> tuple[str, str]: + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +class _Callback(http.server.BaseHTTPRequestHandler): + code: str | None = None + error: str | None = None + + def do_GET(self) -> None: + params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + _Callback.code = params.get("code", [None])[0] + _Callback.error = params.get("error_description", params.get("error", [None]))[0] + self.send_response(200 if _Callback.code else 400) + self.send_header("Content-Type", "text/html") + self.end_headers() + message = ( + "Authenticated. You can close this tab." if _Callback.code else f"Authentication failed: {_Callback.error}" + ) + self.wfile.write( + f"

{message}

".encode() + ) + + def log_message(self, _format: str, *args: Any) -> None: + return + + +def login() -> Config: + config = load_config(require_auth=False) + verifier, challenge = _pkce() + server = http.server.HTTPServer(("127.0.0.1", 0), _Callback) + redirect_uri = f"http://127.0.0.1:{server.server_address[1]}" + registration = httpx.post( + f"{config.api_url}/register", + json={ + "client_name": "Hypercolab CLI", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + }, + timeout=15, + ) + registration.raise_for_status() + client_id = registration.json()["client_id"] + auth_url = ( + f"{config.api_url}/authorize?response_type=code&client_id={urllib.parse.quote(client_id)}" + f"&redirect_uri={urllib.parse.quote(redirect_uri)}&code_challenge={challenge}" + "&code_challenge_method=S256&scope=memory:read+memory:write+memory:admin" + ) + print(f"Open this URL to authenticate:\n{auth_url}") + webbrowser.open(auth_url) + _Callback.code = None + _Callback.error = None + server.timeout = 120 + while _Callback.code is None and _Callback.error is None: + server.handle_request() + server.server_close() + if not _Callback.code: + raise RuntimeError(_Callback.error or "No authorization code received") + response = httpx.post( + f"{config.api_url}/token", + json={ + "grant_type": "authorization_code", + "code": _Callback.code, + "code_verifier": verifier, + "client_id": client_id, + "redirect_uri": redirect_uri, + }, + timeout=15, + ) + response.raise_for_status() + tokens = response.json() + config.access_token = tokens["access_token"] + config.refresh_token = tokens.get("refresh_token", "") + config.client_id = client_id + save_config(config) + return config + + +def refresh(config: Config) -> bool: + if not config.refresh_token or not config.client_id: + return False + response = httpx.post( + f"{config.api_url}/token", + json={ + "grant_type": "refresh_token", + "refresh_token": config.refresh_token, + "client_id": config.client_id, + }, + timeout=15, + ) + if response.status_code >= 400: + return False + tokens = response.json() + config.access_token = tokens.get("access_token", "") + config.refresh_token = tokens.get("refresh_token", config.refresh_token) + save_config(config) + return bool(config.access_token) diff --git a/packages/hypercolab-cli/hypercolab_cli/client.py b/packages/hypercolab-cli/hypercolab_cli/client.py new file mode 100644 index 0000000..14441f3 --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/client.py @@ -0,0 +1,208 @@ +"""Shared HTTP client used by direct CLI commands, hooks, and MCP tools.""" + +from __future__ import annotations + +import json +from typing import Any, Self + +import httpx + +from .auth import refresh +from .config import Config, load_config, remember_check, remember_repository, repository_state +from .git import GitContext, discover_git_context + + +class HypercolabError(RuntimeError): + def __init__(self, message: str, *, status_code: int = 0, detail: Any = None) -> None: + super().__init__(message) + self.status_code = status_code + self.detail = detail + + +class HypercolabClient: + def __init__(self, config: Config | None = None) -> None: + self.config = config or load_config() + self.http = httpx.Client(base_url=f"{self.config.api_url}/api/colab", timeout=30) + + def close(self) -> None: + self.http.close() + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + headers = {"Authorization": f"Bearer {self.config.auth_token}"} + try: + response = self.http.request(method, path, headers=headers, **kwargs) + except (httpx.ConnectError, httpx.TimeoutException) as exc: + raise HypercolabError("Hypercolab service is unavailable") from exc + if response.status_code == 401 and refresh(self.config): + headers["Authorization"] = f"Bearer {self.config.auth_token}" + response = self.http.request(method, path, headers=headers, **kwargs) + try: + data = response.json() + except ValueError: + data = response.text + if response.status_code >= 400: + detail = data.get("detail", data) if isinstance(data, dict) else data + raise HypercolabError(str(detail), status_code=response.status_code, detail=detail) + return data + + def get(self, path: str) -> Any: + return self._request("GET", path) + + def post(self, path: str, body: dict[str, Any]) -> Any: + return self._request("POST", path, json=body) + + def join( + self, + *, + git: GitContext | None = None, + goal: str | None = None, + agent_name: str = "coding-agent", + client_name: str = "cli", + ) -> dict[str, Any]: + git = git or discover_git_context() + result = self.post( + "/join", + { + "repository_remote": git.remote, + "agent_name": agent_name, + "client_name": client_name, + "branch": git.branch, + "worktree": git.worktree, + "goal": goal, + }, + ) + remember_repository(git.remote, project=result["project"], session=result["session"]) + return result + + def ensure_session(self, git: GitContext | None = None, *, client_name: str = "cli") -> tuple[GitContext, dict]: + git = git or discover_git_context() + state = repository_state(git.remote) + if state and state.get("session", {}).get("session_id"): + return git, state + joined = self.join(git=git, client_name=client_name) + return git, {"project": joined["project"], "session": joined["session"]} + + def sync(self, *, git: GitContext | None = None, client_name: str = "cli") -> dict[str, Any]: + _, state = self.ensure_session(git, client_name=client_name) + return self.get(f"/sync/{state['session']['session_id']}") + + def claim(self, paths: list[str], task: str = "", *, git: GitContext | None = None) -> dict[str, Any]: + _, state = self.ensure_session(git) + return self.post( + "/claim", + {"session_id": state["session"]["session_id"], "paths": paths, "task": task, "ttl_seconds": 600}, + ) + + def check( + self, + operations: list[dict[str, Any]], + *, + git: GitContext | None = None, + auto_claim: bool = True, + client_name: str = "cli", + ) -> dict[str, Any]: + git, state = self.ensure_session(git, client_name=client_name) + result = self.post( + "/check", + {"session_id": state["session"]["session_id"], "operations": operations, "auto_claim": auto_claim}, + ) + remember_check(git.remote, operations, result) + return result + + def update( + self, + summary: str, + *, + status: str = "working", + rationale_summary: str | None = None, + paths: list[str] | None = None, + claim_id: str | None = None, + git: GitContext | None = None, + ) -> dict[str, Any]: + _, state = self.ensure_session(git) + return self.post( + "/update", + { + "session_id": state["session"]["session_id"], + "claim_id": claim_id, + "status": status, + "summary": summary, + "rationale_summary": rationale_summary, + "paths": paths or [], + }, + ) + + def finish( + self, + summary: str, + *, + outcome: str = "completed", + changed_files: list[str] | None = None, + commit_refs: list[str] | None = None, + tests: list[str] | None = None, + claim_id: str | None = None, + git: GitContext | None = None, + ) -> dict[str, Any]: + _, state = self.ensure_session(git) + return self.post( + "/finish", + { + "session_id": state["session"]["session_id"], + "claim_id": claim_id, + "outcome": outcome, + "summary": summary, + "changed_files": changed_files or [], + "commit_refs": commit_refs or [], + "tests": tests or [], + }, + ) + + def log_activity( + self, + *, + kind: str, + summary: str, + source: str = "cli", + rationale_summary: str | None = None, + paths: list[str] | None = None, + commit_refs: list[str] | None = None, + metadata: dict[str, Any] | None = None, + git: GitContext | None = None, + ) -> dict[str, Any]: + git, state = self.ensure_session(git) + return self.post( + f"/projects/{state['project']['project_id']}/activity", + { + "session_id": state["session"]["session_id"], + "source": source, + "kind": kind, + "summary": summary, + "rationale_summary": rationale_summary, + "branch": git.branch, + "worktree": git.worktree, + "paths": paths or [], + "commit_refs": commit_refs or [], + "metadata": metadata or {}, + }, + ) + + def timeline(self, *, query: str | None = None, limit: int = 100, git: GitContext | None = None): + _, state = self.ensure_session(git) + return self.post( + f"/projects/{state['project']['project_id']}/timeline", + {"query": query, "limit": limit, "offset": 0}, + ) + + def graph_search(self, query: str, *, limit: int = 25, git: GitContext | None = None): + _, state = self.ensure_session(git) + return self.post(f"/projects/{state['project']['project_id']}/graph/search", {"query": query, "limit": limit}) + + +def compact_json(value: Any) -> str: + return json.dumps(value, separators=(",", ":"), default=str) diff --git a/packages/hypercolab-cli/hypercolab_cli/config.py b/packages/hypercolab-cli/hypercolab_cli/config.py new file mode 100644 index 0000000..26dd500 --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/config.py @@ -0,0 +1,188 @@ +"""Local Hypercolab configuration, credentials and repository session state.""" + +from __future__ import annotations + +import json +import logging +import os +import posixpath +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +DEFAULT_API_URL = "https://api.hypermemory.io" +CONFIG_DIR = Path(os.environ.get("HYPERCOLAB_CONFIG_DIR", Path.home() / ".config" / "hypercolab")) +CONFIG_FILE = CONFIG_DIR / "config.json" +STATE_FILE = CONFIG_DIR / "state.json" +QUEUE_FILE = CONFIG_DIR / "timeline-queue.jsonl" +KEYRING_SERVICE = "io.hypermemory.hypercolab" + + +@dataclass(slots=True) +class Config: + api_url: str = DEFAULT_API_URL + access_token: str = "" + refresh_token: str = "" + client_id: str = "" + + @property + def auth_token(self) -> str: + return os.environ.get("HYPERCOLAB_API_TOKEN") or os.environ.get("HYPERMEMORY_API_KEY") or self.access_token + + +def _ensure_dir() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + try: + CONFIG_DIR.chmod(0o700) + except OSError: + pass + + +def _read_json(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + except (OSError, json.JSONDecodeError) as exc: + logger.debug("config_read_failed: %s", exc) + return {} + + +def _keyring_get(name: str) -> str: + try: + import keyring + + return keyring.get_password(KEYRING_SERVICE, name) or "" + except Exception as exc: # noqa: BLE001 - keyring backends raise platform-specific errors + logger.debug("keyring_read_failed: %s", exc) + return "" + + +def _keyring_set(name: str, value: str) -> None: + try: + import keyring + + if value: + keyring.set_password(KEYRING_SERVICE, name, value) + else: + keyring.delete_password(KEYRING_SERVICE, name) + except Exception as exc: # noqa: BLE001 - keyring backends raise platform-specific errors + logger.debug("keyring_write_failed: %s", exc) + + +def load_config(*, require_auth: bool = True) -> Config: + data = _read_json(CONFIG_FILE) + config = Config( + api_url=(os.environ.get("HYPERCOLAB_API_URL") or data.get("api_url") or DEFAULT_API_URL).rstrip("/"), + access_token=_keyring_get("access_token") or data.get("access_token", ""), + refresh_token=_keyring_get("refresh_token") or data.get("refresh_token", ""), + client_id=data.get("client_id", ""), + ) + if require_auth and not config.auth_token: + raise RuntimeError("Not authenticated. Run `hypercolab login` or set HYPERCOLAB_API_TOKEN.") + return config + + +def save_config(config: Config) -> None: + _ensure_dir() + _keyring_set("access_token", config.access_token) + _keyring_set("refresh_token", config.refresh_token) + data = {"api_url": config.api_url, "client_id": config.client_id} + # Keyring-less/headless installations need a restricted fallback. + if config.access_token and not _keyring_get("access_token"): + data["access_token"] = config.access_token + data["refresh_token"] = config.refresh_token + CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + try: + CONFIG_FILE.chmod(0o600) + except OSError: + pass + + +def clear_credentials() -> None: + current = load_config(require_auth=False) + current.access_token = "" + current.refresh_token = "" + current.client_id = "" + save_config(current) + + +def load_state() -> dict[str, Any]: + return _read_json(STATE_FILE) + + +def save_state(state: dict[str, Any]) -> None: + _ensure_dir() + STATE_FILE.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + try: + STATE_FILE.chmod(0o600) + except OSError: + pass + + +def remember_repository(remote: str, *, project: dict[str, Any], session: dict[str, Any]) -> None: + state = load_state() + state.setdefault("repositories", {})[remote] = {"project": project, "session": session} + save_state(state) + + +def repository_state(remote: str) -> dict[str, Any] | None: + return load_state().get("repositories", {}).get(remote) + + +def remember_check(remote: str, operations: list[dict[str, Any]], result: dict[str, Any]) -> None: + """Cache only server-approved, leased write scopes for brief outages.""" + + state = load_state() + repository = state.setdefault("repositories", {}).setdefault(remote, {}) + leases = repository.setdefault("leases", {}) + for operation, decision in zip(operations, result.get("decisions", []), strict=False): + expires_at = decision.get("lease_expires_at") + if decision.get("decision") != "allow" or not decision.get("claim_id") or not expires_at: + continue + leases[_normalized_path(operation["path"])] = { + "claim_id": decision["claim_id"], + "lease_expires_at": float(expires_at), + } + repository["leases"] = { + path: lease for path, lease in leases.items() if float(lease.get("lease_expires_at", 0)) > time.time() + } + save_state(state) + + +def operations_have_live_cached_leases(remote: str, operations: list[dict[str, Any]]) -> bool: + repository = repository_state(remote) or {} + now = time.time() + leases = { + path: lease + for path, lease in repository.get("leases", {}).items() + if float(lease.get("lease_expires_at", 0)) > now + } + if not leases: + return False + for operation in operations: + if operation.get("operation") == "read": + continue + targets = [_normalized_path(operation["path"])] + if operation.get("destination"): + targets.append(_normalized_path(operation["destination"])) + if not all(any(_paths_overlap(target, scope) for scope in leases) for target in targets): + return False + return True + + +def _normalized_path(path: str) -> str: + value = posixpath.normpath(str(path).replace("\\", "/").removeprefix("./")) + return value.rstrip("/") + + +def _paths_overlap(left: str, right: str) -> bool: + return left == right or left.startswith(right + "/") or right.startswith(left + "/") + + +def config_dict(config: Config) -> dict[str, Any]: + data = asdict(config) + data["access_token"] = "***" if config.access_token else "" + data["refresh_token"] = "***" if config.refresh_token else "" + return data diff --git a/packages/hypercolab-cli/hypercolab_cli/git.py b/packages/hypercolab-cli/hypercolab_cli/git.py new file mode 100644 index 0000000..33026f7 --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/git.py @@ -0,0 +1,55 @@ +"""Git repository context used for safe project resolution and audit events.""" + +from __future__ import annotations + +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(slots=True) +class GitContext: + root: str + remote: str + branch: str + worktree: str + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + +def _git(*args: str, cwd: str | Path | None = None, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=15, + ) + if check and result.returncode: + raise RuntimeError(result.stderr.strip() or "Not inside a Git repository") + return result.stdout.strip() + + +def discover_git_context(cwd: str | Path | None = None) -> GitContext: + root = _git("rev-parse", "--show-toplevel", cwd=cwd) + remote = _git("remote", "get-url", "origin", cwd=root, check=False) + if not remote: + remotes = _git("remote", cwd=root, check=False).splitlines() + if len(remotes) == 1: + remote = _git("remote", "get-url", remotes[0], cwd=root) + if not remote: + raise RuntimeError("The repository has no resolvable Git remote") + branch = _git("branch", "--show-current", cwd=root, check=False) or "detached" + worktree = _git("rev-parse", "--show-toplevel", cwd=root) + return GitContext(root=root, remote=remote, branch=branch, worktree=worktree) + + +def changed_paths(cwd: str | Path | None = None, revision: str = "HEAD") -> list[str]: + output = _git("diff-tree", "--root", "--no-commit-id", "--name-only", "-r", revision, cwd=cwd, check=False) + return [line for line in output.splitlines() if line] + + +def current_commit(cwd: str | Path | None = None) -> str: + return _git("rev-parse", "HEAD", cwd=cwd, check=False) diff --git a/packages/hypercolab-cli/hypercolab_cli/hooks.py b/packages/hypercolab-cli/hypercolab_cli/hooks.py new file mode 100644 index 0000000..cee855b --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/hooks.py @@ -0,0 +1,286 @@ +"""Coding-client guard hook and non-blocking Git timeline hooks.""" + +from __future__ import annotations + +import json +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +from .client import HypercolabClient, HypercolabError +from .config import QUEUE_FILE, operations_have_live_cached_leases +from .git import GitContext, changed_paths, current_commit, discover_git_context + +MANAGED_START = "# >>> hypercolab managed hook >>>" +MANAGED_END = "# <<< hypercolab managed hook <<<" +GIT_HOOKS = ("post-commit", "post-checkout", "post-merge", "post-rewrite", "pre-push") +READ_ONLY_COMMANDS = re.compile( + r"^\s*(git\s+(status|diff|log|show|branch|remote|rev-parse)|" + r"(rg|grep|find|ls|pwd|sed\s+-n|head|tail|cat|wc|which|type)\b)" +) +WRITE_COMMANDS = re.compile( + r"\b(rm|mv|cp|mkdir|touch|install|truncate|tee|sed\s+-i|git\s+(add|commit|merge|rebase))\b|>>?|\|\s*tee\b" +) +GIT_PUSH = re.compile(r"(?:^|[;&|]\s*)git\s+(?:-[^\s]+\s+)*push\b") + + +def _hook_block(reason: str, event_name: str = "PreToolUse") -> dict[str, Any]: + return { + "hookSpecificOutput": { + "hookEventName": event_name, + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + + +def _extract_file_paths(payload: dict[str, Any]) -> list[dict[str, Any]]: + tool = payload.get("tool_name", "") + tool_input = payload.get("tool_input") or {} + for key in ("file_path", "path", "target_file"): + if tool_input.get(key): + return [{"path": tool_input[key], "operation": "modify"}] + if tool == "apply_patch": + command = str(tool_input.get("command") or tool_input.get("patch") or "") + paths = re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", command, re.MULTILINE) + return [{"path": path, "operation": "modify"} for path in paths] + return [] + + +def _command(payload: dict[str, Any]) -> str: + tool_input = payload.get("tool_input") or {} + return str(tool_input.get("command") or tool_input.get("cmd") or payload.get("command") or "") + + +def evaluate_client_hook(payload: dict[str, Any]) -> dict[str, Any]: + """Return cross-client hook JSON. Empty dict means allow.""" + + event = payload.get("hook_event_name", "") + if event in {"SessionStart", "sessionStart"}: + try: + with HypercolabClient() as client: + brief = client.sync(client_name="plugin") + return { + "hookSpecificOutput": { + "hookEventName": payload.get("hook_event_name", "SessionStart"), + "additionalContext": "Hypercolab coordination brief:\n" + json.dumps(brief, default=str), + } + } + except (HypercolabError, RuntimeError): + # Unregistered repositories are intentionally unaffected. + return {} + + if event in {"PostToolUse", "afterFileEdit", "afterShellExecution"}: + try: + operations = _extract_file_paths(payload) + tool = payload.get("tool_name") or payload.get("tool") or "tool" + paths = [operation["path"] for operation in operations] + command = _command(payload) + response = payload.get("tool_response") or payload.get("tool_output") or {} + exit_code = response.get("exit_code") if isinstance(response, dict) else None + kind = "git.push_completed" if GIT_PUSH.search(command) else "tool.completed" + summary = ( + "Git push completed" + if kind == "git.push_completed" and exit_code in {None, 0} + else "Git push failed" + if kind == "git.push_completed" + else f"Agent completed {tool}" + ) + with HypercolabClient() as client: + client.log_activity( + kind=kind, + summary=summary, + source="plugin", + paths=paths, + metadata={"tool": tool, "path_count": len(paths), "exit_code": exit_code}, + ) + except (HypercolabError, RuntimeError): + pass + return {} + + if event in {"Stop", "SessionEnd", "sessionEnd"}: + try: + with HypercolabClient() as client: + client.log_activity( + kind="session.stopped", + summary="Agent session stopped", + source="plugin", + metadata={"hook_event": event}, + ) + except (HypercolabError, RuntimeError): + pass + return {} + + if event not in {"PreToolUse", "beforeFileEdit", "beforeShellExecution"}: + return {} + operations = _extract_file_paths(payload) + command = _command(payload) + if not operations and command: + if GIT_PUSH.search(command): + try: + with HypercolabClient() as client: + client.log_activity( + kind="git.push_attempted", + summary="Git push attempted", + source="plugin", + metadata={"tool": payload.get("tool_name") or payload.get("tool") or "shell"}, + ) + except (HypercolabError, RuntimeError): + pass + return {} + if READ_ONLY_COMMANDS.search(command) and not WRITE_COMMANDS.search(command): + return {} + if WRITE_COMMANDS.search(command): + return _hook_block( + "Hypercolab cannot safely infer every path written by this shell command. " + "Claim the intended files first or use a file-edit tool." + ) + return {} + if not operations: + return {} + try: + git = discover_git_context(payload.get("cwd") or None) + relative: list[dict[str, Any]] = [] + root = Path(git.root).resolve() + for operation in operations: + path = Path(operation["path"]) + if path.is_absolute(): + try: + path = path.resolve().relative_to(root) + except ValueError: + return _hook_block("Hypercolab blocks writes outside the coordinated repository") + relative.append({**operation, "path": path.as_posix()}) + with HypercolabClient() as client: + result = client.check(relative, git=git, auto_claim=True, client_name="plugin") + if not result.get("allowed", False): + blocked = [item for item in result.get("decisions", []) if item.get("decision") == "block"] + return _hook_block("Hypercolab claim conflict: " + json.dumps(blocked, default=str)) + return {} + except HypercolabError as exc: + if exc.status_code == 404: + return {} + if exc.status_code == 0 and operations_have_live_cached_leases(git.remote, relative): + return {} + return _hook_block(str(exc)) + except RuntimeError: + return {} + + +def run_client_hook() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, OSError): + payload = {} + output = evaluate_client_hook(payload) + if output: + print(json.dumps(output)) + return 0 + + +def install_git_hooks(root: str | Path | None = None) -> list[str]: + git = discover_git_context(root) + git_dir_text = subprocess.run( + ["git", "-C", git.root, "rev-parse", "--git-dir"], + check=True, + capture_output=True, + text=True, + timeout=15, + ).stdout.strip() + git_dir = Path(git.root, git_dir_text) if not Path(git_dir_text).is_absolute() else Path(git_dir_text) + hook_dir = git_dir / "hooks" + hook_dir.mkdir(parents=True, exist_ok=True) + installed = [] + for hook_name in GIT_HOOKS: + path = hook_dir / hook_name + existing = path.read_text(encoding="utf-8") if path.exists() else "#!/bin/sh\n" + if MANAGED_START in existing: + continue + block = ( + f"\n{MANAGED_START}\nhypercolab git-event {shlex.quote(hook_name)} >/dev/null 2>&1 || true\n{MANAGED_END}\n" + ) + path.write_text(existing.rstrip() + block, encoding="utf-8") + path.chmod(path.stat().st_mode | 0o111) + installed.append(str(path)) + return installed + + +def uninstall_git_hooks(root: str | Path | None = None) -> list[str]: + git = discover_git_context(root) + git_dir_text = subprocess.run( + ["git", "-C", git.root, "rev-parse", "--git-dir"], + check=True, + capture_output=True, + text=True, + timeout=15, + ).stdout.strip() + git_dir = Path(git.root, git_dir_text) if not Path(git_dir_text).is_absolute() else Path(git_dir_text) + changed = [] + for hook_name in GIT_HOOKS: + path = git_dir / "hooks" / hook_name + if not path.exists(): + continue + content = path.read_text(encoding="utf-8") + updated = re.sub( + rf"\n?{re.escape(MANAGED_START)}.*?{re.escape(MANAGED_END)}\n?", "\n", content, flags=re.DOTALL + ) + if updated != content: + path.write_text(updated, encoding="utf-8") + changed.append(str(path)) + return changed + + +def queue_event(event: dict[str, Any]) -> None: + QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + with QUEUE_FILE.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, default=str) + "\n") + + +def flush_queued_events(client: HypercolabClient) -> int: + """Retry queued Git events, retaining only entries that still fail.""" + + if not QUEUE_FILE.exists(): + return 0 + try: + entries = [json.loads(line) for line in QUEUE_FILE.read_text(encoding="utf-8").splitlines() if line.strip()] + except (OSError, json.JSONDecodeError): + return 0 + remaining: list[dict[str, Any]] = [] + sent = 0 + for entry in entries: + original = dict(entry) + try: + git_data = entry.get("git") + event = {key: value for key, value in entry.items() if key != "git"} + git = GitContext(**git_data) + client.log_activity(git=git, **event) + sent += 1 + except (HypercolabError, RuntimeError, TypeError): + remaining.append(original) + QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + QUEUE_FILE.write_text("".join(json.dumps(entry, default=str) + "\n" for entry in remaining), encoding="utf-8") + return sent + + +def record_git_event(kind: str) -> bool: + git = discover_git_context() + commit = current_commit(git.root) + paths = changed_paths(git.root) + event = { + "kind": f"git.{kind.replace('-', '_')}", + "summary": f"Git {kind.replace('-', ' ')} on {git.branch}", + "source": "git", + "paths": paths, + "commit_refs": [commit] if commit else [], + "metadata": {"branch": git.branch}, + } + try: + with HypercolabClient() as client: + flush_queued_events(client) + client.log_activity(git=git, **event) + return True + except (HypercolabError, RuntimeError): + queue_event({"git": git.to_dict(), **event}) + return False diff --git a/packages/hypercolab-cli/hypercolab_cli/main.py b/packages/hypercolab-cli/hypercolab_cli/main.py new file mode 100644 index 0000000..8815ee8 --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/main.py @@ -0,0 +1,303 @@ +"""Hypercolab command-line interface.""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from typing import Any + +import typer +from rich.console import Console + +from . import __version__ +from .auth import login as oauth_login +from .client import HypercolabClient, HypercolabError +from .config import clear_credentials, config_dict, load_config, repository_state +from .git import current_commit, discover_git_context +from .hooks import install_git_hooks, record_git_event, run_client_hook, uninstall_git_hooks +from .mcp_server import run as run_mcp + +app = typer.Typer(help="Coordinate developers and AI coding agents with project-scoped HyperMemory.") +timeline_app = typer.Typer(help="Read or append project timeline events.") +graph_app = typer.Typer(help="Search the project-scoped HyperMemory graph.") +hooks_app = typer.Typer(help="Install or remove non-blocking Git activity hooks.") +app.add_typer(timeline_app, name="timeline") +app.add_typer(graph_app, name="graph") +app.add_typer(hooks_app, name="hooks") +console = Console(stderr=True) + + +def _print(value: Any) -> None: + print(json.dumps(value, indent=2, default=str)) + + +def _run(callable_, *args: Any, **kwargs: Any) -> None: + try: + with HypercolabClient() as client: + _print(callable_(client, *args, **kwargs)) + except (HypercolabError, RuntimeError) as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) from exc + + +@app.callback(invoke_without_command=True) +def root( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", help="Show the installed version."), +) -> None: + if version: + print(__version__) + raise typer.Exit() + if ctx.invoked_subcommand is None: + console.print(ctx.get_help()) + + +@app.command() +def login() -> None: + """Authenticate through HyperMemory OAuth.""" + + try: + config = oauth_login() + console.print(f"[green]Authenticated[/green] with {config.api_url}") + except Exception as exc: + console.print(f"[red]Authentication failed:[/red] {exc}") + raise typer.Exit(1) from exc + + +@app.command() +def logout() -> None: + """Remove local Hypercolab credentials.""" + + clear_credentials() + console.print("Logged out.") + + +@app.command() +def status() -> None: + """Show authentication, repository, project, and session status.""" + + try: + config = load_config(require_auth=False) + git = discover_git_context() + _print({"config": config_dict(config), "git": git.to_dict(), "state": repository_state(git.remote)}) + except RuntimeError as exc: + _print({"config": config_dict(load_config(require_auth=False)), "repository": None, "warning": str(exc)}) + + +@app.command() +def doctor() -> None: + """Check prerequisites without modifying the repository.""" + + config = load_config(require_auth=False) + checks: dict[str, Any] = { + "hypercolab": __version__, + "git": shutil.which("git") or False, + "authenticated": bool(config.auth_token), + "api_url": config.api_url, + } + try: + checks["repository"] = discover_git_context().to_dict() + except RuntimeError as exc: + checks["repository"] = False + checks["repository_error"] = str(exc) + _print(checks) + if not all((checks["git"], checks["authenticated"])): + raise typer.Exit(1) + + +@app.command() +def setup( + client: str = typer.Option("codex", help="OpenAI coding client to configure (codex)."), +) -> None: + """Configure project-scoped MCP files and install Git timeline hooks.""" + + git = discover_git_context() + if client != "codex": + raise typer.BadParameter(f"Unknown client: {client}") + written = [] + root_path = Path(git.root) + path = root_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True, exist_ok=True) + existing = path.read_text(encoding="utf-8") if path.exists() else "" + marker = "[mcp_servers.hypercolab]" + if marker not in existing: + block = '\n[mcp_servers.hypercolab]\ncommand = "hypercolab"\nargs = ["mcp"]\n' + path.write_text(existing.rstrip() + block, encoding="utf-8") + written.append(str(path)) + hooks = install_git_hooks(git.root) + _print({"configured": written, "git_hooks": hooks}) + + +@app.command() +def join( + goal: str = typer.Option("", help="Current work goal."), + agent_name: str = typer.Option("coding-agent"), + client_name: str = typer.Option("cli"), +) -> None: + """Join the project associated with the current Git remote.""" + + _run(lambda client: client.join(goal=goal or None, agent_name=agent_name, client_name=client_name)) + + +@app.command() +def sync() -> None: + """Get current work ownership, recent timeline, and path guidance.""" + + _run(lambda client: client.sync()) + + +@app.command() +def who() -> None: + """List active developers and agents.""" + + _run(lambda client: {"active_sessions": client.sync().get("active_sessions", [])}) + + +@app.command() +def claim(paths: list[str], task: str = typer.Option("", help="Task or intended outcome.")) -> None: + """Atomically claim files or directories.""" + + _run(lambda client: client.claim(paths, task)) + + +@app.command() +def check( + paths: list[str], + operation: str = typer.Option("modify", help="read, create, modify, rename, or delete"), + auto_claim: bool = typer.Option(True), +) -> None: + """Check paths immediately before an operation.""" + + _run(lambda client: client.check([{"path": path, "operation": operation} for path in paths], auto_claim=auto_claim)) + + +@app.command() +def update( + summary: str, + status: str = typer.Option("working"), + rationale: str = typer.Option(""), + paths: list[str] = typer.Option(None), + claim_id: str = typer.Option(""), +) -> None: + """Publish progress and visible rationale to the project timeline.""" + + _run( + lambda client: client.update( + summary, + status=status, + rationale_summary=rationale or None, + paths=paths or [], + claim_id=claim_id or None, + ) + ) + + +@app.command() +def finish( + summary: str, + outcome: str = typer.Option("completed"), + changed_files: list[str] = typer.Option(None), + commit: list[str] = typer.Option(None), + test: list[str] = typer.Option(None), + claim_id: str = typer.Option(""), +) -> None: + """Finish, abandon, release, or hand off coordinated work.""" + + _run( + lambda client: client.finish( + summary, + outcome=outcome, + changed_files=changed_files or [], + commit_refs=commit or [], + tests=test or [], + claim_id=claim_id or None, + ) + ) + + +@app.command() +def release(claim_id: str, summary: str = typer.Option("Released work scope")) -> None: + """Release a claim without marking the task complete.""" + + _run(lambda client: client.finish(summary, outcome="released", claim_id=claim_id)) + + +@timeline_app.callback(invoke_without_command=True) +def timeline_read(ctx: typer.Context, query: str = typer.Option(""), limit: int = typer.Option(100)) -> None: + """Read or search the project timeline.""" + + if ctx.invoked_subcommand is None: + _run(lambda client: client.timeline(query=query or None, limit=limit)) + + +@timeline_app.command("add") +def timeline_add( + summary: str, + kind: str = typer.Option("note.added"), + rationale: str = typer.Option(""), + paths: list[str] = typer.Option(None), +) -> None: + """Append an explicit structured project event.""" + + _run( + lambda client: client.log_activity( + kind=kind, + summary=summary, + rationale_summary=rationale or None, + paths=paths or [], + ) + ) + + +@graph_app.command("search") +def graph_search(query: str, limit: int = typer.Option(25)) -> None: + """Search durable project graph knowledge.""" + + _run(lambda client: client.graph_search(query, limit=limit)) + + +@hooks_app.command("install") +def hooks_install() -> None: + _print({"installed": install_git_hooks()}) + + +@hooks_app.command("uninstall") +def hooks_uninstall() -> None: + _print({"removed": uninstall_git_hooks()}) + + +@app.command() +def mcp() -> None: + """Run the local Hypercolab MCP server over stdio.""" + + run_mcp() + + +@app.command(hidden=True) +def hook() -> None: + raise typer.Exit(run_client_hook()) + + +@app.command("git-event", hidden=True) +def git_event(kind: str) -> None: + raise typer.Exit(0 if record_git_event(kind) else 1) + + +@app.command(hidden=True) +def commit() -> None: + """Print the current commit; useful for hook diagnostics.""" + + print(current_commit()) + + +def main() -> None: + try: + app() + except BrokenPipeError: + sys.stdout.close() + + +if __name__ == "__main__": + main() diff --git a/packages/hypercolab-cli/hypercolab_cli/mcp_server.py b/packages/hypercolab-cli/hypercolab_cli/mcp_server.py new file mode 100644 index 0000000..b917c9d --- /dev/null +++ b/packages/hypercolab-cli/hypercolab_cli/mcp_server.py @@ -0,0 +1,131 @@ +"""Local stdio MCP shim. All tools share the same Hypercolab client methods.""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import HypercolabClient + +mcp = FastMCP( + "Hypercolab", + instructions=( + "Join and sync before planning. Claim intended paths before editing. " + "Publish progress, rationale summaries, blockers, and completions to the project timeline." + ), +) + + +def _call(method: str, *args: Any, **kwargs: Any) -> Any: + with HypercolabClient() as client: + return getattr(client, method)(*args, **kwargs) + + +@mcp.tool() +def colab_join(goal: str = "", agent_name: str = "coding-agent", client_name: str = "mcp") -> dict[str, Any]: + """Join the Hypercolab project associated with the current Git repository.""" + + return _call("join", goal=goal or None, agent_name=agent_name, client_name=client_name) + + +@mcp.tool() +def colab_sync() -> dict[str, Any]: + """Get active work, ownership, recent timeline, and touch/do-not-touch guidance.""" + + return _call("sync", client_name="mcp") + + +@mcp.tool() +def colab_claim(paths: list[str], task: str = "") -> dict[str, Any]: + """Atomically claim repository-relative files or directories.""" + + return _call("claim", paths, task) + + +@mcp.tool() +def colab_check(operations: list[dict[str, Any]], auto_claim: bool = True) -> dict[str, Any]: + """Check create/modify/rename/delete operations immediately before editing.""" + + return _call("check", operations, auto_claim=auto_claim, client_name="mcp") + + +@mcp.tool() +def colab_update( + summary: str, + status: str = "working", + rationale_summary: str = "", + paths: list[str] | None = None, + claim_id: str = "", +) -> dict[str, Any]: + """Publish meaningful progress and renew an active claim.""" + + return _call( + "update", + summary, + status=status, + rationale_summary=rationale_summary or None, + paths=paths or [], + claim_id=claim_id or None, + ) + + +@mcp.tool() +def colab_finish( + summary: str, + outcome: str = "completed", + changed_files: list[str] | None = None, + commit_refs: list[str] | None = None, + tests: list[str] | None = None, + claim_id: str = "", +) -> dict[str, Any]: + """Complete, release, abandon, or hand off work and release its claim.""" + + return _call( + "finish", + summary, + outcome=outcome, + changed_files=changed_files or [], + commit_refs=commit_refs or [], + tests=tests or [], + claim_id=claim_id or None, + ) + + +@mcp.tool() +def colab_log_activity( + kind: str, + summary: str, + rationale_summary: str = "", + paths: list[str] | None = None, + commit_refs: list[str] | None = None, +) -> dict[str, Any]: + """Append a structured, project-scoped development timeline event.""" + + return _call( + "log_activity", + kind=kind, + summary=summary, + source="plugin", + rationale_summary=rationale_summary or None, + paths=paths or [], + commit_refs=commit_refs or [], + ) + + +@mcp.tool() +def colab_timeline(query: str = "", limit: int = 100) -> dict[str, Any]: + """Search or read the current project's chronological development record.""" + + return _call("timeline", query=query or None, limit=limit) + + +@mcp.tool() +def colab_graph_search(query: str, limit: int = 25) -> dict[str, Any]: + """Search durable project knowledge in the project-scoped HyperMemory graph.""" + + return _call("graph_search", query, limit=limit) + + +def run() -> None: + mcp.run(transport="stdio") diff --git a/packages/hypercolab-cli/pyproject.toml b/packages/hypercolab-cli/pyproject.toml new file mode 100644 index 0000000..bebbb0a --- /dev/null +++ b/packages/hypercolab-cli/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hypercolab" +version = "0.1.0" +description = "Project-scoped coordination, graph memory, and development timelines for AI coding teams." +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [{ name = "HyperMemory AI", email = "hello@runstack.ai" }] +dependencies = [ + "httpx>=0.27,<1", + "keyring>=25,<26", + "mcp[cli]>=1.2,<2", + "rich>=13,<14", + "typer>=0.12,<1", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.8"] + +[project.scripts] +hypercolab = "hypercolab_cli.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["hypercolab_cli"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "UP"] +ignore = ["B008"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/hypercolab-cli/tests/test_git.py b/packages/hypercolab-cli/tests/test_git.py new file mode 100644 index 0000000..068281d --- /dev/null +++ b/packages/hypercolab-cli/tests/test_git.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from hypercolab_cli.git import changed_paths, discover_git_context + + +def _run(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def test_discover_git_context(tmp_path: Path): + _run(tmp_path, "init") + _run(tmp_path, "remote", "add", "origin", "git@github.com:RunStack-AI/example.git") + context = discover_git_context(tmp_path) + assert context.root == str(tmp_path) + assert context.remote == "git@github.com:RunStack-AI/example.git" + + +def test_changed_paths_from_commit(tmp_path: Path): + _run(tmp_path, "init") + _run(tmp_path, "config", "user.email", "test@example.com") + _run(tmp_path, "config", "user.name", "Test") + (tmp_path / "hello.txt").write_text("hello\n", encoding="utf-8") + _run(tmp_path, "add", "hello.txt") + _run(tmp_path, "commit", "-m", "initial") + assert changed_paths(tmp_path) == ["hello.txt"] diff --git a/packages/hypercolab-cli/tests/test_hooks.py b/packages/hypercolab-cli/tests/test_hooks.py new file mode 100644 index 0000000..0bd6e43 --- /dev/null +++ b/packages/hypercolab-cli/tests/test_hooks.py @@ -0,0 +1,53 @@ +from hypercolab_cli import hooks +from hypercolab_cli.hooks import evaluate_client_hook + + +def test_read_only_shell_is_allowed(): + assert ( + evaluate_client_hook( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git status"}} + ) + == {} + ) + + +def test_unknown_writing_shell_command_is_blocked(): + result = evaluate_client_hook( + { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "sed -i '' s/old/new/ src/app.py"}, + } + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_git_push_attempt_and_result_are_structured(monkeypatch): + events = [] + + class _Client: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def log_activity(self, **event): + events.append(event) + + monkeypatch.setattr(hooks, "HypercolabClient", _Client) + before = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git push origin colab"}, + } + after = { + **before, + "hook_event_name": "PostToolUse", + "tool_response": {"exit_code": 0, "output": "raw output must not be logged"}, + } + + assert evaluate_client_hook(before) == {} + assert evaluate_client_hook(after) == {} + assert [event["kind"] for event in events] == ["git.push_attempted", "git.push_completed"] + assert all("output" not in event["metadata"] for event in events) diff --git a/packages/hypercolab-cli/tests/test_offline.py b/packages/hypercolab-cli/tests/test_offline.py new file mode 100644 index 0000000..1f98ca6 --- /dev/null +++ b/packages/hypercolab-cli/tests/test_offline.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import time + +from hypercolab_cli import config, hooks +from hypercolab_cli.config import operations_have_live_cached_leases, remember_check, remember_repository + + +def _configure_paths(monkeypatch, tmp_path): + monkeypatch.setattr(config, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state.json") + monkeypatch.setattr(config, "QUEUE_FILE", tmp_path / "timeline-queue.jsonl") + monkeypatch.setattr(hooks, "QUEUE_FILE", tmp_path / "timeline-queue.jsonl") + + +def test_live_cached_lease_is_honored_only_until_expiry(monkeypatch, tmp_path): + _configure_paths(monkeypatch, tmp_path) + remote = "git@github.com:runstack-ai/example.git" + operation = {"path": "src/auth/token.py", "operation": "modify"} + remember_repository(remote, project={"project_id": "p1"}, session={"session_id": "s1"}) + remember_check( + remote, + [operation], + { + "decisions": [ + { + **operation, + "decision": "allow", + "claim_id": "c1", + "lease_expires_at": time.time() + 60, + } + ] + }, + ) + + assert operations_have_live_cached_leases(remote, [operation]) + assert not operations_have_live_cached_leases(remote, [{"path": "src/billing/invoice.py", "operation": "modify"}]) + + +def test_git_queue_retries_and_removes_delivered_events(monkeypatch, tmp_path): + _configure_paths(monkeypatch, tmp_path) + queued = { + "git": { + "root": str(tmp_path), + "remote": "git@github.com:runstack-ai/example.git", + "branch": "colab", + "worktree": str(tmp_path), + }, + "kind": "git.post_commit", + "summary": "Git post commit on colab", + "source": "git", + "paths": ["src/app.py"], + "commit_refs": ["abc"], + "metadata": {"branch": "colab"}, + } + hooks.queue_event(queued) + + class _Client: + def __init__(self): + self.events = [] + + def log_activity(self, **event): + self.events.append(event) + + client = _Client() + assert hooks.flush_queued_events(client) == 1 + assert len(client.events) == 1 + assert json.loads(json.dumps(client.events[0]["git"].to_dict()))["branch"] == "colab" + assert hooks.QUEUE_FILE.read_text(encoding="utf-8") == "" diff --git a/plugins/hypercolab/.codex-plugin/plugin.json b/plugins/hypercolab/.codex-plugin/plugin.json new file mode 100644 index 0000000..663c291 --- /dev/null +++ b/plugins/hypercolab/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "hypercolab", + "version": "0.1.0", + "description": "Coordinate developers and coding agents with project-scoped memory, timelines, work claims, and collision protection.", + "author": { + "name": "HyperMemory AI", + "email": "hello@runstack.ai", + "url": "https://hypermemory.io" + }, + "homepage": "https://hypermemory.io", + "repository": "https://github.com/hypermemory-ai/hm-plugins-openai", + "license": "MIT", + "keywords": ["hypermemory", "collaboration", "coding-agents", "mcp", "git"], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "HyperColab", + "shortDescription": "Keep coding agents coordinated across a project", + "longDescription": "Give every developer and coding agent the same project graph, chronological development timeline, active work ownership, and deterministic file-level collision protection.", + "developerName": "HyperMemory AI", + "category": "Developer Tools", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://hypermemory.io", + "privacyPolicyURL": "https://hypermemory.io/privacy", + "termsOfServiceURL": "https://hypermemory.io/terms", + "defaultPrompt": [ + "Join this Hypercolab project and show what everyone is working on", + "Claim the files needed for this task before editing", + "Summarize recent project decisions and development activity" + ], + "brandColor": "#2875E5", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png" + } +} diff --git a/plugins/hypercolab/.mcp.json b/plugins/hypercolab/.mcp.json new file mode 100644 index 0000000..18972c6 --- /dev/null +++ b/plugins/hypercolab/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hypercolab": { + "command": "hypercolab", + "args": ["mcp"] + } + } +} diff --git a/plugins/hypercolab/README.md b/plugins/hypercolab/README.md new file mode 100644 index 0000000..5facab9 --- /dev/null +++ b/plugins/hypercolab/README.md @@ -0,0 +1,36 @@ +# HyperColab for ChatGPT and Codex + +HyperColab coordinates developers and coding agents around the Git repository +they are actually working in. It combines a project graph, chronological +development timeline, active ownership, path claims, and file-level collision +protection. + +## Included + +- `.mcp.json` registers the local `hypercolab mcp` stdio shim. +- `skills/hypercolab/` defines join, sync, claim, update, and finish behavior. +- `hooks/hooks.json` loads project context, checks write ownership, and records + structured development activity after explicit Codex trust. +- `scripts/hypercolab_hook.py` provides a safe launcher when the CLI is absent. +- `agents/coordination-writer.md` defines the bounded timeline-writer role used + by the skill. + +## Install + +```bash +pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli" +hypercolab login +codex plugin marketplace add hypermemory-ai/hm-plugins-openai +codex plugin add hypercolab@hypermemory-ai +``` + +Start a new task and review the plugin hooks with `/hooks`. HyperMemory's +personal memory MCP remains a separate plugin and can be installed alongside +HyperColab. + +## Data boundary + +HyperColab sends structured summaries, affected paths, commit identifiers, +claims, and visible rationale to the project service. It does not send raw +source, raw diffs, transcripts, complete shell output, or hidden reasoning by +default. diff --git a/plugins/hypercolab/agents/coordination-writer.md b/plugins/hypercolab/agents/coordination-writer.md new file mode 100644 index 0000000..0114dab --- /dev/null +++ b/plugins/hypercolab/agents/coordination-writer.md @@ -0,0 +1,21 @@ +--- +name: coordination-writer +description: Bounded HyperColab project-timeline role for progress, decisions, tests, and handoffs. +--- + +# Coordination writer + +This is a packaged role contract, not a user-facing skill. The HyperColab skill +may ask the host to spawn one awaited sub-agent when recording project activity +would distract the main implementation agent. + +1. Sync active project context before writing an event. +2. Record only the visible summary, paths, status, tests, commits, and rationale + supplied by the parent. +3. Use update for progress or blockers and activity logging for decisions, + discoveries, tests, commits, and releases. +4. Finish or release work only when the parent explicitly authorizes it. +5. Return a brief status and never spawn another agent. + +Never bypass a path conflict or send credentials, hidden reasoning, raw source, +raw diffs, transcripts, or complete command output. diff --git a/plugins/hypercolab/assets/icon.png b/plugins/hypercolab/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d5462ca623b863912ff93035466580cb09eb04 GIT binary patch literal 3656 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 NSraScazoeHe*;Zsa+Ux9 literal 0 HcmV?d00001 diff --git a/plugins/hypercolab/assets/logo.png b/plugins/hypercolab/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d5462ca623b863912ff93035466580cb09eb04 GIT binary patch literal 3656 zcmdT{`8(9_8h$@BWK4!p$si$hET?QCBF3)5kj9p>C1g#OkeQ(`En-9@lF8E8mn5l( z6esH_`z~3sCd-&HX3l)C>s;q=IOm7=dfxkfp67agd9U}rpZnHUW+sWB+!|!be1}nmX>t8v_Gzz8Qu_G&W<9?5ub+->Ew(?iU9|w!(i|T03JNR z^&tQcg#k$w22@M|XhDER3ulWlRHxY68WxyGfeSj{%?Wvsj_8N}K;RC-tvh3orFAek>5Wx zoVnUEqVFQe@LivO@FORZw{GXR$qDrHxN3W;zuE_K{`&QF4*F#+Yky~evmhld3o@;R zROvG|p%iJYs7<%EiF;&1$(j>sb);$wFGTJ~kTiW7upGe;98+OHRUW6R-6IiF*OrZ@ z8dpNqnSy8_Njo3Dlvn$Jqi0)!c;E)RxhJ|{obBq*j{igOL6DAJs4F678tdL*UlB&k zqbz4F+1Jn1M~e!B%enlY+WyeYaMMDye^pwoUwk*UM7c(gNfcjTe1PGzlT zDUb&H18-A^S0`hT(> zx!=q8ie-BsRHQQpFKAFKO_FZHi%C{i0rG;sT!RY19*diB6CrQ)ql2{`mu;Y)Z9X!% zM{1imPrNnaZca(z44MVLdM(Ny4}^P?u_z;>*ir8f#6OMl?NN_p;$`y^m4~s9zgudE zk9()*v`9(8qz$z@_s$2prt4qf!REg{og9hyz?AT2^&A05?EGb|RC(H3^v_L`4?*a< z14fkVtd5OVvWq;V5Qo*CrLIw9T@-|oF{yl-#`UMIEw_8Nzc(qTq0{>13kzK!m-2f@ZvUa9LP@q=A)E<@M^4heN&F+!R}F480=<%T@K#G z#&j4#ve;NeRGs5sp2+k(^@d~5h@YB!gPVEVVA|{Zt{1+nP=30iEX5mcUz&>|W!C_j zhGQhj8&`1`#&qf)G*x1-^!9w z#`!34)sDUD{bh=z<%UGOeKwVVRAVG!#aH*i5>M11F%z1fKfA!SFB4L}SYZt*RRwks zAI7~ib)?g5$dA_!p(3Z^Ld63Q4&y<0mS@yF4riN7MX1aMTM8{Ee!uz8+a{z z-6rgA%*Kr47Ju(0KZWjhoZIgh@-sv1Yp?c#&08~ly^Pz=H;Xnb4%&8o-(H-#y>64< z8nY9srgSXf>CpmKPTCH4;5c7nfyicWLXEA6NNc4dGwK-qB-+shk;?$_ut?%Z!}~ad>XPRMsyWAMEv+>n ztcdkD%*UP+HL4TNBe(#`xW0!%c&yKdCI{keooV7GJD^0`4x+7G%NW%-P5Tr&A+t34 zZa6^Xb3mt83eK_Hcga)b6)W7ecH&9WG9gr{E<<0aar@(OPOByZZx9Am4yN9y8`ho^ zV;!kJxsn|;26uC$Cst1t1bijW=uezPr$bm?YklE)wytwKu}T2cvsOx=K4a-=;;KyY za_{Q)`|)Pb87I%{dpO3tOfLO@+Q&F-~*f_bZ<}}PFJKcE5bQz+Q3-k z>R~WG{qfO@8D04L#~ftiAw1d$fS3POrjD`b?IT)Q4ePpE)q;R7#aYP{tpT^hCfiSg z(I|cTN0&nIu#Qe(?u_L?kgMN9|My7?k;aXV>Pd@cX|FdBgr03;WQz{Cx{w~|#jW6qh~SDk+e^&4kHRIKp$r`1%yBw+ zOOBcOlPK4e<_Ww<=k&J9X0aKFiyG#@q0WNga*9ta`Yy_*98dBD&+3^fZ?i5jabtg$ zp?}(!M};B7AWF(WM2G#;_X3fL$9jThCAzJelz=>^JtcevO~P(0W0qi(8(;XE#ul?E z2lgBiKu&$CCC5sX09vH71WDZ9V5L1b*Nt=u?Zn&o>pUGUt{kPkMX0cy){pL4l&s#oaR zf?=l#VKsf1medNxIT5q41#czkRtBjQhE2@5K|Hg`&Q`ysWcd__#|C7d*Mul%mBcx> zBbBm8StX*`=dL%9aotx#gVhoM?L1*SK?*jj^Zu`_0IO#nd^3ycy2BjCvfpe*iVzSd z-dtAN)i4sFWf;14udP-W@}vlYM1~J5v1lsX!`xVe=78tV*K^GD1BZ57FSoEx9Iv5Q zSo5k=(X?mSHkqf+V7|ca&+{K)IWOP!?bsjM`mFrLtHvvPK`$mBh8>Bbb7nNpe1pA< zW;w>B>^$Et__jW@OXVCrrJZ363VSbnt!U{M{Ma9|=A&%2QIzW%?&=$PN(815=eYq< zuDF~mf6Q8DY*(CoA^HH_6{6!;`%Amv0;*Kt(K~J1q)i3-$iU)sck%J96$jBUe@980 z6CSlRQl+_ot2Da=&3opp)mDt`Cw6)pCcnV$Y#eT7oj|se@64L7%kI)DCNhiR)B+V`eR91|Nh?1Qm=99uCf>mb1x>Go!ToP7 zg_v`{oder$7=WYZsRUL3hiz*w4~g<%-QDXM=If9J^5tD%es7D>8p$!Zq4&I6{3};! zU$jhAbn0a*CSBhTJIlq^8l@TZ$M%Sr*J$vlB$9nVoEV2T=Y!By#cR~TWJHWQmoE;` zKUXwIl$1g1bM3*fLu%x7#YLIq@2%>=hQQb~>vr(Sl@j#e`rPWED&~cEAWyI@A)a}+ zlsu$3xYEb&x+5jP_c;bB4KopnU_~XI+hU!mPNe1w=HTz3ZlvN zQS;_Evr38~$p}+orNE$vBArYKtsU%7h$|AO1<$7q^Gr3}pQ?-qF~P2)!I|9n3UY|r z_c5_(Ya`Bvr(`T896{n*zHm_k+9e+B6Rzb;ke)}uT=~^YDxUl?^mix6Pe|d>^YDTw zU$@G@)_0^XVIXN8NStyZKNz~+YN@MgJ-LvhWRAyJtXz?5(Of&Nd=OV^sm|2$>6`4x zex-;Rd0SkF^D11g z&U2(D(cekrQ~Rqq(vwFu?GLPJfve7Aj(lJ;DMtAJY!&&>;#9?fZ<(YYKNPT_&t3Gu NSraScazoeHe*;Zsa+Ux9 literal 0 HcmV?d00001 diff --git a/plugins/hypercolab/hooks/hooks.json b/plugins/hypercolab/hooks/hooks.json new file mode 100644 index 0000000..53ef006 --- /dev/null +++ b/plugins/hypercolab/hooks/hooks.json @@ -0,0 +1,53 @@ +{ + "description": "Load Hypercolab context, protect claimed paths, and record structured development activity.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py", + "statusMessage": "Loading Hypercolab project context", + "additionalContextLimit": 12000 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|exec_command|apply_patch|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py", + "statusMessage": "Checking Hypercolab ownership", + "timeout": 30 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|exec_command|apply_patch|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugins/hypercolab/scripts/hypercolab_hook.py b/plugins/hypercolab/scripts/hypercolab_hook.py new file mode 100755 index 0000000..78f67f3 --- /dev/null +++ b/plugins/hypercolab/scripts/hypercolab_hook.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Bridge Codex lifecycle events to the installed HyperColab CLI. + +The plugin keeps the MCP shim and hook implementation in one CLI package. This +launcher makes hook behavior graceful when the CLI has not been installed yet: +session start explains the missing prerequisite, while write operations remain +unblocked rather than making an unconfigured repository unusable. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def _payload(raw: bytes) -> dict[str, Any]: + try: + value = json.loads(raw or b"{}") + except json.JSONDecodeError: + return {} + return value if isinstance(value, dict) else {} + + +def _binary() -> str | None: + configured = os.environ.get("HYPERCOLAB_BIN") + if configured: + candidate = Path(configured).expanduser() + if candidate.is_file(): + return str(candidate) + return shutil.which("hypercolab") + + +def _missing_cli_context(payload: dict[str, Any]) -> None: + event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "") + if event != "SessionStart": + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ( + "HyperColab is installed, but its local CLI/MCP shim is missing. " + "Install the bundled package from this marketplace repository, run " + "`hypercolab login`, and start a new task before relying on project " + "claims or timeline coordination." + ), + } + } + ) + ) + + +def main() -> int: + raw = sys.stdin.buffer.read() + binary = _binary() + if binary is None: + _missing_cli_context(_payload(raw)) + print("HyperColab CLI not found; lifecycle coordination is inactive.", file=sys.stderr) + return 0 + completed = subprocess.run([binary, "hook"], input=raw, check=False) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/hypercolab/skills/hypercolab/SKILL.md b/plugins/hypercolab/skills/hypercolab/SKILL.md new file mode 100644 index 0000000..60fd96a --- /dev/null +++ b/plugins/hypercolab/skills/hypercolab/SKILL.md @@ -0,0 +1,28 @@ +--- +name: hypercolab +description: Coordinate work in a Hypercolab-enabled Git repository. Join and sync before planning, claim paths before edits, respect other agents' ownership, and record meaningful project activity. +--- + +# Hypercolab + +Use this skill whenever the current Git repository resolves to a Hypercolab project. + +1. Call `colab_join` at the start of a coding session, then read the returned brief. +2. Call `colab_sync` before accepting or decomposing work. +3. Call `colab_claim` for planned directories or file sets. File edits may auto-claim a free file through the hook. +4. Treat `do_not_touch` paths as blocked. Coordinate or request a handoff instead of bypassing a conflict. +5. Call `colab_update` when the plan, rationale, scope, status, or blocker changes. +6. Use `colab_log_activity` for architectural choices, rejected alternatives, discoveries, test results, or release activity. +7. Call `colab_finish` before stopping completed, released, abandoned, or handed-off work. + +For work where timeline maintenance would distract the implementation agent, +delegate one bounded coordination writer and wait for it. Follow +[references/coordination-agent.md](references/coordination-agent.md). Keep +`colab_join`, `colab_sync`, and path claims on the main agent because their +results directly affect planning and write safety. + +Never put hidden reasoning, secrets, raw source contents, or full command output in timeline summaries. + +If the `hypercolab` executable is missing, tell the developer to install the +bundled CLI/MCP shim from the marketplace repository and run `hypercolab login`. +Repositories that are not registered with HyperColab remain unaffected. diff --git a/plugins/hypercolab/skills/hypercolab/agents/openai.yaml b/plugins/hypercolab/skills/hypercolab/agents/openai.yaml new file mode 100644 index 0000000..4aab1ab --- /dev/null +++ b/plugins/hypercolab/skills/hypercolab/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "HyperColab" + short_description: "Coordinate coding work across one repository" + default_prompt: "Use $hypercolab to join this project, sync active work, and claim the files needed for my task." + +dependencies: + tools: + - type: "mcp" + value: "hypercolab" + description: "Local project-aware HyperColab MCP shim" + transport: "stdio" + +policy: + allow_implicit_invocation: true diff --git a/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md b/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md new file mode 100644 index 0000000..c685300 --- /dev/null +++ b/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md @@ -0,0 +1,19 @@ +# Coordination agent contract + +Use one bounded coordination sub-agent when project activity must be recorded +without distracting the main implementation agent. The parent supplies the +visible plan, affected repository-relative paths, current status, test results, +and any public rationale summary. + +The coordination agent: + +1. Calls `colab_sync` to refresh active work and ownership. +2. Uses `colab_update` for material progress, scope, status, or blocker changes. +3. Uses `colab_log_activity` for decisions, discoveries, test outcomes, commits, + or release activity that belongs in the project timeline. +4. Calls `colab_finish` only when the parent explicitly says the work is + completed, released, abandoned, or handed off. +5. Returns a short status and never delegates again. + +It never sends secrets, hidden reasoning, raw source, raw diffs, transcripts, +or complete command output to HyperColab. diff --git a/plugins/hypermemory/.codex-plugin/plugin.json b/plugins/hypermemory/.codex-plugin/plugin.json new file mode 100644 index 0000000..c2188f1 --- /dev/null +++ b/plugins/hypermemory/.codex-plugin/plugin.json @@ -0,0 +1,47 @@ +{ + "name": "hypermemory", + "version": "2.1.0", + "description": "Always-on, relationship-aware memory for ChatGPT and Codex with OAuth MCP access, delegated persistence, and token telemetry.", + "author": { + "name": "HyperMemory AI", + "email": "hello@runstack.ai", + "url": "https://hypermemory.io" + }, + "homepage": "https://hypermemory.io", + "repository": "https://github.com/hypermemory-ai/hm-plugins-openai", + "license": "MIT", + "keywords": [ + "memory", + "knowledge-graph", + "mcp", + "oauth", + "chatgpt", + "codex" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "HyperMemory", + "shortDescription": "Durable memory for every ChatGPT and Codex turn", + "longDescription": "Recall relevant context at the start of work, then delegate durable memory writes, timeline updates, and honest token telemetry to a memory-writer sub-agent. Codex adds lifecycle hooks and exact local session-token deltas; ChatGPT uses uncertainty-labelled estimates.", + "developerName": "HyperMemory AI", + "category": "Memory & Knowledge", + "capabilities": [ + "Read", + "Write", + "OAuth", + "Subagents" + ], + "websiteURL": "https://hypermemory.io", + "privacyPolicyURL": "https://hypermemory.io/privacy", + "termsOfServiceURL": "https://hypermemory.io/terms", + "defaultPrompt": [ + "Recall the context for this work and keep memory current.", + "What do you remember about this project?", + "Remember this decision and connect it to the project." + ], + "brandColor": "#2875E5", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png" + } +} diff --git a/plugins/hypermemory/.mcp.json b/plugins/hypermemory/.mcp.json new file mode 100644 index 0000000..ff592e5 --- /dev/null +++ b/plugins/hypermemory/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hypermemory": { + "type": "http", + "url": "https://stage.hypermemory.io/mcp" + } + } +} diff --git a/plugins/hypermemory/README.md b/plugins/hypermemory/README.md new file mode 100644 index 0000000..cdf3c68 --- /dev/null +++ b/plugins/hypermemory/README.md @@ -0,0 +1,50 @@ +# HyperMemory plugin for ChatGPT and Codex + +This universal plugin bundles one ChatGPT/Codex skill, the OAuth-protected +HyperMemory MCP server, and Codex lifecycle enforcement. The shared skill has +separate token-reporting branches for ChatGPT and Codex. + +## Behavior + +- Main agent: overview and recall so memory can inform the response. +- Memory-writer sub-agent: store, update, forget, timeline write, and one token + report before the final response. +- Codex: trusted hooks enforce the lifecycle and read exact cumulative token + counters from the active rollout JSONL using a two-phase inspect/ack helper. +- ChatGPT: reports an uncertainty-labelled workload estimate because consumer + ChatGPT does not expose a stable local exact-usage file to plugins. + +The token listener parses only `token_count` records. It does not return or +upload prompts, model responses, tool arguments, or tool results. + +Codex cannot observe tokens produced after the final tool call of a turn. The +listener carries that exact tail into the next successful report. If a session +never receives another turn, its final tail remains unreported; the plugin does +not falsely label a guess as client-exact. + +## Install from the public Git marketplace + +Install directly from GitHub: + +```bash +codex plugin marketplace add hypermemory-ai/hm-plugins-openai +codex plugin add hypermemory@hypermemory-ai +``` + +Restart the ChatGPT desktop app or start a new Codex session. Complete the +OAuth sign-in when prompted. In Codex CLI, open `/hooks`, review the bundled +hook definition, and trust it; Codex does not run non-managed plugin hooks until +the user explicitly trusts their current hash. + +ChatGPT web local testing requires registering +`https://stage.hypermemory.io/mcp` in developer mode. A public directory +submission should use the **With MCP** flow and submit this MCP server directly; +it does not require a checked-in `.app.json`. + +## Validate + +```bash +python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypermemory +python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypermemory/skills/hypermemory +pytest -q tests/test_hypermemory_plugin.py +``` diff --git a/plugins/hypermemory/agents/memory-writer.md b/plugins/hypermemory/agents/memory-writer.md new file mode 100644 index 0000000..2ef1890 --- /dev/null +++ b/plugins/hypermemory/agents/memory-writer.md @@ -0,0 +1,22 @@ +--- +name: memory-writer +description: Bounded HyperMemory persistence, timeline, and token-reporting role used after every turn. +--- + +# Memory writer + +This is a packaged role contract, not a user-facing skill. The HyperMemory +skill asks the host to spawn one awaited sub-agent with this role after the main +agent has completed the requested work. + +1. Recall related nodes before changing the graph. +2. Store durable new knowledge or update the existing canonical node. +3. Give every new node at least one specific relationship. +4. Write exactly one concise timeline entry. +5. Report token usage exactly once. Use the Codex listener's exact payload when + available; otherwise use an honest estimate with uncertainty. +6. Acknowledge a Codex listener claim only after the MCP accepts the report. +7. Return a brief status to the parent and never spawn another agent. + +Never store credentials, hidden reasoning, raw transcripts, complete command +output, tool payloads, or large code bodies. diff --git a/plugins/hypermemory/assets/icon.png b/plugins/hypermemory/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d512a1a6561f275ddaa5241fed7c52727eb4252f GIT binary patch literal 3631 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 c|2t$M2 dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"cannot read JSON {path}: {exc}") from exc + if not isinstance(value, dict): + raise TypeError(f"JSON root must be an object: {path}") + return value + + +def _atomic_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + + +@contextmanager +def _state_lock(state_file: Path, timeout: float = 5.0) -> Iterator[None]: + """Serialize state updates across concurrent Codex sessions/sub-agents.""" + lock_path = state_file.with_suffix(state_file.suffix + ".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + timeout + descriptor: int | None = None + while descriptor is None: + try: + descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(descriptor, f"{os.getpid()}\n".encode()) + except FileExistsError: + try: + stale = time.time() - lock_path.stat().st_mtime > 30 + if stale: + lock_path.unlink(missing_ok=True) + continue + except FileNotFoundError: + continue + if time.monotonic() >= deadline: + raise RuntimeError( + f"timed out waiting for token state lock: {lock_path}" + ) from None + time.sleep(0.05) + try: + yield + finally: + os.close(descriptor) + lock_path.unlink(missing_ok=True) + + +def _state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"version": 1, "sessions": {}} + value = _read_json(path) + if value.get("version") != 1 or not isinstance(value.get("sessions"), dict): + raise RuntimeError(f"unsupported token state format: {path}") + return value + + +def _reverse_lines(path: Path, chunk_size: int = 65536) -> Iterator[str]: + with path.open("rb") as handle: + handle.seek(0, os.SEEK_END) + position = handle.tell() + remainder = b"" + while position > 0: + size = min(chunk_size, position) + position -= size + handle.seek(position) + block = handle.read(size) + remainder + lines = block.split(b"\n") + remainder = lines[0] + for line in reversed(lines[1:]): + if line: + yield line.decode("utf-8", errors="replace") + if remainder: + yield remainder.decode("utf-8", errors="replace") + + +def _latest_counter(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + for line in _reverse_lines(path): + if '"token_count"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + continue + info = payload.get("info") or {} + usage = info.get("total_token_usage") or {} + if not usage: + continue + return { + "timestamp": str(event.get("timestamp") or ""), + "model": str(info.get("model") or ""), + "counters": {field: int(usage.get(field) or 0) for field in COUNTER_FIELDS}, + } + return None + + +def _rollout_files() -> list[Path]: + codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) + matches: list[Path] = [] + for directory in (codex_home / "sessions", codex_home / "archived_sessions"): + if directory.exists(): + matches.extend(directory.rglob("rollout-*.jsonl")) + return matches + + +def _session_metadata(path: Path) -> tuple[str, str] | None: + """Return physical and logical session ids without reading chat content.""" + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + for _ in range(32): + line = handle.readline() + if not line: + break + if '"session_meta"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "session_meta": + continue + payload = event.get("payload") or {} + physical = str(payload.get("id") or path.stem) + logical = str(payload.get("session_id") or physical) + return physical, logical + except OSError: + return None + return None + + +def _session_rollouts(job: dict[str, Any]) -> list[Path]: + session_id = str(job.get("session_id") or "") + explicit = job.get("transcript_path") + candidates = _rollout_files() + if explicit and Path(str(explicit)).is_file(): + explicit_path = Path(str(explicit)).resolve() + if explicit_path not in candidates: + candidates.append(explicit_path) + + matches: list[Path] = [] + for path in candidates: + metadata = _session_metadata(path) + if metadata and session_id in metadata: + matches.append(path.resolve()) + if not matches: + raise RuntimeError(f"no Codex rollout transcripts found for session {session_id!r}") + return sorted(set(matches)) + + +def _delta(current: dict[str, int], previous: dict[str, int]) -> dict[str, int]: + result = {field: current[field] - int(previous.get(field) or 0) for field in COUNTER_FIELDS} + if any(value < 0 for value in result.values()): + raise RuntimeError("Codex token counters moved backwards; create a new baseline") + return result + + +def baseline(job_path: Path) -> int: + job = _read_json(job_path) + state_file = Path(str(job["state_file"])) + session_id = str(job.get("session_id") or "unknown-session") + rollouts: dict[str, dict[str, int]] = {} + transcript = job.get("transcript_path") + try: + paths = _session_rollouts(job) + except RuntimeError: + paths = [Path(str(transcript)).resolve()] if transcript and Path(str(transcript)).is_file() else [] + for path in paths: + latest = _latest_counter(path) + rollouts[str(path)] = (latest or {"counters": ZERO_COUNTERS})["counters"] + with _state_lock(state_file): + state = _state(state_file) + existing = state["sessions"].get(session_id) + if existing: + print(json.dumps({"baselined": False, "reason": "session_already_tracked", "session_id": session_id})) + return 0 + state["sessions"][session_id] = { + "rollouts": rollouts, + "turn_sequence": 0, + "transcript_path": transcript, + } + _atomic_json(state_file, state) + print(json.dumps({"baselined": True, "session_id": session_id})) + return 0 + + +def inspect(job_path: Path, wait_seconds: float) -> int: + job = _read_json(job_path) + state_file = Path(str(job["state_file"])) + session_id = str(job.get("session_id") or "unknown-session") + with _state_lock(state_file): + state = _state(state_file) + previous_entry = dict(state["sessions"].get(session_id) or {}) + previous_rollouts = previous_entry.get("rollouts") or {} + + deadline = time.monotonic() + max(0.0, wait_seconds) + paths = _session_rollouts(job) + + def collect() -> tuple[dict[str, dict[str, int]], dict[str, int], str]: + current_rollouts: dict[str, dict[str, int]] = {} + combined = ZERO_COUNTERS.copy() + discovered_model = "" + for path in paths: + latest = _latest_counter(path) + current = (latest or {"counters": ZERO_COUNTERS})["counters"] + current_rollouts[str(path)] = current + physical_delta = _delta(current, previous_rollouts.get(str(path)) or ZERO_COUNTERS) + for field in COUNTER_FIELDS: + combined[field] += physical_delta[field] + if latest and latest.get("model"): + discovered_model = str(latest["model"]) + return current_rollouts, combined, discovered_model + + current_rollouts, delta, discovered_model = collect() + while delta["total_tokens"] == 0 and time.monotonic() < deadline: + time.sleep(0.1) + paths = _session_rollouts(job) + current_rollouts, delta, discovered_model = collect() + + if delta["total_tokens"] == 0: + print( + json.dumps( + { + "exact_available": False, + "reason": "no_new_token_count_record", + "session_id": session_id, + "fallback_turn_sequence": int(previous_entry.get("turn_sequence") or 0) + 1, + "fallback": "Submit one self_estimated hm_tokens report with uncertainty.", + }, + indent=2, + ) + ) + return 0 + + turn_sequence = int(previous_entry.get("turn_sequence") or 0) + 1 + model = str(job.get("model") or discovered_model or "unknown") + report = { + "ai_tool": "codex", + "provider": "openai", + "model": model, + "session_id": session_id, + "turn_sequence": turn_sequence, + "measurement_quality": "client_exact", + "input_tokens": delta["input_tokens"], + "output_tokens": delta["output_tokens"], + "cache_tokens": delta["cached_input_tokens"], + "reasoning_tokens": delta["reasoning_output_tokens"], + "total_tokens": delta["total_tokens"], + "cost_quality": "unavailable", + "segments": [ + {"category": "memory", "weight": 10}, + {"category": "context", "weight": 90}, + ], + } + claim = { + "version": 1, + "session_id": session_id, + "turn_id": job.get("turn_id"), + "rollouts": current_rollouts, + "turn_sequence": turn_sequence, + } + claim_path = job_path.with_suffix(job_path.suffix + ".claim.json") + _atomic_json(claim_path, claim) + print( + json.dumps( + { + "exact_available": True, + "privacy": "Only token_count counters were parsed; no conversation content is returned or uploaded.", + "claim_path": str(claim_path), + "hm_tokens_payload": report, + }, + indent=2, + ) + ) + return 0 + + +def ack(job_path: Path) -> int: + job = _read_json(job_path) + claim_path = job_path.with_suffix(job_path.suffix + ".claim.json") + claim = _read_json(claim_path) + if claim.get("session_id") != job.get("session_id") or claim.get("turn_id") != job.get("turn_id"): + raise RuntimeError("token claim does not match its job") + state_file = Path(str(job["state_file"])) + session_id = str(claim["session_id"]) + with _state_lock(state_file): + state = _state(state_file) + state["sessions"][session_id] = { + "rollouts": claim["rollouts"], + "turn_sequence": int(claim["turn_sequence"]), + } + _atomic_json(state_file, state) + claim_path.unlink(missing_ok=True) + job_path.unlink(missing_ok=True) + print(json.dumps({"acknowledged": True, "session_id": session_id})) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="codex-token-listener") + subparsers = parser.add_subparsers(dest="command", required=True) + for name in ("baseline", "ack"): + command = subparsers.add_parser(name) + command.add_argument("--job", type=Path, required=True) + inspect_parser = subparsers.add_parser("inspect") + inspect_parser.add_argument("--job", type=Path, required=True) + inspect_parser.add_argument("--wait-seconds", type=float, default=2.0) + args = parser.parse_args() + try: + if args.command == "baseline": + return baseline(args.job) + if args.command == "inspect": + return inspect(args.job, args.wait_seconds) + return ack(args.job) + except (KeyError, OSError, RuntimeError, TypeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/hypermemory/scripts/hypermemory_hook.py b/plugins/hypermemory/scripts/hypermemory_hook.py new file mode 100755 index 0000000..16dda3f --- /dev/null +++ b/plugins/hypermemory/scripts/hypermemory_hook.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Codex lifecycle bridge for mandatory HyperMemory behavior. + +The hook injects recall instructions and turns end-of-turn persistence into a +bounded sub-agent job. It does not call the MCP server or read conversation +content from the transcript. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +def _read_input() -> dict[str, Any]: + try: + value = json.load(sys.stdin) + except json.JSONDecodeError as exc: + raise RuntimeError(f"invalid hook JSON: {exc}") from exc + if not isinstance(value, dict): + raise TypeError("hook input must be a JSON object") + return value + + +def _plugin_root() -> Path: + configured = os.environ.get("PLUGIN_ROOT") + return Path(configured).resolve() if configured else Path(__file__).resolve().parents[1] + + +def _plugin_data() -> Path: + configured = os.environ.get("PLUGIN_DATA") + path = ( + Path(configured).resolve() + if configured + else Path(tempfile.gettempdir()) / "hypermemory-plugin-data" + ) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _safe_id(value: object, fallback: str) -> str: + text = str(value or fallback) + return "".join(char if char.isalnum() or char in "-_" else "_" for char in text)[:160] + + +def _atomic_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + + +def _context(event: str, text: str) -> None: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": text, + } + } + ) + ) + + +def session_start(payload: dict[str, Any]) -> int: + session_id = _safe_id(payload.get("session_id"), "unknown-session") + state_file = _plugin_data() / "token-state.json" + baseline = { + "version": 1, + "session_id": session_id, + "transcript_path": payload.get("transcript_path"), + "state_file": str(state_file), + } + baseline_path = _plugin_data() / "jobs" / f"baseline-{session_id}.json" + _atomic_json(baseline_path, baseline) + + # Baseline synchronously so installation never uploads historical usage. + listener = _plugin_root() / "scripts" / "codex_token_listener.py" + if listener.exists(): + import subprocess + + subprocess.run( + [sys.executable, str(listener), "baseline", "--job", str(baseline_path)], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + _context( + "SessionStart", + "HyperMemory is mandatory for this session. Before the first response, " + "call hm_get_overview and hm_recall. On every turn recall on the main " + "agent, then delegate store/update/forget, one timeline entry, and one " + "token report to a memory-writer sub-agent before returning the final response.", + ) + return 0 + + +def user_prompt(_: dict[str, Any]) -> int: + _context( + "UserPromptSubmit", + "For this turn, silently call HyperMemory hm_recall before substantive " + "work. Keep graph writes, hm_timeline_write, and hm_tokens off the main " + "agent: a memory-writer sub-agent must perform them during finalization.", + ) + return 0 + + +def stop(payload: dict[str, Any]) -> int: + if bool(payload.get("stop_hook_active")): + print(json.dumps({"continue": True})) + return 0 + + session_id = _safe_id(payload.get("session_id"), "unknown-session") + turn_id = _safe_id(payload.get("turn_id"), "unknown-turn") + data_dir = _plugin_data() + job_path = data_dir / "jobs" / f"turn-{session_id}-{turn_id}.json" + job = { + "version": 1, + "created_at": datetime.now(UTC).isoformat(), + "session_id": str(payload.get("session_id") or session_id), + "turn_id": str(payload.get("turn_id") or turn_id), + "transcript_path": payload.get("transcript_path"), + "model": str(payload.get("model") or "unknown"), + "state_file": str(data_dir / "token-state.json"), + } + _atomic_json(job_path, job) + + listener = _plugin_root() / "scripts" / "codex_token_listener.py" + reason = ( + "Mandatory HyperMemory finalization before showing the response. Spawn " + "exactly one memory-writer sub-agent and wait for it. Do not perform " + "graph writes or token reporting on the main agent. Give the sub-agent " + "a concise summary of this turn. It must recall first; store or update " + "durable knowledge with specific relationships; write exactly one " + "hm_timeline_write entry; run the local token listener; call hm_tokens " + "exactly once; and acknowledge the listener only after hm_tokens " + "succeeds. Pass these exact local paths:\n" + f"listener={listener}\njob={job_path}\n" + "Listener sequence: python3 inspect --job ; call MCP " + "hm_tokens with hm_tokens_payload from stdout; then python3 " + "ack --job . If exact inspection is unavailable, the sub-agent " + "must send one honest self_estimated report instead." + ) + print(json.dumps({"decision": "block", "reason": reason})) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("event", choices=("session-start", "user-prompt", "stop")) + args = parser.parse_args() + try: + payload = _read_input() + if args.event == "session-start": + return session_start(payload) + if args.event == "user-prompt": + return user_prompt(payload) + return stop(payload) + except (OSError, RuntimeError, TypeError) as exc: + print(f"HyperMemory hook error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/hypermemory/skills/hypermemory/SKILL.md b/plugins/hypermemory/skills/hypermemory/SKILL.md new file mode 100644 index 0000000..33ec351 --- /dev/null +++ b/plugins/hypermemory/skills/hypermemory/SKILL.md @@ -0,0 +1,106 @@ +--- +name: hypermemory +description: >- + Use HyperMemory as mandatory durable memory in every ChatGPT and Codex chat, + task, session, and turn whenever the HyperMemory MCP tools are available. + Recall context before responding, then delegate graph writes, updates, + timeline logging, and token reporting to a memory-writer sub-agent. Apply to + every prompt, including short, conversational, planning, coding, blocked, + and no-change turns. +--- + +# HyperMemory + +Keep recall on the main agent because it informs the answer. Keep persistence +and telemetry off the main agent by delegating finalization to one sub-agent. +Routine memory operations are silent. + +## Start and recall + +On the first response in a conversation: + +1. Call `hm_get_overview`. +2. Call `hm_recall` with terms from the user's request. +3. Hydrate exact relevant keys with `hm_get_nodes` when full details or + relationships are needed. + +On every later user turn, call `hm_recall` before substantive work. Use recalled +information naturally and never re-ask for facts already in memory. + +## Delegate finalization + +Before the final response, spawn exactly one memory-writer sub-agent and wait +for it. Pass a concise summary of the user's request, material actions, +decisions, corrections, durable facts, and relevant project or component keys. +The main agent must not call `hm_store`, `hm_update`, `hm_forget`, +`hm_timeline_write`, or `hm_tokens` when delegation is available. + +Use the bounded role contract in +[references/memory-writer-agent.md](references/memory-writer-agent.md) when +constructing the delegated task. + +Tell the memory-writer sub-agent to: + +1. Call `hm_recall` before any write. +2. Use `hm_store` for new durable knowledge and `hm_update` for changed + knowledge. Avoid duplicates and trivial or transient facts. +3. Give every new node at least one specific relationship. +4. Call `hm_timeline_write` exactly once with a concise turn record. +5. Call `hm_tokens` exactly once. + +When operating as the memory-writer sub-agent, execute these finalization steps +directly and do not spawn another sub-agent. This rule prevents recursive +delegation. + +If the host cannot spawn sub-agents, perform persistence directly as an +explicit degraded fallback so memory is not silently lost. Mention degraded +mode only when the user asks about memory operation. + +## Codex token reporting + +When a Codex lifecycle hook supplies a token-listener job path, the +memory-writer sub-agent must: + +1. Run `codex_token_listener.py inspect --job `. +2. Submit the returned `hm_tokens_payload` exactly once through MCP. +3. Run `codex_token_listener.py ack --job ` only after the MCP call + succeeds. + +The listener reads only `session_meta` and `token_count` records from the +logical Codex session's parent and sub-agent rollout JSONL files. It never +returns or uploads prompts, responses, tool arguments, or tool results. It +reports cumulative-counter deltas as `client_exact`. Tokens written after +inspection roll into the next successful delta rather than being discarded. + +If exact local telemetry is unavailable, submit one honest `self_estimated` +report with uncertainty and no invented cost. Use the listener's +`fallback_turn_sequence` and do not run `ack` because no exact claim exists. + +No in-turn observer can count tokens generated after its last tool call. The +listener therefore preserves the unreported tail and includes it in the next +successful Codex delta. A session with no later turn can retain a final tail; +never mislabel an estimate as exact to hide this host limitation. + +## ChatGPT token reporting + +Consumer ChatGPT does not expose a stable, client-exact per-turn usage file to +plugins. The memory-writer sub-agent must estimate the complete workload across +model invocations, including hidden context and tool continuations. Use +`measurement_quality: self_estimated`, normally +`uncertainty_percentage: 40`, an honest `estimation_bias` (prefer `high` for +tool-heavy turns), and `cost_quality: unavailable`. Never claim +provider-actual usage or cost. + +## Canonical segments + +Use exactly these activity segments because the Rust MCP canonicalizes them: + +```json +[ + {"category": "memory", "weight": 10}, + {"category": "context", "weight": 90} +] +``` + +Read [references/protocol.md](references/protocol.md) when storing nodes, +creating relationships, ingesting dense text, or cleaning graph orphans. diff --git a/plugins/hypermemory/skills/hypermemory/agents/openai.yaml b/plugins/hypermemory/skills/hypermemory/agents/openai.yaml new file mode 100644 index 0000000..2b7c4b2 --- /dev/null +++ b/plugins/hypermemory/skills/hypermemory/agents/openai.yaml @@ -0,0 +1,15 @@ +interface: + display_name: "HyperMemory" + short_description: "Always-on memory for ChatGPT and Codex" + default_prompt: "Use $hypermemory to recall context and maintain memory for this turn." + +dependencies: + tools: + - type: "mcp" + value: "hypermemory" + description: "OAuth-protected HyperMemory graph and token telemetry" + transport: "streamable_http" + url: "https://stage.hypermemory.io/mcp" + +policy: + allow_implicit_invocation: true diff --git a/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md b/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md new file mode 100644 index 0000000..af22af7 --- /dev/null +++ b/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md @@ -0,0 +1,24 @@ +# Memory-writer agent contract + +The parent agent delegates exactly one bounded finalization task and waits for +it before returning the user-facing response. The delegated agent must not +delegate again. + +The parent supplies a concise turn summary, relevant project or component keys, +the host name, model, session identifier, and token-listener job paths when +Codex exposes them. + +The memory-writer agent then: + +1. Recalls related nodes before changing the graph. +2. Stores only durable new knowledge and updates existing nodes instead of + duplicating them. +3. Gives every new node at least one specific relationship. +4. Writes exactly one concise timeline entry. +5. Reports tokens exactly once, using the Codex listener payload when available + and an uncertainty-labelled estimate otherwise. +6. Acknowledges a Codex listener claim only after the MCP accepted the report. + +It never stores credentials, hidden reasoning, raw transcripts, full tool +output, or large code bodies. Its return value is a short operational status +for the parent agent. diff --git a/plugins/hypermemory/skills/hypermemory/references/protocol.md b/plugins/hypermemory/skills/hypermemory/references/protocol.md new file mode 100644 index 0000000..81450d9 --- /dev/null +++ b/plugins/hypermemory/skills/hypermemory/references/protocol.md @@ -0,0 +1,15 @@ +# HyperMemory write protocol + +- Recall before writing. Update an existing node instead of creating a duplicate. +- Use canonical node types: `user`, `person`, `organization`, `component`, + `event`, `decision`, `concept`, `artifact`, `project`, `technology`, + `preference`, `fact`, or `skill`. +- Shape keys as `{type}_{name}`. Use `user_profile` for the primary user. +- Never store passwords, API keys, OAuth tokens, credentials, or large code blobs. +- Give each stored node a specific relationship that explains why it connects + to a project, component, person, organization, or decision. +- Use `hm_ingest` only for dense multi-entity text. Immediately call + `hm_list_orphans`; connect useful orphans with `hm_add_relationships` and + delete unenriched noise with `hm_forget`. +- Use `hm_find_related` for traversal and `hm_get_nodes` to hydrate exact keys. +- Use `hm_upload_file` only when the user explicitly asks to store a file. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93cf6ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "UP"] +ignore = ["B008"] + +[tool.pytest.ini_options] +testpaths = ["tests", "packages/hypercolab-cli/tests"] diff --git a/scripts/build_plugin_archives.py b/scripts/build_plugin_archives.py new file mode 100755 index 0000000..cb3da82 --- /dev/null +++ b/scripts/build_plugin_archives.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Build deterministic OpenAI plugin review archives from marketplace sources.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +ROOT = Path(__file__).resolve().parents[1] +PLUGINS = ROOT / "plugins" +DIST = ROOT / "dist" +EXCLUDED_PARTS = {"__pycache__", ".DS_Store"} + + +def _files(plugin: Path) -> list[Path]: + return sorted( + file + for file in plugin.rglob("*") + if file.is_file() + and not any(part in EXCLUDED_PARTS for part in file.relative_to(plugin).parts) + and file.suffix != ".pyc" + ) + + +def build(plugin_name: str) -> Path: + plugin = PLUGINS / plugin_name + manifest_path = plugin / ".codex-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("name") != plugin_name: + raise ValueError(f"manifest name mismatch for {plugin_name}") + version = str(manifest["version"]) + DIST.mkdir(parents=True, exist_ok=True) + destination = DIST / f"{plugin_name}-{version}.zip" + with ZipFile(destination, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive: + for file in _files(plugin): + relative = file.relative_to(plugin).as_posix() + info = ZipInfo(relative, date_time=(2020, 1, 1, 0, 0, 0)) + info.compress_type = ZIP_DEFLATED + info.external_attr = (0o755 if file.stat().st_mode & 0o111 else 0o644) << 16 + archive.writestr(info, file.read_bytes(), compress_type=ZIP_DEFLATED, compresslevel=9) + return destination + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("plugins", nargs="*", default=["hypermemory", "hypercolab"]) + args = parser.parse_args() + for plugin_name in args.plugins: + print(build(plugin_name).relative_to(ROOT)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_hypercolab_plugin.py b/tests/test_hypercolab_plugin.py new file mode 100644 index 0000000..97e0ef6 --- /dev/null +++ b/tests/test_hypercolab_plugin.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLUGIN = ROOT / "plugins" / "hypercolab" +HOOK = PLUGIN / "scripts" / "hypercolab_hook.py" + + +def test_hypercolab_plugin_registers_local_shim_and_agent() -> None: + manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + mcp = json.loads((PLUGIN / ".mcp.json").read_text(encoding="utf-8")) + hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text(encoding="utf-8")) + + assert manifest["name"] == "hypercolab" + assert manifest["mcpServers"] == "./.mcp.json" + assert mcp == {"mcpServers": {"hypercolab": {"command": "hypercolab", "args": ["mcp"]}}} + assert (PLUGIN / "agents" / "coordination-writer.md").is_file() + assert "exec_command" in hooks["hooks"]["PreToolUse"][0]["matcher"] + assert all( + "${PLUGIN_ROOT}/scripts/hypercolab_hook.py" in handler["command"] + for event in hooks["hooks"].values() + for group in event + for handler in group["hooks"] + ) + + +def test_hook_launcher_explains_missing_cli_without_blocking(tmp_path: Path) -> None: + env = {**os.environ, "PATH": str(tmp_path)} + completed = subprocess.run( + [sys.executable, str(HOOK)], + input=json.dumps({"hook_event_name": "SessionStart"}), + text=True, + capture_output=True, + check=True, + env=env, + ) + output = json.loads(completed.stdout) + assert "CLI/MCP shim is missing" in output["hookSpecificOutput"]["additionalContext"] + assert "lifecycle coordination is inactive" in completed.stderr diff --git a/tests/test_hypermemory_plugin.py b/tests/test_hypermemory_plugin.py new file mode 100644 index 0000000..0e035db --- /dev/null +++ b/tests/test_hypermemory_plugin.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLUGIN = ROOT / "plugins" / "hypermemory" +LISTENER = PLUGIN / "scripts" / "codex_token_listener.py" +HOOK = PLUGIN / "scripts" / "hypermemory_hook.py" + + +def _module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _write_rollout( + path: Path, + *, + physical_id: str, + logical_id: str, + total: int, + input_tokens: int, + output_tokens: int, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": "2026-08-12T10:00:00Z", + "type": "session_meta", + "payload": {"id": physical_id, "session_id": logical_id, "source": "test"}, + }, + {"type": "event_msg", "payload": {"type": "message", "content": "must not be parsed"}}, + { + "timestamp": "2026-08-12T10:01:00Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "model": "gpt-test", + "total_token_usage": { + "input_tokens": input_tokens, + "cached_input_tokens": max(0, input_tokens - 5), + "cache_write_input_tokens": 0, + "output_tokens": output_tokens, + "reasoning_output_tokens": 3, + "total_tokens": total, + }, + }, + }, + }, + ] + path.write_text("\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8") + + +def test_plugin_is_chatgpt_and_codex_only() -> None: + manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text()) + assert manifest["name"] == "hypermemory" + assert manifest["mcpServers"] == "./.mcp.json" + assert "hooks" not in manifest # default hooks/hooks.json is auto-discovered + assert (PLUGIN / "hooks" / "hooks.json").is_file() + assert (PLUGIN / "agents" / "memory-writer.md").is_file() + + +def test_public_marketplace_is_self_contained() -> None: + marketplace = json.loads((ROOT / ".agents" / "plugins" / "marketplace.json").read_text()) + assert marketplace["name"] == "hypermemory-ai" + assert marketplace["plugins"] == [ + { + "name": "hypermemory", + "source": {"source": "local", "path": "./plugins/hypermemory"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Memory & Knowledge", + }, + { + "name": "hypercolab", + "source": {"source": "local", "path": "./plugins/hypercolab"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Developer Tools", + }, + ] + + +def test_mcp_uses_rust_stage_oauth_endpoint() -> None: + config = json.loads((PLUGIN / ".mcp.json").read_text()) + assert config == { + "mcpServers": { + "hypermemory": {"type": "http", "url": "https://stage.hypermemory.io/mcp"} + } + } + + +def test_stop_hook_requires_one_subagent_and_guards_recursion(tmp_path: Path) -> None: + env = {**os.environ, "PLUGIN_ROOT": str(PLUGIN), "PLUGIN_DATA": str(tmp_path)} + base = { + "session_id": "session-1", + "turn_id": "turn-1", + "transcript_path": None, + "model": "gpt-test", + "hook_event_name": "Stop", + } + first = subprocess.run( + [sys.executable, str(HOOK), "stop"], + input=json.dumps({**base, "stop_hook_active": False}), + text=True, + capture_output=True, + check=True, + env=env, + ) + output = json.loads(first.stdout) + assert output["decision"] == "block" + assert "exactly one memory-writer sub-agent" in output["reason"] + assert list((tmp_path / "jobs").glob("turn-*.json")) + + second = subprocess.run( + [sys.executable, str(HOOK), "stop"], + input=json.dumps({**base, "stop_hook_active": True}), + text=True, + capture_output=True, + check=True, + env=env, + ) + assert json.loads(second.stdout) == {"continue": True} + + +def test_listener_aggregates_parent_and_subagent_then_acks(tmp_path: Path, capsys) -> None: + logical = "logical-session" + parent = tmp_path / ".codex" / "sessions" / "rollout-parent.jsonl" + child = tmp_path / ".codex" / "sessions" / "rollout-child.jsonl" + _write_rollout(parent, physical_id="parent", logical_id=logical, total=110, input_tokens=90, output_tokens=20) + + state_file = tmp_path / "plugin-data" / "token-state.json" + baseline_job = tmp_path / "baseline.json" + baseline_job.write_text( + json.dumps( + { + "version": 1, + "session_id": logical, + "transcript_path": str(parent), + "state_file": str(state_file), + } + ), + encoding="utf-8", + ) + listener = _module(LISTENER, "hypermemory_token_listener_test") + old_codex_home = os.environ.get("CODEX_HOME") + os.environ["CODEX_HOME"] = str(tmp_path / ".codex") + try: + assert listener.baseline(baseline_job) == 0 + capsys.readouterr() + + _write_rollout(parent, physical_id="parent", logical_id=logical, total=150, input_tokens=125, output_tokens=25) + _write_rollout(child, physical_id="child", logical_id=logical, total=60, input_tokens=45, output_tokens=15) + job_path = tmp_path / "turn.json" + job_path.write_text( + json.dumps( + { + "version": 1, + "session_id": logical, + "turn_id": "turn-1", + "transcript_path": str(parent), + "model": "gpt-test", + "state_file": str(state_file), + } + ), + encoding="utf-8", + ) + assert listener.inspect(job_path, 0) == 0 + inspected = json.loads(capsys.readouterr().out) + report = inspected["hm_tokens_payload"] + assert inspected["exact_available"] is True + assert report["measurement_quality"] == "client_exact" + assert report["total_tokens"] == 100 + assert report["input_tokens"] == 80 + assert report["output_tokens"] == 20 + + assert listener.ack(job_path) == 0 + capsys.readouterr() + state = json.loads(state_file.read_text()) + assert state["sessions"][logical]["turn_sequence"] == 1 + assert len(state["sessions"][logical]["rollouts"]) == 2 + finally: + if old_codex_home is None: + os.environ.pop("CODEX_HOME", None) + else: + os.environ["CODEX_HOME"] = old_codex_home + + +def test_baseline_does_not_discard_unreported_resume_tail(tmp_path: Path, capsys) -> None: + logical = "resume-session" + rollout = tmp_path / ".codex" / "sessions" / "rollout-resume.jsonl" + _write_rollout(rollout, physical_id="parent", logical_id=logical, total=30, input_tokens=20, output_tokens=10) + state_file = tmp_path / "plugin-data" / "token-state.json" + job = tmp_path / "baseline.json" + job.write_text( + json.dumps( + { + "version": 1, + "session_id": logical, + "transcript_path": str(rollout), + "state_file": str(state_file), + } + ), + encoding="utf-8", + ) + listener = _module(LISTENER, "hypermemory_token_listener_resume_test") + old_codex_home = os.environ.get("CODEX_HOME") + os.environ["CODEX_HOME"] = str(tmp_path / ".codex") + try: + assert listener.baseline(job) == 0 + capsys.readouterr() + _write_rollout(rollout, physical_id="parent", logical_id=logical, total=50, input_tokens=35, output_tokens=15) + assert listener.baseline(job) == 0 + resumed = json.loads(capsys.readouterr().out) + assert resumed["baselined"] is False + + turn_job = tmp_path / "turn.json" + turn_job.write_text( + json.dumps( + { + "version": 1, + "session_id": logical, + "turn_id": "turn-after-resume", + "transcript_path": str(rollout), + "model": "gpt-test", + "state_file": str(state_file), + } + ), + encoding="utf-8", + ) + assert listener.inspect(turn_job, 0) == 0 + inspected = json.loads(capsys.readouterr().out) + assert inspected["hm_tokens_payload"]["total_tokens"] == 20 + finally: + if old_codex_home is None: + os.environ.pop("CODEX_HOME", None) + else: + os.environ["CODEX_HOME"] = old_codex_home diff --git a/tests/test_marketplace.py b/tests/test_marketplace.py new file mode 100644 index 0000000..9042d4f --- /dev/null +++ b/tests/test_marketplace.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import struct +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MARKETPLACE = ROOT / ".agents" / "plugins" / "marketplace.json" + + +def test_marketplace_entries_resolve_to_complete_plugins() -> None: + catalog = json.loads(MARKETPLACE.read_text(encoding="utf-8")) + assert catalog["name"] == "hypermemory-ai" + assert [entry["name"] for entry in catalog["plugins"]] == ["hypermemory", "hypercolab"] + + for entry in catalog["plugins"]: + relative = entry["source"]["path"].removeprefix("./") + plugin = ROOT / relative + manifest = json.loads((plugin / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + assert plugin.name == entry["name"] == manifest["name"] + assert manifest["repository"] == "https://github.com/hypermemory-ai/hm-plugins-openai" + assert (plugin / manifest["mcpServers"].removeprefix("./")).is_file() + assert (plugin / "hooks" / "hooks.json").is_file() + assert (plugin / "skills" / entry["name"] / "SKILL.md").is_file() + assert (plugin / "agents").is_dir() + assert entry["policy"] == {"installation": "AVAILABLE", "authentication": "ON_INSTALL"} + + +def test_brand_assets_are_valid_square_pngs() -> None: + for plugin_name in ("hypermemory", "hypercolab"): + plugin = ROOT / "plugins" / plugin_name + manifest = json.loads((plugin / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + for field in ("composerIcon", "logo"): + path = plugin / manifest["interface"][field].removeprefix("./") + data = path.read_bytes() + assert data[:8] == b"\x89PNG\r\n\x1a\n" + assert data[12:16] == b"IHDR" + assert struct.unpack(">II", data[16:24]) == (256, 256) From 597b3af85457f9646874c6d0abdf225525a84671 Mon Sep 17 00:00:00 2001 From: kenblaue Date: Wed, 12 Aug 2026 20:46:28 +0200 Subject: [PATCH 2/3] Update GitHub Actions runtime pins --- .github/workflows/validate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1e444a3..c6c0685 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,8 +13,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install validation dependencies From 245f12dc2249f21694f6266ec1ffa278166dda6e Mon Sep 17 00:00:00 2001 From: kenblaue Date: Wed, 12 Aug 2026 23:34:46 +0200 Subject: [PATCH 3/3] Expand marketplace README --- README.md | 832 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 750 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 77b91c1..9e987cf 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,814 @@

- HyperMemory + HyperMemory logo      - HyperColab + HyperColab logo

-

HyperMemory AI plugins for ChatGPT & Codex

+

HyperMemory AI plugins for ChatGPT and Codex

- One public marketplace. Two focused plugins. Durable memory for every turn, - and coordinated development for every repository. + Durable, relationship-aware memory for every conversation.
+ Shared project context and collision-safe coordination for every repository.

Validation MIT License + Python 3.10+ + OpenAI only

-## The marketplace +> [!IMPORTANT] +> This repository is the Git-backed development marketplace for ChatGPT and +> Codex. HyperMemory currently connects to the Rust staging MCP at +> `https://stage.hypermemory.io/mcp`. Public, one-click installation for normal +> ChatGPT and Codex users requires separate publication of each plugin through +> OpenAI's universal Plugins Directory. + +## Contents + +- [What this repository provides](#what-this-repository-provides) +- [Why two plugins?](#why-two-plugins) +- [Capability matrix](#capability-matrix) +- [Supported surfaces](#supported-surfaces) +- [Quick start](#quick-start) +- [HyperMemory](#hypermemory) +- [HyperColab](#hypercolab) +- [Combined architecture](#combined-architecture) +- [Repository layout](#repository-layout) +- [Authentication and secrets](#authentication-and-secrets) +- [Updating](#updating) +- [Removing](#removing) +- [Development](#development) +- [Release and publication model](#release-and-publication-model) +- [Troubleshooting](#troubleshooting) +- [Frequently asked questions](#frequently-asked-questions) +- [Documentation](#documentation) +- [Support and security](#support-and-security) + +## What this repository provides + +This repository is one plugin marketplace containing two independently +installable OpenAI plugins: + +| Plugin | Package ID | Current version | Purpose | +| --- | --- | ---: | --- | +| **HyperMemory** | `hypermemory@hypermemory-ai` | `2.1.0` | Persistent personal and project memory, relationship-aware recall, delegated writes, timeline logging, and token telemetry | +| **HyperColab** | `hypercolab@hypermemory-ai` | `0.1.0` | Shared project context, work ownership, path claims, project timelines, graph search, and multi-agent collision prevention | + +The marketplace is named `hypermemory-ai`. A marketplace is a catalog and +source of plugins; registering it does **not** install either plugin. Users add +the marketplace once, then choose HyperMemory, HyperColab, or both. -| Plugin | What it adds | Best for | -| --- | --- | --- | -| **HyperMemory** | OAuth MCP, relationship-aware recall, delegated memory writing, lifecycle hooks, and privacy-preserving token telemetry | Keeping personal and project context available across chats and sessions | -| **HyperColab** | Project-aware MCP shim, shared graph and timeline, work claims, collision-prevention hooks, Git activity capture, and coordination-agent guidance | Coordinating developers and coding agents working in the same repository | +```text +GitHub repository Marketplace Installable plugins +hypermemory-ai/hm-plugins-openai -> hypermemory-ai -> hypermemory + -> hypercolab +``` + +## Why two plugins? + +HyperMemory and HyperColab share a graph-oriented foundation, but they solve +different problems and have different runtime boundaries: + +- **HyperMemory follows a person or agent across conversations.** It recalls + durable context before work begins and maintains that context after each + turn. +- **HyperColab follows a Git project.** It resolves the active repository, + coordinates concurrent developers and coding agents, protects claimed paths, + and records a structured development timeline. + +Keeping them separate lets a user install durable memory without repository +coordination, add coordination only where needed, or run both together. -The two plugins are independent. Install either one or both from the same -`hypermemory-ai` marketplace. +## Capability matrix + +| Capability | HyperMemory | HyperColab | +| --- | :---: | :---: | +| Hosted OAuth MCP | Yes | No | +| Local stdio MCP shim | No | Yes | +| Bundled skill | Yes | Yes | +| Codex lifecycle hooks | Yes | Yes | +| Packaged sub-agent role contract | Memory writer | Coordination writer | +| Relationship-aware graph | Personal and cross-session | Project-scoped | +| Chronological timeline | Conversation and decision timeline | Development activity timeline | +| Exact local Codex token deltas | Yes | No | +| Path claims and collision protection | No | Yes | +| Git event capture | No | Optional | +| Works without the other plugin | Yes | Yes | + +## Supported surfaces + +| Surface | HyperMemory | HyperColab | +| --- | --- | --- | +| Codex CLI | Full local behavior after MCP authorization and hook trust | Full behavior after CLI installation, login, plugin installation, and hook trust | +| Codex in the ChatGPT desktop app | Supported where local plugins, MCP, hooks, and sub-agents are available | Supported when the local environment can launch `hypercolab` and access the repository | +| ChatGPT developer/workspace testing | Hosted MCP and skill can be tested | Skill can be tested; repository coordination requires a local process and Git checkout | +| Public Plugins Directory | Requires publication through OpenAI's submission flow | Requires a publication design compatible with the target surface's MCP transport | +| ChatGPT web without a local coding environment | Hosted HyperMemory MCP can work after publication | Local Git claims and activity capture are unavailable | ## Quick start -Add this repository as a plugin source once: +### Prerequisites + +- A current Codex CLI or Codex-enabled ChatGPT desktop app +- A HyperMemory account +- Git +- Python 3.10 or newer and [`pipx`](https://pipx.pypa.io/) for HyperColab + +### 1. Register the marketplace + +Run this once: ```bash codex plugin marketplace add hypermemory-ai/hm-plugins-openai ``` -Install HyperMemory: +Confirm Codex can see it: + +```bash +codex plugin marketplace list +``` + +### 2. Install HyperMemory ```bash codex plugin add hypermemory@hypermemory-ai ``` -Install HyperColab's local CLI/MCP shim, then its plugin: +Complete the HyperMemory OAuth flow when prompted, then start a new task. + +### 3. Install HyperColab + +HyperColab needs its local CLI/MCP shim before Codex loads the plugin: ```bash pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli" hypercolab login +hypercolab doctor codex plugin add hypercolab@hypermemory-ai ``` -Finally, start a new task. In Codex CLI, run `/hooks`, review the bundled hook -definitions, and trust the ones you want active. Codex intentionally does not -run new or changed non-managed hooks until you approve their exact definition. +Start a new task inside a Git repository that is enrolled in HyperColab. + +### 4. Review and trust hooks + +In Codex CLI, run: + +```text +/hooks +``` + +Review each plugin's hook definition and trust the hooks you want to run. Codex +does not automatically trust non-managed plugin hooks. Trust is tied to the +exact hook definition, so changed hooks require review again after an update. -See [Installation](docs/INSTALLATION.md) for OAuth, verification, updates, -ChatGPT surface notes, and troubleshooting. +### 5. Verify the installation -## How the pieces fit +```bash +codex plugin list +hypercolab status +``` + +Try these prompts in a new task: + +```text +What do you remember about this project? +``` + +```text +Join this HyperColab project, sync active work, and claim the files needed for my task. +``` + +For a step-by-step guide, see [Installation](docs/INSTALLATION.md). + +## HyperMemory + +HyperMemory adds durable, relationship-aware memory to ChatGPT and Codex. It is +designed to recall the right context before a response and preserve important +knowledge after the requested work is complete. + +### Included components + +| Component | Path | Responsibility | +| --- | --- | --- | +| Plugin manifest | `plugins/hypermemory/.codex-plugin/plugin.json` | Identity, version, discovery metadata, branding, skill path, and MCP declaration | +| MCP configuration | `plugins/hypermemory/.mcp.json` | Connects to the hosted Rust staging MCP over HTTP | +| Skill | `plugins/hypermemory/skills/hypermemory/` | Defines recall, graph hygiene, delegation, and telemetry behavior | +| Lifecycle hooks | `plugins/hypermemory/hooks/hooks.json` | Reinforces recall at session/prompt boundaries and finalization at stop | +| Memory-writer role | `plugins/hypermemory/agents/memory-writer.md` | Bounded contract for delegated storage, timeline, and telemetry work | +| Hook bridge | `plugins/hypermemory/scripts/hypermemory_hook.py` | Creates lifecycle context and bounded finalization jobs | +| Token listener | `plugins/hypermemory/scripts/codex_token_listener.py` | Reads exact local Codex token-counter deltas without reading chat content | + +### Turn lifecycle + +```mermaid +sequenceDiagram + participant U as User + participant M as Main agent + participant MCP as HyperMemory MCP + participant W as Memory-writer sub-agent + participant L as Codex token listener + + U->>M: Submit a prompt + M->>MCP: Overview and relevant recall + MCP-->>M: Relationship-aware context + M->>M: Complete the requested work + M->>W: Delegate a concise finalization summary + W->>MCP: Recall before writing + W->>MCP: Store or update durable knowledge + W->>MCP: Write one timeline entry + W->>L: Inspect token-counter delta + L-->>W: Exact payload or fallback instruction + W->>MCP: Report tokens once + W->>L: Acknowledge accepted exact claim + W-->>M: Return brief status + M-->>U: Return final response +``` + +The main agent performs recall because remembered context must be available +while reasoning about the user's request. Persistence and telemetry are moved +to one awaited memory-writer sub-agent to keep the main context focused. The +role contract prevents recursive delegation. + +### Memory operations + +The skill uses the HyperMemory MCP for: + +- graph overview and relevant recall; +- exact-node hydration and relationship traversal; +- durable storage and correction of existing knowledge; +- graph relationships and orphan cleanup; +- chronological timeline entries; +- user-requested file storage; and +- per-turn token telemetry. + +The hosted MCP currently exposes these tool families: + +| Area | Tools | +| --- | --- | +| Recall and context | `hm_get_overview`, `hm_recall`, `hm_get_nodes`, `hm_get_chat_context`, `hm_find_related` | +| Graph writes and hygiene | `hm_store`, `hm_update`, `hm_forget`, `hm_add_relationships`, `hm_ingest`, `hm_list_orphans` | +| Timeline | `hm_timeline`, `hm_timeline_write` | +| Files | `hm_upload_file`, `hm_list_files` | +| Skill distribution | `hm_skill` | +| Telemetry | `hm_tokens` | + +Writes follow canonical node types and stable keys. The writer recalls before +changing the graph, updates existing nodes instead of duplicating them, and +gives each new node a specific relationship. File upload is used only when the +user explicitly asks to store a file. + +### OAuth and credentials + +The plugin connects to: + +```text +https://stage.hypermemory.io/mcp +``` + +The server supports authorization-code OAuth, PKCE S256, refresh tokens, and +dynamic client registration. The plugin package contains the server URL only; +it does not contain or require a checked-in API key. + +### Codex token telemetry + +Codex persists cumulative usage counters in local rollout JSONL files. The +listener reads only `session_meta` and `token_count` records from the logical +session's parent and memory-writer rollouts. It does not return or upload: + +- prompts or model responses; +- tool arguments or tool results; +- source code or file contents; or +- complete transcripts. + +Reporting uses a two-phase inspect/ack protocol: + +1. Inspect computes the delta since the last acknowledged checkpoint. +2. The memory-writer sends that payload to `hm_tokens` exactly once. +3. Ack advances the checkpoint only after the MCP accepts the report. + +If reporting fails, the checkpoint does not advance and usage remains eligible +for a later retry. Tokens generated after the final inspection are carried into +the next successful report. If the session has no later turn, that final tail +can remain unreported; the plugin never labels a guess as client-exact to hide +this host limitation. + +Consumer ChatGPT does not expose Codex's local rollout counters. On that +surface, HyperMemory reports an uncertainty-labelled estimate instead of +claiming exact or provider-actual usage. + +### Always-on behavior and its boundary + +HyperMemory uses three complementary layers: + +1. The skill declares itself applicable on every turn. +2. Session and prompt hooks remind the active agent to recall before work. +3. The stop hook requires delegated memory finalization before the response is + released. + +This is the strongest enforcement available to an installed plugin, but it is +not an operating-system guarantee. If the plugin is disabled, its hooks are not +trusted, hooks are disabled by policy, the MCP is unavailable, or the current +surface cannot spawn sub-agents, behavior degrades accordingly. The skill +defines a direct-write fallback when delegation is unavailable so memory is not +silently abandoned. + +## HyperColab + +HyperColab coordinates human developers and coding agents working in the same +Git project. It combines shared context, explicit work ownership, atomic path +claims, structured activity, and project-scoped graph search. + +### Included components + +| Component | Path | Responsibility | +| --- | --- | --- | +| Plugin manifest | `plugins/hypercolab/.codex-plugin/plugin.json` | Identity, version, discovery metadata, branding, skill path, and MCP declaration | +| MCP registration | `plugins/hypercolab/.mcp.json` | Launches `hypercolab mcp` as a local stdio server | +| Skill | `plugins/hypercolab/skills/hypercolab/` | Defines join, sync, claim, progress, activity, and completion behavior | +| Lifecycle hooks | `plugins/hypercolab/hooks/hooks.json` | Loads project context, checks writes, and records structured activity | +| Coordination-writer role | `plugins/hypercolab/agents/coordination-writer.md` | Bounded contract for delegated timeline maintenance | +| Hook launcher | `plugins/hypercolab/scripts/hypercolab_hook.py` | Bridges Codex lifecycle events to the installed CLI and degrades safely if absent | +| CLI and MCP shim | `packages/hypercolab-cli/` | OAuth, Git discovery, MCP tools, direct commands, claims, offline leases, and Git hooks | + +### Why a local shim? + +HyperColab must know which repository the user is actually working in. The +local shim derives the Git root, canonical remote, branch, and worktree before +calling the project service. Agents do not select arbitrary graph or timeline +database identifiers. ```mermaid flowchart LR - Catalog["HyperMemory AI marketplace"] --> Memory["HyperMemory plugin"] - Catalog --> Colab["HyperColab plugin"] + Agent["Codex agent"] --> Plugin["HyperColab plugin"] + Plugin --> Shim["Local stdio MCP shim"] + Shim --> Git["Git root, remote, branch, worktree"] + Shim --> API["HyperColab API"] + API --> Claims["Sessions, claims, and leases"] + API --> Timeline["Append-only project timeline"] + API --> Graph["Project-scoped HyperMemory graph"] +``` + +This routing is a safety boundary: the backend resolves the authorized project +from the authenticated developer and canonical repository identity. + +### MCP tool reference + +| Tool | Purpose | +| --- | --- | +| `colab_join` | Join the project associated with the current Git repository and publish the work goal | +| `colab_sync` | Retrieve active sessions, ownership, recent events, and touch/do-not-touch guidance | +| `colab_claim` | Atomically claim repository-relative files or directories before editing | +| `colab_check` | Check create, modify, rename, or delete operations immediately before a write | +| `colab_update` | Publish material progress, scope, status, rationale, and claim renewal | +| `colab_finish` | Complete, release, abandon, or hand off work and release the claim | +| `colab_log_activity` | Append a structured project event for decisions, discoveries, tests, commits, or releases | +| `colab_timeline` | Read or search the chronological development record | +| `colab_graph_search` | Search durable knowledge in the project-scoped graph | + +### Coordination lifecycle + +The main agent joins and synchronizes before planning, then claims intended +paths before editing. Join, sync, and claim operations stay on the main agent +because their results affect planning and write safety. Routine progress and +timeline maintenance may be delegated to one awaited coordination writer. - Memory --> MemoryMCP["OAuth HyperMemory MCP"] - Memory --> MemorySkill["Always-on memory skill"] - Memory --> MemoryHooks["Recall and finalization hooks"] - Memory --> Tokens["Codex token listener"] +```text +join -> sync -> claim -> check before writes -> update during work -> finish or hand off +``` + +Live conflicts are not bypassed. If another session owns an overlapping path, +the agent coordinates a handoff, waits for lease expiry, or changes scope. + +### Lifecycle and Git hooks + +The plugin hooks cover: + +- `SessionStart`: load the current project brief; +- `PreToolUse`: check inferred file operations and claims before writes; +- `PostToolUse`: record structured completion or Git-push events; and +- `Stop`: record that the coding session stopped. + +The optional repository Git hooks record non-blocking events such as commits, +checkouts, merges, rewrites, and pushes: - Colab --> ColabMCP["Local HyperColab MCP shim"] - Colab --> ColabSkill["Coordination skill"] - Colab --> ColabHooks["Claims and timeline hooks"] - ColabMCP --> Backend["HyperColab project services"] +```bash +hypercolab hooks install ``` -HyperMemory keeps recall on the main agent so remembered context can influence -the answer. Durable writes, timeline updates, and token reporting are delegated -to a bounded memory-writer sub-agent. HyperColab follows the same separation: -join, sync, and claims stay on the main agent, while routine project-timeline -maintenance can be delegated to a coordination writer. +Remove them before uninstalling the CLI: + +```bash +hypercolab hooks uninstall +``` + +### Offline behavior + +HyperColab treats coordination conservatively during an outage: + +- new path claims fail closed; +- a previously approved cached lease is honored only until its server-issued + expiration; +- Git activity is queued locally and retried later; and +- repositories that are not registered with HyperColab remain unaffected. + +### CLI command reference + +| Command | Purpose | +| --- | --- | +| `hypercolab login` / `logout` | Create or remove the local OAuth session | +| `hypercolab status` | Show authentication, Git, project, and session state | +| `hypercolab doctor` | Check the CLI, Git, authentication, API URL, and repository without modifying it | +| `hypercolab setup` | Add project-scoped Codex MCP configuration and install Git activity hooks | +| `hypercolab join` | Join the project resolved from the active Git remote | +| `hypercolab sync` / `who` | Read current coordination state and active sessions | +| `hypercolab claim` / `check` | Claim paths or check an intended file operation | +| `hypercolab update` | Publish progress, status, paths, and visible rationale | +| `hypercolab finish` / `release` | Complete, hand off, abandon, or release claimed work | +| `hypercolab timeline` | Read or search the project timeline | +| `hypercolab timeline add` | Append a deliberate structured timeline event | +| `hypercolab graph search` | Search the project-scoped durable graph | +| `hypercolab hooks install` / `uninstall` | Manage optional repository Git hooks | +| `hypercolab mcp` | Run the local stdio MCP shim used by the plugin | + +### Data boundary + +HyperColab records structured summaries, repository-relative paths, commit +identifiers, claims, statuses, test results, small metadata objects, and visible +rationale summaries. By default it does not send raw source, raw diffs, full +shell output, complete transcripts, or hidden model reasoning. + +## Combined architecture + +```mermaid +flowchart TB + Repo["hypermemory-ai/hm-plugins-openai"] --> Catalog["hypermemory-ai marketplace"] + Catalog --> HM["HyperMemory plugin"] + Catalog --> HC["HyperColab plugin"] + + subgraph PersonalMemory["Durable cross-session memory"] + HM --> HMMCP["Hosted OAuth MCP"] + HM --> HMSkill["Always-on memory skill"] + HM --> HMHooks["Recall and finalization hooks"] + HM --> HMAgent["Memory-writer role"] + HM --> Tokens["Privacy-preserving token listener"] + end + + subgraph ProjectCoordination["Project-scoped coordination"] + HC --> HCSkill["Coordination skill"] + HC --> HCHooks["Claim and activity hooks"] + HC --> HCAgent["Coordination-writer role"] + HC --> LocalMCP["Local stdio MCP shim"] + LocalMCP --> ColabAPI["HyperColab project services"] + end +``` + +The plugins may be enabled independently. When both are enabled, HyperMemory +retains durable conversational context while HyperColab supplies the live, +repository-specific coordination state. ## Repository layout ```text -.agents/plugins/marketplace.json Shared marketplace catalog -plugins/hypermemory/ HyperMemory OpenAI plugin -plugins/hypercolab/ HyperColab OpenAI plugin -packages/hypercolab-cli/ HyperColab CLI and local stdio MCP shim -docs/ Installation and architecture guides -scripts/build_plugin_archives.py Reproducible review ZIP builder -tests/ Marketplace and lifecycle tests -``` - -Each plugin contains its required `.codex-plugin/plugin.json`, MCP -configuration, skill, hook definition, assets, and an `agents/` role contract. -The skills reference those roles because the OpenAI plugin manifest does not -currently define a separate auto-installed custom-agent registry. - -## ChatGPT and Codex - -This repository is the Git-backed marketplace and source package used for -development, Codex installation, and workspace testing. Public one-click -installation in both ChatGPT and Codex requires publishing each plugin through -OpenAI's universal Plugins Directory. - -HyperMemory's hosted MCP is ready for the **With MCP** submission path. -HyperColab uses a local stdio shim to resolve the active Git repository, so its -full coordination behavior requires a local coding surface that can launch the -`hypercolab` command. - -## Privacy by design - -- OAuth credentials are handled by the MCP or HyperColab CLI and are never - committed to this repository. -- HyperMemory's Codex listener parses token counters only. It does not return - or upload prompts, responses, tool arguments, tool results, or source code. -- HyperColab records structured summaries, paths, commits, claims, and visible - rationale. It does not send raw source, raw diffs, transcripts, or hidden - reasoning by default. -- Plugin hooks require explicit trust in Codex and can be reviewed or disabled - with `/hooks`. - -Read [Architecture](docs/ARCHITECTURE.md), [Marketplace maintenance](docs/MARKETPLACE.md), -and [Security](SECURITY.md) for the full operational model. +. +├── .agents/plugins/marketplace.json # Shared Git marketplace catalog +├── .github/workflows/validate.yml # Lint, test, and archive CI +├── plugins/ +│ ├── hypermemory/ +│ │ ├── .codex-plugin/plugin.json # HyperMemory manifest +│ │ ├── .mcp.json # Hosted OAuth MCP connection +│ │ ├── agents/ # Memory-writer role contract +│ │ ├── assets/ # Marketplace icon and logo +│ │ ├── hooks/hooks.json # Codex lifecycle hooks +│ │ ├── scripts/ # Hook bridge and token listener +│ │ └── skills/hypermemory/ # Memory workflow and references +│ └── hypercolab/ +│ ├── .codex-plugin/plugin.json # HyperColab manifest +│ ├── .mcp.json # Local stdio MCP registration +│ ├── agents/ # Coordination-writer role contract +│ ├── assets/ # Marketplace icon and logo +│ ├── hooks/hooks.json # Codex coordination hooks +│ ├── scripts/ # Graceful hook launcher +│ └── skills/hypercolab/ # Coordination workflow and references +├── packages/hypercolab-cli/ # Installable CLI and local MCP shim +├── docs/ +│ ├── ARCHITECTURE.md # Runtime design and trust boundaries +│ ├── INSTALLATION.md # Detailed setup and troubleshooting +│ └── MARKETPLACE.md # Catalog and release maintenance +├── scripts/build_plugin_archives.py # Deterministic review ZIP builder +├── tests/ # Marketplace and lifecycle tests +├── AGENTS.md # Repository rules for coding agents +├── CONTRIBUTING.md # Contribution and release checklist +├── SECURITY.md # Vulnerability reporting and boundaries +└── LICENSE # MIT license +``` + +Only `plugin.json` lives inside each `.codex-plugin/` directory. Skills, MCP +configuration, hooks, assets, scripts, and role contracts remain at the plugin +root according to the Codex plugin package layout. + +## Agent role packaging + +Each plugin contains an `agents/` role contract and a matching skill reference: + +- HyperMemory uses `memory-writer` for storage, timeline, and telemetry. +- HyperColab uses `coordination-writer` for project activity maintenance. + +These files document the bounded role that the skill asks the host to spawn. +They are not a separate manifest-level custom-agent registry: the current +OpenAI plugin manifest packages skills, MCP servers, hooks, apps, and assets, +but does not auto-install arbitrary project-scoped agent TOML files. The skill +therefore controls when delegation happens, what information is passed, and how +recursive delegation is prevented. + +## Authentication and secrets + +| Component | Authentication | Where credentials live | +| --- | --- | --- | +| HyperMemory MCP | OAuth authorization code with PKCE | Codex/host MCP credential storage | +| HyperColab CLI | `hypercolab login` OAuth flow with PKCE | OS keyring, with a restricted local fallback when no keyring is available | +| Git marketplace | Public GitHub repository | No credentials required for this repository | + +No access token, refresh token, client secret, API key, or reviewer credential +belongs in this repository. See [Security](SECURITY.md) for reporting and trust +boundaries. + +## Hook trust and permissions + +Plugin installation does not automatically trust bundled command hooks. Users +must review them with `/hooks`. This provides an explicit boundary around local +commands that can inspect token counters, query Git state, or check write +ownership. + +Administrators may disable hooks or restrict marketplace/MCP sources through +managed Codex policy. Sub-agents inherit the active parent sandbox and +permission mode. Neither plugin expands operating-system permissions on its +own. + +## Updating + +Refresh the Git marketplace snapshot, reinstall the plugins you use, and start +a new task: + +```bash +codex plugin marketplace upgrade hypermemory-ai +codex plugin add hypermemory@hypermemory-ai +codex plugin add hypercolab@hypermemory-ai +pipx upgrade hypercolab +``` + +Review hooks again if their definitions changed. + +## Removing + +If you installed HyperColab Git hooks, remove those first while the CLI is still +available: + +```bash +hypercolab hooks uninstall +``` + +Then remove the plugins, marketplace, and optional CLI: + +```bash +codex plugin remove hypermemory@hypermemory-ai +codex plugin remove hypercolab@hypermemory-ai +codex plugin marketplace remove hypermemory-ai +pipx uninstall hypercolab +``` + +Removing a plugin or marketplace does not delete durable data already stored by +HyperMemory or HyperColab. ## Development +### Clone and create an environment + ```bash +git clone https://github.com/hypermemory-ai/hm-plugins-openai.git +cd hm-plugins-openai +python3 -m venv .venv +source .venv/bin/activate python -m pip install -e "packages/hypercolab-cli[dev]" +``` + +### Run the test suite + +```bash ruff check plugins packages tests scripts pytest -q +``` + +The tests cover: + +- catalog-to-plugin path and identity consistency; +- required manifests, MCP declarations, hooks, skills, and assets; +- HyperMemory stop-hook delegation and recursion protection; +- exact token aggregation and two-phase checkpointing; +- HyperColab hook behavior, Git discovery, cached leases, and queued events; +- logo format and dimensions; and +- graceful behavior when the HyperColab CLI is missing. + +### Run Codex package validators + +```bash +python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py \ + plugins/hypermemory +python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py \ + plugins/hypercolab +python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py \ + plugins/hypermemory/skills/hypermemory +python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py \ + plugins/hypercolab/skills/hypercolab +``` + +### Build review archives + +```bash python scripts/build_plugin_archives.py ``` -Codex's authoring validators are also supported: +This creates deterministic ZIP archives in `dist/`. Marketplace installation +uses the source directories referenced by `marketplace.json`, so checked-in +`.plugin` files are not required. Generated archives remain ignored to avoid +stale binary packages. + +### Test a local marketplace checkout + +In a clean development profile, or after removing another configured source +with the same marketplace name, run from the repository root: + +```bash +codex plugin marketplace add . +codex plugin add hypermemory@hypermemory-ai +codex plugin add hypercolab@hypermemory-ai +``` + +Start a new task after reinstalling so Codex loads the updated skills and MCP +configuration. + +## Release and publication model + +There are two distinct distribution paths: + +1. **Git marketplace:** this repository supports local authoring, Codex + installation, team testing, and controlled pre-publication distribution. +2. **Universal Plugins Directory:** each plugin is submitted and reviewed + independently for public, one-click discovery across supported ChatGPT and + Codex surfaces. + +Plugin versions live in each `.codex-plugin/plugin.json`; the marketplace does +not have a shared plugin version. Increment only the package that changed, +validate its complete file tree, build a fresh review archive, test a clean +installation, and document user-visible changes. + +HyperMemory should use OpenAI's **With MCP** submission path because it combines +a hosted MCP server with a skill. HyperColab's public submission must preserve +its repository-identity safety boundary while using a transport supported by +the target public surface. + +See [Marketplace maintenance](docs/MARKETPLACE.md) and +[Contributing](CONTRIBUTING.md) for the complete release checklist. + +## Troubleshooting + +### The marketplace was added, but no plugin is installed + +That is expected. Registering the marketplace adds the catalog only. Install a +plugin explicitly: + +```bash +codex plugin add hypermemory@hypermemory-ai +codex plugin add hypercolab@hypermemory-ai +``` + +### MCP tools are missing + +Confirm that the plugin is installed and enabled with `codex plugin list`, then +start a new task. HyperColab also requires the `hypercolab` executable to be on +`PATH`; run `hypercolab doctor` to verify its prerequisites. + +### HyperMemory OAuth did not open + +Invoke a HyperMemory MCP operation and complete the connection flow. Confirm +the installed MCP URL is `https://stage.hypermemory.io/mcp` and check whether a +workspace policy blocks the server. + +### HyperColab authentication failed + +Run: + +```bash +hypercolab login +hypercolab doctor +hypercolab status +``` + +The local callback needs an available loopback port and a browser capable of +completing OAuth. + +### Hooks do not run + +Open `/hooks`, locate the plugin hook source, and trust its current definition. +Also confirm hooks are not disabled in Codex configuration or managed policy. + +### A HyperColab write is blocked + +Run `hypercolab sync` to inspect active ownership and claims. Coordinate a +handoff, wait for the conflicting lease to expire, or change the intended path. +Do not bypass a valid ownership conflict. + +### The plugin changed but Codex still uses the old copy + +Refresh and reinstall: ```bash -python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypermemory -python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypercolab -python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypermemory/skills/hypermemory -python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypercolab/skills/hypercolab +codex plugin marketplace upgrade hypermemory-ai +codex plugin add @hypermemory-ai ``` +Then start a new task. Codex loads an installed marketplace snapshot rather than +executing directly from an arbitrary source checkout. + +### Exact token reporting is unavailable + +Exact reporting requires a local Codex rollout with `token_count` records and a +working inspect/ack job. When those counters are unavailable, the memory writer +submits one uncertainty-labelled estimate instead. Consumer ChatGPT always uses +the estimated path. + +## Frequently asked questions + +### Is the marketplace itself a plugin? + +No. The marketplace is the catalog named `hypermemory-ai`. It currently lists +the separate `hypermemory` and `hypercolab` plugins. + +### Do I need both plugins? + +No. HyperMemory and HyperColab are independent. Install only the capabilities +you need. + +### Does HyperColab replace HyperMemory? + +No. HyperColab uses project-scoped knowledge and coordination. HyperMemory is +the durable cross-conversation memory plugin. They complement one another. + +### Are the hooks automatically trusted? + +No. Codex requires explicit trust for non-managed plugin hooks, and changed +definitions must be reviewed again. + +### Are the packaged `agents/` files automatically registered custom agents? + +No. They are bounded role contracts invoked through the bundled skills. They +document delegation behavior but are not a separate manifest-level agent +registry. + +### Does token telemetry upload my conversations? + +No. The Codex listener parses cumulative token counters only. It does not +return or upload chat content, tool payloads, or source code. + +### Can a normal ChatGPT user install directly from this Git URL? + +This repository supports Git-marketplace development and Codex distribution. +Normal public one-click installation requires publication through OpenAI's +universal Plugins Directory. + +### Is the MCP endpoint production? + +No. The checked-in HyperMemory configuration currently targets the Rust staging +endpoint. Treat the package as pre-production until the manifest and docs are +updated to a production MCP URL. + +## Documentation + +- [Detailed installation and troubleshooting](docs/INSTALLATION.md) +- [Architecture and trust boundaries](docs/ARCHITECTURE.md) +- [Marketplace and release maintenance](docs/MARKETPLACE.md) +- [HyperMemory package notes](plugins/hypermemory/README.md) +- [HyperColab package notes](plugins/hypercolab/README.md) +- [Contribution guide](CONTRIBUTING.md) +- [Security policy](SECURITY.md) + +For the current Codex plugin model, see OpenAI's +[plugin packaging documentation](https://developers.openai.com/plugins/build/plugins). + +## Support and security + +For general project questions, use the repository's GitHub issues. Do not post +credentials, tokens, private repository content, or vulnerability details in a +public issue. + +Report security concerns privately according to [SECURITY.md](SECURITY.md). +Legal and product information is available at: + +- [HyperMemory AI](https://hypermemory.io) +- [Privacy policy](https://hypermemory.io/privacy) +- [Terms of service](https://hypermemory.io/terms) + ## License -MIT. See [LICENSE](LICENSE). +Licensed under the MIT License. See [LICENSE](LICENSE).