From 7157a28686b97e020fec301dfdb161c4fe283a44 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 10 May 2026 13:41:39 +0800 Subject: [PATCH 01/12] feat(plugin/codex): add lifecycle hooks (recall, capture, pre-compact) Brings the codex-memory-plugin to feature parity with the claude-code-memory-plugin by wiring the four Codex lifecycle hooks via `hooks.json`: - SessionStart -> bootstrap-runtime.mjs (npm ci into ${CODEX_PLUGIN_DATA}/runtime) - UserPromptSubmit -> auto-recall.mjs (search OV, inject via hookSpecificOutput.additionalContext) - Stop -> auto-capture.mjs (incremental transcript capture + last_assistant_message commit) - PreCompact -> pre-compact-capture.mjs (full transcript -> single OV session -> commit) Differences from the Claude Code plugin baked into the scripts: - Codex output schema does not allow `decision: "approve"`; no-op is `{}` - Stop/PreCompact only support `systemMessage`, not `additionalContext` - Plugin envs are CODEX_PLUGIN_ROOT / CODEX_PLUGIN_DATA - Config section is `codex` (was `claude_code`); config file defaults to `~/.openviking/ovcli.conf`, falling back to legacy `~/.openviking/ov.conf` Other changes: - src/memory-server.ts now reads ovcli.conf-style configs (top-level `url`, `api_key`, `account`, `user`, `agent_id`) so the plugin works against hosted OpenViking deployments out of the box. Env-var-only operation (OPENVIKING_URL set, no config file) is also supported. - .mcp.json points at scripts/start-memory-server.mjs, which boots the same runtime the hooks use, so the MCP path benefits from npm-ci bootstrap. - README rewritten with architecture diagram, validation SOP, configuration reference, and a Codex-vs-Claude-Code differences table. Validated end-to-end against an OpenViking deployment: - Auto-recall returns ranked memories with full content and emits hookSpecificOutput.additionalContext. - Auto-capture (last_assistant_message path) creates a session, commits, and the OV pipeline extracts events + preferences within ~60s. - Pre-compact-capture posts a full 4-turn transcript to one OV session, commits with archived=true, and produces structured leaf memories (preferences, events, entities) under viking://user//memories/. Co-Authored-By: Claude Opus 4.7 --- .../.codex-plugin/plugin.json | 26 +- examples/codex-memory-plugin/.gitignore | 5 +- examples/codex-memory-plugin/.mcp.json | 6 +- examples/codex-memory-plugin/README.md | 362 ++++- examples/codex-memory-plugin/hooks/hooks.json | 52 + .../codex-memory-plugin/package-lock.json | 1174 +++++++++++++++++ examples/codex-memory-plugin/package.json | 4 +- .../scripts/auto-capture.mjs | 375 ++++++ .../scripts/auto-recall.mjs | 348 +++++ .../scripts/bootstrap-runtime.mjs | 39 + .../codex-memory-plugin/scripts/config.mjs | 130 ++ .../codex-memory-plugin/scripts/debug-log.mjs | 64 + .../scripts/pre-compact-capture.mjs | 251 ++++ .../scripts/runtime-common.mjs | 278 ++++ .../scripts/start-memory-server.mjs | 54 + .../servers/memory-server.js | 318 +++++ .../codex-memory-plugin/src/memory-server.ts | 66 +- 17 files changed, 3464 insertions(+), 88 deletions(-) create mode 100644 examples/codex-memory-plugin/hooks/hooks.json create mode 100644 examples/codex-memory-plugin/package-lock.json create mode 100644 examples/codex-memory-plugin/scripts/auto-capture.mjs create mode 100644 examples/codex-memory-plugin/scripts/auto-recall.mjs create mode 100644 examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs create mode 100644 examples/codex-memory-plugin/scripts/config.mjs create mode 100644 examples/codex-memory-plugin/scripts/debug-log.mjs create mode 100644 examples/codex-memory-plugin/scripts/pre-compact-capture.mjs create mode 100644 examples/codex-memory-plugin/scripts/runtime-common.mjs create mode 100644 examples/codex-memory-plugin/scripts/start-memory-server.mjs create mode 100644 examples/codex-memory-plugin/servers/memory-server.js diff --git a/examples/codex-memory-plugin/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json index 623615caee..6fd9e61e6b 100644 --- a/examples/codex-memory-plugin/.codex-plugin/plugin.json +++ b/examples/codex-memory-plugin/.codex-plugin/plugin.json @@ -1,14 +1,32 @@ { "name": "openviking-memory", - "description": "Explicit OpenViking memory tools for Codex via MCP.", + "version": "0.2.0", + "description": "Long-term semantic memory for Codex, powered by OpenViking. Auto-recall on UserPromptSubmit, auto-capture on Stop, and full-session capture before context compaction.", + "author": { + "name": "OpenViking", + "url": "https://github.com/volcengine/OpenViking" + }, + "repository": "https://github.com/volcengine/OpenViking", + "license": "Apache-2.0", + "keywords": [ + "memory", + "openviking", + "semantic-search", + "long-term-memory", + "context-engine", + "codex" + ], + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", "interface": { "displayName": "OpenViking Memory", - "shortDescription": "OpenViking memory tools for Codex", - "longDescription": "Adds explicit OpenViking MCP tools for manual memory recall, store, forget, and health checks.", + "shortDescription": "Long-term semantic memory for Codex", + "longDescription": "Hooks Codex's lifecycle to keep an external semantic memory: recall relevant memories before each user turn, capture user/assistant turns on Stop, and commit the full transcript before PreCompact wipes context. Also exposes explicit MCP tools (openviking_recall, openviking_store, openviking_forget, openviking_health) for manual use.", "developerName": "OpenViking", - "category": "productivity", + "category": "Productivity", "capabilities": [ "Memory recall", + "Auto memory capture", "Manual memory storage", "Manual memory deletion" ], diff --git a/examples/codex-memory-plugin/.gitignore b/examples/codex-memory-plugin/.gitignore index b8283e8bd8..d74551f997 100644 --- a/examples/codex-memory-plugin/.gitignore +++ b/examples/codex-memory-plugin/.gitignore @@ -1,2 +1,5 @@ node_modules/ -servers/ +servers/*.mjs +servers/*.js +!servers/memory-server.js +*.tsbuildinfo diff --git a/examples/codex-memory-plugin/.mcp.json b/examples/codex-memory-plugin/.mcp.json index e68b93f556..46f9ef5a6f 100644 --- a/examples/codex-memory-plugin/.mcp.json +++ b/examples/codex-memory-plugin/.mcp.json @@ -3,9 +3,11 @@ "openviking-memory": { "command": "node", "args": [ - "servers/memory-server.js" + "${CODEX_PLUGIN_ROOT}/scripts/start-memory-server.mjs" ], - "cwd": "." + "env": { + "OPENVIKING_CONFIG_FILE": "${OPENVIKING_CONFIG_FILE}" + } } } } diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 1a0315be36..fc93e9f146 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -1,42 +1,187 @@ -# OpenViking Memory MCP Server for Codex +# OpenViking Memory Plugin for Codex -Small Codex MCP example for explicit OpenViking memory operations. +Long-term semantic memory for [Codex](https://developers.openai.com/codex), powered by [OpenViking](https://github.com/volcengine/OpenViking). -This example intentionally stays MCP-only: +This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-memory-plugin). It hooks Codex's lifecycle to: -- no lifecycle hooks -- no background capture worker -- no writes to `~/.codex` -- no checked-in build output +- **Auto-recall** relevant memories on every `UserPromptSubmit` and inject them via `hookSpecificOutput.additionalContext` +- **Auto-capture** the user turn (and last assistant message) on `Stop` by committing a short-lived OpenViking session +- **Pre-compact capture** the entire transcript on `PreCompact` so detail survives Codex's context summarization +- **Bootstrap** the MCP runtime once on `SessionStart` -Codex gets four tools: +It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health`) for manual use. -- `find` -- `remember` -- `forget` -- `health` +## Architecture -## Files +``` + ┌────────────────────────────────────────────┐ + │ Codex │ + └────────┬───────────┬──────────┬─────────┬──┘ + │ │ │ │ + SessionStart UserPromptSubmit Stop PreCompact + │ │ │ │ + ┌────────▼──────┐ ┌──▼─────┐ ┌──▼─────┐ ┌─▼─────────────┐ + │ bootstrap- │ │ auto- │ │ auto- │ │ pre-compact- │ + │ runtime.mjs │ │ recall │ │ capture│ │ capture │ + │ (npm ci) │ │ │ │ │ │ (full commit) │ + └────────┬──────┘ └───┬────┘ └────┬───┘ └───┬───────────┘ + │ │ │ │ + │ ┌────▼───────────▼─────────▼──┐ + │ │ OpenViking server │ + │ │ /api/v1/search/find │ + │ │ /api/v1/sessions │ + │ │ /api/v1/sessions/{id}/commit │ + └──────►│ /api/v1/content/read │ + └───────────────────────────────┘ + + ┌──────────────────────────────────────┐ + │ MCP Server (memory-server.ts) │ + │ Tools for explicit use: │ + │ • openviking_recall │ + │ • openviking_store │ + │ • openviking_forget │ + │ • openviking_health │ + └──────────────────────────────────────┘ +``` + +## How It Works + +### Runtime bootstrap (transparent, on session start) + +`SessionStart` fires `bootstrap-runtime.mjs`, which hashes `package.json` + `package-lock.json` + `servers/memory-server.js`, copies them into `${CODEX_PLUGIN_DATA}/runtime` (or `~/.openviking/codex-memory-plugin/runtime` if Codex doesn't inject `CODEX_PLUGIN_DATA`), and runs `npm ci --omit=dev`. `install-state.json` records the resolved hashes so subsequent sessions skip reinstall. The MCP launcher (`start-memory-server.mjs`) can also bootstrap on demand if it starts before `SessionStart`. + +### Auto-recall (transparent, every turn) + +`UserPromptSubmit` fires `auto-recall.mjs`. It reads `prompt` from stdin, calls `/api/v1/search/find` for both `viking://user/memories` and `viking://agent/memories` (and `viking://agent/skills`), ranks results with query-aware scoring (leaf boost, preference boost, temporal boost, lexical overlap), reads full content for top-ranked leaves, and emits: + +```json +{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "..." } } +``` + +Codex injects `additionalContext` into the model turn, so memories arrive without an extra tool call. + +### Auto-capture (transparent, on Stop) + +`Stop` fires `auto-capture.mjs`. Codex hands us `last_assistant_message`, `transcript_path`, `session_id`, and `turn_id`. The script: + +1. Incrementally parses the rollout JSONL at `transcript_path` (skipping turns we've already captured this session) +2. Captures user turns (and assistant turns when `captureAssistantTurns=true`) +3. Captures `last_assistant_message` separately (deduped via hash) so Stop continues to work even if `transcript_path` is unavailable +4. Each capture creates an OpenViking session, posts the text, calls `/api/v1/sessions/{id}/commit`, and lets OV's pipeline extract memories asynchronously + +State per `session_id` is kept under `$TMPDIR/openviking-codex-capture-state/`. + +### Pre-compact capture (the key Codex extension) + +`PreCompact` fires `pre-compact-capture.mjs` *before* Codex compacts the conversation. The script reads the entire transcript, opens **one** OpenViking session, posts every captured turn in order, and commits — so a structured extraction lands in long-term memory before Codex throws the detail away. The `Stop` state is also advanced so the next Stop won't re-capture pre-compact content. + +### MCP tools (explicit, on demand) + +The MCP server provides tools for when Codex or the user needs explicit memory operations. See "Tools" below. + +## Codex hook output schema + +Codex's hook output schema differs from Claude Code's. Notably: + +| Hook | Input field of interest | Output channel for context injection | +|------|------------------------|--------------------------------------| +| `SessionStart` | `source` (`startup`/`resume`/`clear`) | `hookSpecificOutput.additionalContext` | +| `UserPromptSubmit` | `prompt` | `hookSpecificOutput.additionalContext` | +| `Stop` | `last_assistant_message`, `transcript_path` | `systemMessage` (only) | +| `PreCompact` | `trigger` (`manual`/`auto`), `transcript_path`| `systemMessage` (only) | + +Unlike Claude Code, **Codex does not support `decision: "approve"`**; only `decision: "block"`. A no-op is `{}` (which is what these scripts emit when there's nothing to add). + +Source: [`codex-rs/hooks/schema/generated/`](https://github.com/openai/codex/tree/main/codex-rs/hooks/schema/generated). + +## Quick Start + +### 1. Install Node.js 22+ and Codex 0.124+ + +```bash +node --version # >= 22 +codex --version # >= 0.124.0 +``` + +Make sure `codex_hooks` is enabled (it's stable since April 2026): + +```bash +codex features list | grep codex_hooks +# codex_hooks stable true +``` -- `.codex-plugin/plugin.json`: plugin metadata -- `.mcp.json`: MCP server wiring for Codex -- `src/memory-server.ts`: MCP server source -- `package.json`: build and start scripts -- `tsconfig.json`: TypeScript build config +### 2. Configure OpenViking client -## Prerequisites +The plugin reads connection settings from `~/.openviking/ovcli.conf` (the same file the `ov` CLI uses). For a cloud OpenViking deployment: -- Codex CLI -- OpenViking server -- Node.js 22+ +```jsonc +{ + "url": "https://ov.example.com", + "api_key": "", + "account": "default", + "user": "" +} +``` + +For a local server, omit `url` and the plugin will fall back to `~/.openviking/ov.conf`'s `server.host` / `server.port`. + +Plugin-specific overrides go in an optional `codex` section: + +```jsonc +{ + "url": "https://ov.example.com", + "api_key": "...", + "codex": { + "agentId": "codex", + "recallLimit": 6, + "captureMode": "semantic", + "captureAssistantTurns": false, + "autoCommitOnCompact": true + } +} +``` + +### 3. Install the plugin + +The plugin lives at `examples/codex-memory-plugin/` in the OpenViking repo. Once a marketplace ships it, install with: + +```bash +codex plugin marketplace add +# then enable in ~/.codex/config.toml: +# [plugins."openviking-memory@"] +# enabled = true +``` -Start OpenViking before using the MCP server: +For local development, point a tiny marketplace fixture at this directory: ```bash -openviking-server --config ~/.openviking/ov.conf +mkdir -p /tmp/ov-codex-mp/.claude-plugin +ln -s /abs/path/to/OpenViking/examples/codex-memory-plugin /tmp/ov-codex-mp/openviking-memory +cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' +{ + "name": "openviking-codex-local", + "plugins": [ + { "name": "openviking-memory", "source": "./openviking-memory" } + ] +} +EOF +codex plugin marketplace add /tmp/ov-codex-mp + +# Enable the plugin +cat >> ~/.codex/config.toml <<'EOF' + +[plugins."openviking-memory@openviking-codex-local"] +enabled = true +EOF + +# Codex installs plugins lazily — for fastest iteration, copy the plugin into +# the cache so it resolves immediately: +INSTALL_DIR=~/.codex/plugins/cache/openviking-codex-local/openviking-memory +mkdir -p "$INSTALL_DIR" +cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.2.0" ``` -## Build +### 4. Build the MCP server ```bash cd examples/codex-memory-plugin @@ -44,71 +189,172 @@ npm install npm run build ``` -## Install in Codex +The MCP server compiles to `servers/memory-server.js`, which `start-memory-server.mjs` launches via the bootstrapped runtime. -Use the built server: +### 5. Start a Codex session ```bash -codex mcp add openviking-memory -- \ - node /ABS/PATH/TO/OpenViking/examples/codex-memory-plugin/servers/memory-server.js +codex +``` + +The first session installs runtime deps; subsequent sessions skip reinstall. + +## Validation SOP + +This is the canonical end-to-end validation for an OpenViking plugin. Run it after any plugin change. + +```bash +export OPENVIKING_API_KEY= +export OPENVIKING_URL=https://ov.example.com # or your server +ACCT=default + +# 1. Trigger something memorable in a Codex session, then close it. +# e.g.: "I prefer pour-over coffee for memory testing — please remember." + +# 2. Verify a session was created and committed. +ov --account "$ACCT" ls viking://session | head +# Pick the most recently created session id (one we just made). + +SID= + +# 3. Confirm the session has messages + history archive. +ov --account "$ACCT" ls "viking://session/$SID" +ov --account "$ACCT" ls "viking://session/$SID/history" +# Expect: messages.jsonl and a history/archive_NNN/ entry. + +# 4. Read the messages back to confirm the captured payload. +ov --account "$ACCT" read "viking://session/$SID/messages.jsonl" + +# 5. Wait ~1 minute (or `ov wait`) for OV's extraction pipeline. +ov --account "$ACCT" wait --timeout 120 + +# 6. Verify long-term memories landed under the user (and/or agent) folder. +ov --account "$ACCT" find "" -u viking://user//memories -n 5 +# Expect leaf memories under preferences/, events/, entities/, etc. ``` -Or copy `.mcp.json` into a Codex workspace and adjust the `cwd` path if needed. +If step 6 returns no leaf memories, check: + +- The capture hook actually ran — `tail -f ~/.openviking/logs/codex-hooks.log` (with `OPENVIKING_DEBUG=1` or `codex.debug=true` in `ovcli.conf`). +- The OV server's extraction queue isn't backed up — `ov --account "$ACCT" status`. +- The committed text passed `shouldCapture` thresholds (`length`, `commands`, `keyword` mode). + +## Configuration + +| Field (`codex` section) | Default | Description | +|-------------------------|---------|-------------| +| `agentId` | `codex` | Agent identity for memory isolation | +| `timeoutMs` | `15000` | HTTP request timeout for recall/general requests (ms) | +| `autoRecall` | `true` | Enable auto-recall on every user prompt | +| `recallLimit` | `6` | Max memories to inject per turn | +| `scoreThreshold` | `0.01` | Min relevance score (0–1) | +| `minQueryLength` | `3` | Skip recall for very short queries | +| `logRankingDetails` | `false` | Per-candidate ranking logs (verbose) | +| `autoCapture` | `true` | Enable auto-capture on Stop | +| `captureMode` | `semantic` | `semantic` (always capture) or `keyword` (trigger-based) | +| `captureMaxLength` | `24000` | Max text length for capture | +| `captureTimeoutMs` | `30000` | HTTP request timeout for capture/commit (ms) | +| `captureAssistantTurns` | `false` | Include assistant turns in transcript-incremental capture | +| `captureLastAssistantOnStop` | `true` | Capture `last_assistant_message` separately on every Stop | +| `autoCommitOnCompact` | `true` | Commit the full transcript on `PreCompact` | +| `debug` | `false` | Write structured debug logs | -## Config +Connection settings (URL, account, user, api_key) come from `ovcli.conf` plus standard env overrides: -The server reads OpenViking connection settings from `~/.openviking/ov.conf`. +- `OPENVIKING_CONFIG_FILE`: alternate config path (defaults to `~/.openviking/ovcli.conf`, then `~/.openviking/ov.conf`) +- `OPENVIKING_URL`: override server URL +- `OPENVIKING_API_KEY`: override API key +- `OPENVIKING_ACCOUNT`: override account +- `OPENVIKING_USER`: override user +- `OPENVIKING_AGENT_ID`: override agent identity -Supported environment overrides: +## Hook timeouts -- `OPENVIKING_CONFIG_FILE`: alternate `ov.conf` path -- `OPENVIKING_API_KEY`: API key override -- `OPENVIKING_ACCOUNT`: account identity, default from `ov.conf` -- `OPENVIKING_USER`: user identity, default from `ov.conf` -- `OPENVIKING_AGENT_ID`: agent identity, default `codex` -- `OPENVIKING_TIMEOUT_MS`: HTTP timeout, default `15000` -- `OPENVIKING_RECALL_LIMIT`: recall result limit, default `6` -- `OPENVIKING_SCORE_THRESHOLD`: recall threshold, default `0.01` +| Hook | Default timeout | Notes | +|------|-----------------|-------| +| `SessionStart` | `120s` | First session may need time to install runtime deps | +| `UserPromptSubmit` | `8s` | Recall must stay fast — keep `timeoutMs` low | +| `Stop` | `45s` | Gives capture room to finish | +| `PreCompact` | `60s` | Whole transcript posts plus commit | -## Tools +## Debug logging -### `find` +Set `OPENVIKING_DEBUG=1` or `codex.debug=true` in `ovcli.conf` to write structured JSON-Lines events to `~/.openviking/logs/codex-hooks.log`. Each entry is `{ts, hook, stage, data}` (or `error`). -Find OpenViking memory. +## MCP Tools + +### `openviking_recall` + +Search OpenViking memory. Parameters: -- `query`: find query -- `target_uri`: optional find scope, default `viking://user/memories` +- `query`: search query +- `target_uri`: optional search scope, default `viking://user/memories` - `limit`: optional max results - `score_threshold`: optional minimum score -### `remember` +### `openviking_store` -Store a memory by creating a short OpenViking session, adding the text, and -committing the session. Memory creation is extraction-dependent; the tool -reports when OpenViking commits the session but extracts zero memory items. +Store a memory by creating a short OpenViking session, adding the text, and committing. Memory creation is extraction-dependent; the tool reports when OpenViking commits the session but extracts zero items. Parameters: - `text`: information to store - `role`: optional message role, default `user` -### `forget` +### `openviking_forget` -Delete an exact memory URI. This example intentionally does not auto-delete by -query; use `find` first, then pass the exact URI. +Delete an exact memory URI. Use `openviking_recall` first to find the URI. Parameters: -- `uri`: exact `viking://user/.../memories/...` or `viking://agent/.../memories/...` URI +- `uri`: exact `viking://user/.../memories/...` or `viking://agent/.../memories/...` -### `health` +### `openviking_health` Check server reachability. -## Remove +## Plugin Structure -```bash -codex mcp remove openviking-memory ``` +codex-memory-plugin/ +├── .codex-plugin/ +│ └── plugin.json # Plugin manifest (hooks + mcp wiring) +├── hooks/ +│ └── hooks.json # SessionStart + UserPromptSubmit + Stop + PreCompact +├── scripts/ +│ ├── config.mjs # Shared config loader (ovcli.conf + env) +│ ├── debug-log.mjs # Structured JSONL logger +│ ├── runtime-common.mjs # Plugin data root + install-state helpers +│ ├── bootstrap-runtime.mjs # SessionStart installer +│ ├── start-memory-server.mjs # Launches MCP server through the runtime +│ ├── auto-recall.mjs # UserPromptSubmit hook +│ ├── auto-capture.mjs # Stop hook +│ └── pre-compact-capture.mjs # PreCompact hook (commits full transcript) +├── servers/ +│ └── memory-server.js # Compiled MCP server (checked in) +├── src/ +│ └── memory-server.ts # MCP server source +├── .mcp.json # MCP server definition (consumed by Codex) +├── package.json +├── tsconfig.json +└── README.md +``` + +## Differences from the Claude Code Plugin + +| Aspect | Claude Code Plugin | Codex Plugin | +|--------|--------------------|--------------| +| Plugin root env var | `CLAUDE_PLUGIN_ROOT` | `CODEX_PLUGIN_ROOT` | +| Plugin data env var | `CLAUDE_PLUGIN_DATA` | `CODEX_PLUGIN_DATA` | +| `UserPromptSubmit` injection | `decision: "approve"` + `hookSpecificOutput.additionalContext` | `hookSpecificOutput.additionalContext` only — `approve` is not a Codex output | +| `Stop` decision | `decision: "approve"` no-op | `{}` no-op — only `block` is a valid Codex `decision` | +| Compaction hook | n/a (Claude Code does not expose one) | `PreCompact` — full-transcript commit before context loss | +| Config section | `claude_code` | `codex` | +| Default config file | `~/.openviking/ov.conf` | `~/.openviking/ovcli.conf`, falls back to `ov.conf` | +| Identity headers | `X-OpenViking-Agent` only | Adds `X-OpenViking-Account` + `X-OpenViking-User` when configured | + +## License + +Apache-2.0 — same as [OpenViking](https://github.com/volcengine/OpenViking). diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json new file mode 100644 index 0000000000..54d498234c --- /dev/null +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -0,0 +1,52 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/bootstrap-runtime.mjs\"", + "timeout": 120 + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/auto-recall.mjs\"", + "timeout": 8 + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/auto-capture.mjs\"", + "timeout": 45 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/pre-compact-capture.mjs\"", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/examples/codex-memory-plugin/package-lock.json b/examples/codex-memory-plugin/package-lock.json new file mode 100644 index 0000000000..7b3c427977 --- /dev/null +++ b/examples/codex-memory-plugin/package-lock.json @@ -0,0 +1,1174 @@ +{ + "name": "codex-openviking-memory", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-openviking-memory", + "version": "0.2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/examples/codex-memory-plugin/package.json b/examples/codex-memory-plugin/package.json index f4ba3607b3..fa98326a02 100644 --- a/examples/codex-memory-plugin/package.json +++ b/examples/codex-memory-plugin/package.json @@ -1,7 +1,7 @@ { "name": "codex-openviking-memory", - "version": "0.1.0", - "description": "OpenViking memory MCP server for Codex", + "version": "0.2.0", + "description": "OpenViking memory plugin for Codex — hooks (recall/capture/pre-compact) + MCP server for explicit memory operations.", "type": "module", "scripts": { "build": "tsc", diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs new file mode 100644 index 0000000000..3df7c82d2d --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +/** + * Auto-Capture Hook Script for Codex. + * + * Triggered by the Stop hook. + * Codex passes `last_assistant_message`, `transcript_path`, `session_id`, `turn_id` on stdin. + * + * Strategy: + * 1. Use `last_assistant_message` directly when available (cheap path). + * 2. Fall back to incrementally parsing `transcript_path` (rollout JSONL). + * + * Each captured turn opens a short-lived OpenViking session, posts the text, + * extracts memories, then deletes the session. State per session_id tracks + * how many transcript turns we've already consumed so we don't re-capture. + * + * Codex Stop output schema does NOT support `decision: "approve"`. A no-op is `{}`. + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig } from "./config.mjs"; +import { createLogger } from "./debug-log.mjs"; + +const cfg = loadConfig(); +const { log, logError } = createLogger("auto-capture"); + +const STATE_DIR = join(tmpdir(), "openviking-codex-capture-state"); + +function output(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function noop(message) { + if (message) { + output({ systemMessage: message }); + } else { + output({}); + } +} + +async function fetchJSON(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); + try { + const headers = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; + if (cfg.user) headers["X-OpenViking-User"] = cfg.user; + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; + const res = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }); + const body = await res.json().catch(() => null); + if (!body) return null; + if (!res.ok || body.status === "error") return null; + return body.result ?? body; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +// --------------------------------------------------------------------------- +// State (per session_id, tracks last transcript turn index) +// --------------------------------------------------------------------------- + +function stateFilePath(sessionId) { + const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return join(STATE_DIR, `${safe}.json`); +} + +async function loadState(sessionId) { + try { + const data = await readFile(stateFilePath(sessionId), "utf-8"); + return JSON.parse(data); + } catch { + return { capturedTurnCount: 0, lastAssistantMessageHash: null }; + } +} + +async function saveState(sessionId, state) { + try { + await mkdir(STATE_DIR, { recursive: true }); + await writeFile(stateFilePath(sessionId), JSON.stringify(state)); + } catch { /* best effort */ } +} + +// --------------------------------------------------------------------------- +// Capture decision +// --------------------------------------------------------------------------- + +const MEMORY_TRIGGERS = [ + /remember|preference|prefer|important|decision|decided|always|never/i, + /记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i, + /[\w.-]+@[\w.-]+\.\w+/, + /\+\d{10,}/, + /(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i, + /(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i, + /(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i, +]; + +const RELEVANT_MEMORIES_BLOCK_RE = /[\s\S]*?<\/relevant-memories>/gi; +const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i; +const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u; +const CJK_CHAR_RE = /[぀-ヿ㐀-鿿豈-﫿가-힯]/; + +function sanitize(text) { + return text + .replace(RELEVANT_MEMORIES_BLOCK_RE, " ") + .replace(/\u0000/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function shouldCapture(text) { + const normalized = sanitize(text); + if (!normalized) return { capture: false, reason: "empty", text: "" }; + + const compact = normalized.replace(/\s+/g, ""); + const minLen = CJK_CHAR_RE.test(compact) ? 4 : 10; + if (compact.length < minLen || normalized.length > cfg.captureMaxLength) { + return { capture: false, reason: "length_out_of_range", text: normalized }; + } + + if (COMMAND_TEXT_RE.test(normalized)) { + return { capture: false, reason: "command", text: normalized }; + } + + if (NON_CONTENT_TEXT_RE.test(normalized)) { + return { capture: false, reason: "non_content", text: normalized }; + } + + if (cfg.captureMode === "keyword") { + for (const trigger of MEMORY_TRIGGERS) { + if (trigger.test(normalized)) { + return { capture: true, reason: `trigger:${trigger}`, text: normalized }; + } + } + return { capture: false, reason: "no_trigger", text: normalized }; + } + + return { capture: true, reason: "semantic", text: normalized }; +} + +// --------------------------------------------------------------------------- +// Transcript parsing (JSONL rollout) +// --------------------------------------------------------------------------- + +function extractTextFromContent(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((b) => b && (b.type === "text" || b.type === "input_text" || b.type === "output_text")) + .map((b) => b.text || "") + .join("\n"); + } + return ""; +} + +function parseTranscript(content) { + try { + const data = JSON.parse(content); + if (Array.isArray(data)) return data; + } catch { /* not a JSON array */ } + + const lines = content.split("\n").filter((l) => l.trim()); + const out = []; + for (const line of lines) { + try { out.push(JSON.parse(line)); } catch { /* skip */ } + } + return out; +} + +function extractTurns(rolloutEntries) { + const turns = []; + for (const entry of rolloutEntries) { + if (!entry || typeof entry !== "object") continue; + // Codex rollout entries can be wrapped in payload. + const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : entry; + let role = payload.role; + let text = ""; + + if (typeof payload.content === "string") { + text = payload.content; + } else if (Array.isArray(payload.content)) { + text = extractTextFromContent(payload.content); + } else if (payload.message && typeof payload.message === "object") { + role = payload.message.role || role; + text = typeof payload.message.content === "string" + ? payload.message.content + : extractTextFromContent(payload.message.content); + } + + if (role !== "user" && role !== "assistant") continue; + if (text.trim()) turns.push({ role, text: text.trim() }); + } + return turns; +} + +// --------------------------------------------------------------------------- +// Capture +// --------------------------------------------------------------------------- + +async function captureToOpenViking(text) { + const sessionResult = await fetchJSON("/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }); + if (!sessionResult?.session_id) return { ok: false, reason: "session_create_failed" }; + + const ovSessionId = sessionResult.session_id; + + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role: "user", content: text }), + }); + + const commit = await fetchJSON( + `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, + { method: "POST", body: JSON.stringify({}) }, + ); + + const extractedCount = commit?.memories_extracted && typeof commit.memories_extracted === "object" + ? Object.values(commit.memories_extracted).reduce((a, b) => a + (typeof b === "number" ? b : 0), 0) + : (typeof commit?.memories_extracted === "number" ? commit.memories_extracted : 0); + + return { + ok: true, + count: extractedCount, + ovSessionId, + archived: commit?.archived ?? false, + taskId: commit?.task_id, + status: commit?.status, + }; +} + +function fastHash(text) { + let h = 0; + for (let i = 0; i < text.length; i++) { + h = ((h << 5) - h + text.charCodeAt(i)) | 0; + } + return String(h); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + if (!cfg.autoCapture) { + log("skip", { stage: "init", reason: "autoCapture disabled" }); + noop(); + return; + } + + let input; + try { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + input = JSON.parse(Buffer.concat(chunks).toString()); + } catch { + log("skip", { stage: "stdin_parse", reason: "invalid input" }); + noop(); + return; + } + + const sessionId = input.session_id || "unknown"; + const transcriptPath = input.transcript_path || null; + const lastAssistantMessage = input.last_assistant_message || null; + log("start", { + sessionId, + transcriptPath, + hasLastAssistantMessage: Boolean(lastAssistantMessage), + }); + + const health = await fetchJSON("/health"); + if (!health) { + logError("health_check", "server unreachable or unhealthy"); + noop(); + return; + } + + const state = await loadState(sessionId); + let totalCaptured = 0; + let totalExtracted = 0; + + // Strategy A: capture user turns from transcript (incremental). + if (transcriptPath) { + let raw; + try { + raw = await readFile(transcriptPath, "utf-8"); + } catch (err) { + logError("transcript_read", err); + raw = null; + } + + if (raw && raw.trim()) { + const entries = parseTranscript(raw); + const allTurns = extractTurns(entries); + const newTurns = allTurns.slice(state.capturedTurnCount); + const captureTurns = cfg.captureAssistantTurns + ? newTurns + : newTurns.filter((t) => t.role === "user"); + + log("transcript_parse", { + totalTurns: allTurns.length, + previouslyCaptured: state.capturedTurnCount, + newTurns: newTurns.length, + captureTurns: captureTurns.length, + }); + + if (captureTurns.length > 0) { + const turnText = captureTurns.map((t) => `[${t.role}]: ${t.text}`).join("\n"); + const decision = shouldCapture(turnText); + log("should_capture_transcript", { capture: decision.capture, reason: decision.reason }); + if (decision.capture) { + const result = await captureToOpenViking(decision.text); + log("openviking_capture_transcript", { + sessionCreated: result.ok, + ovSessionId: result.ovSessionId, + extracted: result.count || 0, + }); + if (result.ok) { + totalCaptured += captureTurns.length; + totalExtracted += result.count || 0; + } + } + } + + state.capturedTurnCount = allTurns.length; + } + } + + // Strategy B: capture last_assistant_message (independent of transcript availability). + // Only when (a) we want assistant turns or (b) transcript was unavailable. + if (cfg.captureLastAssistantOnStop && lastAssistantMessage) { + const hash = fastHash(lastAssistantMessage); + if (hash !== state.lastAssistantMessageHash) { + const decision = shouldCapture(lastAssistantMessage); + log("should_capture_last_assistant", { capture: decision.capture, reason: decision.reason }); + if (decision.capture) { + const result = await captureToOpenViking(decision.text); + log("openviking_capture_last_assistant", { + sessionCreated: result.ok, + ovSessionId: result.ovSessionId, + extracted: result.count || 0, + }); + if (result.ok) { + totalCaptured += 1; + totalExtracted += result.count || 0; + } + } + state.lastAssistantMessageHash = hash; + } else { + log("skip", { stage: "last_assistant_dedup", reason: "same hash as last capture" }); + } + } + + await saveState(sessionId, state); + + if (totalExtracted > 0) { + log("done", { captured: totalCaptured, extracted: totalExtracted }); + noop(`captured ${totalCaptured} turn(s), extracted ${totalExtracted} memory item(s)`); + return; + } + + if (totalCaptured > 0) { + log("done", { captured: totalCaptured, extracted: 0 }); + } + noop(); +} + +main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/auto-recall.mjs b/examples/codex-memory-plugin/scripts/auto-recall.mjs new file mode 100644 index 0000000000..892b580562 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-recall.mjs @@ -0,0 +1,348 @@ +#!/usr/bin/env node + +/** + * Auto-Recall Hook Script for Codex. + * + * Triggered by UserPromptSubmit hook. + * Reads `prompt` from stdin → searches OpenViking → returns recalled memories + * via `hookSpecificOutput.additionalContext` so Codex injects them into the turn. + * + * Codex output schema (codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json): + * { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: "" } } + * — `decision: "approve"` is NOT a codex thing; only `decision: "block"` is. So a no-op + * is just `{}`. + */ + +import { loadConfig } from "./config.mjs"; +import { createLogger } from "./debug-log.mjs"; + +const cfg = loadConfig(); +const { log, logError } = createLogger("auto-recall"); + +function output(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function emit(additionalContext) { + if (!additionalContext) { + output({}); + return; + } + output({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext, + }, + }); +} + +async function fetchJSON(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), cfg.timeoutMs); + try { + const headers = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; + if (cfg.user) headers["X-OpenViking-User"] = cfg.user; + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; + const res = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }); + const body = await res.json().catch(() => null); + if (!body) return null; + if (!res.ok || body.status === "error") return null; + return body.result ?? body; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +// --------------------------------------------------------------------------- +// Ranking +// --------------------------------------------------------------------------- + +function clampScore(v) { + if (typeof v !== "number" || Number.isNaN(v)) return 0; + return Math.max(0, Math.min(1, v)); +} + +const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i; +const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i; +const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi; +const STOPWORDS = new Set([ + "what", "when", "where", "which", "who", "whom", "whose", "why", "how", "did", "does", + "is", "are", "was", "were", "the", "and", "for", "with", "from", "that", "this", "your", "you", +]); + +function buildQueryProfile(query) { + const text = query.trim(); + const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || []; + const tokens = allTokens.filter((t) => !STOPWORDS.has(t)); + return { + tokens, + wantsPreference: PREFERENCE_QUERY_RE.test(text), + wantsTemporal: TEMPORAL_QUERY_RE.test(text), + }; +} + +function lexicalOverlapBoost(tokens, text) { + if (tokens.length === 0 || !text) return 0; + const haystack = ` ${text.toLowerCase()} `; + let matched = 0; + for (const token of tokens.slice(0, 8)) { + if (haystack.includes(token)) matched += 1; + } + return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2); +} + +function getRankingBreakdown(item, profile) { + const base = clampScore(item.score); + const abstract = (item.abstract || item.overview || "").trim(); + const cat = (item.category || "").toLowerCase(); + const uri = item.uri.toLowerCase(); + const leafBoost = (item.level === 2 || uri.endsWith(".md")) ? 0.12 : 0; + const eventBoost = profile.wantsTemporal && (cat === "events" || uri.includes("/events/")) ? 0.1 : 0; + const prefBoost = profile.wantsPreference && (cat === "preferences" || uri.includes("/preferences/")) ? 0.08 : 0; + const overlapBoost = lexicalOverlapBoost(profile.tokens, `${item.uri} ${abstract}`); + return { + baseScore: base, + leafBoost, + eventBoost, + prefBoost, + overlapBoost, + finalScore: base + leafBoost + eventBoost + prefBoost + overlapBoost, + }; +} + +function rankForInjection(item, profile) { + return getRankingBreakdown(item, profile).finalScore; +} + +function dedupeByAbstract(items) { + const seen = new Set(); + return items.filter((item) => { + const key = (item.abstract || item.overview || "").trim().toLowerCase() || item.uri; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function pickMemories(items, limit, queryText) { + if (items.length === 0 || limit <= 0) return []; + const profile = buildQueryProfile(queryText); + const sorted = [...items].sort((a, b) => rankForInjection(b, profile) - rankForInjection(a, profile)); + const deduped = dedupeByAbstract(sorted); + const leaves = deduped.filter((m) => m.level === 2 || m.uri.endsWith(".md")); + if (leaves.length >= limit) return leaves.slice(0, limit); + const picked = [...leaves]; + const used = new Set(picked.map((m) => m.uri)); + for (const item of deduped) { + if (picked.length >= limit) break; + if (used.has(item.uri)) continue; + picked.push(item); + } + return picked; +} + +function postProcess(items, limit, threshold) { + const seen = new Set(); + const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score)); + const result = []; + for (const item of sorted) { + if (item.level !== 2) continue; + if (clampScore(item.score) < threshold) continue; + const cat = (item.category || "").toLowerCase() || "unknown"; + const abs = (item.abstract || item.overview || "").trim().toLowerCase(); + const key = abs ? `${cat}:${abs}` : `uri:${item.uri}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + if (result.length >= limit) break; + } + return result; +} + +// --------------------------------------------------------------------------- +// URI space resolution (mirrors MCP normalizeTargetUri) +// --------------------------------------------------------------------------- + +const USER_RESERVED_DIRS = new Set(["memories"]); +const AGENT_RESERVED_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]); +const _spaceCache = {}; + +async function resolveScopeSpace(scope) { + if (_spaceCache[scope]) return _spaceCache[scope]; + + let fallbackSpace = "default"; + try { + const status = await fetchJSON("/api/v1/system/status"); + if (status && typeof status.user === "string" && status.user.trim()) { + fallbackSpace = status.user.trim(); + } + } catch { /* fallback */ } + + const reservedDirs = scope === "user" ? USER_RESERVED_DIRS : AGENT_RESERVED_DIRS; + try { + const entries = await fetchJSON(`/api/v1/fs/ls?uri=${encodeURIComponent(`viking://${scope}`)}&output=original`); + if (Array.isArray(entries)) { + const spaces = entries + .filter((e) => e?.isDir) + .map((e) => (typeof e.name === "string" ? e.name.trim() : "")) + .filter((n) => n && !n.startsWith(".") && !reservedDirs.has(n)); + if (spaces.length > 0) { + if (spaces.includes(fallbackSpace)) { _spaceCache[scope] = fallbackSpace; return fallbackSpace; } + if (scope === "user" && spaces.includes("default")) { _spaceCache[scope] = "default"; return "default"; } + if (spaces.length === 1) { _spaceCache[scope] = spaces[0]; return spaces[0]; } + } + } + } catch { /* fallback */ } + + _spaceCache[scope] = fallbackSpace; + return fallbackSpace; +} + +async function resolveTargetUri(targetUri) { + const trimmed = targetUri.trim().replace(/\/+$/, ""); + const m = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); + if (!m) return trimmed; + const scope = m[1]; + const rawRest = (m[2] ?? "").trim(); + if (!rawRest) return trimmed; + const parts = rawRest.split("/").filter(Boolean); + if (parts.length === 0) return trimmed; + const reservedDirs = scope === "user" ? USER_RESERVED_DIRS : AGENT_RESERVED_DIRS; + if (!reservedDirs.has(parts[0])) return trimmed; + const space = await resolveScopeSpace(scope); + return `viking://${scope}/${space}/${parts.join("/")}`; +} + +async function searchScope(query, targetUri, limit) { + const resolvedUri = await resolveTargetUri(targetUri); + const result = await fetchJSON("/api/v1/search/find", { + method: "POST", + body: JSON.stringify({ query, target_uri: resolvedUri, limit, score_threshold: 0 }), + }); + return result?.memories || []; +} + +async function searchAll(query, limit) { + const [userMems, agentMems, agentSkills] = await Promise.all([ + searchScope(query, "viking://user/memories", limit), + searchScope(query, "viking://agent/memories", limit), + searchScope(query, "viking://agent/skills", limit), + ]); + log("search_complete", { scope: "user", rawCount: userMems.length, topScores: userMems.slice(0, 3).map((m) => m.score) }); + log("search_complete", { scope: "agent", rawCount: agentMems.length, topScores: agentMems.slice(0, 3).map((m) => m.score) }); + log("search_complete", { scope: "skills", rawCount: agentSkills.length, topScores: agentSkills.slice(0, 3).map((m) => m.score) }); + const all = [...userMems, ...agentMems, ...agentSkills]; + const seen = new Set(); + return all.filter((m) => { + if (seen.has(m.uri)) return false; + seen.add(m.uri); + return true; + }); +} + +async function readMemoryContent(uri) { + try { + const result = await fetchJSON(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`); + if (result && typeof result === "string" && result.trim()) return result.trim(); + } catch { /* fallback */ } + return null; +} + +async function main() { + if (!cfg.autoRecall) { + log("skip", { stage: "init", reason: "autoRecall disabled" }); + emit(); + return; + } + + let input; + try { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + input = JSON.parse(Buffer.concat(chunks).toString()); + } catch { + log("skip", { stage: "stdin_parse", reason: "invalid input" }); + emit(); + return; + } + + const userPrompt = (input.prompt || "").trim(); + log("start", { + query: userPrompt.slice(0, 200), + queryLength: userPrompt.length, + config: { recallLimit: cfg.recallLimit, scoreThreshold: cfg.scoreThreshold }, + }); + + if (!userPrompt || userPrompt.length < cfg.minQueryLength) { + log("skip", { stage: "query_check", reason: "query too short or empty" }); + emit(); + return; + } + + const health = await fetchJSON("/health"); + if (!health) { + logError("health_check", "server unreachable or unhealthy"); + emit(); + return; + } + + const candidateLimit = Math.max(cfg.recallLimit * 4, 20); + const allMemories = await searchAll(userPrompt, candidateLimit); + if (allMemories.length === 0) { + log("skip", { stage: "search", reason: "no results" }); + emit(); + return; + } + + const processed = postProcess(allMemories, candidateLimit, cfg.scoreThreshold); + log("post_process", { beforeCount: allMemories.length, afterCount: processed.length }); + + const profile = buildQueryProfile(userPrompt); + const ranked = [...processed] + .map((item) => ({ item, breakdown: getRankingBreakdown(item, profile) })) + .sort((a, b) => b.breakdown.finalScore - a.breakdown.finalScore); + + if (cfg.logRankingDetails) { + for (const entry of ranked) { + log("ranking_detail", { uri: entry.item.uri, ...entry.breakdown }); + } + } else { + log("ranking_summary", { + candidateCount: processed.length, + topCandidates: ranked.slice(0, 5).map((entry) => ({ uri: entry.item.uri, finalScore: entry.breakdown.finalScore })), + }); + } + + const memories = pickMemories(processed, cfg.recallLimit, userPrompt); + if (memories.length === 0) { + log("skip", { stage: "pick", reason: "no memories survived ranking" }); + emit(); + return; + } + + log("picked", { pickedCount: memories.length, uris: memories.map((m) => m.uri) }); + + const lines = await Promise.all( + memories.map(async (item) => { + if (item.level === 2) { + const content = await readMemoryContent(item.uri); + if (content) return `- [${item.category || "memory"}] ${content}`; + } + return `- [${item.category || "memory"}] ${(item.abstract || item.overview || item.uri).trim()}`; + }), + ); + + const memoryContext = + "\n" + + "The following long-term memories from OpenViking may be relevant to this conversation:\n" + + lines.join("\n") + "\n" + + ""; + + emit(memoryContext); +} + +main().catch((err) => { logError("uncaught", err); emit(); }); diff --git a/examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs b/examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs new file mode 100644 index 0000000000..bc94705a08 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs @@ -0,0 +1,39 @@ +import { + computeSourceState, + ensureRuntimeInstalled, + getRuntimePaths, +} from "./runtime-common.mjs"; + +async function main() { + // Codex hook stdin: JSON object — we ignore it (SessionStart payload). + // Read & discard to keep the pipe clean across platforms. + process.stdin.resume(); + for await (const _ of process.stdin) { /* drain */ } + + let paths; + try { + paths = getRuntimePaths(); + } catch (err) { + process.stderr.write( + `[openviking-memory] CODEX_PLUGIN_ROOT not set; skipping runtime bootstrap. ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + + const expectedState = await computeSourceState(paths); + + try { + await ensureRuntimeInstalled(paths, expectedState); + } catch (err) { + process.stderr.write( + `[openviking-memory] Failed to prepare MCP runtime dependencies: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } +} + +main().catch((err) => { + process.stderr.write( + `[openviking-memory] Runtime bootstrap failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(0); +}); diff --git a/examples/codex-memory-plugin/scripts/config.mjs b/examples/codex-memory-plugin/scripts/config.mjs new file mode 100644 index 0000000000..8dea865d8a --- /dev/null +++ b/examples/codex-memory-plugin/scripts/config.mjs @@ -0,0 +1,130 @@ +/** + * Shared configuration loader for the Codex OpenViking memory plugin. + * + * Reads connection settings from `~/.openviking/ovcli.conf` (the canonical CLIENT + * config that the `ov` CLI uses), and falls back to the legacy `~/.openviking/ov.conf` + * server config when ovcli.conf is missing. + * + * Plugin-specific overrides go in an optional `codex` section of either file. + * + * Env vars: + * OPENVIKING_CONFIG_FILE alternate ovcli.conf path + * OPENVIKING_URL override server URL + * OPENVIKING_API_KEY override API key + * OPENVIKING_ACCOUNT override account + * OPENVIKING_USER override user + * OPENVIKING_AGENT_ID override agent identity + * OPENVIKING_DEBUG=1 enable debug logging + * OPENVIKING_DEBUG_LOG debug log path + */ + +import { readFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; + +const DEFAULT_CLI_CONFIG = join(homedir(), ".openviking", "ovcli.conf"); +const DEFAULT_SERVER_CONFIG = join(homedir(), ".openviking", "ov.conf"); + +function num(val, fallback) { + if (typeof val === "number" && Number.isFinite(val)) return val; + if (typeof val === "string" && val.trim()) { + const n = Number(val); + if (Number.isFinite(n)) return n; + } + return fallback; +} + +function str(val, fallback) { + if (typeof val === "string" && val.trim()) return val.trim(); + return fallback; +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +function deriveBaseUrl(file) { + const direct = str(file?.url, ""); + if (direct) return direct.replace(/\/+$/, ""); + const server = file?.server || {}; + const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); + const port = Math.floor(num(server.port, 1933)); + return `http://${host}:${port}`; +} + +export function loadConfig() { + const explicitPath = process.env.OPENVIKING_CONFIG_FILE + ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) + : null; + + const candidates = explicitPath + ? [explicitPath] + : [DEFAULT_CLI_CONFIG, DEFAULT_SERVER_CONFIG]; + + let configPath = null; + let file = null; + for (const candidate of candidates) { + if (existsSync(candidate)) { + configPath = candidate; + file = readJson(candidate) || {}; + break; + } + } + if (!file) { + file = {}; + configPath = explicitPath || DEFAULT_CLI_CONFIG; + } + + const baseUrlFromFile = deriveBaseUrl(file); + const baseUrl = (str(process.env.OPENVIKING_URL, baseUrlFromFile) || "http://127.0.0.1:1933").replace(/\/+$/, ""); + + const apiKeyFromFile = str(file.api_key, "") || str(file?.server?.root_api_key, ""); + const apiKey = str(process.env.OPENVIKING_API_KEY, apiKeyFromFile); + + const account = str(process.env.OPENVIKING_ACCOUNT, str(file.account, "")); + const user = str(process.env.OPENVIKING_USER, str(file.user, "")); + + const cx = file.codex || {}; + + const debug = cx.debug === true || process.env.OPENVIKING_DEBUG === "1"; + const defaultLogPath = join(homedir(), ".openviking", "logs", "codex-hooks.log"); + const debugLogPath = str(process.env.OPENVIKING_DEBUG_LOG, defaultLogPath); + + const timeoutMs = Math.max(1000, Math.floor(num(cx.timeoutMs, 15000))); + const captureTimeoutMs = Math.max( + 1000, + Math.floor(num(cx.captureTimeoutMs, Math.max(timeoutMs * 2, 30000))), + ); + + return { + configPath, + baseUrl, + apiKey, + account, + user, + agentId: str(process.env.OPENVIKING_AGENT_ID, str(cx.agentId, "codex")), + timeoutMs, + + autoRecall: cx.autoRecall !== false, + recallLimit: Math.max(1, Math.floor(num(cx.recallLimit, 6))), + scoreThreshold: Math.min(1, Math.max(0, num(cx.scoreThreshold, 0.01))), + minQueryLength: Math.max(1, Math.floor(num(cx.minQueryLength, 3))), + logRankingDetails: cx.logRankingDetails === true, + + autoCapture: cx.autoCapture !== false, + captureMode: cx.captureMode === "keyword" ? "keyword" : "semantic", + captureMaxLength: Math.max(200, Math.floor(num(cx.captureMaxLength, 24000))), + captureTimeoutMs, + captureAssistantTurns: cx.captureAssistantTurns === true, + captureLastAssistantOnStop: cx.captureLastAssistantOnStop !== false, + + autoCommitOnCompact: cx.autoCommitOnCompact !== false, + + debug, + debugLogPath, + }; +} diff --git a/examples/codex-memory-plugin/scripts/debug-log.mjs b/examples/codex-memory-plugin/scripts/debug-log.mjs new file mode 100644 index 0000000000..c9c2238fd6 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/debug-log.mjs @@ -0,0 +1,64 @@ +/** + * Shared structured debug logger for Codex hook scripts. + * + * Activation: OPENVIKING_DEBUG=1 env var OR codex.debug=true in ovcli.conf/ov.conf. + * Log path: OPENVIKING_DEBUG_LOG env var OR ~/.openviking/logs/codex-hooks.log. + * Format: JSON Lines — { ts, hook, stage, data } | { ts, hook, stage, error }. + */ + +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { loadConfig } from "./config.mjs"; + +let _cfg; +function cfg() { + if (!_cfg) _cfg = loadConfig(); + return _cfg; +} + +function ensureDir(filePath) { + try { + mkdirSync(dirname(filePath), { recursive: true }); + } catch { /* best effort */ } +} + +function writeLine(filePath, obj) { + try { + appendFileSync(filePath, JSON.stringify(obj) + "\n"); + } catch { /* best effort */ } +} + +function localISO() { + const d = new Date(); + const off = d.getTimezoneOffset(); + const sign = off <= 0 ? "+" : "-"; + const abs = Math.abs(off); + const local = new Date(d.getTime() - off * 60000); + return local.toISOString().replace( + "Z", + `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`, + ); +} + +const noop = () => {}; + +export function createLogger(hookName, overrideCfg) { + const c = overrideCfg || cfg(); + if (!c.debug) return { log: noop, logError: noop }; + + const logPath = c.debugLogPath; + ensureDir(logPath); + + function log(stage, data) { + writeLine(logPath, { ts: localISO(), hook: hookName, stage, data }); + } + + function logError(stage, err) { + const error = err instanceof Error + ? { message: err.message, stack: err.stack } + : String(err); + writeLine(logPath, { ts: localISO(), hook: hookName, stage, error }); + } + + return { log, logError }; +} diff --git a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs new file mode 100644 index 0000000000..d292f60650 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +/** + * Pre-Compact Hook Script for Codex. + * + * Triggered by the PreCompact hook. Codex passes: + * { session_id, transcript_path, cwd, hook_event_name: "PreCompact", + * model, trigger: "manual"|"auto", turn_id } + * + * Codex is about to summarize / compact the conversation, dropping detail. + * Before that happens, we open ONE OpenViking session, push every uncaptured + * turn from the rollout in order, and commit. This produces a structured + * extraction that survives compaction. + * + * PreCompact output schema (codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json) + * does NOT support `decision`. Valid keys: continue, stopReason, suppressOutput, systemMessage. + * No-op output is `{}`. + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig } from "./config.mjs"; +import { createLogger } from "./debug-log.mjs"; + +const cfg = loadConfig(); +const { log, logError } = createLogger("pre-compact"); + +const STATE_DIR = join(tmpdir(), "openviking-codex-capture-state"); + +function output(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function noop(message) { + if (message) { + output({ systemMessage: message }); + } else { + output({}); + } +} + +async function fetchJSON(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); + try { + const headers = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; + if (cfg.user) headers["X-OpenViking-User"] = cfg.user; + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; + const res = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }); + const body = await res.json().catch(() => null); + if (!body) return null; + if (!res.ok || body.status === "error") return null; + return body.result ?? body; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function stateFilePath(sessionId) { + const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return join(STATE_DIR, `${safe}.json`); +} + +async function loadState(sessionId) { + try { + const data = await readFile(stateFilePath(sessionId), "utf-8"); + return JSON.parse(data); + } catch { + return { capturedTurnCount: 0, lastAssistantMessageHash: null, compactedAt: null }; + } +} + +async function saveState(sessionId, state) { + try { + await mkdir(STATE_DIR, { recursive: true }); + await writeFile(stateFilePath(sessionId), JSON.stringify(state)); + } catch { /* best effort */ } +} + +function extractTextFromContent(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((b) => b && (b.type === "text" || b.type === "input_text" || b.type === "output_text")) + .map((b) => b.text || "") + .join("\n"); + } + return ""; +} + +function parseTranscript(content) { + try { + const data = JSON.parse(content); + if (Array.isArray(data)) return data; + } catch { /* not array */ } + const lines = content.split("\n").filter((l) => l.trim()); + const out = []; + for (const line of lines) { + try { out.push(JSON.parse(line)); } catch { /* skip */ } + } + return out; +} + +function extractTurns(entries) { + const turns = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : entry; + let role = payload.role; + let text = ""; + + if (typeof payload.content === "string") { + text = payload.content; + } else if (Array.isArray(payload.content)) { + text = extractTextFromContent(payload.content); + } else if (payload.message && typeof payload.message === "object") { + role = payload.message.role || role; + text = typeof payload.message.content === "string" + ? payload.message.content + : extractTextFromContent(payload.message.content); + } + + if (role !== "user" && role !== "assistant") continue; + if (text.trim()) turns.push({ role, text: text.trim() }); + } + return turns; +} + +async function commitFullSession(turns) { + const created = await fetchJSON("/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }); + if (!created?.session_id) return { ok: false, reason: "session_create_failed" }; + const ovSessionId = created.session_id; + + for (const turn of turns) { + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role: turn.role, content: turn.text }), + }); + } + + const commit = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, { + method: "POST", + body: JSON.stringify({}), + }); + + return { + ok: true, + ovSessionId, + extracted: commit?.memories_extracted || null, + archived: commit?.archived ?? false, + taskId: commit?.task_id, + status: commit?.status, + }; +} + +async function main() { + if (!cfg.autoCommitOnCompact) { + log("skip", { stage: "init", reason: "autoCommitOnCompact disabled" }); + noop(); + return; + } + + let input; + try { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + input = JSON.parse(Buffer.concat(chunks).toString()); + } catch { + log("skip", { stage: "stdin_parse", reason: "invalid input" }); + noop(); + return; + } + + const sessionId = input.session_id || "unknown"; + const transcriptPath = input.transcript_path || null; + const trigger = input.trigger || "auto"; + log("start", { sessionId, transcriptPath, trigger }); + + const health = await fetchJSON("/health"); + if (!health) { + logError("health_check", "server unreachable"); + noop(); + return; + } + + if (!transcriptPath) { + log("skip", { stage: "input_check", reason: "no transcript_path" }); + noop(); + return; + } + + let raw; + try { + raw = await readFile(transcriptPath, "utf-8"); + } catch (err) { + logError("transcript_read", err); + noop(); + return; + } + if (!raw.trim()) { + log("skip", { stage: "transcript_read", reason: "empty" }); + noop(); + return; + } + + const entries = parseTranscript(raw); + const allTurns = extractTurns(entries); + log("transcript_parse", { totalTurns: allTurns.length }); + + if (allTurns.length === 0) { + noop(); + return; + } + + // Truncate over-long turns rather than dropping them — compaction is a one-shot. + const trimmed = allTurns.map((turn) => ({ + role: turn.role, + text: turn.text.length > cfg.captureMaxLength + ? turn.text.slice(0, cfg.captureMaxLength) + : turn.text, + })); + + const result = await commitFullSession(trimmed); + log("commit_full_session", result); + + // Mark transcript as fully consumed so Stop hook stops re-capturing. + const state = await loadState(sessionId); + state.capturedTurnCount = allTurns.length; + state.compactedAt = Date.now(); + await saveState(sessionId, state); + + if (result.ok) { + const mem = result.extracted && typeof result.extracted === "object" + ? Object.values(result.extracted).reduce((a, b) => a + (typeof b === "number" ? b : 0), 0) + : 0; + noop(`pre-compact commit: ${trimmed.length} turns sent to OpenViking, ${mem} memory item(s) extracted${result.archived ? " (archived)" : ""}`); + } else { + noop(); + } +} + +main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/runtime-common.mjs b/examples/codex-memory-plugin/scripts/runtime-common.mjs new file mode 100644 index 0000000000..14f2abefc8 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/runtime-common.mjs @@ -0,0 +1,278 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { access, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const INSTALL_TIMEOUT_MS = 120000; + +const LOCK_STALE_MS = 10 * 60 * 1000; +const FALLBACK_PLUGIN_DATA_ROOT = join(homedir(), ".openviking", "codex-memory-plugin"); +const RUNTIME_ENV_META_PATH = ".runtime-env.json"; + +export function getRuntimePaths() { + const pluginRoot = process.env.CODEX_PLUGIN_ROOT; + const pluginDataRoot = process.env.CODEX_PLUGIN_DATA || FALLBACK_PLUGIN_DATA_ROOT; + + if (!pluginRoot) throw new Error("CODEX_PLUGIN_ROOT is not set"); + + const runtimeRoot = join(pluginDataRoot, "runtime"); + + return { + pluginRoot, + pluginDataRoot, + runtimeRoot, + sourcePackagePath: join(pluginRoot, "package.json"), + sourceLockPath: join(pluginRoot, "package-lock.json"), + sourceServerPath: join(pluginRoot, "servers", "memory-server.js"), + runtimePackagePath: join(runtimeRoot, "package.json"), + runtimeLockPath: join(runtimeRoot, "package-lock.json"), + runtimeServerPath: join(runtimeRoot, "servers", "memory-server.js"), + runtimeNodeModulesPath: join(runtimeRoot, "node_modules"), + statePath: join(runtimeRoot, "install-state.json"), + lockDir: join(runtimeRoot, ".install-lock"), + envMetaPath: join(runtimeRoot, RUNTIME_ENV_META_PATH), + usingFallbackPluginData: !process.env.CODEX_PLUGIN_DATA, + }; +} + +export async function computeSourceState(paths) { + const [pkgRaw, lockRaw, serverRaw] = await Promise.all([ + readFile(paths.sourcePackagePath), + readFile(paths.sourceLockPath), + readFile(paths.sourceServerPath), + ]); + + const pkg = JSON.parse(pkgRaw.toString("utf8")); + + return { + pluginVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + manifestHash: sha256(pkgRaw, lockRaw), + serverHash: sha256(serverRaw), + }; +} + +export async function loadInstallState(paths) { + try { + return JSON.parse(await readFile(paths.statePath, "utf8")); + } catch { + return null; + } +} + +export async function writeInstallState(paths, state) { + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile( + paths.statePath, + JSON.stringify( + { + ...state, + updatedAt: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + ); +} + +export async function writeRuntimeEnvMeta(paths) { + await mkdir(paths.runtimeRoot, { recursive: true }); + await writeFile( + paths.envMetaPath, + JSON.stringify( + { + pluginDataRoot: paths.pluginDataRoot, + runtimeRoot: paths.runtimeRoot, + usingFallbackPluginData: paths.usingFallbackPluginData, + updatedAt: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + ); +} + +export async function runtimeIsReady(paths, expectedState) { + const state = await loadInstallState(paths); + if (!state || state.status !== "ready") return false; + if (state.manifestHash !== expectedState.manifestHash) return false; + if (state.serverHash !== expectedState.serverHash) return false; + + for (const target of [ + paths.runtimePackagePath, + paths.runtimeLockPath, + paths.runtimeServerPath, + paths.runtimeNodeModulesPath, + ]) { + if (!(await pathExists(target))) return false; + } + + return true; +} + +export async function syncRuntimeFiles(paths) { + await mkdir(join(paths.runtimeRoot, "servers"), { recursive: true }); + await copyFile(paths.sourcePackagePath, paths.runtimePackagePath); + await copyFile(paths.sourceLockPath, paths.runtimeLockPath); + await copyFile(paths.sourceServerPath, paths.runtimeServerPath); + await writeRuntimeEnvMeta(paths); +} + +export async function acquireInstallLock(paths, timeoutMs = INSTALL_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + await mkdir(paths.runtimeRoot, { recursive: true }); + + try { + await mkdir(paths.lockDir); + await writeFile( + join(paths.lockDir, "owner.json"), + JSON.stringify({ + pid: process.pid, + createdAt: new Date().toISOString(), + }) + "\n", + ); + return async () => { + await rm(paths.lockDir, { recursive: true, force: true }); + }; + } catch (err) { + if (err?.code !== "EEXIST") throw err; + + if (await isStaleLock(paths.lockDir)) { + await rm(paths.lockDir, { recursive: true, force: true }); + continue; + } + + await wait(500); + } + } + + throw new Error(`Timed out waiting for install lock in ${paths.runtimeRoot}`); +} + +export async function waitForRuntime(paths, expectedState, options = {}) { + const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS; + const pollMs = options.pollMs ?? 500; + const graceMs = options.graceMs ?? 5000; + const startedAt = Date.now(); + let sawLock = await pathExists(paths.lockDir); + + while (Date.now() - startedAt < timeoutMs) { + if (await runtimeIsReady(paths, expectedState)) return true; + + const lockExists = await pathExists(paths.lockDir); + sawLock ||= lockExists; + + if (!sawLock && Date.now() - startedAt >= graceMs) return false; + + await wait(pollMs); + } + + return runtimeIsReady(paths, expectedState); +} + +export function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function ensureRuntimeInstalled(paths, expectedState) { + if (await runtimeIsReady(paths, expectedState)) return false; + + const releaseLock = await acquireInstallLock(paths, INSTALL_TIMEOUT_MS); + + try { + if (await runtimeIsReady(paths, expectedState)) return false; + + await syncRuntimeFiles(paths); + const result = spawnSync(getNpmCommand(), installArgs(), { + cwd: paths.runtimeRoot, + encoding: "utf8", + stdio: "pipe", + shell: process.platform === "win32", + }); + + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(formatInstallFailure(result)); + + await writeInstallState(paths, { + status: "ready", + pluginVersion: expectedState.pluginVersion, + manifestHash: expectedState.manifestHash, + serverHash: expectedState.serverHash, + pluginDataRoot: paths.pluginDataRoot, + usingFallbackPluginData: paths.usingFallbackPluginData, + }); + + return true; + } catch (err) { + await rm(paths.runtimeNodeModulesPath, { recursive: true, force: true }); + + await writeInstallState(paths, { + status: "error", + pluginVersion: expectedState.pluginVersion, + manifestHash: expectedState.manifestHash, + serverHash: expectedState.serverHash, + pluginDataRoot: paths.pluginDataRoot, + usingFallbackPluginData: paths.usingFallbackPluginData, + error: err instanceof Error ? err.message : String(err), + }); + + throw err; + } finally { + await releaseLock(); + } +} + +async function pathExists(target) { + try { + await access(target, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +async function isStaleLock(lockDir) { + try { + const info = await stat(lockDir); + return Date.now() - info.mtimeMs > LOCK_STALE_MS; + } catch { + return false; + } +} + +function sha256(...buffers) { + const hash = createHash("sha256"); + for (const buf of buffers) hash.update(buf); + return hash.digest("hex"); +} + +function getNpmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function installArgs() { + return ["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]; +} + +function formatInstallFailure(result) { + const lines = [ + `npm ci exited with status ${result.status ?? "unknown"}`, + trimOutput(result.stderr), + trimOutput(result.stdout), + ].filter(Boolean); + + return lines.join("\n"); +} + +function trimOutput(output) { + if (!output) return ""; + const text = output.trim(); + if (!text) return ""; + + const maxChars = 4000; + if (text.length <= maxChars) return text; + return text.slice(-maxChars); +} diff --git a/examples/codex-memory-plugin/scripts/start-memory-server.mjs b/examples/codex-memory-plugin/scripts/start-memory-server.mjs new file mode 100644 index 0000000000..423b79420c --- /dev/null +++ b/examples/codex-memory-plugin/scripts/start-memory-server.mjs @@ -0,0 +1,54 @@ +import { spawn } from "node:child_process"; +import { + computeSourceState, + ensureRuntimeInstalled, + getRuntimePaths, + loadInstallState, +} from "./runtime-common.mjs"; + +async function main() { + const paths = getRuntimePaths(); + const expectedState = await computeSourceState(paths); + + try { + await ensureRuntimeInstalled(paths, expectedState); + } catch { + const state = await loadInstallState(paths); + const detail = state?.error ? ` Last install error: ${state.error}` : ""; + process.stderr.write( + `[openviking-memory] MCP runtime is not ready in ${paths.runtimeRoot}.${detail}\n`, + ); + process.exit(1); + return; + } + + const child = spawn(process.execPath, [paths.runtimeServerPath], { + cwd: paths.runtimeRoot, + env: process.env, + stdio: "inherit", + }); + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); + } + + child.on("error", (err) => { + process.stderr.write( + `[openviking-memory] Failed to start MCP server: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); + }); + + child.on("exit", (code) => { + process.exit(code ?? 1); + }); +} + +main().catch((err) => { + process.stderr.write( + `[openviking-memory] MCP launcher failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/examples/codex-memory-plugin/servers/memory-server.js b/examples/codex-memory-plugin/servers/memory-server.js new file mode 100644 index 0000000000..fc49107491 --- /dev/null +++ b/examples/codex-memory-plugin/servers/memory-server.js @@ -0,0 +1,318 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +function readJson(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} +function loadOvConf() { + const defaultCli = join(homedir(), ".openviking", "ovcli.conf"); + const defaultServer = join(homedir(), ".openviking", "ov.conf"); + const explicit = process.env.OPENVIKING_CONFIG_FILE + ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) + : null; + const candidates = explicit ? [explicit] : [defaultCli, defaultServer]; + for (const candidate of candidates) { + if (!existsSync(candidate)) + continue; + try { + return { file: readJson(candidate), configPath: candidate }; + } + catch { + process.stderr.write(`[openviking-memory] Invalid config file: ${candidate}\n`); + process.exit(1); + } + } + // No config file. Allow env-var-only operation (cloud mode with OPENVIKING_URL). + if (process.env.OPENVIKING_URL) { + return { file: {}, configPath: explicit || defaultCli }; + } + process.stderr.write(`[openviking-memory] Config file not found at ${defaultCli} or ${defaultServer}; set OPENVIKING_CONFIG_FILE or OPENVIKING_URL.\n`); + process.exit(1); +} +function deriveBaseUrl(file) { + const direct = str(file.url, ""); + if (direct) + return direct.replace(/\/+$/, ""); + const server = (file.server ?? {}); + const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); + const port = Math.floor(num(server.port, 1933)); + return `http://${host}:${port}`; +} +function str(value, fallback) { + if (typeof value === "string" && value.trim()) + return value.trim(); + return fallback; +} +function num(value, fallback) { + if (typeof value === "number" && Number.isFinite(value)) + return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) + return parsed; + } + return fallback; +} +function md5Short(value) { + return createHash("md5").update(value).digest("hex").slice(0, 12); +} +function clampScore(value) { + if (typeof value !== "number" || Number.isNaN(value)) + return 0; + return Math.max(0, Math.min(1, value)); +} +function isMemoryUri(uri) { + return /^viking:\/\/(?:user|agent)\/[^/]+\/memories(?:\/|$)/.test(uri); +} +function totalCommitMemories(result) { + return Object.values(result.memories_extracted ?? {}).reduce((sum, count) => sum + count, 0); +} +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +const { file: ovConf, configPath } = loadOvConf(); +const serverConfig = (ovConf.server ?? {}); +const baseUrlFromFile = deriveBaseUrl(ovConf); +const apiKeyFromFile = str(ovConf.api_key, "") || str(serverConfig.root_api_key, ""); +const config = { + configPath, + baseUrl: str(process.env.OPENVIKING_URL, baseUrlFromFile).replace(/\/+$/, ""), + apiKey: str(process.env.OPENVIKING_API_KEY, apiKeyFromFile), + accountId: str(process.env.OPENVIKING_ACCOUNT, str(ovConf.account, str(ovConf.default_account, "default"))), + userId: str(process.env.OPENVIKING_USER, str(ovConf.user, str(ovConf.default_user, "default"))), + agentId: str(process.env.OPENVIKING_AGENT_ID, str(ovConf.agent_id, str(ovConf.default_agent, "codex"))), + timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, 15000))), + recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, 6))), + scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, 0.01))), +}; +class OpenVikingClient { + baseUrl; + apiKey; + accountId; + userId; + agentId; + timeoutMs; + runtimeIdentity = null; + constructor(baseUrl, apiKey, accountId, userId, agentId, timeoutMs) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.accountId = accountId; + this.userId = userId; + this.agentId = agentId; + this.timeoutMs = timeoutMs; + } + async request(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers = new Headers(init.headers ?? {}); + if (this.apiKey) + headers.set("X-API-Key", this.apiKey); + if (this.accountId) + headers.set("X-OpenViking-Account", this.accountId); + if (this.userId) + headers.set("X-OpenViking-User", this.userId); + if (this.agentId) + headers.set("X-OpenViking-Agent", this.agentId); + if (init.body && !headers.has("Content-Type")) + headers.set("Content-Type", "application/json"); + const response = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers, + signal: controller.signal, + }); + const payload = (await response.json().catch(() => ({}))); + if (!response.ok || payload.status === "error") { + const code = payload.error?.code ? ` [${payload.error.code}]` : ""; + const message = payload.error?.message ?? `HTTP ${response.status}`; + throw new Error(`OpenViking request failed${code}: ${message}`); + } + return (payload.result ?? payload); + } + finally { + clearTimeout(timer); + } + } + async healthCheck() { + try { + await this.request("/health"); + return true; + } + catch { + return false; + } + } + async getRuntimeIdentity() { + if (this.runtimeIdentity) + return this.runtimeIdentity; + const fallback = { userId: this.userId || "default", agentId: this.agentId || "default" }; + try { + const status = await this.request("/api/v1/system/status"); + const userId = typeof status.user === "string" && status.user.trim() ? status.user.trim() : fallback.userId; + this.runtimeIdentity = { userId, agentId: this.agentId || "default" }; + return this.runtimeIdentity; + } + catch { + this.runtimeIdentity = fallback; + return fallback; + } + } + async normalizeMemoryTargetUri(targetUri) { + const trimmed = targetUri.trim().replace(/\/+$/, ""); + const match = trimmed.match(/^viking:\/\/(user|agent)\/memories(?:\/(.*))?$/); + if (!match) + return trimmed; + const scope = match[1]; + const rest = match[2] ? `/${match[2]}` : ""; + const identity = await this.getRuntimeIdentity(); + const space = scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`); + return `viking://${scope}/${space}/memories${rest}`; + } + async find(query, targetUri, limit, scoreThreshold) { + const normalizedTargetUri = await this.normalizeMemoryTargetUri(targetUri); + return this.request("/api/v1/search/find", { + method: "POST", + body: JSON.stringify({ + query, + target_uri: normalizedTargetUri, + limit, + score_threshold: scoreThreshold, + }), + }); + } + async read(uri) { + return this.request(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`); + } + async createSession() { + const result = await this.request("/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }); + return result.session_id; + } + async addSessionMessage(sessionId, role, content) { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role, content }), + }); + } + async commitSession(sessionId) { + const result = await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { method: "POST", body: JSON.stringify({}) }); + if (!result.task_id) + return result; + const deadline = Date.now() + Math.max(this.timeoutMs, 30000); + while (Date.now() < deadline) { + await sleep(500); + const task = await this.getTask(result.task_id).catch(() => null); + if (!task) + break; + if (task.status === "completed") { + const taskResult = (task.result ?? {}); + return { + ...result, + status: "completed", + memories_extracted: (taskResult.memories_extracted ?? {}), + }; + } + if (task.status === "failed") + return { ...result, status: "failed", error: task.error }; + } + return { ...result, status: "timeout" }; + } + async getTask(taskId) { + return this.request(`/api/v1/tasks/${encodeURIComponent(taskId)}`, { method: "GET" }); + } + async deleteSession(sessionId) { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); + } + async deleteUri(uri) { + await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { method: "DELETE" }); + } +} +function formatMemoryResults(items) { + return items + .map((item, index) => { + const summary = item.abstract?.trim() || item.overview?.trim() || item.uri; + const score = Math.round(clampScore(item.score) * 100); + return `${index + 1}. ${summary}\n URI: ${item.uri}\n Score: ${score}%`; + }) + .join("\n\n"); +} +const client = new OpenVikingClient(config.baseUrl, config.apiKey, config.accountId, config.userId, config.agentId, config.timeoutMs); +const server = new McpServer({ name: "openviking-memory-codex", version: "0.1.0" }); +server.tool("find", "Find OpenViking long-term memory.", { + query: z.string().describe("Find query"), + target_uri: z.string().optional().describe("Find scope URI, default viking://user/memories"), + limit: z.number().optional().describe("Max results, default 6"), + score_threshold: z.number().optional().describe("Minimum relevance score 0-1, default 0.01"), +}, async ({ query, target_uri, limit, score_threshold }) => { + const recallLimit = limit ?? config.recallLimit; + const threshold = score_threshold ?? config.scoreThreshold; + const result = await client.find(query, target_uri ?? "viking://user/memories", recallLimit, threshold); + const items = [...(result.memories ?? []), ...(result.resources ?? []), ...(result.skills ?? [])] + .filter((item) => clampScore(item.score) >= threshold) + .sort((left, right) => clampScore(right.score) - clampScore(left.score)) + .slice(0, recallLimit); + if (items.length === 0) { + return { content: [{ type: "text", text: "No relevant OpenViking memories found." }] }; + } + return { content: [{ type: "text", text: formatMemoryResults(items) }] }; +}); +server.tool("remember", "Store information in OpenViking long-term memory.", { + text: z.string().describe("Information to store"), + role: z.string().optional().describe("Message role, default user"), +}, async ({ text, role }) => { + let sessionId; + try { + sessionId = await client.createSession(); + await client.addSessionMessage(sessionId, role || "user", text); + const result = await client.commitSession(sessionId); + const count = totalCommitMemories(result); + if (result.status === "failed") { + return { content: [{ type: "text", text: `Memory extraction failed: ${String(result.error)}` }] }; + } + if (result.status === "timeout") { + return { + content: [{ + type: "text", + text: `Memory extraction is still running (task_id=${result.task_id ?? "unknown"}).`, + }], + }; + } + if (count === 0) { + return { + content: [{ + type: "text", + text: "Committed session, but OpenViking extracted 0 memory item(s).", + }], + }; + } + return { content: [{ type: "text", text: `Stored memory. Extracted ${count} item(s).` }] }; + } + finally { + if (sessionId) + await client.deleteSession(sessionId).catch(() => { }); + } +}); +server.tool("forget", "Delete an exact OpenViking memory URI. Use find first if you only have a query.", { + uri: z.string().describe("Exact memory URI to delete"), +}, async ({ uri }) => { + if (!isMemoryUri(uri)) { + return { content: [{ type: "text", text: `Refusing to delete non-memory URI: ${uri}` }] }; + } + await client.deleteUri(uri); + return { content: [{ type: "text", text: `Deleted memory: ${uri}` }] }; +}); +server.tool("health", "Check whether the OpenViking server is reachable.", {}, async () => { + const ok = await client.healthCheck(); + const text = ok + ? `OpenViking is reachable at ${config.baseUrl}.` + : `OpenViking is unreachable at ${config.baseUrl}.`; + return { content: [{ type: "text", text }] }; +}); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/codex-memory-plugin/src/memory-server.ts b/examples/codex-memory-plugin/src/memory-server.ts index 557287be3c..915de5510f 100644 --- a/examples/codex-memory-plugin/src/memory-server.ts +++ b/examples/codex-memory-plugin/src/memory-server.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto" -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join, resolve as resolvePath } from "node:path" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" @@ -42,19 +42,42 @@ function readJson(path: string): Record { return JSON.parse(readFileSync(path, "utf-8")) as Record } -function loadOvConf(): Record { - const defaultPath = join(homedir(), ".openviking", "ov.conf") - const configPath = resolvePath( - (process.env.OPENVIKING_CONFIG_FILE || defaultPath).replace(/^~/, homedir()), - ) - try { - return readJson(configPath) - } catch (err) { - const code = (err as { code?: string })?.code - const detail = code === "ENOENT" ? `Config file not found: ${configPath}` : `Invalid config file: ${configPath}` - process.stderr.write(`[openviking-memory] ${detail}\n`) - process.exit(1) +function loadOvConf(): { file: Record; configPath: string } { + const defaultCli = join(homedir(), ".openviking", "ovcli.conf") + const defaultServer = join(homedir(), ".openviking", "ov.conf") + const explicit = process.env.OPENVIKING_CONFIG_FILE + ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) + : null + + const candidates = explicit ? [explicit] : [defaultCli, defaultServer] + for (const candidate of candidates) { + if (!existsSync(candidate)) continue + try { + return { file: readJson(candidate), configPath: candidate } + } catch { + process.stderr.write(`[openviking-memory] Invalid config file: ${candidate}\n`) + process.exit(1) + } } + + // No config file. Allow env-var-only operation (cloud mode with OPENVIKING_URL). + if (process.env.OPENVIKING_URL) { + return { file: {}, configPath: explicit || defaultCli } + } + + process.stderr.write( + `[openviking-memory] Config file not found at ${defaultCli} or ${defaultServer}; set OPENVIKING_CONFIG_FILE or OPENVIKING_URL.\n`, + ) + process.exit(1) +} + +function deriveBaseUrl(file: Record): string { + const direct = str((file as { url?: unknown }).url, "") + if (direct) return direct.replace(/\/+$/, "") + const server = (file.server ?? {}) as Record + const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1") + const port = Math.floor(num(server.port, 1933)) + return `http://${host}:${port}` } function str(value: unknown, fallback: string): string { @@ -92,17 +115,18 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -const ovConf = loadOvConf() +const { file: ovConf, configPath } = loadOvConf() const serverConfig = (ovConf.server ?? {}) as Record -const host = str(serverConfig.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1") -const port = Math.floor(num(serverConfig.port, 1933)) +const baseUrlFromFile = deriveBaseUrl(ovConf) +const apiKeyFromFile = str((ovConf as { api_key?: unknown }).api_key, "") || str(serverConfig.root_api_key, "") const config = { - baseUrl: `http://${host}:${port}`, - apiKey: str(process.env.OPENVIKING_API_KEY, str(serverConfig.root_api_key, "")), - accountId: str(process.env.OPENVIKING_ACCOUNT, str(ovConf.default_account, "default")), - userId: str(process.env.OPENVIKING_USER, str(ovConf.default_user, "default")), - agentId: str(process.env.OPENVIKING_AGENT_ID, str(ovConf.default_agent, "codex")), + configPath, + baseUrl: str(process.env.OPENVIKING_URL, baseUrlFromFile).replace(/\/+$/, ""), + apiKey: str(process.env.OPENVIKING_API_KEY, apiKeyFromFile), + accountId: str(process.env.OPENVIKING_ACCOUNT, str((ovConf as { account?: unknown; default_account?: unknown }).account, str((ovConf as { default_account?: unknown }).default_account, "default"))), + userId: str(process.env.OPENVIKING_USER, str((ovConf as { user?: unknown; default_user?: unknown }).user, str((ovConf as { default_user?: unknown }).default_user, "default"))), + agentId: str(process.env.OPENVIKING_AGENT_ID, str((ovConf as { agent_id?: unknown; default_agent?: unknown }).agent_id, str((ovConf as { default_agent?: unknown }).default_agent, "codex"))), timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, 15000))), recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, 6))), scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, 0.01))), From b2e5c6ae4cf2b28e4290aeafafb2927b05d5d98e Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 10 May 2026 15:10:23 +0800 Subject: [PATCH 02/12] refactor(plugin/codex): drop SessionStart, split Stop=add_message vs PreCompact=commit Codex's `Stop` hook fires per turn, not at session end, so committing per-Stop over-fragments memory extraction. And codex re-fires `SessionStart` on short reconnects, so registering an `npm ci` bootstrap there reinstalls the runtime unnecessarily. This change keeps one long-lived OpenViking session per codex `session_id` across all `Stop` invocations, and only triggers the OV memory extractor on `PreCompact` (or via an idle-sweep best-effort commit when codex exits without compacting). - hooks.json: drop SessionStart entry; keep UserPromptSubmit/Stop/PreCompact - scripts/session-state.mjs (new): per-codex-session state under ~/.openviking/codex-plugin-state/, tracks ovSessionId + capturedTurnCount - scripts/auto-capture.mjs (Stop): incremental add_message only, idle-sweep at the tail to commit stale codex sessions (default IDLE_TTL=30 min, override with OPENVIKING_CODEX_IDLE_TTL_MS) - scripts/pre-compact-capture.mjs (PreCompact): catch-up append + commit the long-lived OV session, then null out ovSessionId so the next Stop opens a fresh OV session for the post-compact half - MCP runtime install stays lazy in start-memory-server.mjs (already there); no SessionStart hook means short reconnects don't re-trigger npm ci - VERIFICATION.md: end-to-end SOP against a live OV server (~3 min) - bump plugin to 0.3.0 Verified end-to-end against ov.zaynjarvis.com: Stop adds turns idempotently and incrementally; PreCompact commits to history/archive_001/ with extractor producing memories under viking://user//memories/profile.md after ~30 s; post-compact Stop opens a fresh OV session; idle-sweep commits stale state files. Co-Authored-By: Claude Opus 4.7 --- .../.codex-plugin/plugin.json | 6 +- examples/codex-memory-plugin/README.md | 85 +++-- examples/codex-memory-plugin/VERIFICATION.md | 180 ++++++++++ examples/codex-memory-plugin/hooks/hooks.json | 14 +- examples/codex-memory-plugin/package.json | 2 +- .../scripts/auto-capture.mjs | 339 +++++++----------- .../scripts/pre-compact-capture.mjs | 201 ++++++----- .../scripts/session-state.mjs | 81 +++++ 8 files changed, 536 insertions(+), 372 deletions(-) create mode 100644 examples/codex-memory-plugin/VERIFICATION.md create mode 100644 examples/codex-memory-plugin/scripts/session-state.mjs diff --git a/examples/codex-memory-plugin/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json index 6fd9e61e6b..31c7032339 100644 --- a/examples/codex-memory-plugin/.codex-plugin/plugin.json +++ b/examples/codex-memory-plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "openviking-memory", - "version": "0.2.0", - "description": "Long-term semantic memory for Codex, powered by OpenViking. Auto-recall on UserPromptSubmit, auto-capture on Stop, and full-session capture before context compaction.", + "version": "0.3.0", + "description": "Long-term semantic memory for Codex, powered by OpenViking. Recall on UserPromptSubmit, incremental add_message on Stop (per turn), commit on PreCompact, with idle-sweep commit for graceful exits.", "author": { "name": "OpenViking", "url": "https://github.com/volcengine/OpenViking" @@ -21,7 +21,7 @@ "interface": { "displayName": "OpenViking Memory", "shortDescription": "Long-term semantic memory for Codex", - "longDescription": "Hooks Codex's lifecycle to keep an external semantic memory: recall relevant memories before each user turn, capture user/assistant turns on Stop, and commit the full transcript before PreCompact wipes context. Also exposes explicit MCP tools (openviking_recall, openviking_store, openviking_forget, openviking_health) for manual use.", + "longDescription": "Hooks Codex's lifecycle to keep an external semantic memory: recall relevant memories on each UserPromptSubmit; on every Stop (turn end) append the new user/assistant turns to a long-lived OpenViking session keyed by codex session_id; on PreCompact commit that session so OV's extractor produces durable memories before context is summarized. A best-effort idle-sweep on Stop also commits sessions that exit gracefully without compacting. Also exposes explicit MCP tools (openviking_recall, openviking_store, openviking_forget, openviking_health) for manual use.", "developerName": "OpenViking", "category": "Productivity", "capabilities": [ diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index fc93e9f146..90d8761395 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -5,34 +5,37 @@ Long-term semantic memory for [Codex](https://developers.openai.com/codex), powe This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-memory-plugin). It hooks Codex's lifecycle to: - **Auto-recall** relevant memories on every `UserPromptSubmit` and inject them via `hookSpecificOutput.additionalContext` -- **Auto-capture** the user turn (and last assistant message) on `Stop` by committing a short-lived OpenViking session -- **Pre-compact capture** the entire transcript on `PreCompact` so detail survives Codex's context summarization -- **Bootstrap** the MCP runtime once on `SessionStart` +- **Incremental capture on `Stop`** (turn end): append the new user/assistant turns to a single long-lived OpenViking session keyed by Codex `session_id`. No commit per turn. +- **Commit on `PreCompact`**: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it. +- **Idle sweep on `Stop`**: opportunistically commit OV sessions whose Codex session has been silent past the idle TTL (default 30 min) — best-effort session-end signal because Codex has no `SessionEnd` hook today. +- **MCP runtime bootstrap is lazy**: the MCP launcher (`start-memory-server.mjs`) installs runtime deps on first MCP invocation. We do **not** register a `SessionStart` hook, so short reconnects don't re-trigger `npm ci`. It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health`) for manual use. ## Architecture ``` - ┌────────────────────────────────────────────┐ - │ Codex │ - └────────┬───────────┬──────────┬─────────┬──┘ - │ │ │ │ - SessionStart UserPromptSubmit Stop PreCompact - │ │ │ │ - ┌────────▼──────┐ ┌──▼─────┐ ┌──▼─────┐ ┌─▼─────────────┐ - │ bootstrap- │ │ auto- │ │ auto- │ │ pre-compact- │ - │ runtime.mjs │ │ recall │ │ capture│ │ capture │ - │ (npm ci) │ │ │ │ │ │ (full commit) │ - └────────┬──────┘ └───┬────┘ └────┬───┘ └───┬───────────┘ - │ │ │ │ - │ ┌────▼───────────▼─────────▼──┐ - │ │ OpenViking server │ - │ │ /api/v1/search/find │ - │ │ /api/v1/sessions │ - │ │ /api/v1/sessions/{id}/commit │ - └──────►│ /api/v1/content/read │ - └───────────────────────────────┘ + ┌──────────────────────────────────────┐ + │ Codex │ + └──────┬───────────────┬────────────┬──┘ + │ │ │ + UserPromptSubmit Stop PreCompact + │ │ │ + ┌──────▼─────┐ ┌──────▼─────┐ ┌──▼─────────────┐ + │ auto- │ │ auto- │ │ pre-compact- │ + │ recall.mjs │ │ capture.mjs│ │ capture.mjs │ + │ (search) │ │ (append + │ │ (commit + │ + │ │ │ idle-sweep)│ │ reset session) │ + └──────┬─────┘ └──────┬─────┘ └──────┬─────────┘ + │ │ │ + │ ┌───────▼───────────────▼────┐ + └──────►│ OpenViking server │ + │ /api/v1/search/find │ + │ /api/v1/sessions │ + │ /api/v1/sessions/{id}/ │ + │ messages | commit │ + │ /api/v1/content/read │ + └────────────────────────────┘ ┌──────────────────────────────────────┐ │ MCP Server (memory-server.ts) │ @@ -41,18 +44,20 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op │ • openviking_store │ │ • openviking_forget │ │ • openviking_health │ + │ Lazily npm ci's its runtime on │ + │ first launch (not on SessionStart). │ └──────────────────────────────────────┘ ``` ## How It Works -### Runtime bootstrap (transparent, on session start) +### Why no `SessionStart` hook -`SessionStart` fires `bootstrap-runtime.mjs`, which hashes `package.json` + `package-lock.json` + `servers/memory-server.js`, copies them into `${CODEX_PLUGIN_DATA}/runtime` (or `~/.openviking/codex-memory-plugin/runtime` if Codex doesn't inject `CODEX_PLUGIN_DATA`), and runs `npm ci --omit=dev`. `install-state.json` records the resolved hashes so subsequent sessions skip reinstall. The MCP launcher (`start-memory-server.mjs`) can also bootstrap on demand if it starts before `SessionStart`. +Codex fires `SessionStart` on every short reconnect and resume — not just genuine new sessions. Registering a hook that runs `npm ci` on every `SessionStart` is the wrong shape: it would reinstall the runtime on every reconnect, and short reconnects don't need a memory boundary. Instead we **lazily bootstrap** the MCP runtime in `scripts/start-memory-server.mjs` the first time codex actually launches the MCP server. The bootstrap is content-hashed and idempotent (`scripts/runtime-common.mjs`), so subsequent launches are no-ops. -### Auto-recall (transparent, every turn) +### Auto-recall (every UserPromptSubmit) -`UserPromptSubmit` fires `auto-recall.mjs`. It reads `prompt` from stdin, calls `/api/v1/search/find` for both `viking://user/memories` and `viking://agent/memories` (and `viking://agent/skills`), ranks results with query-aware scoring (leaf boost, preference boost, temporal boost, lexical overlap), reads full content for top-ranked leaves, and emits: +`auto-recall.mjs` reads `prompt` from stdin, calls `/api/v1/search/find` for both `viking://user/memories` and `viking://agent/memories` (and `viking://agent/skills`), ranks results with query-aware scoring (leaf boost, preference boost, temporal boost, lexical overlap), reads full content for top-ranked leaves, and emits: ```json { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "..." } } @@ -60,20 +65,23 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op Codex injects `additionalContext` into the model turn, so memories arrive without an extra tool call. -### Auto-capture (transparent, on Stop) +### Stop (turn end → `add_message`, NOT `commit`) -`Stop` fires `auto-capture.mjs`. Codex hands us `last_assistant_message`, `transcript_path`, `session_id`, and `turn_id`. The script: +Codex's `Stop` fires per turn, not at session end. So `auto-capture.mjs` keeps **one** long-lived OpenViking session per Codex `session_id` and incrementally appends every new user/assistant turn from the rollout JSONL via `/api/v1/sessions/{id}/messages`. Per-codex-session state lives at `~/.openviking/codex-plugin-state/.json` and tracks `{ ovSessionId, capturedTurnCount, lastUpdatedAt }`. -1. Incrementally parses the rollout JSONL at `transcript_path` (skipping turns we've already captured this session) -2. Captures user turns (and assistant turns when `captureAssistantTurns=true`) -3. Captures `last_assistant_message` separately (deduped via hash) so Stop continues to work even if `transcript_path` is unavailable -4. Each capture creates an OpenViking session, posts the text, calls `/api/v1/sessions/{id}/commit`, and lets OV's pipeline extract memories asynchronously +We do **not** call `/commit` per turn — committing extracts memories, and per-turn extraction would over-fragment the memory tree and waste OV's extractor. -State per `session_id` is kept under `$TMPDIR/openviking-codex-capture-state/`. +### Idle sweep (best-effort session-end commit) -### Pre-compact capture (the key Codex extension) +Codex has no `SessionEnd` hook today (the schema only ships `SessionStart`, `UserPromptSubmit`, `Stop`, `PreCompact`, `PostCompact`, and tool-use events). To still produce memories from sessions that exit gracefully without compacting, every `Stop` invocation also runs an idle sweep at the end: any tracked codex session whose state file is older than `IDLE_TTL` (default 30 min, override with `OPENVIKING_CODEX_IDLE_TTL_MS`) gets committed and its state file removed. -`PreCompact` fires `pre-compact-capture.mjs` *before* Codex compacts the conversation. The script reads the entire transcript, opens **one** OpenViking session, posts every captured turn in order, and commits — so a structured extraction lands in long-term memory before Codex throws the detail away. The `Stop` state is also advanced so the next Stop won't re-capture pre-compact content. +### PreCompact (deterministic commit) + +`PreCompact` fires before Codex summarizes. `pre-compact-capture.mjs` does: + +1. **Catch-up**: append any transcript turns Stop hasn't captured yet (race-safe via `capturedTurnCount`). +2. **Commit** the long-lived OV session for this Codex `session_id` so OV's extractor runs against the full pre-compact transcript. +3. **Reset** state: clear `ovSessionId` so the next `Stop` opens a fresh OV session for the post-compact half. `capturedTurnCount` stays so we don't re-capture pre-compact turns. ### MCP tools (explicit, on demand) @@ -85,10 +93,11 @@ Codex's hook output schema differs from Claude Code's. Notably: | Hook | Input field of interest | Output channel for context injection | |------|------------------------|--------------------------------------| -| `SessionStart` | `source` (`startup`/`resume`/`clear`) | `hookSpecificOutput.additionalContext` | | `UserPromptSubmit` | `prompt` | `hookSpecificOutput.additionalContext` | -| `Stop` | `last_assistant_message`, `transcript_path` | `systemMessage` (only) | -| `PreCompact` | `trigger` (`manual`/`auto`), `transcript_path`| `systemMessage` (only) | +| `Stop` | `last_assistant_message`, `transcript_path`, `session_id` | `systemMessage` (only) | +| `PreCompact` | `trigger` (`manual`/`auto`), `transcript_path`, `session_id` | `systemMessage` (only) | + +> Note: this plugin no longer registers `SessionStart`. Codex fires it on short reconnects too, and the MCP runtime install belongs in `start-memory-server.mjs` (lazy on first MCP call), not in a per-reconnect hook. Unlike Claude Code, **Codex does not support `decision: "approve"`**; only `decision: "block"`. A no-op is `{}` (which is what these scripts emit when there's nothing to add). diff --git a/examples/codex-memory-plugin/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md new file mode 100644 index 0000000000..cb0d603536 --- /dev/null +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -0,0 +1,180 @@ +# Verification SOP — codex plugin (v0.3.0) + +End-to-end smoke test against a live OpenViking server. Run this whenever the +hook scripts change. Takes ~3 minutes; the only async wait is OV's memory +extractor (~30–60 s). + +## 0. Prereqs + +- `ov` CLI installed and reachable +- `~/.openviking/ovcli.conf` (or a per-tenant variant like `ovcli.conf.bob`) + pointing at the OV server you want to write to. The plugin sends + `X-API-Key`, `X-OpenViking-Account`, `X-OpenViking-User` from this file. +- Node.js 22+ + +```bash +export OV_CONF=$HOME/.openviking/ovcli.conf.bob # or whichever tenant +export PLUGIN=/path/to/OpenViking/examples/codex-memory-plugin +export STATE_DIR=/tmp/codex-plugin-verify +rm -rf "$STATE_DIR" && mkdir -p "$STATE_DIR" +``` + +## 1. Stop hook — first turn appends + +```bash +cat > "$STATE_DIR/transcript.jsonl" <<'EOF' +{"payload":{"role":"user","content":"My favorite color is fuchsia."}} +{"payload":{"role":"assistant","content":"Got it — fuchsia noted."}} +EOF + +OPENVIKING_CONFIG_FILE=$OV_CONF \ +OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ +CODEX_PLUGIN_ROOT=$PLUGIN \ +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ + | node $PLUGIN/scripts/auto-capture.mjs +``` + +Expect: `{"systemMessage":"appended 2 turn(s) to OpenViking session "}`. + +State file: +```bash +cat $STATE_DIR/state/verify-sess.json +# {"codexSessionId":"verify-sess","ovSessionId":"","capturedTurnCount":2,...} +``` + +OV side: +```bash +OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session//messages.jsonl +# 2 JSONL records: user "fuchsia", assistant "noted" +``` + +## 2. Stop hook idempotency — re-run without changes is a no-op + +```bash +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/auto-capture.mjs +``` + +Expect: `{}` (no new turns). `capturedTurnCount` still 2. + +## 3. Stop hook — incremental append + +Append two more turns to the transcript and re-run: + +```bash +cat >> "$STATE_DIR/transcript.jsonl" <<'EOF' +{"payload":{"role":"user","content":"Actually, mint green."}} +{"payload":{"role":"assistant","content":"Updated to mint green."}} +EOF + +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/auto-capture.mjs +``` + +Expect: `appended 2 turn(s)` (only the new ones). Re-read +`viking://session//messages.jsonl` — 4 records now. + +## 4. PreCompact — commit + reset + +```bash +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","trigger":"manual"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/pre-compact-capture.mjs +``` + +Expect: `pre-compact commit: → N memory item(s) extracted (archived)`. + +State file: `ovSessionId` is now `null`, `capturedTurnCount` stays at 4. + +OV side: +```bash +OPENVIKING_CONFIG_FILE=$OV_CONF ov ls viking://session/ +# messages.jsonl is now size 0 (archived) +# history/archive_001/ exists with the committed messages +OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session//history/archive_001/messages.jsonl +``` + +## 5. Post-compact Stop — fresh OV session + +Append more turns and run Stop. A new OV session UUID should appear: + +```bash +cat >> "$STATE_DIR/transcript.jsonl" <<'EOF' +{"payload":{"role":"user","content":"After compaction: I prefer serif fonts."}} +{"payload":{"role":"assistant","content":"Noted serif preference."}} +EOF + +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/auto-capture.mjs +``` + +Expect: `appended 2 turn(s) to OpenViking session ` — different +from step 4's UUID. + +## 6. Idle-sweep — graceful-exit commit + +The sweep only commits sessions whose state file is older than the idle TTL +(default 30 min). For verification we shorten the TTL to 1 second: + +```bash +# Backdate the verify-sess state to force-stale it +python3 -c " +import json, sys +p = '$STATE_DIR/state/verify-sess.json' +s = json.load(open(p)) +s['lastUpdatedAt'] = 1 +open(p, 'w').write(json.dumps(s)) +" + +# Run a Stop for a brand-new session_id; idle-sweep at the tail commits the stale one +echo '{"session_id":"sweep-trigger","transcript_path":"/tmp/empty-nonexistent.jsonl"}' \ + | OPENVIKING_CODEX_IDLE_TTL_MS=60000 \ + OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/auto-capture.mjs + +ls $STATE_DIR/state/ +# verify-sess.json is gone; only sweep-trigger.json remains +``` + +OV side: the post-compact `` from step 5 is now archived (its +`messages.jsonl` is size 0; `history/archive_001/` exists). + +## 7. Memory extraction landed in user namespace + +Wait ~60 s for OV's extractor, then: + +```bash +OPENVIKING_CONFIG_FILE=$OV_CONF ov ls viking://user//memories/ +OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://user//memories/profile.md +``` + +Expect new entries describing the captured preferences (favorite color, +serif fonts, etc.) with timestamps from this run. + +## 8. Codex CLI smoke test (requires codex auth) + +```bash +codex plugin marketplace add /path/to/OpenViking-codex-marketplace # if not already +codex # interactive +# Have a brief conversation that mentions a clear preference, +# then /compact (manual PreCompact) to force a commit, then exit. +``` + +Verify with steps 4 + 7 above. + +--- + +**Cleanup**: `rm -rf $STATE_DIR && rm -rf ~/.openviking/codex-plugin-state/verify-sess.json` diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json index 54d498234c..e48d71d316 100644 --- a/examples/codex-memory-plugin/hooks/hooks.json +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -1,17 +1,5 @@ { "hooks": { - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/bootstrap-runtime.mjs\"", - "timeout": 120 - } - ] - } - ], "UserPromptSubmit": [ { "matcher": "*", @@ -31,7 +19,7 @@ { "type": "command", "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/auto-capture.mjs\"", - "timeout": 45 + "timeout": 30 } ] } diff --git a/examples/codex-memory-plugin/package.json b/examples/codex-memory-plugin/package.json index fa98326a02..75c12f39ed 100644 --- a/examples/codex-memory-plugin/package.json +++ b/examples/codex-memory-plugin/package.json @@ -1,6 +1,6 @@ { "name": "codex-openviking-memory", - "version": "0.2.0", + "version": "0.3.0", "description": "OpenViking memory plugin for Codex — hooks (recall/capture/pre-compact) + MCP server for explicit memory operations.", "type": "module", "scripts": { diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index 3df7c82d2d..ef2a57f102 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -1,43 +1,42 @@ #!/usr/bin/env node /** - * Auto-Capture Hook Script for Codex. + * Stop hook for Codex (turn end). * - * Triggered by the Stop hook. - * Codex passes `last_assistant_message`, `transcript_path`, `session_id`, `turn_id` on stdin. + * Codex passes JSON on stdin including session_id, transcript_path, + * last_assistant_message. Stop fires per turn — NOT at session end. * * Strategy: - * 1. Use `last_assistant_message` directly when available (cheap path). - * 2. Fall back to incrementally parsing `transcript_path` (rollout JSONL). + * 1. For this codex session_id, lazily create one long-lived OpenViking + * session and remember it in state. Do NOT commit per turn. + * 2. Read transcript_path, parse JSONL rollout, append every new + * user/assistant turn since last capture via add_message. + * 3. Idle sweep: any OTHER codex session whose state file is older than + * IDLE_TTL gets committed and cleared. This is a best-effort + * session-end signal because codex has no SessionEnd hook today. * - * Each captured turn opens a short-lived OpenViking session, posts the text, - * extracts memories, then deletes the session. State per session_id tracks - * how many transcript turns we've already consumed so we don't re-capture. + * The PreCompact hook is the deterministic commit path; this idle sweep + * is the catch-all for graceful exits. * - * Codex Stop output schema does NOT support `decision: "approve"`. A no-op is `{}`. + * Stop output schema accepts {} as a no-op. */ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { loadConfig } from "./config.mjs"; import { createLogger } from "./debug-log.mjs"; +import { clearState, listStates, loadState, saveState } from "./session-state.mjs"; const cfg = loadConfig(); const { log, logError } = createLogger("auto-capture"); -const STATE_DIR = join(tmpdir(), "openviking-codex-capture-state"); +const IDLE_TTL_MS = Math.max(60_000, Number(process.env.OPENVIKING_CODEX_IDLE_TTL_MS) || 30 * 60_000); function output(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } function noop(message) { - if (message) { - output({ systemMessage: message }); - } else { - output({}); - } + output(message ? { systemMessage: message } : {}); } async function fetchJSON(path, init = {}) { @@ -61,88 +60,6 @@ async function fetchJSON(path, init = {}) { } } -// --------------------------------------------------------------------------- -// State (per session_id, tracks last transcript turn index) -// --------------------------------------------------------------------------- - -function stateFilePath(sessionId) { - const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_"); - return join(STATE_DIR, `${safe}.json`); -} - -async function loadState(sessionId) { - try { - const data = await readFile(stateFilePath(sessionId), "utf-8"); - return JSON.parse(data); - } catch { - return { capturedTurnCount: 0, lastAssistantMessageHash: null }; - } -} - -async function saveState(sessionId, state) { - try { - await mkdir(STATE_DIR, { recursive: true }); - await writeFile(stateFilePath(sessionId), JSON.stringify(state)); - } catch { /* best effort */ } -} - -// --------------------------------------------------------------------------- -// Capture decision -// --------------------------------------------------------------------------- - -const MEMORY_TRIGGERS = [ - /remember|preference|prefer|important|decision|decided|always|never/i, - /记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i, - /[\w.-]+@[\w.-]+\.\w+/, - /\+\d{10,}/, - /(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i, - /(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i, - /(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i, -]; - -const RELEVANT_MEMORIES_BLOCK_RE = /[\s\S]*?<\/relevant-memories>/gi; -const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i; -const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u; -const CJK_CHAR_RE = /[぀-ヿ㐀-鿿豈-﫿가-힯]/; - -function sanitize(text) { - return text - .replace(RELEVANT_MEMORIES_BLOCK_RE, " ") - .replace(/\u0000/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -function shouldCapture(text) { - const normalized = sanitize(text); - if (!normalized) return { capture: false, reason: "empty", text: "" }; - - const compact = normalized.replace(/\s+/g, ""); - const minLen = CJK_CHAR_RE.test(compact) ? 4 : 10; - if (compact.length < minLen || normalized.length > cfg.captureMaxLength) { - return { capture: false, reason: "length_out_of_range", text: normalized }; - } - - if (COMMAND_TEXT_RE.test(normalized)) { - return { capture: false, reason: "command", text: normalized }; - } - - if (NON_CONTENT_TEXT_RE.test(normalized)) { - return { capture: false, reason: "non_content", text: normalized }; - } - - if (cfg.captureMode === "keyword") { - for (const trigger of MEMORY_TRIGGERS) { - if (trigger.test(normalized)) { - return { capture: true, reason: `trigger:${trigger}`, text: normalized }; - } - } - return { capture: false, reason: "no_trigger", text: normalized }; - } - - return { capture: true, reason: "semantic", text: normalized }; -} - // --------------------------------------------------------------------------- // Transcript parsing (JSONL rollout) // --------------------------------------------------------------------------- @@ -164,7 +81,6 @@ function parseTranscript(content) { const data = JSON.parse(content); if (Array.isArray(data)) return data; } catch { /* not a JSON array */ } - const lines = content.split("\n").filter((l) => l.trim()); const out = []; for (const line of lines) { @@ -177,7 +93,6 @@ function extractTurns(rolloutEntries) { const turns = []; for (const entry of rolloutEntries) { if (!entry || typeof entry !== "object") continue; - // Codex rollout entries can be wrapped in payload. const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : entry; let role = payload.role; let text = ""; @@ -194,54 +109,108 @@ function extractTurns(rolloutEntries) { } if (role !== "user" && role !== "assistant") continue; - if (text.trim()) turns.push({ role, text: text.trim() }); + const trimmed = text.trim(); + if (!trimmed) continue; + + const capped = trimmed.length > cfg.captureMaxLength + ? trimmed.slice(0, cfg.captureMaxLength) + : trimmed; + turns.push({ role, text: capped }); } return turns; } +async function readTranscriptTurns(transcriptPath) { + if (!transcriptPath) return []; + try { + const raw = await readFile(transcriptPath, "utf-8"); + if (!raw.trim()) return []; + return extractTurns(parseTranscript(raw)); + } catch (err) { + logError("transcript_read", err); + return []; + } +} + // --------------------------------------------------------------------------- -// Capture +// OpenViking session ops // --------------------------------------------------------------------------- -async function captureToOpenViking(text) { - const sessionResult = await fetchJSON("/api/v1/sessions", { +async function ensureOvSession(state) { + if (state.ovSessionId) return state.ovSessionId; + const created = await fetchJSON("/api/v1/sessions", { method: "POST", body: JSON.stringify({}), }); - if (!sessionResult?.session_id) return { ok: false, reason: "session_create_failed" }; - - const ovSessionId = sessionResult.session_id; + if (!created?.session_id) return null; + state.ovSessionId = created.session_id; + return state.ovSessionId; +} - await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { - method: "POST", - body: JSON.stringify({ role: "user", content: text }), - }); +async function appendTurns(ovSessionId, turns) { + for (const turn of turns) { + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role: turn.role, content: turn.text }), + }); + } +} - const commit = await fetchJSON( +async function commitOvSession(ovSessionId) { + if (!ovSessionId) return null; + return fetchJSON( `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, { method: "POST", body: JSON.stringify({}) }, ); +} - const extractedCount = commit?.memories_extracted && typeof commit.memories_extracted === "object" - ? Object.values(commit.memories_extracted).reduce((a, b) => a + (typeof b === "number" ? b : 0), 0) - : (typeof commit?.memories_extracted === "number" ? commit.memories_extracted : 0); - - return { - ok: true, - count: extractedCount, - ovSessionId, - archived: commit?.archived ?? false, - taskId: commit?.task_id, - status: commit?.status, - }; +function countExtracted(commit) { + if (!commit?.memories_extracted) return 0; + if (typeof commit.memories_extracted === "number") return commit.memories_extracted; + if (typeof commit.memories_extracted === "object") { + return Object.values(commit.memories_extracted).reduce( + (a, b) => a + (typeof b === "number" ? b : 0), + 0, + ); + } + return 0; } -function fastHash(text) { - let h = 0; - for (let i = 0; i < text.length; i++) { - h = ((h << 5) - h + text.charCodeAt(i)) | 0; +// --------------------------------------------------------------------------- +// Idle sweep — best-effort session-end commit (codex has no SessionEnd hook) +// --------------------------------------------------------------------------- + +async function sweepIdleSessions(currentSessionId) { + const states = await listStates(); + const now = Date.now(); + let sweptCount = 0; + let sweptExtracted = 0; + + for (const s of states) { + if (!s?.codexSessionId || s.codexSessionId === currentSessionId) continue; + const idleMs = now - (typeof s.lastUpdatedAt === "number" ? s.lastUpdatedAt : 0); + if (idleMs < IDLE_TTL_MS) continue; + + if (s.ovSessionId) { + const commit = await commitOvSession(s.ovSessionId); + const extracted = countExtracted(commit); + log("idle_sweep_commit", { + codexSessionId: s.codexSessionId, + ovSessionId: s.ovSessionId, + idleMs, + extracted, + }); + sweptExtracted += extracted; + } else { + log("idle_sweep_clear", { codexSessionId: s.codexSessionId, idleMs }); + } + await clearState(s.codexSessionId); + sweptCount += 1; + } + + if (sweptCount > 0) { + log("idle_sweep_done", { sweptCount, sweptExtracted }); } - return String(h); } // --------------------------------------------------------------------------- @@ -268,12 +237,7 @@ async function main() { const sessionId = input.session_id || "unknown"; const transcriptPath = input.transcript_path || null; - const lastAssistantMessage = input.last_assistant_message || null; - log("start", { - sessionId, - transcriptPath, - hasLastAssistantMessage: Boolean(lastAssistantMessage), - }); + log("start", { sessionId, transcriptPath }); const health = await fetchJSON("/health"); if (!health) { @@ -283,93 +247,36 @@ async function main() { } const state = await loadState(sessionId); - let totalCaptured = 0; - let totalExtracted = 0; - - // Strategy A: capture user turns from transcript (incremental). - if (transcriptPath) { - let raw; - try { - raw = await readFile(transcriptPath, "utf-8"); - } catch (err) { - logError("transcript_read", err); - raw = null; - } + const allTurns = await readTranscriptTurns(transcriptPath); + const newTurns = allTurns.slice(state.capturedTurnCount); - if (raw && raw.trim()) { - const entries = parseTranscript(raw); - const allTurns = extractTurns(entries); - const newTurns = allTurns.slice(state.capturedTurnCount); - const captureTurns = cfg.captureAssistantTurns - ? newTurns - : newTurns.filter((t) => t.role === "user"); - - log("transcript_parse", { - totalTurns: allTurns.length, - previouslyCaptured: state.capturedTurnCount, - newTurns: newTurns.length, - captureTurns: captureTurns.length, - }); - - if (captureTurns.length > 0) { - const turnText = captureTurns.map((t) => `[${t.role}]: ${t.text}`).join("\n"); - const decision = shouldCapture(turnText); - log("should_capture_transcript", { capture: decision.capture, reason: decision.reason }); - if (decision.capture) { - const result = await captureToOpenViking(decision.text); - log("openviking_capture_transcript", { - sessionCreated: result.ok, - ovSessionId: result.ovSessionId, - extracted: result.count || 0, - }); - if (result.ok) { - totalCaptured += captureTurns.length; - totalExtracted += result.count || 0; - } - } - } - - state.capturedTurnCount = allTurns.length; - } - } + log("transcript_parse", { + totalTurns: allTurns.length, + previouslyCaptured: state.capturedTurnCount, + newTurns: newTurns.length, + }); - // Strategy B: capture last_assistant_message (independent of transcript availability). - // Only when (a) we want assistant turns or (b) transcript was unavailable. - if (cfg.captureLastAssistantOnStop && lastAssistantMessage) { - const hash = fastHash(lastAssistantMessage); - if (hash !== state.lastAssistantMessageHash) { - const decision = shouldCapture(lastAssistantMessage); - log("should_capture_last_assistant", { capture: decision.capture, reason: decision.reason }); - if (decision.capture) { - const result = await captureToOpenViking(decision.text); - log("openviking_capture_last_assistant", { - sessionCreated: result.ok, - ovSessionId: result.ovSessionId, - extracted: result.count || 0, - }); - if (result.ok) { - totalCaptured += 1; - totalExtracted += result.count || 0; - } - } - state.lastAssistantMessageHash = hash; + let added = 0; + if (newTurns.length > 0) { + const ovSessionId = await ensureOvSession(state); + if (!ovSessionId) { + logError("ensure_ov_session", "failed to create OV session"); } else { - log("skip", { stage: "last_assistant_dedup", reason: "same hash as last capture" }); + await appendTurns(ovSessionId, newTurns); + added = newTurns.length; + state.capturedTurnCount = allTurns.length; + log("appended", { ovSessionId, added }); } } - await saveState(sessionId, state); - - if (totalExtracted > 0) { - log("done", { captured: totalCaptured, extracted: totalExtracted }); - noop(`captured ${totalCaptured} turn(s), extracted ${totalExtracted} memory item(s)`); - return; - } + await saveState(state); + await sweepIdleSessions(sessionId); - if (totalCaptured > 0) { - log("done", { captured: totalCaptured, extracted: 0 }); + if (added > 0) { + noop(`appended ${added} turn(s) to OpenViking session ${state.ovSessionId}`); + } else { + noop(); } - noop(); } main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs index d292f60650..7e3d0668a2 100644 --- a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -1,43 +1,38 @@ #!/usr/bin/env node /** - * Pre-Compact Hook Script for Codex. + * PreCompact hook for Codex. * - * Triggered by the PreCompact hook. Codex passes: - * { session_id, transcript_path, cwd, hook_event_name: "PreCompact", - * model, trigger: "manual"|"auto", turn_id } + * Codex is about to summarize/compact the conversation. We commit the + * long-lived OpenViking session for this codex session_id (Stop hooks + * have already been appending turns), which triggers OV's memory + * extractor on the full pre-compact transcript. * - * Codex is about to summarize / compact the conversation, dropping detail. - * Before that happens, we open ONE OpenViking session, push every uncaptured - * turn from the rollout in order, and commit. This produces a structured - * extraction that survives compaction. + * Catch-up: if the transcript has new turns the Stop hook hasn't + * appended yet, we append them before committing. * - * PreCompact output schema (codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json) - * does NOT support `decision`. Valid keys: continue, stopReason, suppressOutput, systemMessage. - * No-op output is `{}`. + * After commit, we clear ovSessionId from state but keep + * capturedTurnCount so post-compact Stop hooks don't re-capture pre- + * compact turns. The next Stop will create a fresh OV session for the + * post-compact half of the conversation. + * + * PreCompact output schema accepts {} as a no-op. */ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import { loadConfig } from "./config.mjs"; import { createLogger } from "./debug-log.mjs"; +import { loadState, saveState } from "./session-state.mjs"; const cfg = loadConfig(); const { log, logError } = createLogger("pre-compact"); -const STATE_DIR = join(tmpdir(), "openviking-codex-capture-state"); - function output(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } function noop(message) { - if (message) { - output({ systemMessage: message }); - } else { - output({}); - } + output(message ? { systemMessage: message } : {}); } async function fetchJSON(path, init = {}) { @@ -61,27 +56,6 @@ async function fetchJSON(path, init = {}) { } } -function stateFilePath(sessionId) { - const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_"); - return join(STATE_DIR, `${safe}.json`); -} - -async function loadState(sessionId) { - try { - const data = await readFile(stateFilePath(sessionId), "utf-8"); - return JSON.parse(data); - } catch { - return { capturedTurnCount: 0, lastAssistantMessageHash: null, compactedAt: null }; - } -} - -async function saveState(sessionId, state) { - try { - await mkdir(STATE_DIR, { recursive: true }); - await writeFile(stateFilePath(sessionId), JSON.stringify(state)); - } catch { /* best effort */ } -} - function extractTextFromContent(content) { if (!content) return ""; if (typeof content === "string") return content; @@ -127,39 +101,59 @@ function extractTurns(entries) { } if (role !== "user" && role !== "assistant") continue; - if (text.trim()) turns.push({ role, text: text.trim() }); + const trimmed = text.trim(); + if (!trimmed) continue; + + const capped = trimmed.length > cfg.captureMaxLength + ? trimmed.slice(0, cfg.captureMaxLength) + : trimmed; + turns.push({ role, text: capped }); } return turns; } -async function commitFullSession(turns) { +async function readTranscriptTurns(transcriptPath) { + if (!transcriptPath) return []; + try { + const raw = await readFile(transcriptPath, "utf-8"); + if (!raw.trim()) return []; + return extractTurns(parseTranscript(raw)); + } catch (err) { + logError("transcript_read", err); + return []; + } +} + +async function ensureOvSession(state) { + if (state.ovSessionId) return state.ovSessionId; const created = await fetchJSON("/api/v1/sessions", { method: "POST", body: JSON.stringify({}), }); - if (!created?.session_id) return { ok: false, reason: "session_create_failed" }; - const ovSessionId = created.session_id; + if (!created?.session_id) return null; + state.ovSessionId = created.session_id; + return state.ovSessionId; +} +async function appendTurns(ovSessionId, turns) { for (const turn of turns) { await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { method: "POST", body: JSON.stringify({ role: turn.role, content: turn.text }), }); } +} - const commit = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, { - method: "POST", - body: JSON.stringify({}), - }); - - return { - ok: true, - ovSessionId, - extracted: commit?.memories_extracted || null, - archived: commit?.archived ?? false, - taskId: commit?.task_id, - status: commit?.status, - }; +function countExtracted(commit) { + if (!commit?.memories_extracted) return 0; + if (typeof commit.memories_extracted === "number") return commit.memories_extracted; + if (typeof commit.memories_extracted === "object") { + return Object.values(commit.memories_extracted).reduce( + (a, b) => a + (typeof b === "number" ? b : 0), + 0, + ); + } + return 0; } async function main() { @@ -192,60 +186,65 @@ async function main() { return; } - if (!transcriptPath) { - log("skip", { stage: "input_check", reason: "no transcript_path" }); - noop(); - return; - } + const state = await loadState(sessionId); + const allTurns = await readTranscriptTurns(transcriptPath); + const newTurns = allTurns.slice(state.capturedTurnCount); - let raw; - try { - raw = await readFile(transcriptPath, "utf-8"); - } catch (err) { - logError("transcript_read", err); - noop(); - return; - } - if (!raw.trim()) { - log("skip", { stage: "transcript_read", reason: "empty" }); + log("transcript_parse", { + totalTurns: allTurns.length, + previouslyCaptured: state.capturedTurnCount, + newTurns: newTurns.length, + }); + + if (allTurns.length === 0 && !state.ovSessionId) { + log("skip", { stage: "nothing_to_commit", reason: "no transcript and no open OV session" }); noop(); return; } - const entries = parseTranscript(raw); - const allTurns = extractTurns(entries); - log("transcript_parse", { totalTurns: allTurns.length }); + if (newTurns.length > 0) { + const ovSessionId = await ensureOvSession(state); + if (!ovSessionId) { + logError("ensure_ov_session", "failed to create OV session for catch-up"); + noop(); + return; + } + await appendTurns(ovSessionId, newTurns); + state.capturedTurnCount = allTurns.length; + log("appended_catchup", { ovSessionId, added: newTurns.length }); + } - if (allTurns.length === 0) { + if (!state.ovSessionId) { + log("skip", { stage: "commit", reason: "no OV session for this codex session" }); + await saveState(state); noop(); return; } - // Truncate over-long turns rather than dropping them — compaction is a one-shot. - const trimmed = allTurns.map((turn) => ({ - role: turn.role, - text: turn.text.length > cfg.captureMaxLength - ? turn.text.slice(0, cfg.captureMaxLength) - : turn.text, - })); + const ovSessionId = state.ovSessionId; + const commit = await fetchJSON( + `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, + { method: "POST", body: JSON.stringify({}) }, + ); + const extracted = countExtracted(commit); + log("commit", { + ovSessionId, + extracted, + archived: commit?.archived ?? false, + taskId: commit?.task_id, + status: commit?.status, + }); - const result = await commitFullSession(trimmed); - log("commit_full_session", result); + // Reset OV session for the post-compact half. Keep capturedTurnCount so + // we don't re-capture pre-compact turns when Stop fires next. + state.ovSessionId = null; + await saveState(state); - // Mark transcript as fully consumed so Stop hook stops re-capturing. - const state = await loadState(sessionId); - state.capturedTurnCount = allTurns.length; - state.compactedAt = Date.now(); - await saveState(sessionId, state); - - if (result.ok) { - const mem = result.extracted && typeof result.extracted === "object" - ? Object.values(result.extracted).reduce((a, b) => a + (typeof b === "number" ? b : 0), 0) - : 0; - noop(`pre-compact commit: ${trimmed.length} turns sent to OpenViking, ${mem} memory item(s) extracted${result.archived ? " (archived)" : ""}`); - } else { - noop(); - } + noop( + commit + ? `pre-compact commit: ${ovSessionId} → ${extracted} memory item(s) extracted${commit.archived ? " (archived)" : ""}` + : `pre-compact commit attempted on ${ovSessionId}; result unavailable`, + ); } main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/session-state.mjs b/examples/codex-memory-plugin/scripts/session-state.mjs new file mode 100644 index 0000000000..dfc7f54885 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/session-state.mjs @@ -0,0 +1,81 @@ +/** + * Per-codex-session state for the OpenViking memory plugin. + * + * One state file per codex session_id, holding the long-lived OpenViking + * session that we incrementally append turns to via the Stop hook. The + * OV session is committed (which extracts memories) by the PreCompact + * hook or by the idle-sweep that runs at the tail of each Stop. + * + * State directory: $OPENVIKING_CODEX_STATE_DIR or ~/.openviking/codex-plugin-state + */ + +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_STATE_DIR = join(homedir(), ".openviking", "codex-plugin-state"); + +export function getStateDir() { + return process.env.OPENVIKING_CODEX_STATE_DIR || DEFAULT_STATE_DIR; +} + +function safeId(codexSessionId) { + return String(codexSessionId).replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function statePath(codexSessionId) { + return join(getStateDir(), `${safeId(codexSessionId)}.json`); +} + +function defaultState(codexSessionId) { + const now = Date.now(); + return { + codexSessionId, + ovSessionId: null, + capturedTurnCount: 0, + createdAt: now, + lastUpdatedAt: now, + }; +} + +export async function loadState(codexSessionId) { + try { + const raw = await readFile(statePath(codexSessionId), "utf-8"); + const parsed = JSON.parse(raw); + return { ...defaultState(codexSessionId), ...parsed }; + } catch { + return defaultState(codexSessionId); + } +} + +export async function saveState(state) { + if (!state || !state.codexSessionId) return; + await mkdir(getStateDir(), { recursive: true }); + const next = { ...state, lastUpdatedAt: Date.now() }; + await writeFile(statePath(state.codexSessionId), JSON.stringify(next)); +} + +export async function clearState(codexSessionId) { + try { + await rm(statePath(codexSessionId), { force: true }); + } catch { /* best effort */ } +} + +export async function listStates() { + try { + const dir = getStateDir(); + const files = await readdir(dir); + const out = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const raw = await readFile(join(dir, file), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed?.codexSessionId) out.push(parsed); + } catch { /* skip */ } + } + return out; + } catch { + return []; + } +} From d41e8f617d81e460e5cf5291daddb2656883d42c Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 10 May 2026 15:35:00 +0800 Subject: [PATCH 03/12] refactor(plugin/codex): replace idle-sweep with SessionStart(source=clear) commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Zayn's followup ("非必要不要加 idle commit"): drop the idle-sweep added in the previous commit and use codex's actual context-disappearing signal — SessionStart with source=clear — to commit orphaned sessions. Codex hook signal map: - /compact → PreCompact ✅ commit (already) - /clear → SessionStart(source=clear) for the NEW session_id; the prior transcript is orphaned. Now committed. - /new → SessionStart(source=startup); ambiguous with fresh codex startup, so we don't act on it. - /resume / short reconnect → SessionStart(source=resume|startup); no-op to avoid corrupting still-active sessions. - SIGTERM/Ctrl+C/exit → no hook fires. Documented as a known gap; users should /compact before /exit if they want commit. Changes: - new scripts/session-start-commit.mjs: gates internally on source=clear, iterates listStates(), and commits any state file whose codexSessionId != the new SessionStart session_id, then clears that state file - hooks/hooks.json: re-register SessionStart pointing at the new script (timeout 30s) - scripts/auto-capture.mjs: remove sweepIdleSessions() and IDLE_TTL_MS env handling; Stop is now strictly add_message - README/VERIFICATION.md: update arch diagram, replace idle-sweep step with SessionStart(source=clear) verify (positive + negative paths), add "Known gap: SIGTERM/exit are silent" section - bump to 0.3.1 Verified end-to-end against ov.zaynjarvis.com: Stop add+idempotent ✓ SessionStart source=startup → {} ✓ SessionStart source=resume → {} ✓ SessionStart source=clear → committed prior OV session, history/archive_001/ appeared, profile.md gained "Favorite snack: dark chocolate" within 30 s. Co-Authored-By: Claude Opus 4.7 --- .../.codex-plugin/plugin.json | 6 +- examples/codex-memory-plugin/README.md | 87 +++++------ examples/codex-memory-plugin/VERIFICATION.md | 47 +++--- examples/codex-memory-plugin/hooks/hooks.json | 12 ++ examples/codex-memory-plugin/package.json | 2 +- .../scripts/auto-capture.mjs | 50 +------ .../scripts/session-start-commit.mjs | 137 ++++++++++++++++++ 7 files changed, 228 insertions(+), 113 deletions(-) create mode 100644 examples/codex-memory-plugin/scripts/session-start-commit.mjs diff --git a/examples/codex-memory-plugin/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json index 31c7032339..82181b3ca2 100644 --- a/examples/codex-memory-plugin/.codex-plugin/plugin.json +++ b/examples/codex-memory-plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "openviking-memory", - "version": "0.3.0", - "description": "Long-term semantic memory for Codex, powered by OpenViking. Recall on UserPromptSubmit, incremental add_message on Stop (per turn), commit on PreCompact, with idle-sweep commit for graceful exits.", + "version": "0.3.1", + "description": "Long-term semantic memory for Codex, powered by OpenViking. Recall on UserPromptSubmit, incremental add_message on Stop (per turn), commit on PreCompact, and commit on SessionStart with source=clear (when /clear orphans the prior session).", "author": { "name": "OpenViking", "url": "https://github.com/volcengine/OpenViking" @@ -21,7 +21,7 @@ "interface": { "displayName": "OpenViking Memory", "shortDescription": "Long-term semantic memory for Codex", - "longDescription": "Hooks Codex's lifecycle to keep an external semantic memory: recall relevant memories on each UserPromptSubmit; on every Stop (turn end) append the new user/assistant turns to a long-lived OpenViking session keyed by codex session_id; on PreCompact commit that session so OV's extractor produces durable memories before context is summarized. A best-effort idle-sweep on Stop also commits sessions that exit gracefully without compacting. Also exposes explicit MCP tools (openviking_recall, openviking_store, openviking_forget, openviking_health) for manual use.", + "longDescription": "Hooks Codex's lifecycle to keep an external semantic memory: recall relevant memories on each UserPromptSubmit; on every Stop (turn end) append the new user/assistant turns to a long-lived OpenViking session keyed by codex session_id; on PreCompact commit that session so OV's extractor produces durable memories before context is summarized; on SessionStart(source=clear) commit any orphaned prior session before /clear discards it. Also exposes explicit MCP tools (openviking_recall, openviking_store, openviking_forget, openviking_health) for manual use.", "developerName": "OpenViking", "category": "Productivity", "capabilities": [ diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 90d8761395..7b3fa55a55 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -7,53 +7,55 @@ This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-me - **Auto-recall** relevant memories on every `UserPromptSubmit` and inject them via `hookSpecificOutput.additionalContext` - **Incremental capture on `Stop`** (turn end): append the new user/assistant turns to a single long-lived OpenViking session keyed by Codex `session_id`. No commit per turn. - **Commit on `PreCompact`**: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it. -- **Idle sweep on `Stop`**: opportunistically commit OV sessions whose Codex session has been silent past the idle TTL (default 30 min) — best-effort session-end signal because Codex has no `SessionEnd` hook today. -- **MCP runtime bootstrap is lazy**: the MCP launcher (`start-memory-server.mjs`) installs runtime deps on first MCP invocation. We do **not** register a `SessionStart` hook, so short reconnects don't re-trigger `npm ci`. +- **Commit on `SessionStart` with `source=clear`**: when the user runs `/clear`, the previous OpenViking session is committed before Codex orphans it. `source=startup` and `source=resume` are no-ops (short reconnects re-fire SessionStart and we don't want to commit a still-active session). +- **MCP runtime bootstrap is lazy**: the MCP launcher (`start-memory-server.mjs`) installs runtime deps on first MCP invocation, not in a hook. It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health`) for manual use. ## Architecture ``` - ┌──────────────────────────────────────┐ - │ Codex │ - └──────┬───────────────┬────────────┬──┘ - │ │ │ - UserPromptSubmit Stop PreCompact - │ │ │ - ┌──────▼─────┐ ┌──────▼─────┐ ┌──▼─────────────┐ - │ auto- │ │ auto- │ │ pre-compact- │ - │ recall.mjs │ │ capture.mjs│ │ capture.mjs │ - │ (search) │ │ (append + │ │ (commit + │ - │ │ │ idle-sweep)│ │ reset session) │ - └──────┬─────┘ └──────┬─────┘ └──────┬─────────┘ - │ │ │ - │ ┌───────▼───────────────▼────┐ - └──────►│ OpenViking server │ - │ /api/v1/search/find │ - │ /api/v1/sessions │ - │ /api/v1/sessions/{id}/ │ - │ messages | commit │ - │ /api/v1/content/read │ - └────────────────────────────┘ - - ┌──────────────────────────────────────┐ - │ MCP Server (memory-server.ts) │ - │ Tools for explicit use: │ - │ • openviking_recall │ - │ • openviking_store │ - │ • openviking_forget │ - │ • openviking_health │ - │ Lazily npm ci's its runtime on │ - │ first launch (not on SessionStart). │ - └──────────────────────────────────────┘ + ┌──────────────────────────────────────────────────────────────┐ + │ Codex │ + └──┬─────────────────┬────────────────┬───────────────────┬────┘ + │ │ │ │ + SessionStart UserPromptSubmit Stop PreCompact + (source=clear) │ (per turn) │ + │ │ │ │ + ┌────▼──────────┐ ┌────▼──────┐ ┌──────▼──────┐ ┌──────────▼──────┐ + │ session-start │ │ auto- │ │ auto- │ │ pre-compact- │ + │ -commit.mjs │ │ recall.mjs│ │ capture.mjs │ │ capture.mjs │ + │ (commit prior │ │ (search) │ │ (append + │ │ (commit + reset │ + │ orphan only │ │ │ │ no commit) │ │ ovSessionId) │ + │ on /clear) │ │ │ │ │ │ │ + └────┬──────────┘ └────┬──────┘ └──────┬──────┘ └──────────┬──────┘ + │ │ │ │ + │ ┌───▼────────────────▼───────────────────▼──┐ + └────────────►│ OpenViking server │ + │ /api/v1/search/find │ + │ /api/v1/sessions [+/{id}/{messages,commit}]│ + │ /api/v1/content/read │ + └───────────────────────────────────────────┘ + + ┌──────────────────────────────────────┐ + │ MCP Server (memory-server.ts) │ + │ Tools for explicit use: │ + │ • openviking_recall │ + │ • openviking_store │ + │ • openviking_forget │ + │ • openviking_health │ + │ Lazily npm ci's its runtime on │ + │ first launch. │ + └──────────────────────────────────────┘ ``` ## How It Works -### Why no `SessionStart` hook +### Why the SessionStart hook is `source=clear`-only -Codex fires `SessionStart` on every short reconnect and resume — not just genuine new sessions. Registering a hook that runs `npm ci` on every `SessionStart` is the wrong shape: it would reinstall the runtime on every reconnect, and short reconnects don't need a memory boundary. Instead we **lazily bootstrap** the MCP runtime in `scripts/start-memory-server.mjs` the first time codex actually launches the MCP server. The bootstrap is content-hashed and idempotent (`scripts/runtime-common.mjs`), so subsequent launches are no-ops. +Codex fires `SessionStart` with one of three `source` values: `startup` (fresh process or `/new`), `resume` (`/resume` or short reconnect), and `clear` (`/clear` — the previous transcript is being orphaned to a new session_id). Only `source=clear` is a deterministic "context is about to disappear for a previous session" signal. `startup` and `resume` are also fired on short reconnects, so committing on those would corrupt still-active sessions. + +`session-start-commit.mjs` therefore exits early on every source except `clear`. On `clear`, it commits any state file whose `codexSessionId` differs from the new session_id (those state files are orphaned by `/clear`). MCP runtime install does **not** live in this hook — it lazily runs from `scripts/start-memory-server.mjs` on first MCP launch. ### Auto-recall (every UserPromptSubmit) @@ -71,10 +73,6 @@ Codex's `Stop` fires per turn, not at session end. So `auto-capture.mjs` keeps * We do **not** call `/commit` per turn — committing extracts memories, and per-turn extraction would over-fragment the memory tree and waste OV's extractor. -### Idle sweep (best-effort session-end commit) - -Codex has no `SessionEnd` hook today (the schema only ships `SessionStart`, `UserPromptSubmit`, `Stop`, `PreCompact`, `PostCompact`, and tool-use events). To still produce memories from sessions that exit gracefully without compacting, every `Stop` invocation also runs an idle sweep at the end: any tracked codex session whose state file is older than `IDLE_TTL` (default 30 min, override with `OPENVIKING_CODEX_IDLE_TTL_MS`) gets committed and its state file removed. - ### PreCompact (deterministic commit) `PreCompact` fires before Codex summarizes. `pre-compact-capture.mjs` does: @@ -83,6 +81,12 @@ Codex has no `SessionEnd` hook today (the schema only ships `SessionStart`, `Use 2. **Commit** the long-lived OV session for this Codex `session_id` so OV's extractor runs against the full pre-compact transcript. 3. **Reset** state: clear `ovSessionId` so the next `Stop` opens a fresh OV session for the post-compact half. `capturedTurnCount` stays so we don't re-capture pre-compact turns. +### Known gap: SIGTERM / Ctrl+C / `/exit` are silent + +Codex fires no hook on process exit. `/compact` (PreCompact) and `/clear` (SessionStart with `source=clear`) are the only deterministic "context disappearing" signals. If you `/exit` (or Ctrl+C, or kill the process) without first running `/compact`, the OpenViking session for that codex session_id stays open with messages but never has memories extracted. It will, however, be committed the next time you run `/clear` from any codex session on the same machine — which sweeps all orphaned state files. + +If you care about preserving memory from a particular session before exiting: run `/compact` first, or have the model call the `openviking_store` MCP tool with the conclusions you want kept. (We considered an idle-timer-based commit on `Stop` but it produces false-commits for sessions that are merely paused, so this plugin does not include one.) + ### MCP tools (explicit, on demand) The MCP server provides tools for when Codex or the user needs explicit memory operations. See "Tools" below. @@ -93,11 +97,12 @@ Codex's hook output schema differs from Claude Code's. Notably: | Hook | Input field of interest | Output channel for context injection | |------|------------------------|--------------------------------------| +| `SessionStart` | `source` (`startup`/`resume`/`clear`), `session_id` | `hookSpecificOutput.additionalContext` | | `UserPromptSubmit` | `prompt` | `hookSpecificOutput.additionalContext` | | `Stop` | `last_assistant_message`, `transcript_path`, `session_id` | `systemMessage` (only) | | `PreCompact` | `trigger` (`manual`/`auto`), `transcript_path`, `session_id` | `systemMessage` (only) | -> Note: this plugin no longer registers `SessionStart`. Codex fires it on short reconnects too, and the MCP runtime install belongs in `start-memory-server.mjs` (lazy on first MCP call), not in a per-reconnect hook. +> Note: this plugin only acts on `SessionStart` when `source=clear`. The other sources (`startup` / `resume`) are no-ops because codex re-fires them on short reconnects. Unlike Claude Code, **Codex does not support `decision: "approve"`**; only `decision: "block"`. A no-op is `{}` (which is what these scripts emit when there's nothing to add). diff --git a/examples/codex-memory-plugin/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md index cb0d603536..2911989baa 100644 --- a/examples/codex-memory-plugin/VERIFICATION.md +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -122,35 +122,38 @@ echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.j Expect: `appended 2 turn(s) to OpenViking session ` — different from step 4's UUID. -## 6. Idle-sweep — graceful-exit commit +## 6. SessionStart(source=clear) — orphan commit on `/clear` -The sweep only commits sessions whose state file is older than the idle TTL -(default 30 min). For verification we shorten the TTL to 1 second: +`/clear` orphans the previous session and starts a new one. The +SessionStart hook commits any state files whose codexSessionId differs +from the new one. ```bash -# Backdate the verify-sess state to force-stale it -python3 -c " -import json, sys -p = '$STATE_DIR/state/verify-sess.json' -s = json.load(open(p)) -s['lastUpdatedAt'] = 1 -open(p, 'w').write(json.dumps(s)) -" - -# Run a Stop for a brand-new session_id; idle-sweep at the tail commits the stale one -echo '{"session_id":"sweep-trigger","transcript_path":"/tmp/empty-nonexistent.jsonl"}' \ - | OPENVIKING_CODEX_IDLE_TTL_MS=60000 \ - OPENVIKING_CONFIG_FILE=$OV_CONF \ +# Simulate /clear: the new SessionStart payload carries a brand-new session_id +echo '{"session_id":"clear-after-verify","source":"clear","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ - node $PLUGIN/scripts/auto-capture.mjs - -ls $STATE_DIR/state/ -# verify-sess.json is gone; only sweep-trigger.json remains + node $PLUGIN/scripts/session-start-commit.mjs ``` -OV side: the post-compact `` from step 5 is now archived (its -`messages.jsonl` is size 0; `history/archive_001/` exists). +Expect: `/clear: committed N prior OpenViking session(s), …`. After this +the state dir contains nothing except (optionally) the just-cleared +session's fresh state. OV side: the post-compact `` from step +5 is now archived (`messages.jsonl` size 0, `history/archive_001/` +exists). + +Verify the negative path too — `source=startup` and `source=resume` MUST +be no-ops: + +```bash +echo '{"session_id":"x","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/session-start-commit.mjs +# Expect: {} (no commit; short reconnects fire startup/resume too) +``` ## 7. Memory extraction landed in user namespace diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json index e48d71d316..af6485452f 100644 --- a/examples/codex-memory-plugin/hooks/hooks.json +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -1,5 +1,17 @@ { "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/session-start-commit.mjs\"", + "timeout": 30 + } + ] + } + ], "UserPromptSubmit": [ { "matcher": "*", diff --git a/examples/codex-memory-plugin/package.json b/examples/codex-memory-plugin/package.json index 75c12f39ed..3b738cd872 100644 --- a/examples/codex-memory-plugin/package.json +++ b/examples/codex-memory-plugin/package.json @@ -1,6 +1,6 @@ { "name": "codex-openviking-memory", - "version": "0.3.0", + "version": "0.3.1", "description": "OpenViking memory plugin for Codex — hooks (recall/capture/pre-compact) + MCP server for explicit memory operations.", "type": "module", "scripts": { diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index ef2a57f102..579608e2d8 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -11,12 +11,10 @@ * session and remember it in state. Do NOT commit per turn. * 2. Read transcript_path, parse JSONL rollout, append every new * user/assistant turn since last capture via add_message. - * 3. Idle sweep: any OTHER codex session whose state file is older than - * IDLE_TTL gets committed and cleared. This is a best-effort - * session-end signal because codex has no SessionEnd hook today. * - * The PreCompact hook is the deterministic commit path; this idle sweep - * is the catch-all for graceful exits. + * Commit happens in two other places, never here: + * - PreCompact hook (deterministic, before context compaction) + * - SessionStart hook with source=clear (when user runs /clear) * * Stop output schema accepts {} as a no-op. */ @@ -24,13 +22,11 @@ import { readFile } from "node:fs/promises"; import { loadConfig } from "./config.mjs"; import { createLogger } from "./debug-log.mjs"; -import { clearState, listStates, loadState, saveState } from "./session-state.mjs"; +import { loadState, saveState } from "./session-state.mjs"; const cfg = loadConfig(); const { log, logError } = createLogger("auto-capture"); -const IDLE_TTL_MS = Math.max(60_000, Number(process.env.OPENVIKING_CODEX_IDLE_TTL_MS) || 30 * 60_000); - function output(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } @@ -176,43 +172,6 @@ function countExtracted(commit) { return 0; } -// --------------------------------------------------------------------------- -// Idle sweep — best-effort session-end commit (codex has no SessionEnd hook) -// --------------------------------------------------------------------------- - -async function sweepIdleSessions(currentSessionId) { - const states = await listStates(); - const now = Date.now(); - let sweptCount = 0; - let sweptExtracted = 0; - - for (const s of states) { - if (!s?.codexSessionId || s.codexSessionId === currentSessionId) continue; - const idleMs = now - (typeof s.lastUpdatedAt === "number" ? s.lastUpdatedAt : 0); - if (idleMs < IDLE_TTL_MS) continue; - - if (s.ovSessionId) { - const commit = await commitOvSession(s.ovSessionId); - const extracted = countExtracted(commit); - log("idle_sweep_commit", { - codexSessionId: s.codexSessionId, - ovSessionId: s.ovSessionId, - idleMs, - extracted, - }); - sweptExtracted += extracted; - } else { - log("idle_sweep_clear", { codexSessionId: s.codexSessionId, idleMs }); - } - await clearState(s.codexSessionId); - sweptCount += 1; - } - - if (sweptCount > 0) { - log("idle_sweep_done", { sweptCount, sweptExtracted }); - } -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -270,7 +229,6 @@ async function main() { } await saveState(state); - await sweepIdleSessions(sessionId); if (added > 0) { noop(`appended ${added} turn(s) to OpenViking session ${state.ovSessionId}`); diff --git a/examples/codex-memory-plugin/scripts/session-start-commit.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.mjs new file mode 100644 index 0000000000..688fb34334 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/session-start-commit.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * SessionStart hook for Codex. + * + * The ONLY action this script takes is on `source === "clear"`: + * /clear in Codex creates a new session (with the new session_id in this + * payload) and orphans the previous in-memory transcript. We treat that + * as a deterministic "context is about to disappear" signal and commit + * any pending OpenViking sessions for *other* codex session_ids. + * + * For `source === "startup"` and `source === "resume"`, this hook is a + * no-op. Codex re-fires SessionStart on short reconnects and resume, and + * we don't want to commit during a still-active session. + * + * SessionStart output schema accepts {} as a no-op. + */ + +import { loadConfig } from "./config.mjs"; +import { createLogger } from "./debug-log.mjs"; +import { clearState, listStates } from "./session-state.mjs"; + +const cfg = loadConfig(); +const { log, logError } = createLogger("session-start"); + +function output(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function noop(message) { + output(message ? { systemMessage: message } : {}); +} + +async function fetchJSON(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); + try { + const headers = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; + if (cfg.user) headers["X-OpenViking-User"] = cfg.user; + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; + const res = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }); + const body = await res.json().catch(() => null); + if (!body) return null; + if (!res.ok || body.status === "error") return null; + return body.result ?? body; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function countExtracted(commit) { + if (!commit?.memories_extracted) return 0; + if (typeof commit.memories_extracted === "number") return commit.memories_extracted; + if (typeof commit.memories_extracted === "object") { + return Object.values(commit.memories_extracted).reduce( + (a, b) => a + (typeof b === "number" ? b : 0), + 0, + ); + } + return 0; +} + +async function commitOvSession(ovSessionId) { + if (!ovSessionId) return null; + return fetchJSON( + `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, + { method: "POST", body: JSON.stringify({}) }, + ); +} + +async function main() { + let input; + try { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + input = JSON.parse(Buffer.concat(chunks).toString()); + } catch { + log("skip", { stage: "stdin_parse", reason: "invalid input" }); + noop(); + return; + } + + const source = input.source || "unknown"; + const newSessionId = input.session_id || "unknown"; + log("start", { source, newSessionId }); + + if (source !== "clear") { + log("skip", { stage: "source_check", reason: `source=${source} (only 'clear' triggers commit)` }); + noop(); + return; + } + + const health = await fetchJSON("/health"); + if (!health) { + logError("health_check", "server unreachable; cannot commit"); + noop(); + return; + } + + const states = await listStates(); + let committedCount = 0; + let totalExtracted = 0; + + for (const s of states) { + if (!s?.codexSessionId || s.codexSessionId === newSessionId) continue; + if (s.ovSessionId) { + const commit = await commitOvSession(s.ovSessionId); + const extracted = countExtracted(commit); + log("commit_orphan", { + codexSessionId: s.codexSessionId, + ovSessionId: s.ovSessionId, + extracted, + }); + totalExtracted += extracted; + } else { + log("clear_orphan_no_ov", { codexSessionId: s.codexSessionId }); + } + await clearState(s.codexSessionId); + committedCount += 1; + } + + if (committedCount > 0) { + log("done", { committedCount, totalExtracted }); + noop( + `/clear: committed ${committedCount} prior OpenViking session(s), ${totalExtracted} memory item(s) extracted`, + ); + } else { + log("done", { committedCount: 0, totalExtracted: 0 }); + noop(); + } +} + +main().catch((err) => { logError("uncaught", err); noop(); }); From a5b667c910156b8efdd31e8a8a4576cf33eb3d86 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 10 May 2026 15:37:51 +0800 Subject: [PATCH 04/12] chore(plugin/codex): SessionStart matcher = "clear" (native dispatcher gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's hooks dispatcher matches the SessionStart hook's `matcher` field against the SessionStart `source` value. Setting matcher to "clear" means codex won't even spawn our script on `source=startup` or `source=resume` (short reconnects); we previously gated this in-script. The internal source check in session-start-commit.mjs is kept as defense-in-depth. Source: codex-rs/hooks/src/events/session_start.rs `select_handlers(..., matcher_input: Some(request.source.as_str()))` and codex-rs/hooks/src/events/common.rs `is_exact_matcher` — "clear" is all-alphanumeric so it's matched as exact equality, not regex. Co-Authored-By: Claude Opus 4.7 --- examples/codex-memory-plugin/README.md | 4 +++- examples/codex-memory-plugin/hooks/hooks.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 7b3fa55a55..63fe061bf6 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -55,7 +55,9 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op Codex fires `SessionStart` with one of three `source` values: `startup` (fresh process or `/new`), `resume` (`/resume` or short reconnect), and `clear` (`/clear` — the previous transcript is being orphaned to a new session_id). Only `source=clear` is a deterministic "context is about to disappear for a previous session" signal. `startup` and `resume` are also fired on short reconnects, so committing on those would corrupt still-active sessions. -`session-start-commit.mjs` therefore exits early on every source except `clear`. On `clear`, it commits any state file whose `codexSessionId` differs from the new session_id (those state files are orphaned by `/clear`). MCP runtime install does **not** live in this hook — it lazily runs from `scripts/start-memory-server.mjs` on first MCP launch. +We pin this in two layers: `hooks.json` registers `SessionStart` with `matcher: "clear"` so codex's dispatcher only invokes the script on `source=clear` (the matcher is matched against the SessionStart `source` field — see [`codex-rs/hooks/src/events/session_start.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/events/session_start.rs)). And `session-start-commit.mjs` itself also early-returns on any other source as defense-in-depth. + +On `clear`, the script commits any state file whose `codexSessionId` differs from the new session_id (those state files are orphaned by `/clear`). MCP runtime install does **not** live in this hook — it lazily runs from `scripts/start-memory-server.mjs` on first MCP launch. ### Auto-recall (every UserPromptSubmit) diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json index af6485452f..518bff2210 100644 --- a/examples/codex-memory-plugin/hooks/hooks.json +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "SessionStart": [ { - "matcher": "*", + "matcher": "clear", "hooks": [ { "type": "command", From f68b34cc2ae3a766a0d1d451f90a255d120d2e51 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Sun, 10 May 2026 18:26:46 +0800 Subject: [PATCH 05/12] refactor(plugin/codex): active-window heuristic + idle-TTL sweep at SessionStart (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source of truth: examples/codex-memory-plugin/DESIGN.md (added in this commit). Behavioral changes: - SessionStart matcher widens from `clear` to `clear|startup`. Both sources run the same active-window heuristic; `resume` is a hard no-op (still fires on short reconnects). - Heuristic (DESIGN.md §3): count state files (excluding new session_id) within ACTIVE_WINDOW_MS (default 2 min). 0 → noop, 1 → commit it (just-ended session), ≥2 → skip and rely on idle TTL. Tunable via OPENVIKING_CODEX_ACTIVE_WINDOW_MS. - Idle-TTL sweep returns at the tail of session-start-commit.mjs only (not every Stop). Default IDLE_TTL_MS = 30 min via OPENVIKING_CODEX_IDLE_TTL_MS. Catches SIGTERM/Ctrl+C/`/exit` orphans and the ≥2-active skip path. - Stop hook deliberately does NOT sweep — state-write-on-every-turn already gives us the freshness signal. Marker comment added. - Stop hook adds post-compact transcript-shrink defense: if allTurns.length < state.capturedTurnCount, reset capturedTurnCount = 0. - Commit-on-failure preserves state everywhere (PreCompact, heuristic, idle sweep). A non-2xx /commit no longer clears ovSessionId; the next sweep retries. - session-state.mjs saveState now uses atomic write (tmpfile + rename) for crash safety. listStates ignores the brief `.json.tmp` window. Bump: package.json + .codex-plugin/plugin.json → 0.4.0. Docs: README "How It Works" gained a DESIGN.md pointer and rewrites the SessionStart section to reflect heuristic + idle TTL. VERIFICATION.md step 6 now exercises all four heuristic branches (0/1/≥2 active, idle TTL, resume). Phase-2 resume context inject documented in DESIGN.md but explicitly out of scope here. Verified locally with synthetic stdin tests against a fake OV server: 1-active commit, ≥2-active skip, idle TTL sweep, resume noop, unreachable-server keeps state. Co-Authored-By: Claude Opus 4.7 --- .../.codex-plugin/plugin.json | 4 +- examples/codex-memory-plugin/DESIGN.md | 275 ++++++++++++++++++ examples/codex-memory-plugin/README.md | 44 ++- examples/codex-memory-plugin/VERIFICATION.md | 94 +++++- examples/codex-memory-plugin/hooks/hooks.json | 2 +- examples/codex-memory-plugin/package.json | 2 +- .../scripts/auto-capture.mjs | 23 +- .../scripts/pre-compact-capture.mjs | 22 +- .../scripts/session-start-commit.mjs | 192 +++++++++--- .../scripts/session-state.mjs | 11 +- 10 files changed, 593 insertions(+), 76 deletions(-) create mode 100644 examples/codex-memory-plugin/DESIGN.md diff --git a/examples/codex-memory-plugin/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json index 82181b3ca2..4a1453bfb1 100644 --- a/examples/codex-memory-plugin/.codex-plugin/plugin.json +++ b/examples/codex-memory-plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "openviking-memory", - "version": "0.3.1", - "description": "Long-term semantic memory for Codex, powered by OpenViking. Recall on UserPromptSubmit, incremental add_message on Stop (per turn), commit on PreCompact, and commit on SessionStart with source=clear (when /clear orphans the prior session).", + "version": "0.4.0", + "description": "Long-term semantic memory for Codex, powered by OpenViking. Recall on UserPromptSubmit, incremental add_message on Stop (per turn), commit on PreCompact, and active-window heuristic + idle-TTL sweep on SessionStart (source=startup|clear).", "author": { "name": "OpenViking", "url": "https://github.com/volcengine/OpenViking" diff --git a/examples/codex-memory-plugin/DESIGN.md b/examples/codex-memory-plugin/DESIGN.md new file mode 100644 index 0000000000..6ba56ebc26 --- /dev/null +++ b/examples/codex-memory-plugin/DESIGN.md @@ -0,0 +1,275 @@ +# Codex memory plugin — commit decision design + +This document records *why* the plugin commits when it commits. The commit +shape (which OpenViking session is sealed by which hook event) is the part +worth understanding before reading code: the codex hook surface gives us +**no clean SessionEnd signal**, so we have to reason about which observable +events imply "context for a particular codex `session_id` is gone". + +## Vocabulary + +- **codex `session_id`** — the codex thread/session id. Stable across + process restarts when zouk-daemon resumes the same thread; replaced when + `/clear`, `/new`, fresh codex startup, or zouk reset occurs. +- **OV session** — `viking://session/`. We open one per codex + `session_id`, append messages on every `Stop`, and commit it (which + triggers OV's memory extractor) at session-end-equivalent moments. +- **State file** — `~/.openviking/codex-plugin-state/.json`, + shape `{ codexSessionId, ovSessionId, capturedTurnCount, createdAt, lastUpdatedAt }`. +- **Active window** — state files whose `lastUpdatedAt` is within + `ACTIVE_WINDOW_MS` (default 2 min) of "now". Used to detect "the codex + session that just ended". + +## Codex hook surface (what we observe) + +| Codex event | Fires when | What we learn | +|---|---|---| +| `SessionStart` source=`startup` | fresh codex process; `/new`; zouk daemon spawn-without-sessionId; zouk reset | new `session_id` was created | +| `SessionStart` source=`resume` | `/resume`; short reconnect; zouk daemon spawn-with-sessionId | same `session_id` continues | +| `SessionStart` source=`clear` | `/clear` (creates a fresh thread, preserves prior thread on disk as resumable) | new `session_id`; previous one orphaned | +| `UserPromptSubmit` | every user turn before model | recall context inject | +| `Stop` | end of every model turn (NOT end of session) | append turns to OV session | +| `PreCompact` | `/compact` or auto-compact | context is about to be summarized | +| `PostCompact` | after compaction | (unused) | +| SIGTERM / SIGINT / Ctrl+C / `/exit` | process killed | **no hook fires** — confirmed in `codex-rs/hooks/src/events/` | + +Verified against codex-rs `main` 2026-05-10. Upstream issues #17421, #20374 +have requested a `SessionEnd` hook; OpenAI rejected with two reasons: +"threads can always be resumed" and "/exit only makes sense in TUI". Not +landing. + +## Commit triggers + +We commit an OV session in exactly these places. Everything else is no-op +or append-only. + +### 1. `PreCompact` — deterministic, current session + +Codex fires `PreCompact` before summarizing. We catch up with any +unappended turns from the transcript, commit the OV session for this codex +`session_id`, and clear `ovSessionId` so the next `Stop` opens a fresh OV +session for the post-compact half. `capturedTurnCount` is preserved unless +the transcript was truncated by compaction (see "Post-compact transcript +shrink" below). + +### 2. `SessionStart` source=`clear` — heuristic, same shape as `startup` + +`/clear` creates a brand-new codex `session_id` and orphans the previous +in-memory thread (preserved on disk). Naively committing "every state file +whose `codexSessionId` ≠ new id" would falsely commit concurrent codex +processes' still-active sessions on the same machine. + +Instead, we treat `clear` and `startup` identically: both run the +**active-window heuristic** below. `/clear` only invalidates the current +codex process's *previous* session; the heuristic correctly catches that +session (a single recently-touched orphan) without trampling unrelated +parallel codex processes. + +### 3. `SessionStart` source=`startup` — heuristic, active-window + +Triggered by `/new`, fresh codex CLI startup, and zouk daemon +spawn-without-sessionId (including zouk's "reset codex" UI action). + +The hook script gates internally on `source ∈ {startup, clear}`. On a +match, it iterates state files (excluding the new `session_id` itself) and +counts how many were touched within `ACTIVE_WINDOW_MS`: + +``` +recently-active count ⇒ action +───────────────────────────────── +0 ⇒ no-op (no orphan to commit) +1 ⇒ commit it (the just-ended session) +≥2 ⇒ skip; rely on idle TTL +``` + +The single-recent case captures the common path: user runs codex, hits +`/new` or `/clear` after a turn or two; the previous session's `Stop` just +fired and bumped `lastUpdatedAt`; we commit it. The multi-recent case +implies concurrent codex sessions are active; we can't tell which one (if +any) ended, so we defer to idle TTL to clean up genuinely-dead ones. + +### 4. `SessionStart` source=`resume` — never commits + +Short reconnects and `/resume` re-fire `SessionStart` for the same +`session_id`. Committing here would seal a still-active session. So +`resume` is a no-op for commit purposes. + +### 5. Idle TTL sweep — fallback + +State files whose `lastUpdatedAt` is older than `IDLE_TTL_MS` (default 30 +min) get committed and cleared. Mental model: a session not touched for +30 min is "temporarily concluded"; if the user resumes later, they get a +fresh OV session for the new turns (memory will be split, but each chunk +gets extracted). + +This covers: +- SIGTERM / Ctrl+C / `/exit` (no hook fires; state file rots) +- Crashes +- Mid-turn zouk reset where `Stop` got cancelled before bumping + `lastUpdatedAt` +- The `≥2 recently-active` skip from rule 3 + +**Sweep trigger**: at the tail of `session-start-commit.mjs` only. We do +not sweep on every `Stop` because state-write-on-every-turn already gives +us the freshness signal we need; running the sweep once per session start +is the right cadence. The Stop hook contains a comment marking the option +to add sweep there if codex's session creation rate is low enough that +arbitrarily-orphaned state files accumulate. + +**Known limitation**: if the user never starts another codex on this +machine, no sweep ever runs and the OV session stays open server-side +forever. Accepted. Future work could add an MCP tool +`openviking_commit_pending` so the model can commit explicitly. + +## Stop hook — append only, no commit + +Every `Stop` reads `transcript_path`, slices to `[capturedTurnCount, end)`, +and appends each new user/assistant turn to the OV session for this codex +`session_id` (creating one on first append). State is updated: +`{ovSessionId, capturedTurnCount, lastUpdatedAt: now}`. Never commits. + +## Edge cases handled + +### Post-compact transcript shrink + +Codex's `/compact` may rewrite or truncate `transcript_path`. After +compaction, if `allTurns.length < state.capturedTurnCount`, our slice +math underflows and we silently drop new turns. Defensive fix: when this +inequality is detected on `Stop`, reset `capturedTurnCount = 0` so the +next slice captures everything in the new transcript. + +### Commit failure + +When OV `/commit` returns non-2xx or times out, we currently log and treat +the result as null. We must NOT call `clearState` on failure — keep the +state file so the next sweep / SessionStart can retry. A transient OV +outage shouldn't lose a session's worth of memory. + +### Race: SIGTERM before Stop completes + +Codex's tokio runtime cancels in-flight async tasks on SIGTERM, so the last +turn's `Stop` hook may be aborted before it bumps `lastUpdatedAt`. This +makes the state look older than it actually is. Consequence: that session +may fall outside the 2 min active window when the user respawns codex and +we can't commit it deterministically — idle TTL will catch it later. + +### Commit-then-resume + +After PreCompact (or idle sweep, or rule-3 commit) we set `ovSessionId = +null` but keep `capturedTurnCount`. The next `Stop` for the same codex +`session_id` opens a fresh OV session and starts appending from +`capturedTurnCount`. Memory ends up split across two OV sessions; each +gets extracted independently. Acceptable. + +## State file schema + +```json +{ + "codexSessionId": "0193af...", // codex thread id + "ovSessionId": "uuid-or-null", // null means "committed, awaiting next Stop" + "capturedTurnCount": 7, // turns from transcript already appended + "createdAt": 1715000000000, + "lastUpdatedAt": 1715000300000 +} +``` + +State files are atomic-write (tmpfile + rename) to survive crash mid-write. + +## Configuration + +Env var overrides for tuning without rebuilding: + +| Var | Default | Purpose | +|---|---|---| +| `OPENVIKING_CODEX_STATE_DIR` | `~/.openviking/codex-plugin-state` | state file dir | +| `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` | `120000` (2 min) | rule-3 active window | +| `OPENVIKING_CODEX_IDLE_TTL_MS` | `1800000` (30 min) | idle sweep TTL | +| `OPENVIKING_DEBUG` | `0` | enable hook debug log | + +## Phase 2: resume context inject (not yet implemented) + +When `SessionStart` source=`resume` fires for a codex `session_id` whose +state shows `ovSessionId = null` (already committed via idle TTL or +PreCompact), we have no live OV session to resume into. The model loses +continuity unless the most recent committed memories are surfaced. + +Proposed flow: +1. Load state for the resumed `session_id`. If `ovSessionId` is non-null, + no action — the session is still appendable. +2. Otherwise list `viking://session//history/archive_*/` + on the OV server, take the most recent. +3. Read its abstract (L0) / overview (L1). +4. Emit via `hookSpecificOutput.additionalContext` so codex injects the + summary into the resumed turn. + +Deferred because (a) it requires a new OV API call shape, (b) the failure +mode is acceptable in v0.3 (model just lacks continuity for one turn, +recovers via auto-recall), and (c) the core commit logic above must be +proven first. + +## What changed vs v0.3.1 + +- `SessionStart` matcher widened from `"clear"` to `"clear|startup"` so the + active-window heuristic runs on both /clear and /new (and zouk reset). +- `session-start-commit.mjs` switches commit logic from "all non-current" + to active-window heuristic. +- Idle TTL sweep brought back, but only at the tail of + `session-start-commit.mjs` (not every `Stop`). Default TTL 30 min. +- `auto-capture.mjs` Stop hook guards against post-compact transcript + shrink (resets `capturedTurnCount` to 0 if `allTurns.length` < cached). +- All commit failure paths preserve state instead of clearing. +- All state writes go through tmpfile + rename for crash safety. + +## Open questions / future work + +- **Phase 2 resume context inject** (above). +- **MCP tool `openviking_commit_pending`**: explicit commit for the model + to call, useful when user knows they're about to exit. +- **Subagent hook events**: kimicode has them, codex doesn't yet. + When codex adds them, we should hook to keep subagent memory threads + separate from main session. +- **Upstream `SessionEnd`**: rejected by OpenAI. If they reverse, idle + TTL becomes redundant — replace with deterministic SessionEnd commit. + +## Verified hook payload reference + +```json +// SessionStart input (from codex-rs/hooks/schema/generated/session-start.command.input.schema.json) +{ + "session_id": "0193af...", + "source": "startup" | "resume" | "clear", + "cwd": "/path/to/cwd", + "model": "gpt-5.5", + "permission_mode": "default" | "acceptEdits" | "plan" | "dontAsk" | "bypassPermissions", + "transcript_path": "/path/to/rollout.jsonl" | null, + "hook_event_name": "SessionStart" +} + +// Stop input +{ + "session_id": "0193af...", + "turn_id": "turn-N", + "transcript_path": "/path/to/rollout.jsonl", + "last_assistant_message": "...", + "stop_hook_active": false, + "model": "gpt-5.5", + "permission_mode": "default", + "cwd": "/path/to/cwd", + "hook_event_name": "Stop" +} + +// PreCompact input +{ + "session_id": "0193af...", + "transcript_path": "/path/to/rollout.jsonl", + "trigger": "manual" | "auto", + "cwd": "/path/to/cwd", + "model": "gpt-5.5", + "hook_event_name": "PreCompact" +} +``` + +Output schema for SessionStart / UserPromptSubmit supports +`hookSpecificOutput.additionalContext`. Stop / PreCompact only support +`{ continue, stopReason, suppressOutput, systemMessage }` — `{}` is a +valid no-op. diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 63fe061bf6..b027b6985d 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -7,7 +7,7 @@ This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-me - **Auto-recall** relevant memories on every `UserPromptSubmit` and inject them via `hookSpecificOutput.additionalContext` - **Incremental capture on `Stop`** (turn end): append the new user/assistant turns to a single long-lived OpenViking session keyed by Codex `session_id`. No commit per turn. - **Commit on `PreCompact`**: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it. -- **Commit on `SessionStart` with `source=clear`**: when the user runs `/clear`, the previous OpenViking session is committed before Codex orphans it. `source=startup` and `source=resume` are no-ops (short reconnects re-fire SessionStart and we don't want to commit a still-active session). +- **Commit on `SessionStart` (source=startup|clear)**: active-window heuristic — if exactly one *other* state file was touched within the last 2 min, commit it (the just-ended session). On `≥2`, defer to idle-TTL sweep at the tail. `source=resume` is a hard no-op (short reconnects re-fire `resume` and we don't want to commit a still-active session). See `DESIGN.md` for the full decision tree. - **MCP runtime bootstrap is lazy**: the MCP launcher (`start-memory-server.mjs`) installs runtime deps on first MCP invocation, not in a hook. It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health`) for manual use. @@ -20,14 +20,14 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op └──┬─────────────────┬────────────────┬───────────────────┬────┘ │ │ │ │ SessionStart UserPromptSubmit Stop PreCompact - (source=clear) │ (per turn) │ + (startup|clear) │ (per turn) │ │ │ │ │ ┌────▼──────────┐ ┌────▼──────┐ ┌──────▼──────┐ ┌──────────▼──────┐ │ session-start │ │ auto- │ │ auto- │ │ pre-compact- │ │ -commit.mjs │ │ recall.mjs│ │ capture.mjs │ │ capture.mjs │ - │ (commit prior │ │ (search) │ │ (append + │ │ (commit + reset │ - │ orphan only │ │ │ │ no commit) │ │ ovSessionId) │ - │ on /clear) │ │ │ │ │ │ │ + │ (active-win │ │ (search) │ │ (append + │ │ (commit + reset │ + │ heuristic + │ │ │ │ no commit) │ │ ovSessionId) │ + │ idle TTL) │ │ │ │ │ │ │ └────┬──────────┘ └────┬──────┘ └──────┬──────┘ └──────────┬──────┘ │ │ │ │ │ ┌───▼────────────────▼───────────────────▼──┐ @@ -51,13 +51,25 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op ## How It Works -### Why the SessionStart hook is `source=clear`-only +> See [`DESIGN.md`](./DESIGN.md) for the commit decision tree — it's the source of truth for *which* OpenViking session is sealed by *which* hook event. -Codex fires `SessionStart` with one of three `source` values: `startup` (fresh process or `/new`), `resume` (`/resume` or short reconnect), and `clear` (`/clear` — the previous transcript is being orphaned to a new session_id). Only `source=clear` is a deterministic "context is about to disappear for a previous session" signal. `startup` and `resume` are also fired on short reconnects, so committing on those would corrupt still-active sessions. +### SessionStart commit logic (source=startup|clear, heuristic + idle TTL) -We pin this in two layers: `hooks.json` registers `SessionStart` with `matcher: "clear"` so codex's dispatcher only invokes the script on `source=clear` (the matcher is matched against the SessionStart `source` field — see [`codex-rs/hooks/src/events/session_start.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/events/session_start.rs)). And `session-start-commit.mjs` itself also early-returns on any other source as defense-in-depth. +Codex fires `SessionStart` with one of three `source` values: `startup` (fresh process / `/new` / zouk daemon spawn-without-sessionId), `resume` (`/resume` or short reconnect), and `clear` (`/clear` — the previous transcript is orphaned and a new session_id is created). `resume` is the *only* source we treat as a hard no-op; on `startup` and `clear` we run the same active-window heuristic. -On `clear`, the script commits any state file whose `codexSessionId` differs from the new session_id (those state files are orphaned by `/clear`). MCP runtime install does **not** live in this hook — it lazily runs from `scripts/start-memory-server.mjs` on first MCP launch. +`hooks.json` registers `SessionStart` with `matcher: "clear|startup"` so codex's dispatcher invokes the script on both sources (the matcher is matched against the SessionStart `source` field — see [`codex-rs/hooks/src/events/session_start.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/events/session_start.rs)). `session-start-commit.mjs` gates internally on `source ∈ {startup, clear}` as defense-in-depth. + +On `startup` or `clear`, the script: + +1. Counts state files (excluding the new session_id) whose `lastUpdatedAt` is within `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` (default 2 min) of "now": + - **0 active** → no-op (no orphan to commit) + - **1 active** → commit it (the just-ended session) + - **≥2 active** → skip; rely on idle TTL (we can't tell which one ended) +2. **Idle-TTL sweep at the tail**: any state file (regardless of session_id) older than `OPENVIKING_CODEX_IDLE_TTL_MS` (default 30 min) gets committed and cleared. This catches `SIGTERM` / Ctrl+C / `/exit` exits and crashes that left state files orphaned. The sweep runs *only* at SessionStart — the Stop hook deliberately does not sweep, because state-write-on-every-turn already gives us the freshness signal. + +On any /commit failure (OV unreachable, non-2xx, timeout) we **preserve state** (don't `clearState`) so the next sweep can retry. A transient OV outage shouldn't lose memory. + +MCP runtime install does **not** live in this hook — it lazily runs from `scripts/start-memory-server.mjs` on first MCP launch. ### Auto-recall (every UserPromptSubmit) @@ -85,9 +97,14 @@ We do **not** call `/commit` per turn — committing extracts memories, and per- ### Known gap: SIGTERM / Ctrl+C / `/exit` are silent -Codex fires no hook on process exit. `/compact` (PreCompact) and `/clear` (SessionStart with `source=clear`) are the only deterministic "context disappearing" signals. If you `/exit` (or Ctrl+C, or kill the process) without first running `/compact`, the OpenViking session for that codex session_id stays open with messages but never has memories extracted. It will, however, be committed the next time you run `/clear` from any codex session on the same machine — which sweeps all orphaned state files. +Codex fires no hook on process exit. `/compact` (PreCompact) is the only fully-deterministic "context disappearing" signal. If you `/exit` (or Ctrl+C, or kill the process) without first running `/compact`, the OpenViking session for that codex session_id stays open with messages but never has memories extracted in that moment. + +Two fallbacks recover the orphan: + +1. **Idle-TTL sweep**: the next `SessionStart` (source=startup|clear) on the same machine commits any state file older than 30 min (`OPENVIKING_CODEX_IDLE_TTL_MS`). So as long as you start another codex session within ~30 min, the orphan is reclaimed. +2. **Active-window heuristic**: if you run `/new` or `/clear` shortly after the orphaned session was last touched, the heuristic catches it as the unique "recently-active" state and commits it deterministically. -If you care about preserving memory from a particular session before exiting: run `/compact` first, or have the model call the `openviking_store` MCP tool with the conclusions you want kept. (We considered an idle-timer-based commit on `Stop` but it produces false-commits for sessions that are merely paused, so this plugin does not include one.) +The remaining limitation: if you never start another codex on this machine, no sweep runs and the OV session stays open server-side. If you care about preserving memory from a particular session before exiting, run `/compact` first or call `openviking_store` with the conclusions you want kept. ### MCP tools (explicit, on demand) @@ -104,7 +121,7 @@ Codex's hook output schema differs from Claude Code's. Notably: | `Stop` | `last_assistant_message`, `transcript_path`, `session_id` | `systemMessage` (only) | | `PreCompact` | `trigger` (`manual`/`auto`), `transcript_path`, `session_id` | `systemMessage` (only) | -> Note: this plugin only acts on `SessionStart` when `source=clear`. The other sources (`startup` / `resume`) are no-ops because codex re-fires them on short reconnects. +> Note: this plugin acts on `SessionStart` when `source=startup` or `source=clear` (matcher `clear|startup`). `source=resume` is a no-op because codex re-fires it on short reconnects. Unlike Claude Code, **Codex does not support `decision: "approve"`**; only `decision: "block"`. A no-op is `{}` (which is what these scripts emit when there's nothing to add). @@ -283,6 +300,9 @@ Connection settings (URL, account, user, api_key) come from `ovcli.conf` plus st - `OPENVIKING_ACCOUNT`: override account - `OPENVIKING_USER`: override user - `OPENVIKING_AGENT_ID`: override agent identity +- `OPENVIKING_CODEX_STATE_DIR`: state file directory (default `~/.openviking/codex-plugin-state`) +- `OPENVIKING_CODEX_ACTIVE_WINDOW_MS`: SessionStart active-window threshold in ms (default `120000` = 2 min) +- `OPENVIKING_CODEX_IDLE_TTL_MS`: SessionStart idle-TTL sweep threshold in ms (default `1800000` = 30 min) ## Hook timeouts diff --git a/examples/codex-memory-plugin/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md index 2911989baa..6ec3a4ff58 100644 --- a/examples/codex-memory-plugin/VERIFICATION.md +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -1,4 +1,4 @@ -# Verification SOP — codex plugin (v0.3.0) +# Verification SOP — codex plugin (v0.4.0) End-to-end smoke test against a live OpenViking server. Run this whenever the hook scripts change. Takes ~3 minutes; the only async wait is OV's memory @@ -122,37 +122,99 @@ echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.j Expect: `appended 2 turn(s) to OpenViking session ` — different from step 4's UUID. -## 6. SessionStart(source=clear) — orphan commit on `/clear` +## 6. SessionStart — active-window heuristic + idle-TTL sweep -`/clear` orphans the previous session and starts a new one. The -SessionStart hook commits any state files whose codexSessionId differs -from the new one. +`source=startup` and `source=clear` both run the same logic +(matcher = `clear|startup`). `source=resume` is the only hard no-op. +See `DESIGN.md` §3 + §5 for the full decision tree. + +### 6a. `1 active` → commit + +After step 5, the `verify-sess` state file is fresh (touched within the +last 2 min) and is the only state file other than the new session_id. +Heuristic should commit it. ```bash -# Simulate /clear: the new SessionStart payload carries a brand-new session_id -echo '{"session_id":"clear-after-verify","source":"clear","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ +echo '{"session_id":"new-after-verify","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/session-start-commit.mjs +``` + +Expect: `SessionStart(startup): committed 1 OpenViking session(s) (heuristic=1, idle=0), N memory item(s) extracted`. +After this `verify-sess.json` is gone from `$STATE_DIR/state`. + +### 6b. `0 active` → no-op + +```bash +# State dir empty (after 6a). Fire SessionStart-startup again. +echo '{"session_id":"another-fresh","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ + | OPENVIKING_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/session-start-commit.mjs +# Expect: {} (no orphan to commit) +``` + +### 6c. `≥2 active` → skip; rely on idle TTL + +```bash +# Manufacture two fresh state files for different session_ids (no ovSessionId +# so no real commit needed, just exercise the skip-path log). +NOW=$(node -e 'console.log(Date.now())') +mkdir -p "$STATE_DIR/state" +cat > "$STATE_DIR/state/sess-aaa.json" < "$STATE_DIR/state/sess-bbb.json" <=2_active","action":"skip; rely on idle TTL"`. The two state +files are still present — the skip path does not clear them. + +### 6d. Idle-TTL sweep at the tail + +```bash +# Backdate one of the state files to be older than IDLE_TTL_MS (default 30 min). +OLD=$(node -e 'console.log(Date.now() - 60*60*1000)') # 1 hour ago +cat > "$STATE_DIR/state/sess-aaa.json" <` from step -5 is now archived (`messages.jsonl` size 0, `history/archive_001/` -exists). +Expect: log shows `idle_sweep` for `sess-aaa` (committed and cleared). +`sess-bbb.json` is still present (still fresh). `sess-aaa.json` is gone. +If `sess-bbb` was in `≥2 active` from 6c, the heuristic on this call sees +just `sess-bbb` (1 active) and commits it — that's expected and shows the +heuristic + sweep working together. -Verify the negative path too — `source=startup` and `source=resume` MUST -be no-ops: +### 6e. `source=resume` → hard no-op (no commit, no sweep) ```bash -echo '{"session_id":"x","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ +echo '{"session_id":"any","source":"resume","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ | OPENVIKING_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/session-start-commit.mjs -# Expect: {} (no commit; short reconnects fire startup/resume too) +# Expect: {} — resume neither commits nor sweeps; short reconnects fire resume too. ``` ## 7. Memory extraction landed in user namespace diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json index 518bff2210..2765abe637 100644 --- a/examples/codex-memory-plugin/hooks/hooks.json +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "SessionStart": [ { - "matcher": "clear", + "matcher": "clear|startup", "hooks": [ { "type": "command", diff --git a/examples/codex-memory-plugin/package.json b/examples/codex-memory-plugin/package.json index 3b738cd872..0798bda639 100644 --- a/examples/codex-memory-plugin/package.json +++ b/examples/codex-memory-plugin/package.json @@ -1,6 +1,6 @@ { "name": "codex-openviking-memory", - "version": "0.3.1", + "version": "0.4.0", "description": "OpenViking memory plugin for Codex — hooks (recall/capture/pre-compact) + MCP server for explicit memory operations.", "type": "module", "scripts": { diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index 579608e2d8..16912456c8 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -14,9 +14,14 @@ * * Commit happens in two other places, never here: * - PreCompact hook (deterministic, before context compaction) - * - SessionStart hook with source=clear (when user runs /clear) + * - SessionStart hook (active-window heuristic + idle-TTL sweep at tail) * * Stop output schema accepts {} as a no-op. + * + * Note: we deliberately do NOT run an idle-TTL sweep here. State-write-on- + * every-turn already gives us the freshness signal we need; running the + * sweep once per session start (in session-start-commit.mjs) is the right + * cadence. See DESIGN.md §5 ("Sweep trigger"). */ import { readFile } from "node:fs/promises"; @@ -207,6 +212,20 @@ async function main() { const state = await loadState(sessionId); const allTurns = await readTranscriptTurns(transcriptPath); + + // Post-compact transcript-shrink defense: codex's /compact may rewrite or + // truncate transcript_path. If allTurns has fewer entries than we cached, + // our slice math would underflow and silently drop turns. Reset the + // counter so the next slice captures everything in the new transcript. + // See DESIGN.md "Post-compact transcript shrink". + if (allTurns.length < state.capturedTurnCount) { + log("transcript_shrink_detected", { + cached: state.capturedTurnCount, + observed: allTurns.length, + }); + state.capturedTurnCount = 0; + } + const newTurns = allTurns.slice(state.capturedTurnCount); log("transcript_parse", { @@ -230,6 +249,8 @@ async function main() { await saveState(state); + // could also sweep here, deliberately not — see header comment + DESIGN.md §5. + if (added > 0) { noop(`appended ${added} turn(s) to OpenViking session ${state.ovSessionId}`); } else { diff --git a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs index 7e3d0668a2..574cb4d86f 100644 --- a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -226,13 +226,25 @@ async function main() { `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, { method: "POST", body: JSON.stringify({}) }, ); + + // Commit failure handling (see DESIGN.md "Commit failure"): if /commit + // fails (server unreachable, non-2xx, timeout) we MUST NOT reset + // ovSessionId — keep state intact so the next sweep / SessionStart can + // retry. A transient OV outage shouldn't lose a session's memory. + if (!commit) { + logError("commit_failed_keep_state", { ovSessionId }); + await saveState(state); // bumps lastUpdatedAt only, keeps ovSessionId + noop(`pre-compact commit attempted on ${ovSessionId}; result unavailable (state preserved for retry)`); + return; + } + const extracted = countExtracted(commit); log("commit", { ovSessionId, extracted, - archived: commit?.archived ?? false, - taskId: commit?.task_id, - status: commit?.status, + archived: commit.archived ?? false, + taskId: commit.task_id, + status: commit.status, }); // Reset OV session for the post-compact half. Keep capturedTurnCount so @@ -241,9 +253,7 @@ async function main() { await saveState(state); noop( - commit - ? `pre-compact commit: ${ovSessionId} → ${extracted} memory item(s) extracted${commit.archived ? " (archived)" : ""}` - : `pre-compact commit attempted on ${ovSessionId}; result unavailable`, + `pre-compact commit: ${ovSessionId} → ${extracted} memory item(s) extracted${commit.archived ? " (archived)" : ""}`, ); } diff --git a/examples/codex-memory-plugin/scripts/session-start-commit.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.mjs index 688fb34334..a3d91351d5 100644 --- a/examples/codex-memory-plugin/scripts/session-start-commit.mjs +++ b/examples/codex-memory-plugin/scripts/session-start-commit.mjs @@ -3,17 +3,31 @@ /** * SessionStart hook for Codex. * - * The ONLY action this script takes is on `source === "clear"`: - * /clear in Codex creates a new session (with the new session_id in this - * payload) and orphans the previous in-memory transcript. We treat that - * as a deterministic "context is about to disappear" signal and commit - * any pending OpenViking sessions for *other* codex session_ids. + * Triggers (matcher = "clear|startup" in hooks.json): + * - source=startup → fresh codex CLI / `/new` / zouk daemon spawn-without-sessionId + * - source=clear → `/clear` (orphans the current process's previous session) + * - source=resume → `/resume` or short reconnect (HARD no-op for commit) * - * For `source === "startup"` and `source === "resume"`, this hook is a - * no-op. Codex re-fires SessionStart on short reconnects and resume, and - * we don't want to commit during a still-active session. + * Behavior (see DESIGN.md §3 — "SessionStart source=startup, heuristic"): + * On `startup` or `clear`, run the active-window heuristic over state files + * excluding the new session_id: + * - 0 recently-active → no-op + * - 1 recently-active → commit it (the just-ended session) + * - ≥2 recently-active → skip; rely on idle TTL + * "Recently-active" means lastUpdatedAt within ACTIVE_WINDOW_MS (default 2 min). * - * SessionStart output schema accepts {} as a no-op. + * At the tail (regardless of which branch above ran), run an idle-TTL sweep: + * any state file (including the new session_id, but in practice it's just + * been created and is fresh) older than IDLE_TTL_MS (default 30 min) gets + * committed and cleared. This catches SIGTERM/Ctrl+C/`/exit` exits and + * crashes that left state files orphaned. + * + * Commit failure handling: + * On any /commit failure (OV unreachable, non-2xx, timeout) we DO NOT call + * clearState — we keep the state file with ovSessionId still set so the + * next sweep retries. A transient OV outage shouldn't lose memory. + * + * Output schema accepts {} as a no-op. */ import { loadConfig } from "./config.mjs"; @@ -23,6 +37,16 @@ import { clearState, listStates } from "./session-state.mjs"; const cfg = loadConfig(); const { log, logError } = createLogger("session-start"); +const ACTIVE_WINDOW_MS = (() => { + const v = Number(process.env.OPENVIKING_CODEX_ACTIVE_WINDOW_MS); + return Number.isFinite(v) && v > 0 ? Math.floor(v) : 120_000; +})(); + +const IDLE_TTL_MS = (() => { + const v = Number(process.env.OPENVIKING_CODEX_IDLE_TTL_MS); + return Number.isFinite(v) && v > 0 ? Math.floor(v) : 1_800_000; +})(); + function output(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } @@ -72,6 +96,43 @@ async function commitOvSession(ovSessionId) { ); } +/** + * Commit and clear a single state file. On commit failure, preserve state + * (don't call clearState) so the next sweep retries. + * + * Returns { committed: bool, extracted: number }. + */ +async function commitAndClear(state, reason) { + if (state.ovSessionId) { + const commit = await commitOvSession(state.ovSessionId); + if (!commit) { + logError("commit_failed_keep_state", { + reason, + codexSessionId: state.codexSessionId, + ovSessionId: state.ovSessionId, + }); + return { committed: false, extracted: 0 }; + } + const extracted = countExtracted(commit); + log("commit", { + reason, + codexSessionId: state.codexSessionId, + ovSessionId: state.ovSessionId, + extracted, + archived: commit.archived ?? false, + taskId: commit.task_id, + status: commit.status, + }); + await clearState(state.codexSessionId); + return { committed: true, extracted }; + } + // No OV session attached — nothing to commit on the server, but the local + // state file is still stale and should be removed. + log("clear_no_ov", { reason, codexSessionId: state.codexSessionId }); + await clearState(state.codexSessionId); + return { committed: true, extracted: 0 }; +} + async function main() { let input; try { @@ -86,50 +147,111 @@ async function main() { const source = input.source || "unknown"; const newSessionId = input.session_id || "unknown"; - log("start", { source, newSessionId }); + log("start", { source, newSessionId, activeWindowMs: ACTIVE_WINDOW_MS, idleTtlMs: IDLE_TTL_MS }); - if (source !== "clear") { - log("skip", { stage: "source_check", reason: `source=${source} (only 'clear' triggers commit)` }); + // resume is a hard no-op — we don't even sweep, because resume re-fires on + // short reconnects and we'd otherwise sweep on every reconnect blip. + if (source !== "startup" && source !== "clear") { + log("skip", { stage: "source_check", reason: `source=${source} (only startup|clear act)` }); noop(); return; } const health = await fetchJSON("/health"); if (!health) { - logError("health_check", "server unreachable; cannot commit"); + logError("health_check", "server unreachable; skipping commit + sweep"); noop(); return; } + const now = Date.now(); const states = await listStates(); - let committedCount = 0; - let totalExtracted = 0; - - for (const s of states) { - if (!s?.codexSessionId || s.codexSessionId === newSessionId) continue; - if (s.ovSessionId) { - const commit = await commitOvSession(s.ovSessionId); - const extracted = countExtracted(commit); - log("commit_orphan", { - codexSessionId: s.codexSessionId, - ovSessionId: s.ovSessionId, - extracted, - }); - totalExtracted += extracted; - } else { - log("clear_orphan_no_ov", { codexSessionId: s.codexSessionId }); + + // ------------------------------------------------------------------------- + // Active-window heuristic (DESIGN.md §3) + // ------------------------------------------------------------------------- + const otherStates = states.filter( + (s) => s?.codexSessionId && s.codexSessionId !== newSessionId, + ); + + const recentlyActive = otherStates.filter( + (s) => typeof s.lastUpdatedAt === "number" + && (now - s.lastUpdatedAt) <= ACTIVE_WINDOW_MS, + ); + + let heuristicCommitted = 0; + let heuristicExtracted = 0; + const skippedSessionIds = new Set(); + + if (recentlyActive.length === 0) { + log("heuristic", { branch: "0_active", action: "noop", otherStates: otherStates.length }); + } else if (recentlyActive.length === 1) { + const target = recentlyActive[0]; + log("heuristic", { + branch: "1_active", + action: "commit", + codexSessionId: target.codexSessionId, + ovSessionId: target.ovSessionId, + }); + const r = await commitAndClear(target, "heuristic_1_active"); + if (r.committed) { + heuristicCommitted += 1; + heuristicExtracted += r.extracted; } - await clearState(s.codexSessionId); - committedCount += 1; + } else { + log("heuristic", { + branch: ">=2_active", + action: "skip; rely on idle TTL", + activeCount: recentlyActive.length, + activeIds: recentlyActive.map((s) => s.codexSessionId), + }); + for (const s of recentlyActive) skippedSessionIds.add(s.codexSessionId); } - if (committedCount > 0) { - log("done", { committedCount, totalExtracted }); + // ------------------------------------------------------------------------- + // Idle TTL sweep (tail) — applies to ALL state files including ones we just + // skipped above (≥2 active path). We re-list because the heuristic branch + // may have removed entries. + // ------------------------------------------------------------------------- + const postHeuristic = await listStates(); + let idleCommitted = 0; + let idleExtracted = 0; + + for (const s of postHeuristic) { + if (!s?.codexSessionId) continue; + if (typeof s.lastUpdatedAt !== "number") continue; + if ((now - s.lastUpdatedAt) <= IDLE_TTL_MS) continue; + log("idle_sweep", { + codexSessionId: s.codexSessionId, + ovSessionId: s.ovSessionId, + ageMs: now - s.lastUpdatedAt, + }); + const r = await commitAndClear(s, "idle_ttl"); + if (r.committed) { + idleCommitted += 1; + idleExtracted += r.extracted; + } + } + + const totalCommitted = heuristicCommitted + idleCommitted; + const totalExtracted = heuristicExtracted + idleExtracted; + + log("done", { + source, + heuristicCommitted, + idleCommitted, + totalCommitted, + totalExtracted, + skipped: [...skippedSessionIds], + }); + + if (totalCommitted > 0) { noop( - `/clear: committed ${committedCount} prior OpenViking session(s), ${totalExtracted} memory item(s) extracted`, + `SessionStart(${source}): committed ${totalCommitted} OpenViking session(s) (` + + `heuristic=${heuristicCommitted}, idle=${idleCommitted}), ` + + `${totalExtracted} memory item(s) extracted`, ); } else { - log("done", { committedCount: 0, totalExtracted: 0 }); noop(); } } diff --git a/examples/codex-memory-plugin/scripts/session-state.mjs b/examples/codex-memory-plugin/scripts/session-state.mjs index dfc7f54885..da4d89fc54 100644 --- a/examples/codex-memory-plugin/scripts/session-state.mjs +++ b/examples/codex-memory-plugin/scripts/session-state.mjs @@ -9,7 +9,7 @@ * State directory: $OPENVIKING_CODEX_STATE_DIR or ~/.openviking/codex-plugin-state */ -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -52,7 +52,12 @@ export async function saveState(state) { if (!state || !state.codexSessionId) return; await mkdir(getStateDir(), { recursive: true }); const next = { ...state, lastUpdatedAt: Date.now() }; - await writeFile(statePath(state.codexSessionId), JSON.stringify(next)); + // Atomic write (tmpfile + rename) so a crash mid-write can't leave a + // truncated/corrupt state file. See DESIGN.md "State file schema". + const final = statePath(state.codexSessionId); + const tmp = `${final}.tmp`; + await writeFile(tmp, JSON.stringify(next)); + await rename(tmp, final); } export async function clearState(codexSessionId) { @@ -67,6 +72,8 @@ export async function listStates() { const files = await readdir(dir); const out = []; for (const file of files) { + // .json only — atomic writes briefly create `.json.tmp`, skipped + // by this check (endsWith(".json") is false for ".json.tmp"). if (!file.endsWith(".json")) continue; try { const raw = await readFile(join(dir, file), "utf-8"); From 0a82e545574925e8a02fa5a433e6e72f94a58069 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Wed, 13 May 2026 17:56:41 +0800 Subject: [PATCH 06/12] refactor(plugin/codex): align config loading with claude-code plugin Addresses three review points on PR #1957: 1. Honor OPENVIKING_CLI_CONFIG_FILE for the ovcli.conf override path (matches the convention used by `ov` CLI and claude-code-memory-plugin). OPENVIKING_CONFIG_FILE stays as the ov.conf override; for backward compat it still works when pointed at an ovcli-shaped file. 2. Strict env-first priority for every connection / identity field (baseUrl, apiKey, account, user, agentId). Env vars now win over ovcli.conf, which wins over ov.conf's codex.* block / server.*, which wins over built-in defaults. 3. Unify hook and MCP-server config loading: src/memory-server.ts now imports loadConfig from scripts/config.mjs (relative path stays valid post-compile because servers/ and scripts/ are siblings), eliminating the divergent account/user/agentId fallback chains the PR-Agent reviewer flagged. Auth header: emit Authorization: Bearer (primary, required by OpenViking Cloud) plus the legacy X-API-Key during the transition window. All six fetch sites updated (4 hook scripts + memory-server.ts + compiled servers/memory-server.js). README: document the new resolution chain, OPENVIKING_CLI_CONFIG_FILE, OPENVIKING_BEARER_TOKEN alias, and the Authorization: Bearer migration. --- examples/codex-memory-plugin/README.md | 36 ++- .../codex-memory-plugin/package-lock.json | 4 +- .../scripts/auto-capture.mjs | 5 +- .../scripts/auto-recall.mjs | 5 +- .../codex-memory-plugin/scripts/config.mjs | 246 +++++++++++++----- .../scripts/pre-compact-capture.mjs | 5 +- .../scripts/session-start-commit.mjs | 5 +- .../servers/memory-server.js | 94 ++----- .../codex-memory-plugin/src/memory-server.ts | 112 +++----- 9 files changed, 287 insertions(+), 225 deletions(-) diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index b027b6985d..ddf19a7c1d 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -292,18 +292,38 @@ If step 6 returns no leaf memories, check: | `autoCommitOnCompact` | `true` | Commit the full transcript on `PreCompact` | | `debug` | `false` | Write structured debug logs | -Connection settings (URL, account, user, api_key) come from `ovcli.conf` plus standard env overrides: - -- `OPENVIKING_CONFIG_FILE`: alternate config path (defaults to `~/.openviking/ovcli.conf`, then `~/.openviking/ov.conf`) -- `OPENVIKING_URL`: override server URL -- `OPENVIKING_API_KEY`: override API key -- `OPENVIKING_ACCOUNT`: override account -- `OPENVIKING_USER`: override user -- `OPENVIKING_AGENT_ID`: override agent identity +Connection settings resolve in this strict priority — env vars always win: + +1. **Environment variables** (`OPENVIKING_*`) +2. **`ovcli.conf`** — CLI client config (`url`, `api_key`, `account`, `user`, `agent_id`) +3. **`ov.conf`** — server config (`server.*` + optional `codex.*` tuning block) +4. **Built-in defaults** + +Setting `OPENVIKING_URL` alone is enough to run in env-var-only mode (no config files needed) — useful for daemon-spawned agents. + +File-path overrides (aligned with `ov` CLI and `claude-code-memory-plugin`): + +- `OPENVIKING_CLI_CONFIG_FILE` — alternate `ovcli.conf` path (default `~/.openviking/ovcli.conf`) +- `OPENVIKING_CONFIG_FILE` — alternate `ov.conf` path (default `~/.openviking/ov.conf`). For backward compat, if this points at an ovcli-shaped file (top-level `url`/`api_key`, no `server` section), it is treated as the CLI config. + +Connection / identity overrides: + +- `OPENVIKING_URL` / `OPENVIKING_BASE_URL` — server URL +- `OPENVIKING_API_KEY` / `OPENVIKING_BEARER_TOKEN` — API key (sent as `Authorization: Bearer` either way) +- `OPENVIKING_ACCOUNT` — account +- `OPENVIKING_USER` — user +- `OPENVIKING_AGENT_ID` — agent identity + +State-file / SessionStart tuning: + - `OPENVIKING_CODEX_STATE_DIR`: state file directory (default `~/.openviking/codex-plugin-state`) - `OPENVIKING_CODEX_ACTIVE_WINDOW_MS`: SessionStart active-window threshold in ms (default `120000` = 2 min) - `OPENVIKING_CODEX_IDLE_TTL_MS`: SessionStart idle-TTL sweep threshold in ms (default `1800000` = 30 min) +### Auth header + +Requests send both `Authorization: Bearer ` (primary — required by OpenViking Cloud) and `X-API-Key` (legacy — accepted by older self-hosted servers). The legacy header will be dropped once `X-API-Key` is fully retired upstream. + ## Hook timeouts | Hook | Default timeout | Notes | diff --git a/examples/codex-memory-plugin/package-lock.json b/examples/codex-memory-plugin/package-lock.json index 7b3c427977..409c4d2f3f 100644 --- a/examples/codex-memory-plugin/package-lock.json +++ b/examples/codex-memory-plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-openviking-memory", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-openviking-memory", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "zod": "^4.3.6" diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index 16912456c8..6d7e9a6c6f 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -45,7 +45,10 @@ async function fetchJSON(path, init = {}) { const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); try { const headers = { "Content-Type": "application/json" }; - if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.apiKey) { + headers["Authorization"] = `Bearer ${cfg.apiKey}`; + headers["X-API-Key"] = cfg.apiKey; + } if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; if (cfg.user) headers["X-OpenViking-User"] = cfg.user; if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; diff --git a/examples/codex-memory-plugin/scripts/auto-recall.mjs b/examples/codex-memory-plugin/scripts/auto-recall.mjs index 892b580562..2426e92d05 100644 --- a/examples/codex-memory-plugin/scripts/auto-recall.mjs +++ b/examples/codex-memory-plugin/scripts/auto-recall.mjs @@ -41,7 +41,10 @@ async function fetchJSON(path, init = {}) { const timer = setTimeout(() => controller.abort(), cfg.timeoutMs); try { const headers = { "Content-Type": "application/json" }; - if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.apiKey) { + headers["Authorization"] = `Bearer ${cfg.apiKey}`; + headers["X-API-Key"] = cfg.apiKey; + } if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; if (cfg.user) headers["X-OpenViking-User"] = cfg.user; if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; diff --git a/examples/codex-memory-plugin/scripts/config.mjs b/examples/codex-memory-plugin/scripts/config.mjs index 8dea865d8a..0d6c532e4b 100644 --- a/examples/codex-memory-plugin/scripts/config.mjs +++ b/examples/codex-memory-plugin/scripts/config.mjs @@ -1,29 +1,43 @@ /** * Shared configuration loader for the Codex OpenViking memory plugin. * - * Reads connection settings from `~/.openviking/ovcli.conf` (the canonical CLIENT - * config that the `ov` CLI uses), and falls back to the legacy `~/.openviking/ov.conf` - * server config when ovcli.conf is missing. + * Resolution priority (highest → lowest), per-field: + * 1. Environment variables (OPENVIKING_*) + * 2. ovcli.conf — the CLI client config (carries url/api_key/account/user/agent_id) + * 3. ov.conf — the server config (server.* + optional codex.* block for tuning) + * 4. Built-in defaults * - * Plugin-specific overrides go in an optional `codex` section of either file. + * Mirrors examples/claude-code-memory-plugin/scripts/config.mjs so the + * hook surface and the MCP server (src/memory-server.ts imports loadConfig + * from here) resolve identity identically. Aligning the two prevents + * silent identity drift between auto-capture and explicit `remember` calls. * - * Env vars: - * OPENVIKING_CONFIG_FILE alternate ovcli.conf path - * OPENVIKING_URL override server URL - * OPENVIKING_API_KEY override API key - * OPENVIKING_ACCOUNT override account - * OPENVIKING_USER override user - * OPENVIKING_AGENT_ID override agent identity - * OPENVIKING_DEBUG=1 enable debug logging - * OPENVIKING_DEBUG_LOG debug log path + * File-path env vars: + * OPENVIKING_CLI_CONFIG_FILE alternate ovcli.conf path (preferred) + * OPENVIKING_CONFIG_FILE alternate ov.conf path + * + * For backward compat, if only OPENVIKING_CONFIG_FILE is set and the file + * it points at parses as an ovcli.conf (top-level `url`/`api_key`, no + * `server` section), it is treated as ovcli.conf — earlier versions of + * this plugin used OPENVIKING_CONFIG_FILE to mean either file. + * + * Connection / identity env vars: + * OPENVIKING_URL / OPENVIKING_BASE_URL + * OPENVIKING_API_KEY / OPENVIKING_BEARER_TOKEN + * OPENVIKING_ACCOUNT, OPENVIKING_USER, OPENVIKING_AGENT_ID + * + * Misc env vars: + * OPENVIKING_TIMEOUT_MS, OPENVIKING_CAPTURE_TIMEOUT_MS + * OPENVIKING_RECALL_LIMIT, OPENVIKING_SCORE_THRESHOLD + * OPENVIKING_DEBUG=1, OPENVIKING_DEBUG_LOG */ -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; -const DEFAULT_CLI_CONFIG = join(homedir(), ".openviking", "ovcli.conf"); -const DEFAULT_SERVER_CONFIG = join(homedir(), ".openviking", "ov.conf"); +const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf"); +const DEFAULT_OV_CONF_PATH = join(homedir(), ".openviking", "ov.conf"); function num(val, fallback) { if (typeof val === "number" && Number.isFinite(val)) return val; @@ -39,90 +53,184 @@ function str(val, fallback) { return fallback; } -function readJson(path) { +function envBool(name) { + const v = process.env[name]; + if (v == null || v === "") return undefined; + const lower = v.trim().toLowerCase(); + if (lower === "0" || lower === "false" || lower === "no") return false; + if (lower === "1" || lower === "true" || lower === "yes") return true; + return undefined; +} + +function tryLoadJson(path) { + let raw; + try { + raw = readFileSync(path, "utf-8"); + } catch { + return null; + } try { - return JSON.parse(readFileSync(path, "utf-8")); + return JSON.parse(raw); } catch { + process.stderr.write(`[openviking-memory] Invalid config file: ${path}\n`); return null; } } -function deriveBaseUrl(file) { - const direct = str(file?.url, ""); - if (direct) return direct.replace(/\/+$/, ""); - const server = file?.server || {}; - const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); - const port = Math.floor(num(server.port, 1933)); - return `http://${host}:${port}`; +function looksLikeOvcli(obj) { + if (!obj || typeof obj !== "object") return false; + if (obj.server && typeof obj.server === "object") return false; + return typeof obj.url === "string" || typeof obj.api_key === "string"; } -export function loadConfig() { - const explicitPath = process.env.OPENVIKING_CONFIG_FILE +/** + * Returns { cliFile, cliPath, ovFile, ovPath }. Missing files are + * represented as empty objects, so callers can read fields unconditionally. + * + * OPENVIKING_CLI_CONFIG_FILE overrides the ovcli.conf path. + * OPENVIKING_CONFIG_FILE overrides the ov.conf path; if the file looks + * like an ovcli.conf (no `server` section + has `url`/`api_key`), it is + * also used as the cliFile to support the legacy "pass any conf via + * OPENVIKING_CONFIG_FILE" pattern. + */ +function loadFiles() { + const cliPathEnv = process.env.OPENVIKING_CLI_CONFIG_FILE + ? resolvePath(process.env.OPENVIKING_CLI_CONFIG_FILE.replace(/^~/, homedir())) + : null; + const ovPathEnv = process.env.OPENVIKING_CONFIG_FILE ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) : null; - const candidates = explicitPath - ? [explicitPath] - : [DEFAULT_CLI_CONFIG, DEFAULT_SERVER_CONFIG]; - - let configPath = null; - let file = null; - for (const candidate of candidates) { - if (existsSync(candidate)) { - configPath = candidate; - file = readJson(candidate) || {}; - break; - } - } - if (!file) { - file = {}; - configPath = explicitPath || DEFAULT_CLI_CONFIG; + const cliPath = cliPathEnv || DEFAULT_OVCLI_CONF_PATH; + const ovPath = ovPathEnv || DEFAULT_OV_CONF_PATH; + + let cliFile = tryLoadJson(cliPath); + let cliLoadedFrom = cliFile ? cliPath : null; + let ovFile = tryLoadJson(ovPath); + let ovLoadedFrom = ovFile ? ovPath : null; + + // Backward compat: OPENVIKING_CONFIG_FILE pointing at an ovcli-shaped file. + // Earlier plugin versions had a single OPENVIKING_CONFIG_FILE that could + // point at either ov.conf or ovcli.conf; preserve that by promoting. + if (ovPathEnv && !cliPathEnv && looksLikeOvcli(ovFile)) { + cliFile = ovFile; + cliLoadedFrom = ovLoadedFrom; + ovFile = null; + ovLoadedFrom = null; } - const baseUrlFromFile = deriveBaseUrl(file); - const baseUrl = (str(process.env.OPENVIKING_URL, baseUrlFromFile) || "http://127.0.0.1:1933").replace(/\/+$/, ""); + return { + cliFile: cliFile || {}, + cliPath: cliLoadedFrom, + ovFile: ovFile || {}, + ovPath: ovLoadedFrom, + }; +} + +function deriveBaseUrl({ cliFile, ovFile }) { + const envUrl = str(process.env.OPENVIKING_URL, null) || str(process.env.OPENVIKING_BASE_URL, null); + if (envUrl) return envUrl.replace(/\/+$/, ""); - const apiKeyFromFile = str(file.api_key, "") || str(file?.server?.root_api_key, ""); - const apiKey = str(process.env.OPENVIKING_API_KEY, apiKeyFromFile); + const cliUrl = str(cliFile.url, null); + if (cliUrl) return cliUrl.replace(/\/+$/, ""); - const account = str(process.env.OPENVIKING_ACCOUNT, str(file.account, "")); - const user = str(process.env.OPENVIKING_USER, str(file.user, "")); + const server = ovFile.server || {}; + const ovUrl = str(server.url, null); + if (ovUrl) return ovUrl.replace(/\/+$/, ""); - const cx = file.codex || {}; + const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); + const port = Math.floor(num(server.port, 1933)); + return `http://${host}:${port}`; +} - const debug = cx.debug === true || process.env.OPENVIKING_DEBUG === "1"; +export function loadConfig() { + const { cliFile, cliPath, ovFile, ovPath } = loadFiles(); + const configPath = cliPath || ovPath || null; + + const server = ovFile.server || {}; + const cx = ovFile.codex || {}; + + const baseUrl = deriveBaseUrl({ cliFile, ovFile }); + + // apiKey: env > cliFile.api_key > codex.apiKey > server.root_api_key + // Accepts OPENVIKING_BEARER_TOKEN or OPENVIKING_API_KEY (sent as Bearer either way). + const apiKey = + str(process.env.OPENVIKING_BEARER_TOKEN, null) || + str(process.env.OPENVIKING_API_KEY, null) || + str(cliFile.api_key, null) || + str(cx.apiKey, null) || + str(server.root_api_key, ""); + + // account: env > cliFile.account > codex.accountId > "" + const account = + str(process.env.OPENVIKING_ACCOUNT, null) || + str(cliFile.account, null) || + str(cx.accountId, ""); + + // user: env > cliFile.user > codex.userId > "" + const user = + str(process.env.OPENVIKING_USER, null) || + str(cliFile.user, null) || + str(cx.userId, ""); + + // agentId: env > cliFile.agent_id > codex.agentId > "codex" + const agentId = + str(process.env.OPENVIKING_AGENT_ID, null) || + str(cliFile.agent_id, null) || + str(cx.agentId, "codex"); + + const debug = envBool("OPENVIKING_DEBUG") ?? (cx.debug === true); const defaultLogPath = join(homedir(), ".openviking", "logs", "codex-hooks.log"); const debugLogPath = str(process.env.OPENVIKING_DEBUG_LOG, defaultLogPath); - const timeoutMs = Math.max(1000, Math.floor(num(cx.timeoutMs, 15000))); - const captureTimeoutMs = Math.max( - 1000, - Math.floor(num(cx.captureTimeoutMs, Math.max(timeoutMs * 2, 30000))), - ); + const timeoutMs = Math.max(1000, Math.floor(num( + process.env.OPENVIKING_TIMEOUT_MS, + num(cx.timeoutMs, 15000), + ))); + const captureTimeoutMs = Math.max(1000, Math.floor(num( + process.env.OPENVIKING_CAPTURE_TIMEOUT_MS, + num(cx.captureTimeoutMs, Math.max(timeoutMs * 2, 30000)), + ))); return { configPath, + cliConfigPath: cliPath, + ovConfigPath: ovPath, baseUrl, apiKey, account, user, - agentId: str(process.env.OPENVIKING_AGENT_ID, str(cx.agentId, "codex")), + agentId, timeoutMs, - autoRecall: cx.autoRecall !== false, - recallLimit: Math.max(1, Math.floor(num(cx.recallLimit, 6))), - scoreThreshold: Math.min(1, Math.max(0, num(cx.scoreThreshold, 0.01))), - minQueryLength: Math.max(1, Math.floor(num(cx.minQueryLength, 3))), - logRankingDetails: cx.logRankingDetails === true, - - autoCapture: cx.autoCapture !== false, - captureMode: cx.captureMode === "keyword" ? "keyword" : "semantic", - captureMaxLength: Math.max(200, Math.floor(num(cx.captureMaxLength, 24000))), + autoRecall: envBool("OPENVIKING_AUTO_RECALL") ?? (cx.autoRecall !== false), + recallLimit: Math.max(1, Math.floor(num( + process.env.OPENVIKING_RECALL_LIMIT, + num(cx.recallLimit, 6), + ))), + scoreThreshold: Math.min(1, Math.max(0, num( + process.env.OPENVIKING_SCORE_THRESHOLD, + num(cx.scoreThreshold, 0.01), + ))), + minQueryLength: Math.max(1, Math.floor(num( + process.env.OPENVIKING_MIN_QUERY_LENGTH, + num(cx.minQueryLength, 3), + ))), + logRankingDetails: envBool("OPENVIKING_LOG_RANKING_DETAILS") ?? (cx.logRankingDetails === true), + + autoCapture: envBool("OPENVIKING_AUTO_CAPTURE") ?? (cx.autoCapture !== false), + captureMode: (str(process.env.OPENVIKING_CAPTURE_MODE, str(cx.captureMode, "semantic")) === "keyword") + ? "keyword" + : "semantic", + captureMaxLength: Math.max(200, Math.floor(num( + process.env.OPENVIKING_CAPTURE_MAX_LENGTH, + num(cx.captureMaxLength, 24000), + ))), captureTimeoutMs, - captureAssistantTurns: cx.captureAssistantTurns === true, - captureLastAssistantOnStop: cx.captureLastAssistantOnStop !== false, + captureAssistantTurns: envBool("OPENVIKING_CAPTURE_ASSISTANT_TURNS") ?? (cx.captureAssistantTurns === true), + captureLastAssistantOnStop: envBool("OPENVIKING_CAPTURE_LAST_ASSISTANT_ON_STOP") ?? (cx.captureLastAssistantOnStop !== false), - autoCommitOnCompact: cx.autoCommitOnCompact !== false, + autoCommitOnCompact: envBool("OPENVIKING_AUTO_COMMIT_ON_COMPACT") ?? (cx.autoCommitOnCompact !== false), debug, debugLogPath, diff --git a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs index 574cb4d86f..193d69b470 100644 --- a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -40,7 +40,10 @@ async function fetchJSON(path, init = {}) { const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); try { const headers = { "Content-Type": "application/json" }; - if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.apiKey) { + headers["Authorization"] = `Bearer ${cfg.apiKey}`; + headers["X-API-Key"] = cfg.apiKey; + } if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; if (cfg.user) headers["X-OpenViking-User"] = cfg.user; if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; diff --git a/examples/codex-memory-plugin/scripts/session-start-commit.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.mjs index a3d91351d5..2fbafc731f 100644 --- a/examples/codex-memory-plugin/scripts/session-start-commit.mjs +++ b/examples/codex-memory-plugin/scripts/session-start-commit.mjs @@ -60,7 +60,10 @@ async function fetchJSON(path, init = {}) { const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs); try { const headers = { "Content-Type": "application/json" }; - if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey; + if (cfg.apiKey) { + headers["Authorization"] = `Bearer ${cfg.apiKey}`; + headers["X-API-Key"] = cfg.apiKey; + } if (cfg.account) headers["X-OpenViking-Account"] = cfg.account; if (cfg.user) headers["X-OpenViking-User"] = cfg.user; if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId; diff --git a/examples/codex-memory-plugin/servers/memory-server.js b/examples/codex-memory-plugin/servers/memory-server.js index fc49107491..8d436398a5 100644 --- a/examples/codex-memory-plugin/servers/memory-server.js +++ b/examples/codex-memory-plugin/servers/memory-server.js @@ -1,62 +1,11 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join, resolve as resolvePath } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -function readJson(path) { - return JSON.parse(readFileSync(path, "utf-8")); -} -function loadOvConf() { - const defaultCli = join(homedir(), ".openviking", "ovcli.conf"); - const defaultServer = join(homedir(), ".openviking", "ov.conf"); - const explicit = process.env.OPENVIKING_CONFIG_FILE - ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) - : null; - const candidates = explicit ? [explicit] : [defaultCli, defaultServer]; - for (const candidate of candidates) { - if (!existsSync(candidate)) - continue; - try { - return { file: readJson(candidate), configPath: candidate }; - } - catch { - process.stderr.write(`[openviking-memory] Invalid config file: ${candidate}\n`); - process.exit(1); - } - } - // No config file. Allow env-var-only operation (cloud mode with OPENVIKING_URL). - if (process.env.OPENVIKING_URL) { - return { file: {}, configPath: explicit || defaultCli }; - } - process.stderr.write(`[openviking-memory] Config file not found at ${defaultCli} or ${defaultServer}; set OPENVIKING_CONFIG_FILE or OPENVIKING_URL.\n`); - process.exit(1); -} -function deriveBaseUrl(file) { - const direct = str(file.url, ""); - if (direct) - return direct.replace(/\/+$/, ""); - const server = (file.server ?? {}); - const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); - const port = Math.floor(num(server.port, 1933)); - return `http://${host}:${port}`; -} -function str(value, fallback) { - if (typeof value === "string" && value.trim()) - return value.trim(); - return fallback; -} -function num(value, fallback) { - if (typeof value === "number" && Number.isFinite(value)) - return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - if (Number.isFinite(parsed)) - return parsed; - } - return fallback; -} +// Shared with scripts/*.mjs so hook surface and MCP server resolve identity identically. +// Compiled output lives in servers/memory-server.js, so the relative path stays valid. +// @ts-ignore -- pure JS module, no type declarations +import { loadConfig } from "../scripts/config.mjs"; function md5Short(value) { return createHash("md5").update(value).digest("hex").slice(0, 12); } @@ -74,21 +23,24 @@ function totalCommitMemories(result) { function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -const { file: ovConf, configPath } = loadOvConf(); -const serverConfig = (ovConf.server ?? {}); -const baseUrlFromFile = deriveBaseUrl(ovConf); -const apiKeyFromFile = str(ovConf.api_key, "") || str(serverConfig.root_api_key, ""); +const shared = loadConfig(); const config = { - configPath, - baseUrl: str(process.env.OPENVIKING_URL, baseUrlFromFile).replace(/\/+$/, ""), - apiKey: str(process.env.OPENVIKING_API_KEY, apiKeyFromFile), - accountId: str(process.env.OPENVIKING_ACCOUNT, str(ovConf.account, str(ovConf.default_account, "default"))), - userId: str(process.env.OPENVIKING_USER, str(ovConf.user, str(ovConf.default_user, "default"))), - agentId: str(process.env.OPENVIKING_AGENT_ID, str(ovConf.agent_id, str(ovConf.default_agent, "codex"))), - timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, 15000))), - recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, 6))), - scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, 0.01))), + configPath: shared.configPath, + baseUrl: shared.baseUrl, + apiKey: shared.apiKey, + // The MCP server defaults missing account/user to "default" rather than "" so the + // X-OpenViking-* headers carry a value; hooks leave them empty (server-side default). + accountId: shared.account || "default", + userId: shared.user || "default", + agentId: shared.agentId || "codex", + timeoutMs: shared.timeoutMs, + recallLimit: shared.recallLimit, + scoreThreshold: shared.scoreThreshold, }; +if (!config.baseUrl) { + process.stderr.write("[openviking-memory] No OpenViking URL resolved. Set OPENVIKING_URL or provide ovcli.conf / ov.conf.\n"); + process.exit(1); +} class OpenVikingClient { baseUrl; apiKey; @@ -110,8 +62,12 @@ class OpenVikingClient { const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { const headers = new Headers(init.headers ?? {}); - if (this.apiKey) + if (this.apiKey) { + // OV Cloud only accepts Authorization: Bearer; self-hosted servers + // still accept X-API-Key, so we emit both for transition compat. + headers.set("Authorization", `Bearer ${this.apiKey}`); headers.set("X-API-Key", this.apiKey); + } if (this.accountId) headers.set("X-OpenViking-Account", this.accountId); if (this.userId) diff --git a/examples/codex-memory-plugin/src/memory-server.ts b/examples/codex-memory-plugin/src/memory-server.ts index 915de5510f..e0fb565dfc 100644 --- a/examples/codex-memory-plugin/src/memory-server.ts +++ b/examples/codex-memory-plugin/src/memory-server.ts @@ -1,10 +1,11 @@ import { createHash } from "node:crypto" -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join, resolve as resolvePath } from "node:path" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod" +// Shared with scripts/*.mjs so hook surface and MCP server resolve identity identically. +// Compiled output lives in servers/memory-server.js, so the relative path stays valid. +// @ts-ignore -- pure JS module, no type declarations +import { loadConfig } from "../scripts/config.mjs" type FindResultItem = { uri: string @@ -38,62 +39,6 @@ type SystemStatus = { user?: unknown } -function readJson(path: string): Record { - return JSON.parse(readFileSync(path, "utf-8")) as Record -} - -function loadOvConf(): { file: Record; configPath: string } { - const defaultCli = join(homedir(), ".openviking", "ovcli.conf") - const defaultServer = join(homedir(), ".openviking", "ov.conf") - const explicit = process.env.OPENVIKING_CONFIG_FILE - ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir())) - : null - - const candidates = explicit ? [explicit] : [defaultCli, defaultServer] - for (const candidate of candidates) { - if (!existsSync(candidate)) continue - try { - return { file: readJson(candidate), configPath: candidate } - } catch { - process.stderr.write(`[openviking-memory] Invalid config file: ${candidate}\n`) - process.exit(1) - } - } - - // No config file. Allow env-var-only operation (cloud mode with OPENVIKING_URL). - if (process.env.OPENVIKING_URL) { - return { file: {}, configPath: explicit || defaultCli } - } - - process.stderr.write( - `[openviking-memory] Config file not found at ${defaultCli} or ${defaultServer}; set OPENVIKING_CONFIG_FILE or OPENVIKING_URL.\n`, - ) - process.exit(1) -} - -function deriveBaseUrl(file: Record): string { - const direct = str((file as { url?: unknown }).url, "") - if (direct) return direct.replace(/\/+$/, "") - const server = (file.server ?? {}) as Record - const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1") - const port = Math.floor(num(server.port, 1933)) - return `http://${host}:${port}` -} - -function str(value: unknown, fallback: string): string { - if (typeof value === "string" && value.trim()) return value.trim() - return fallback -} - -function num(value: unknown, fallback: number): number { - if (typeof value === "number" && Number.isFinite(value)) return value - if (typeof value === "string" && value.trim()) { - const parsed = Number(value) - if (Number.isFinite(parsed)) return parsed - } - return fallback -} - function md5Short(value: string): string { return createHash("md5").update(value).digest("hex").slice(0, 12) } @@ -115,21 +60,37 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -const { file: ovConf, configPath } = loadOvConf() -const serverConfig = (ovConf.server ?? {}) as Record -const baseUrlFromFile = deriveBaseUrl(ovConf) -const apiKeyFromFile = str((ovConf as { api_key?: unknown }).api_key, "") || str(serverConfig.root_api_key, "") +const shared = loadConfig() as { + baseUrl: string + apiKey: string + account: string + user: string + agentId: string + timeoutMs: number + recallLimit: number + scoreThreshold: number + configPath: string | null +} const config = { - configPath, - baseUrl: str(process.env.OPENVIKING_URL, baseUrlFromFile).replace(/\/+$/, ""), - apiKey: str(process.env.OPENVIKING_API_KEY, apiKeyFromFile), - accountId: str(process.env.OPENVIKING_ACCOUNT, str((ovConf as { account?: unknown; default_account?: unknown }).account, str((ovConf as { default_account?: unknown }).default_account, "default"))), - userId: str(process.env.OPENVIKING_USER, str((ovConf as { user?: unknown; default_user?: unknown }).user, str((ovConf as { default_user?: unknown }).default_user, "default"))), - agentId: str(process.env.OPENVIKING_AGENT_ID, str((ovConf as { agent_id?: unknown; default_agent?: unknown }).agent_id, str((ovConf as { default_agent?: unknown }).default_agent, "codex"))), - timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, 15000))), - recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, 6))), - scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, 0.01))), + configPath: shared.configPath, + baseUrl: shared.baseUrl, + apiKey: shared.apiKey, + // The MCP server defaults missing account/user to "default" rather than "" so the + // X-OpenViking-* headers carry a value; hooks leave them empty (server-side default). + accountId: shared.account || "default", + userId: shared.user || "default", + agentId: shared.agentId || "codex", + timeoutMs: shared.timeoutMs, + recallLimit: shared.recallLimit, + scoreThreshold: shared.scoreThreshold, +} + +if (!config.baseUrl) { + process.stderr.write( + "[openviking-memory] No OpenViking URL resolved. Set OPENVIKING_URL or provide ovcli.conf / ov.conf.\n", + ) + process.exit(1) } class OpenVikingClient { @@ -150,7 +111,12 @@ class OpenVikingClient { try { const headers = new Headers(init.headers ?? {}) - if (this.apiKey) headers.set("X-API-Key", this.apiKey) + if (this.apiKey) { + // OV Cloud only accepts Authorization: Bearer; self-hosted servers + // still accept X-API-Key, so we emit both for transition compat. + headers.set("Authorization", `Bearer ${this.apiKey}`) + headers.set("X-API-Key", this.apiKey) + } if (this.accountId) headers.set("X-OpenViking-Account", this.accountId) if (this.userId) headers.set("X-OpenViking-User", this.userId) if (this.agentId) headers.set("X-OpenViking-Agent", this.agentId) From 67c770c9e05f0442cb07a6483e219d7fad4d9374 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 13 May 2026 18:22:41 +0800 Subject: [PATCH 07/12] docs(plugin/codex): put installation first --- docs/en/agent-integrations/01-overview.md | 5 +- .../en/agent-integrations/04-other-plugins.md | 66 +++++- examples/codex-memory-plugin/README.md | 212 +++++++++--------- 3 files changed, 168 insertions(+), 115 deletions(-) diff --git a/docs/en/agent-integrations/01-overview.md b/docs/en/agent-integrations/01-overview.md index 7848e3a860..da21806f14 100644 --- a/docs/en/agent-integrations/01-overview.md +++ b/docs/en/agent-integrations/01-overview.md @@ -9,7 +9,8 @@ OpenViking can act as the long-term memory and context backend for many agent ru | **Claude Code** | [Claude Code Memory Plugin](./02-claude-code.md) — auto-recall + auto-capture via hooks, no MCP tool calls required from the model | | **OpenClaw** | [OpenClaw Plugin](./03-openclaw.md) — context-engine + hooks + tools + runtime manager, deep lifecycle integration | | **LangChain / LangGraph** | [LangChain and LangGraph](./05-langchain-langgraph.md) — session context backend, chat history, retriever, `viking_*` tools, LangGraph store, and middleware for agent workflows | -| **Codex / OpenCode** | [Other community plugins](./04-other-plugins.md) — MCP-only and tool-mechanism variants | +| **Codex** | [Codex Memory Plugin](./04-other-plugins.md#codex-memory-plugin) — lifecycle hooks for auto-recall, incremental capture, and pre-compact commit | +| **OpenCode** | [Other community plugins](./04-other-plugins.md) — explicit-tool and context-injection variants | | **Cursor / Trae / Manus / Claude Desktop / ChatGPT / …** | [MCP Integration Guide](../guides/06-mcp-integration.md) — point any MCP-compatible client at the built-in `/mcp` endpoint | | **Hermes Agent (Nous Research)** | [Hermes — OpenViking memory provider](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#openviking) — first-class OpenViking memory provider, no plugin install needed | @@ -18,7 +19,7 @@ OpenViking can act as the long-term memory and context backend for many agent ru Some integrations go beyond what a generic MCP client can do: - **Generic MCP clients** call OpenViking on demand through tools the model decides to invoke. Setup is one config snippet. -- **Hooks-based plugins** (Claude Code, OpenClaw) drive recall and capture from runtime lifecycle events — every prompt, every turn, session start/end, compact, subagent spawn. The model doesn't need to "remember to recall." +- **Hooks-based plugins** (Claude Code, Codex, OpenClaw) drive recall and capture from runtime lifecycle events — every prompt, every turn, session start/end, compact, subagent spawn. The model doesn't need to "remember to recall." - **SDK integrations** (LangChain/LangGraph) wire OpenViking into framework-native abstractions such as retrievers, tools, chat history, stores, and middleware. For agents whose runtime exposes hooks, middleware, or a context-engine slot, the native integration path is usually the better default. diff --git a/docs/en/agent-integrations/04-other-plugins.md b/docs/en/agent-integrations/04-other-plugins.md index ae70c92037..ef21d0c5fd 100644 --- a/docs/en/agent-integrations/04-other-plugins.md +++ b/docs/en/agent-integrations/04-other-plugins.md @@ -2,20 +2,70 @@ The repo also ships several community/experimental plugins beyond the headline Claude Code and OpenClaw integrations. They differ in target runtime, integration depth, and maintenance status — read each one's README before adopting. -## Codex Memory MCP Server +## Codex Memory Plugin Source: [examples/codex-memory-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/codex-memory-plugin) -A minimal MCP-only server for [Codex](https://github.com/openai/codex). Intentionally narrow scope: +[Codex](https://github.com/openai/codex) integration with lifecycle hooks and explicit MCP tools. It follows the same install-first shape as the [Claude Code integration](./02-claude-code.md), but uses Codex hook events. -- no lifecycle hooks -- no background capture worker -- no writes to `~/.codex` -- no checked-in build output +### Install -Codex gets four explicit memory tools: `find`, `remember`, plus a couple more. +```bash +node --version # >= 22 +codex --version # >= 0.124.0 +codex features list | grep codex_hooks +``` -If you only need explicit memory operations from Codex (no auto-recall or auto-capture), this is the simplest option. +From an OpenViking checkout: + +```bash +mkdir -p /tmp/ov-codex-mp/.claude-plugin +ln -s "$(pwd)/examples/codex-memory-plugin" /tmp/ov-codex-mp/openviking-memory +cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' +{ + "name": "openviking-codex-local", + "plugins": [ + { "name": "openviking-memory", "source": "./openviking-memory" } + ] +} +EOF + +codex plugin marketplace add /tmp/ov-codex-mp +cat >> ~/.codex/config.toml <<'EOF' + +[plugins."openviking-memory@openviking-codex-local"] +enabled = true +EOF + +cd examples/codex-memory-plugin +npm install +npm run build +``` + +### Configure + +Use `~/.openviking/ovcli.conf`, shared with the `ov` CLI: + +```jsonc +{ + "url": "https://ov.example.com", + "api_key": "", + "account": "default", + "user": "" +} +``` + +Environment variables win over files. Use `OPENVIKING_CLI_CONFIG_FILE` for an alternate `ovcli.conf`; `OPENVIKING_API_KEY` and `OPENVIKING_BEARER_TOKEN` are equivalent. + +### What it does + +- Auto-recall on `UserPromptSubmit` +- Incremental capture on `Stop` +- Commit before compaction on `PreCompact` +- Orphan cleanup on `SessionStart` startup/clear +- Manual MCP tools: `openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health` + +Full behavior and validation details are in the [plugin README](https://github.com/volcengine/OpenViking/tree/main/examples/codex-memory-plugin). ## OpenCode plugins diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index ddf19a7c1d..64a89e633e 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -12,6 +12,113 @@ This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-me It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `openviking_forget`, `openviking_health`) for manual use. +## Quick Start + +Installation is first here, matching the shape of the [Claude Code integration doc](../../docs/en/agent-integrations/02-claude-code.md). + +### 1. Install prerequisites + +```bash +node --version # >= 22 +codex --version # >= 0.124.0 +``` + +Make sure `codex_hooks` is enabled: + +```bash +codex features list | grep codex_hooks +``` + +### 2. Install the plugin + +The plugin lives at `examples/codex-memory-plugin/`. + +```bash +mkdir -p /tmp/ov-codex-mp/.claude-plugin +ln -s /abs/path/to/OpenViking/examples/codex-memory-plugin /tmp/ov-codex-mp/openviking-memory +cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' +{ + "name": "openviking-codex-local", + "plugins": [ + { "name": "openviking-memory", "source": "./openviking-memory" } + ] +} +EOF + +codex plugin marketplace add /tmp/ov-codex-mp +cat >> ~/.codex/config.toml <<'EOF' + +[plugins."openviking-memory@openviking-codex-local"] +enabled = true +EOF +``` + +For local development, pre-populate Codex's plugin cache so it resolves immediately: + +```bash +INSTALL_DIR=~/.codex/plugins/cache/openviking-codex-local/openviking-memory +mkdir -p "$INSTALL_DIR" +cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.2.0" +``` + +### 3. Build the MCP server + +```bash +cd examples/codex-memory-plugin +npm install +npm run build +``` + +### 4. Configure OpenViking + +Use the same client config file as the `ov` CLI: + +```jsonc +// ~/.openviking/ovcli.conf +{ + "url": "https://ov.example.com", + "api_key": "", + "account": "default", + "user": "" +} +``` + +Local server mode works without this file; the plugin falls back to `http://127.0.0.1:1933`. + +### 5. Start Codex + +```bash +codex +``` + +First MCP launch installs runtime deps; later launches reuse them. + +## Configuration + +Resolution priority, highest to lowest: + +1. Environment variables: `OPENVIKING_URL`, `OPENVIKING_API_KEY` / `OPENVIKING_BEARER_TOKEN`, `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, `OPENVIKING_AGENT_ID` +2. `ovcli.conf`: `~/.openviking/ovcli.conf` or `OPENVIKING_CLI_CONFIG_FILE` +3. `ov.conf`: `~/.openviking/ov.conf` or `OPENVIKING_CONFIG_FILE` +4. Built-in defaults + +Auth is sent as `Authorization: Bearer ` plus legacy `X-API-Key` during migration. + +Optional Codex-specific tuning can live under `codex` in `ovcli.conf`: + +```jsonc +{ + "url": "https://ov.example.com", + "api_key": "...", + "codex": { + "agentId": "codex", + "recallLimit": 6, + "captureAssistantTurns": false, + "autoCommitOnCompact": true + } +} +``` + ## Architecture ``` @@ -127,111 +234,6 @@ Unlike Claude Code, **Codex does not support `decision: "approve"`**; only `deci Source: [`codex-rs/hooks/schema/generated/`](https://github.com/openai/codex/tree/main/codex-rs/hooks/schema/generated). -## Quick Start - -### 1. Install Node.js 22+ and Codex 0.124+ - -```bash -node --version # >= 22 -codex --version # >= 0.124.0 -``` - -Make sure `codex_hooks` is enabled (it's stable since April 2026): - -```bash -codex features list | grep codex_hooks -# codex_hooks stable true -``` - -### 2. Configure OpenViking client - -The plugin reads connection settings from `~/.openviking/ovcli.conf` (the same file the `ov` CLI uses). For a cloud OpenViking deployment: - -```jsonc -{ - "url": "https://ov.example.com", - "api_key": "", - "account": "default", - "user": "" -} -``` - -For a local server, omit `url` and the plugin will fall back to `~/.openviking/ov.conf`'s `server.host` / `server.port`. - -Plugin-specific overrides go in an optional `codex` section: - -```jsonc -{ - "url": "https://ov.example.com", - "api_key": "...", - "codex": { - "agentId": "codex", - "recallLimit": 6, - "captureMode": "semantic", - "captureAssistantTurns": false, - "autoCommitOnCompact": true - } -} -``` - -### 3. Install the plugin - -The plugin lives at `examples/codex-memory-plugin/` in the OpenViking repo. Once a marketplace ships it, install with: - -```bash -codex plugin marketplace add -# then enable in ~/.codex/config.toml: -# [plugins."openviking-memory@"] -# enabled = true -``` - -For local development, point a tiny marketplace fixture at this directory: - -```bash -mkdir -p /tmp/ov-codex-mp/.claude-plugin -ln -s /abs/path/to/OpenViking/examples/codex-memory-plugin /tmp/ov-codex-mp/openviking-memory -cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' -{ - "name": "openviking-codex-local", - "plugins": [ - { "name": "openviking-memory", "source": "./openviking-memory" } - ] -} -EOF -codex plugin marketplace add /tmp/ov-codex-mp - -# Enable the plugin -cat >> ~/.codex/config.toml <<'EOF' - -[plugins."openviking-memory@openviking-codex-local"] -enabled = true -EOF - -# Codex installs plugins lazily — for fastest iteration, copy the plugin into -# the cache so it resolves immediately: -INSTALL_DIR=~/.codex/plugins/cache/openviking-codex-local/openviking-memory -mkdir -p "$INSTALL_DIR" -cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.2.0" -``` - -### 4. Build the MCP server - -```bash -cd examples/codex-memory-plugin -npm install -npm run build -``` - -The MCP server compiles to `servers/memory-server.js`, which `start-memory-server.mjs` launches via the bootstrapped runtime. - -### 5. Start a Codex session - -```bash -codex -``` - -The first session installs runtime deps; subsequent sessions skip reinstall. - ## Validation SOP This is the canonical end-to-end validation for an OpenViking plugin. Run it after any plugin change. From 6d09617ac75b4f576ec2810101d1f9fc7db70a02 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 13 May 2026 18:38:23 +0800 Subject: [PATCH 08/12] fix(plugin/codex): harden runtime and capture paths --- examples/codex-memory-plugin/README.md | 2 +- .../scripts/auto-capture.mjs | 23 +++++++++++--- .../scripts/auto-recall.mjs | 6 ++-- .../scripts/pre-compact-capture.mjs | 30 ++++++++++++++++--- .../scripts/runtime-common.mjs | 10 +++++-- .../servers/memory-server.js | 8 ++--- .../codex-memory-plugin/src/memory-server.ts | 10 +++---- 7 files changed, 66 insertions(+), 23 deletions(-) diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 64a89e633e..f57fb4b5d9 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -58,7 +58,7 @@ For local development, pre-populate Codex's plugin cache so it resolves immediat ```bash INSTALL_DIR=~/.codex/plugins/cache/openviking-codex-local/openviking-memory mkdir -p "$INSTALL_DIR" -cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.2.0" +cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.4.0" ``` ### 3. Build the MCP server diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index 6d7e9a6c6f..ece49a6fe6 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -113,6 +113,7 @@ function extractTurns(rolloutEntries) { } if (role !== "user" && role !== "assistant") continue; + if (role === "assistant" && !cfg.captureAssistantTurns) continue; const trimmed = text.trim(); if (!trimmed) continue; @@ -152,12 +153,16 @@ async function ensureOvSession(state) { } async function appendTurns(ovSessionId, turns) { + let appended = 0; for (const turn of turns) { - await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { + const result = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { method: "POST", body: JSON.stringify({ role: turn.role, content: turn.text }), }); + if (!result) break; + appended += 1; } + return appended; } async function commitOvSession(ovSessionId) { @@ -237,15 +242,21 @@ async function main() { newTurns: newTurns.length, }); + if (cfg.captureMode === "keyword" && newTurns.length > 0 && !hasCaptureKeyword(newTurns)) { + log("skip", { stage: "capture_mode", reason: "keyword mode without capture trigger" }); + await saveState(state); + noop(); + return; + } + let added = 0; if (newTurns.length > 0) { const ovSessionId = await ensureOvSession(state); if (!ovSessionId) { logError("ensure_ov_session", "failed to create OV session"); } else { - await appendTurns(ovSessionId, newTurns); - added = newTurns.length; - state.capturedTurnCount = allTurns.length; + added = await appendTurns(ovSessionId, newTurns); + state.capturedTurnCount += added; log("appended", { ovSessionId, added }); } } @@ -261,4 +272,8 @@ async function main() { } } +function hasCaptureKeyword(turns) { + return turns.some((turn) => /\b(remember|memorize|store|save|capture|note|record)\b|记住|保存|记录|记忆/i.test(turn.text)); +} + main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/auto-recall.mjs b/examples/codex-memory-plugin/scripts/auto-recall.mjs index 2426e92d05..b14e36dd95 100644 --- a/examples/codex-memory-plugin/scripts/auto-recall.mjs +++ b/examples/codex-memory-plugin/scripts/auto-recall.mjs @@ -220,20 +220,20 @@ async function resolveTargetUri(targetUri) { return `viking://${scope}/${space}/${parts.join("/")}`; } -async function searchScope(query, targetUri, limit) { +async function searchScope(query, targetUri, limit, bucket = "memories") { const resolvedUri = await resolveTargetUri(targetUri); const result = await fetchJSON("/api/v1/search/find", { method: "POST", body: JSON.stringify({ query, target_uri: resolvedUri, limit, score_threshold: 0 }), }); - return result?.memories || []; + return result?.[bucket] || []; } async function searchAll(query, limit) { const [userMems, agentMems, agentSkills] = await Promise.all([ searchScope(query, "viking://user/memories", limit), searchScope(query, "viking://agent/memories", limit), - searchScope(query, "viking://agent/skills", limit), + searchScope(query, "viking://agent/skills", limit, "skills"), ]); log("search_complete", { scope: "user", rawCount: userMems.length, topScores: userMems.slice(0, 3).map((m) => m.score) }); log("search_complete", { scope: "agent", rawCount: agentMems.length, topScores: agentMems.slice(0, 3).map((m) => m.score) }); diff --git a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs index 193d69b470..b982ac62b4 100644 --- a/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -104,6 +104,7 @@ function extractTurns(entries) { } if (role !== "user" && role !== "assistant") continue; + if (role === "assistant" && !cfg.captureAssistantTurns) continue; const trimmed = text.trim(); if (!trimmed) continue; @@ -139,12 +140,16 @@ async function ensureOvSession(state) { } async function appendTurns(ovSessionId, turns) { + let appended = 0; for (const turn of turns) { - await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { + const result = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, { method: "POST", body: JSON.stringify({ role: turn.role, content: turn.text }), }); + if (!result) break; + appended += 1; } + return appended; } function countExtracted(commit) { @@ -205,6 +210,13 @@ async function main() { return; } + if (newTurns.length > 0 && !state.ovSessionId && cfg.captureMode === "keyword" && !hasCaptureKeyword(newTurns)) { + log("skip", { stage: "capture_mode", reason: "keyword mode without capture trigger" }); + await saveState(state); + noop(); + return; + } + if (newTurns.length > 0) { const ovSessionId = await ensureOvSession(state); if (!ovSessionId) { @@ -212,9 +224,15 @@ async function main() { noop(); return; } - await appendTurns(ovSessionId, newTurns); - state.capturedTurnCount = allTurns.length; - log("appended_catchup", { ovSessionId, added: newTurns.length }); + const added = await appendTurns(ovSessionId, newTurns); + state.capturedTurnCount += added; + log("appended_catchup", { ovSessionId, added }); + if (added < newTurns.length) { + logError("append_failed_keep_state", { ovSessionId, attempted: newTurns.length, added }); + await saveState(state); + noop(`pre-compact catch-up append incomplete for ${ovSessionId}; state preserved for retry`); + return; + } } if (!state.ovSessionId) { @@ -260,4 +278,8 @@ async function main() { ); } +function hasCaptureKeyword(turns) { + return turns.some((turn) => /\b(remember|memorize|store|save|capture|note|record)\b|记住|保存|记录|记忆/i.test(turn.text)); +} + main().catch((err) => { logError("uncaught", err); noop(); }); diff --git a/examples/codex-memory-plugin/scripts/runtime-common.mjs b/examples/codex-memory-plugin/scripts/runtime-common.mjs index 14f2abefc8..5636c31b9b 100644 --- a/examples/codex-memory-plugin/scripts/runtime-common.mjs +++ b/examples/codex-memory-plugin/scripts/runtime-common.mjs @@ -25,9 +25,11 @@ export function getRuntimePaths() { runtimeRoot, sourcePackagePath: join(pluginRoot, "package.json"), sourceLockPath: join(pluginRoot, "package-lock.json"), + sourceConfigPath: join(pluginRoot, "scripts", "config.mjs"), sourceServerPath: join(pluginRoot, "servers", "memory-server.js"), runtimePackagePath: join(runtimeRoot, "package.json"), runtimeLockPath: join(runtimeRoot, "package-lock.json"), + runtimeConfigPath: join(runtimeRoot, "scripts", "config.mjs"), runtimeServerPath: join(runtimeRoot, "servers", "memory-server.js"), runtimeNodeModulesPath: join(runtimeRoot, "node_modules"), statePath: join(runtimeRoot, "install-state.json"), @@ -38,9 +40,10 @@ export function getRuntimePaths() { } export async function computeSourceState(paths) { - const [pkgRaw, lockRaw, serverRaw] = await Promise.all([ + const [pkgRaw, lockRaw, configRaw, serverRaw] = await Promise.all([ readFile(paths.sourcePackagePath), readFile(paths.sourceLockPath), + readFile(paths.sourceConfigPath), readFile(paths.sourceServerPath), ]); @@ -49,7 +52,7 @@ export async function computeSourceState(paths) { return { pluginVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", manifestHash: sha256(pkgRaw, lockRaw), - serverHash: sha256(serverRaw), + serverHash: sha256(configRaw, serverRaw), }; } @@ -102,6 +105,7 @@ export async function runtimeIsReady(paths, expectedState) { for (const target of [ paths.runtimePackagePath, paths.runtimeLockPath, + paths.runtimeConfigPath, paths.runtimeServerPath, paths.runtimeNodeModulesPath, ]) { @@ -113,8 +117,10 @@ export async function runtimeIsReady(paths, expectedState) { export async function syncRuntimeFiles(paths) { await mkdir(join(paths.runtimeRoot, "servers"), { recursive: true }); + await mkdir(join(paths.runtimeRoot, "scripts"), { recursive: true }); await copyFile(paths.sourcePackagePath, paths.runtimePackagePath); await copyFile(paths.sourceLockPath, paths.runtimeLockPath); + await copyFile(paths.sourceConfigPath, paths.runtimeConfigPath); await copyFile(paths.sourceServerPath, paths.runtimeServerPath); await writeRuntimeEnvMeta(paths); } diff --git a/examples/codex-memory-plugin/servers/memory-server.js b/examples/codex-memory-plugin/servers/memory-server.js index 8d436398a5..6dae165951 100644 --- a/examples/codex-memory-plugin/servers/memory-server.js +++ b/examples/codex-memory-plugin/servers/memory-server.js @@ -200,7 +200,7 @@ function formatMemoryResults(items) { } const client = new OpenVikingClient(config.baseUrl, config.apiKey, config.accountId, config.userId, config.agentId, config.timeoutMs); const server = new McpServer({ name: "openviking-memory-codex", version: "0.1.0" }); -server.tool("find", "Find OpenViking long-term memory.", { +server.tool("openviking_recall", "Find OpenViking long-term memory.", { query: z.string().describe("Find query"), target_uri: z.string().optional().describe("Find scope URI, default viking://user/memories"), limit: z.number().optional().describe("Max results, default 6"), @@ -218,7 +218,7 @@ server.tool("find", "Find OpenViking long-term memory.", { } return { content: [{ type: "text", text: formatMemoryResults(items) }] }; }); -server.tool("remember", "Store information in OpenViking long-term memory.", { +server.tool("openviking_store", "Store information in OpenViking long-term memory.", { text: z.string().describe("Information to store"), role: z.string().optional().describe("Message role, default user"), }, async ({ text, role }) => { @@ -254,7 +254,7 @@ server.tool("remember", "Store information in OpenViking long-term memory.", { await client.deleteSession(sessionId).catch(() => { }); } }); -server.tool("forget", "Delete an exact OpenViking memory URI. Use find first if you only have a query.", { +server.tool("openviking_forget", "Delete an exact OpenViking memory URI. Use openviking_recall first if you only have a query.", { uri: z.string().describe("Exact memory URI to delete"), }, async ({ uri }) => { if (!isMemoryUri(uri)) { @@ -263,7 +263,7 @@ server.tool("forget", "Delete an exact OpenViking memory URI. Use find first if await client.deleteUri(uri); return { content: [{ type: "text", text: `Deleted memory: ${uri}` }] }; }); -server.tool("health", "Check whether the OpenViking server is reachable.", {}, async () => { +server.tool("openviking_health", "Check whether the OpenViking server is reachable.", {}, async () => { const ok = await client.healthCheck(); const text = ok ? `OpenViking is reachable at ${config.baseUrl}.` diff --git a/examples/codex-memory-plugin/src/memory-server.ts b/examples/codex-memory-plugin/src/memory-server.ts index e0fb565dfc..12520309ed 100644 --- a/examples/codex-memory-plugin/src/memory-server.ts +++ b/examples/codex-memory-plugin/src/memory-server.ts @@ -274,7 +274,7 @@ const client = new OpenVikingClient( const server = new McpServer({ name: "openviking-memory-codex", version: "0.1.0" }) server.tool( - "find", + "openviking_recall", "Find OpenViking long-term memory.", { query: z.string().describe("Find query"), @@ -300,7 +300,7 @@ server.tool( ) server.tool( - "remember", + "openviking_store", "Store information in OpenViking long-term memory.", { text: z.string().describe("Information to store"), @@ -342,8 +342,8 @@ server.tool( ) server.tool( - "forget", - "Delete an exact OpenViking memory URI. Use find first if you only have a query.", + "openviking_forget", + "Delete an exact OpenViking memory URI. Use openviking_recall first if you only have a query.", { uri: z.string().describe("Exact memory URI to delete"), }, @@ -358,7 +358,7 @@ server.tool( ) server.tool( - "health", + "openviking_health", "Check whether the OpenViking server is reachable.", {}, async () => { From 027357867d3052786250ae1ccdf1670c2ee2e65e Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 13 May 2026 18:57:11 +0800 Subject: [PATCH 09/12] docs(plugin/codex): align local marketplace name --- docs/en/agent-integrations/04-other-plugins.md | 4 ++-- examples/codex-memory-plugin/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/en/agent-integrations/04-other-plugins.md b/docs/en/agent-integrations/04-other-plugins.md index ef21d0c5fd..8751a61024 100644 --- a/docs/en/agent-integrations/04-other-plugins.md +++ b/docs/en/agent-integrations/04-other-plugins.md @@ -23,7 +23,7 @@ mkdir -p /tmp/ov-codex-mp/.claude-plugin ln -s "$(pwd)/examples/codex-memory-plugin" /tmp/ov-codex-mp/openviking-memory cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' { - "name": "openviking-codex-local", + "name": "openviking-plugins-local", "plugins": [ { "name": "openviking-memory", "source": "./openviking-memory" } ] @@ -33,7 +33,7 @@ EOF codex plugin marketplace add /tmp/ov-codex-mp cat >> ~/.codex/config.toml <<'EOF' -[plugins."openviking-memory@openviking-codex-local"] +[plugins."openviking-memory@openviking-plugins-local"] enabled = true EOF diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index f57fb4b5d9..90e4686aed 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -38,7 +38,7 @@ mkdir -p /tmp/ov-codex-mp/.claude-plugin ln -s /abs/path/to/OpenViking/examples/codex-memory-plugin /tmp/ov-codex-mp/openviking-memory cat > /tmp/ov-codex-mp/.claude-plugin/marketplace.json <<'EOF' { - "name": "openviking-codex-local", + "name": "openviking-plugins-local", "plugins": [ { "name": "openviking-memory", "source": "./openviking-memory" } ] @@ -48,7 +48,7 @@ EOF codex plugin marketplace add /tmp/ov-codex-mp cat >> ~/.codex/config.toml <<'EOF' -[plugins."openviking-memory@openviking-codex-local"] +[plugins."openviking-memory@openviking-plugins-local"] enabled = true EOF ``` @@ -56,7 +56,7 @@ EOF For local development, pre-populate Codex's plugin cache so it resolves immediately: ```bash -INSTALL_DIR=~/.codex/plugins/cache/openviking-codex-local/openviking-memory +INSTALL_DIR=~/.codex/plugins/cache/openviking-plugins-local/openviking-memory mkdir -p "$INSTALL_DIR" cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.4.0" ``` From c0023d8c2f20d1fcdb45e44a68bafb89dd2dd846 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 13 May 2026 19:14:29 +0800 Subject: [PATCH 10/12] docs(plugin/codex): add one-line installer --- .../en/agent-integrations/04-other-plugins.md | 29 +++- examples/codex-memory-plugin/README.md | 47 ++++-- .../setup-helper/install.sh | 153 ++++++++++++++++++ 3 files changed, 214 insertions(+), 15 deletions(-) create mode 100755 examples/codex-memory-plugin/setup-helper/install.sh diff --git a/docs/en/agent-integrations/04-other-plugins.md b/docs/en/agent-integrations/04-other-plugins.md index 8751a61024..585c7300a7 100644 --- a/docs/en/agent-integrations/04-other-plugins.md +++ b/docs/en/agent-integrations/04-other-plugins.md @@ -10,12 +10,29 @@ Source: [examples/codex-memory-plugin](https://github.com/volcengine/OpenViking/ ### Install +Recommended one-line installer: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/codex-memory-plugin/setup-helper/install.sh) +``` + +It installs from a local `openviking-plugins-local` marketplace, enables `openviking-memory@openviking-plugins-local`, sets `features.plugin_hooks = true`, and uses `~/.openviking/ovcli.conf` for the OpenViking connection when present. + +Manual setup: + ```bash node --version # >= 22 codex --version # >= 0.124.0 codex features list | grep codex_hooks ``` +Enable plugin lifecycle hooks: + +```toml +[features] +plugin_hooks = true +``` + From an OpenViking checkout: ```bash @@ -36,12 +53,18 @@ cat >> ~/.codex/config.toml <<'EOF' [plugins."openviking-memory@openviking-plugins-local"] enabled = true EOF +``` -cd examples/codex-memory-plugin -npm install -npm run build +For local development, pre-populate Codex's cache so it resolves immediately: + +```bash +INSTALL_DIR=~/.codex/plugins/cache/openviking-plugins-local/openviking-memory +mkdir -p "$INSTALL_DIR" +cp -R "$(pwd)/examples/codex-memory-plugin" "$INSTALL_DIR/0.4.0" ``` +`npm install && npm run build` is only required when editing the TypeScript MCP server source; the checked-in plugin already includes `servers/memory-server.js`. + ### Configure Use `~/.openviking/ovcli.conf`, shared with the `ov` CLI: diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 90e4686aed..232bd7eb41 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -16,7 +16,19 @@ It also exposes explicit MCP tools (`openviking_recall`, `openviking_store`, `op Installation is first here, matching the shape of the [Claude Code integration doc](../../docs/en/agent-integrations/02-claude-code.md). -### 1. Install prerequisites +### One-line installer (recommended) + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/volcengine/OpenViking/main/examples/codex-memory-plugin/setup-helper/install.sh) +``` + +The installer checks `codex`, `git`, and Node.js 22+, clones OpenViking to `~/.openviking/openviking-repo` if needed, registers a local `openviking-plugins-local` marketplace, enables `openviking-memory@openviking-plugins-local`, sets `features.plugin_hooks = true`, and pre-populates Codex's plugin cache so the plugin resolves immediately. It uses `~/.openviking/ovcli.conf` when present; otherwise the plugin falls back to `http://127.0.0.1:1933`. + +If you'd rather do it by hand, use the manual setup below. + +### Manual setup + +#### 1. Install prerequisites ```bash node --version # >= 22 @@ -29,7 +41,14 @@ Make sure `codex_hooks` is enabled: codex features list | grep codex_hooks ``` -### 2. Install the plugin +Plugin lifecycle hooks also require `plugin_hooks`: + +```toml +[features] +plugin_hooks = true +``` + +#### 2. Install the plugin The plugin lives at `examples/codex-memory-plugin/`. @@ -61,15 +80,7 @@ mkdir -p "$INSTALL_DIR" cp -R /abs/path/to/OpenViking/examples/codex-memory-plugin "$INSTALL_DIR/0.4.0" ``` -### 3. Build the MCP server - -```bash -cd examples/codex-memory-plugin -npm install -npm run build -``` - -### 4. Configure OpenViking +#### 3. Configure OpenViking Use the same client config file as the `ov` CLI: @@ -85,7 +96,7 @@ Use the same client config file as the `ov` CLI: Local server mode works without this file; the plugin falls back to `http://127.0.0.1:1933`. -### 5. Start Codex +#### 4. Start Codex ```bash codex @@ -93,6 +104,18 @@ codex First MCP launch installs runtime deps; later launches reuse them. +### Development from source + +Only needed when editing `src/memory-server.ts`: + +```bash +cd examples/codex-memory-plugin +npm install +npm run build +``` + +`codex exec` does not reliably fire plugin lifecycle hooks in current Codex builds. For hook validation, use an interactive `codex` session or the scripts in `hooks/hooks.json` with synthetic JSON input. + ## Configuration Resolution priority, highest to lowest: diff --git a/examples/codex-memory-plugin/setup-helper/install.sh b/examples/codex-memory-plugin/setup-helper/install.sh new file mode 100755 index 0000000000..2ae118a77d --- /dev/null +++ b/examples/codex-memory-plugin/setup-helper/install.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${OPENVIKING_REPO_URL:-https://github.com/volcengine/OpenViking.git}" +REPO_DIR="${OPENVIKING_REPO_DIR:-$HOME/.openviking/openviking-repo}" +MARKETPLACE_NAME="${OPENVIKING_CODEX_MARKETPLACE_NAME:-openviking-plugins-local}" +MARKETPLACE_ROOT="${OPENVIKING_CODEX_MARKETPLACE_ROOT:-$HOME/.codex/${MARKETPLACE_NAME}-marketplace}" +PLUGIN_NAME="openviking-memory" +PLUGIN_ID="${PLUGIN_NAME}@${MARKETPLACE_NAME}" +CODEX_CONFIG="${CODEX_CONFIG_FILE:-$HOME/.codex/config.toml}" + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +need codex +need git +need node + +NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')" +if [ "$NODE_MAJOR" -lt 22 ]; then + echo "Node.js 22+ is required; found $(node --version)." >&2 + exit 1 +fi + +mkdir -p "$(dirname "$REPO_DIR")" "$HOME/.codex" + +if [ ! -d "$REPO_DIR/.git" ]; then + git clone --depth 1 "$REPO_URL" "$REPO_DIR" +fi + +PLUGIN_DIR="$REPO_DIR/examples/codex-memory-plugin" +if [ ! -d "$PLUGIN_DIR/.codex-plugin" ]; then + echo "Codex plugin not found at $PLUGIN_DIR" >&2 + exit 1 +fi + +PLUGIN_VERSION="$(node -e 'const p=require(process.argv[1]); console.log(p.version || "0.0.0")' "$PLUGIN_DIR/package.json")" + +mkdir -p "$MARKETPLACE_ROOT/.claude-plugin" +rm -f "$MARKETPLACE_ROOT/$PLUGIN_NAME" +ln -s "$PLUGIN_DIR" "$MARKETPLACE_ROOT/$PLUGIN_NAME" + +cat > "$MARKETPLACE_ROOT/.claude-plugin/marketplace.json" </dev/null 2>&1 || true + +node - "$CODEX_CONFIG" "$PLUGIN_ID" <<'NODE' +const fs = require("node:fs"); +const path = process.argv[2]; +const pluginId = process.argv[3]; + +let text = ""; +try { + text = fs.readFileSync(path, "utf8"); +} catch { + text = ""; +} + +function ensureSectionLine(src, section, key, value) { + const lines = src.split(/\n/); + const header = `[${section}]`; + const start = lines.findIndex((line) => line.trim() === header); + if (start === -1) { + const prefix = src.trimEnd(); + return `${prefix}${prefix ? "\n\n" : ""}${header}\n${key} = ${value}\n`; + } + + let end = lines.length; + for (let i = start + 1; i < lines.length; i += 1) { + if (/^\s*\[/.test(lines[i])) { + end = i; + break; + } + } + + for (let i = start + 1; i < end; i += 1) { + if (new RegExp(`^\\s*${key}\\s*=`).test(lines[i])) { + lines[i] = `${key} = ${value}`; + return lines.join("\n").replace(/\n*$/, "\n"); + } + } + + lines.splice(end, 0, `${key} = ${value}`); + return lines.join("\n").replace(/\n*$/, "\n"); +} + +function ensurePluginEnabled(src, pluginId) { + const header = `[plugins."${pluginId}"]`; + const lines = src.split(/\n/); + const start = lines.findIndex((line) => line.trim() === header); + if (start === -1) { + const prefix = src.trimEnd(); + return `${prefix}${prefix ? "\n\n" : ""}${header}\nenabled = true\n`; + } + + let end = lines.length; + for (let i = start + 1; i < lines.length; i += 1) { + if (/^\s*\[/.test(lines[i])) { + end = i; + break; + } + } + + for (let i = start + 1; i < end; i += 1) { + if (/^\s*enabled\s*=/.test(lines[i])) { + lines[i] = "enabled = true"; + return lines.join("\n").replace(/\n*$/, "\n"); + } + } + + lines.splice(end, 0, "enabled = true"); + return lines.join("\n").replace(/\n*$/, "\n"); +} + +text = ensurePluginEnabled(text, pluginId); +text = ensureSectionLine(text, "features", "plugin_hooks", "true"); + +fs.mkdirSync(require("node:path").dirname(path), { recursive: true }); +fs.writeFileSync(path, text); +NODE + +CACHE_DIR="$HOME/.codex/plugins/cache/$MARKETPLACE_NAME/$PLUGIN_NAME/$PLUGIN_VERSION" +mkdir -p "$(dirname "$CACHE_DIR")" +rm -rf "$CACHE_DIR" +cp -R "$PLUGIN_DIR" "$CACHE_DIR" + +if [ ! -f "$HOME/.openviking/ovcli.conf" ]; then + cat >&2 <<'EOF' + +Note: ~/.openviking/ovcli.conf was not found. +The plugin will use http://127.0.0.1:1933 unless OPENVIKING_URL / OPENVIKING_API_KEY are set. +EOF +fi + +cat < Date: Wed, 13 May 2026 19:16:01 +0800 Subject: [PATCH 11/12] fix(plugin/codex): support branch installer testing --- .../codex-memory-plugin/setup-helper/install.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/codex-memory-plugin/setup-helper/install.sh b/examples/codex-memory-plugin/setup-helper/install.sh index 2ae118a77d..473931106d 100755 --- a/examples/codex-memory-plugin/setup-helper/install.sh +++ b/examples/codex-memory-plugin/setup-helper/install.sh @@ -2,6 +2,7 @@ set -euo pipefail REPO_URL="${OPENVIKING_REPO_URL:-https://github.com/volcengine/OpenViking.git}" +REPO_REF="${OPENVIKING_REPO_REF:-}" REPO_DIR="${OPENVIKING_REPO_DIR:-$HOME/.openviking/openviking-repo}" MARKETPLACE_NAME="${OPENVIKING_CODEX_MARKETPLACE_NAME:-openviking-plugins-local}" MARKETPLACE_ROOT="${OPENVIKING_CODEX_MARKETPLACE_ROOT:-$HOME/.codex/${MARKETPLACE_NAME}-marketplace}" @@ -28,8 +29,18 @@ fi mkdir -p "$(dirname "$REPO_DIR")" "$HOME/.codex" -if [ ! -d "$REPO_DIR/.git" ]; then - git clone --depth 1 "$REPO_URL" "$REPO_DIR" +if [ ! -e "$REPO_DIR/.git" ]; then + if [ -e "$REPO_DIR" ]; then + echo "$REPO_DIR exists but is not a git checkout." >&2 + exit 1 + fi + if [ -n "$REPO_REF" ]; then + git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$REPO_DIR" + else + git clone --depth 1 "$REPO_URL" "$REPO_DIR" + fi +elif [ -n "$REPO_REF" ]; then + git -C "$REPO_DIR" fetch --depth 1 origin "$REPO_REF" >/dev/null 2>&1 || true fi PLUGIN_DIR="$REPO_DIR/examples/codex-memory-plugin" From a76572e599012595f7c8e3e87ad8707b97368a47 Mon Sep 17 00:00:00 2001 From: "zhiheng.liu" Date: Wed, 13 May 2026 19:17:51 +0800 Subject: [PATCH 12/12] fix(plugin/codex): keep installer env surface stable --- examples/codex-memory-plugin/setup-helper/install.sh | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/examples/codex-memory-plugin/setup-helper/install.sh b/examples/codex-memory-plugin/setup-helper/install.sh index 473931106d..69d31bf3b2 100755 --- a/examples/codex-memory-plugin/setup-helper/install.sh +++ b/examples/codex-memory-plugin/setup-helper/install.sh @@ -2,7 +2,6 @@ set -euo pipefail REPO_URL="${OPENVIKING_REPO_URL:-https://github.com/volcengine/OpenViking.git}" -REPO_REF="${OPENVIKING_REPO_REF:-}" REPO_DIR="${OPENVIKING_REPO_DIR:-$HOME/.openviking/openviking-repo}" MARKETPLACE_NAME="${OPENVIKING_CODEX_MARKETPLACE_NAME:-openviking-plugins-local}" MARKETPLACE_ROOT="${OPENVIKING_CODEX_MARKETPLACE_ROOT:-$HOME/.codex/${MARKETPLACE_NAME}-marketplace}" @@ -34,13 +33,7 @@ if [ ! -e "$REPO_DIR/.git" ]; then echo "$REPO_DIR exists but is not a git checkout." >&2 exit 1 fi - if [ -n "$REPO_REF" ]; then - git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$REPO_DIR" - else - git clone --depth 1 "$REPO_URL" "$REPO_DIR" - fi -elif [ -n "$REPO_REF" ]; then - git -C "$REPO_DIR" fetch --depth 1 origin "$REPO_REF" >/dev/null 2>&1 || true + git clone --depth 1 "$REPO_URL" "$REPO_DIR" fi PLUGIN_DIR="$REPO_DIR/examples/codex-memory-plugin"