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..585c7300a7 100644 --- a/docs/en/agent-integrations/04-other-plugins.md +++ b/docs/en/agent-integrations/04-other-plugins.md @@ -2,20 +2,93 @@ 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. +Recommended one-line installer: -If you only need explicit memory operations from Codex (no auto-recall or auto-capture), this is the simplest option. +```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 +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-plugins-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-plugins-local"] +enabled = true +EOF +``` + +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: + +```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/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json index 623615caee..4a1453bfb1 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.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" + }, + "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 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", + "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/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 1a0315be36..232bd7eb41 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -1,42 +1,112 @@ -# 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` +- **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` (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. -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` +## Quick Start -## Files +Installation is first here, matching the shape of the [Claude Code integration doc](../../docs/en/agent-integrations/02-claude-code.md). -- `.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 +### One-line installer (recommended) -## Prerequisites +```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 +codex --version # >= 0.124.0 +``` + +Make sure `codex_hooks` is enabled: + +```bash +codex features list | grep codex_hooks +``` + +Plugin lifecycle hooks also require `plugin_hooks`: + +```toml +[features] +plugin_hooks = true +``` + +#### 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-plugins-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-plugins-local"] +enabled = true +EOF +``` + +For local development, pre-populate Codex's plugin cache so it resolves immediately: + +```bash +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" +``` + +#### 3. Configure OpenViking -- Codex CLI -- OpenViking server -- Node.js 22+ +Use the same client config file as the `ov` CLI: -Start OpenViking before using the MCP server: +```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`. + +#### 4. Start Codex ```bash -openviking-server --config ~/.openviking/ov.conf +codex ``` -## Build +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 @@ -44,71 +114,328 @@ npm install npm run build ``` -## Install in Codex +`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: + +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 + +``` + ┌──────────────────────────────────────────────────────────────┐ + │ Codex │ + └──┬─────────────────┬────────────────┬───────────────────┬────┘ + │ │ │ │ + SessionStart UserPromptSubmit Stop PreCompact + (startup|clear) │ (per turn) │ + │ │ │ │ + ┌────▼──────────┐ ┌────▼──────┐ ┌──────▼──────┐ ┌──────────▼──────┐ + │ session-start │ │ auto- │ │ auto- │ │ pre-compact- │ + │ -commit.mjs │ │ recall.mjs│ │ capture.mjs │ │ capture.mjs │ + │ (active-win │ │ (search) │ │ (append + │ │ (commit + reset │ + │ heuristic + │ │ │ │ no commit) │ │ ovSessionId) │ + │ idle TTL) │ │ │ │ │ │ │ + └────┬──────────┘ └────┬──────┘ └──────┬──────┘ └──────────┬──────┘ + │ │ │ │ + │ ┌───▼────────────────▼───────────────────▼──┐ + └────────────►│ 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 + +> 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. + +### SessionStart commit logic (source=startup|clear, heuristic + idle TTL) + +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. + +`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) -Use the built server: +`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": "..." } } +``` + +Codex injects `additionalContext` into the model turn, so memories arrive without an extra tool call. + +### Stop (turn end → `add_message`, NOT `commit`) + +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 }`. + +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. + +### 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. + +### Known gap: SIGTERM / Ctrl+C / `/exit` are silent + +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. + +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) + +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`), `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 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). + +Source: [`codex-rs/hooks/schema/generated/`](https://github.com/openai/codex/tree/main/codex-rs/hooks/schema/generated). + +## Validation SOP + +This is the canonical end-to-end validation for an OpenViking plugin. Run it after any plugin change. ```bash -codex mcp add openviking-memory -- \ - node /ABS/PATH/TO/OpenViking/examples/codex-memory-plugin/servers/memory-server.js +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 | + +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. -## Config +Connection / identity overrides: -The server reads OpenViking connection settings from `~/.openviking/ov.conf`. +- `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 -Supported environment overrides: +State-file / SessionStart tuning: -- `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` +- `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) -## Tools +### Auth header -### `find` +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. -Find OpenViking memory. +## Hook timeouts + +| 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 | + +## Debug logging + +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`). + +## 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/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md new file mode 100644 index 0000000000..6ec3a4ff58 --- /dev/null +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -0,0 +1,245 @@ +# 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 +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. SessionStart — active-window heuristic + idle-TTL sweep + +`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 +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" </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 new file mode 100644 index 0000000000..2765abe637 --- /dev/null +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -0,0 +1,52 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "clear|startup", + "hooks": [ + { + "type": "command", + "command": "node \"${CODEX_PLUGIN_ROOT}/scripts/session-start-commit.mjs\"", + "timeout": 30 + } + ] + } + ], + "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": 30 + } + ] + } + ], + "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..409c4d2f3f --- /dev/null +++ b/examples/codex-memory-plugin/package-lock.json @@ -0,0 +1,1174 @@ +{ + "name": "codex-openviking-memory", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-openviking-memory", + "version": "0.4.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..0798bda639 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.4.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..ece49a6fe6 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +/** + * Stop hook for Codex (turn end). + * + * Codex passes JSON on stdin including session_id, transcript_path, + * last_assistant_message. Stop fires per turn — NOT at session end. + * + * Strategy: + * 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. + * + * Commit happens in two other places, never here: + * - PreCompact hook (deterministic, before context compaction) + * - 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"; +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("auto-capture"); + +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["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; + 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); + } +} + +// --------------------------------------------------------------------------- +// 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; + 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 (role === "assistant" && !cfg.captureAssistantTurns) continue; + 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 []; + } +} + +// --------------------------------------------------------------------------- +// OpenViking session ops +// --------------------------------------------------------------------------- + +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 null; + state.ovSessionId = created.session_id; + return state.ovSessionId; +} + +async function appendTurns(ovSessionId, turns) { + let appended = 0; + for (const turn of turns) { + 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) { + if (!ovSessionId) return null; + return fetchJSON( + `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, + { method: "POST", body: JSON.stringify({}) }, + ); +} + +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; +} + +// --------------------------------------------------------------------------- +// 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; + log("start", { sessionId, transcriptPath }); + + const health = await fetchJSON("/health"); + if (!health) { + logError("health_check", "server unreachable or unhealthy"); + noop(); + return; + } + + 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", { + totalTurns: allTurns.length, + previouslyCaptured: state.capturedTurnCount, + 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 { + added = await appendTurns(ovSessionId, newTurns); + state.capturedTurnCount += added; + log("appended", { ovSessionId, added }); + } + } + + 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 { + noop(); + } +} + +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 new file mode 100644 index 0000000000..b14e36dd95 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-recall.mjs @@ -0,0 +1,351 @@ +#!/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["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; + 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, 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?.[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, "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) }); + 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..0d6c532e4b --- /dev/null +++ b/examples/codex-memory-plugin/scripts/config.mjs @@ -0,0 +1,238 @@ +/** + * Shared configuration loader for the Codex OpenViking memory plugin. + * + * 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 + * + * 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. + * + * 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 } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; + +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; + 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 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(raw); + } catch { + process.stderr.write(`[openviking-memory] Invalid config file: ${path}\n`); + return null; + } +} + +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"; +} + +/** + * 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 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; + } + + 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 cliUrl = str(cliFile.url, null); + if (cliUrl) return cliUrl.replace(/\/+$/, ""); + + const server = ovFile.server || {}; + const ovUrl = str(server.url, null); + if (ovUrl) return ovUrl.replace(/\/+$/, ""); + + 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 { 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( + 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, + timeoutMs, + + 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: envBool("OPENVIKING_CAPTURE_ASSISTANT_TURNS") ?? (cx.captureAssistantTurns === true), + captureLastAssistantOnStop: envBool("OPENVIKING_CAPTURE_LAST_ASSISTANT_ON_STOP") ?? (cx.captureLastAssistantOnStop !== false), + + autoCommitOnCompact: envBool("OPENVIKING_AUTO_COMMIT_ON_COMPACT") ?? (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..b982ac62b4 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/pre-compact-capture.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node + +/** + * PreCompact hook for Codex. + * + * 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. + * + * Catch-up: if the transcript has new turns the Stop hook hasn't + * appended yet, we append them before committing. + * + * 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 } 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"); + +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["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; + 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 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 (role === "assistant" && !cfg.captureAssistantTurns) continue; + 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 []; + } +} + +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 null; + state.ovSessionId = created.session_id; + return state.ovSessionId; +} + +async function appendTurns(ovSessionId, turns) { + let appended = 0; + for (const turn of turns) { + 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) { + 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() { + 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; + } + + const state = await loadState(sessionId); + const allTurns = await readTranscriptTurns(transcriptPath); + const newTurns = allTurns.slice(state.capturedTurnCount); + + 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; + } + + 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) { + logError("ensure_ov_session", "failed to create OV session for catch-up"); + noop(); + return; + } + 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) { + log("skip", { stage: "commit", reason: "no OV session for this codex session" }); + await saveState(state); + noop(); + return; + } + + const ovSessionId = state.ovSessionId; + const commit = await fetchJSON( + `/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, + }); + + // 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); + + noop( + `pre-compact commit: ${ovSessionId} → ${extracted} memory item(s) extracted${commit.archived ? " (archived)" : ""}`, + ); +} + +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 new file mode 100644 index 0000000000..5636c31b9b --- /dev/null +++ b/examples/codex-memory-plugin/scripts/runtime-common.mjs @@ -0,0 +1,284 @@ +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"), + 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"), + 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, configRaw, serverRaw] = await Promise.all([ + readFile(paths.sourcePackagePath), + readFile(paths.sourceLockPath), + readFile(paths.sourceConfigPath), + 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(configRaw, 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.runtimeConfigPath, + 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 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); +} + +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/session-start-commit.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.mjs new file mode 100644 index 0000000000..2fbafc731f --- /dev/null +++ b/examples/codex-memory-plugin/scripts/session-start-commit.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +/** + * SessionStart hook for Codex. + * + * 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) + * + * 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). + * + * 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"; +import { createLogger } from "./debug-log.mjs"; +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"); +} + +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["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; + 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({}) }, + ); +} + +/** + * 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 { + 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, activeWindowMs: ACTIVE_WINDOW_MS, idleTtlMs: IDLE_TTL_MS }); + + // 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; skipping commit + sweep"); + noop(); + return; + } + + const now = Date.now(); + const states = await listStates(); + + // ------------------------------------------------------------------------- + // 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; + } + } 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); + } + + // ------------------------------------------------------------------------- + // 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( + `SessionStart(${source}): committed ${totalCommitted} OpenViking session(s) (` + + `heuristic=${heuristicCommitted}, idle=${idleCommitted}), ` + + `${totalExtracted} memory item(s) extracted`, + ); + } else { + noop(); + } +} + +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..da4d89fc54 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/session-state.mjs @@ -0,0 +1,88 @@ +/** + * 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, rename, 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() }; + // 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) { + 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) { + // .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"); + const parsed = JSON.parse(raw); + if (parsed?.codexSessionId) out.push(parsed); + } catch { /* skip */ } + } + return out; + } catch { + return []; + } +} 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..6dae165951 --- /dev/null +++ b/examples/codex-memory-plugin/servers/memory-server.js @@ -0,0 +1,274 @@ +import { createHash } from "node:crypto"; +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"; +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 shared = loadConfig(); +const config = { + 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; + 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) { + // 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); + 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("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"), + 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("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 }) => { + 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("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)) { + 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("openviking_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/setup-helper/install.sh b/examples/codex-memory-plugin/setup-helper/install.sh new file mode 100755 index 0000000000..69d31bf3b2 --- /dev/null +++ b/examples/codex-memory-plugin/setup-helper/install.sh @@ -0,0 +1,157 @@ +#!/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 [ ! -e "$REPO_DIR/.git" ]; then + if [ -e "$REPO_DIR" ]; then + echo "$REPO_DIR exists but is not a git checkout." >&2 + exit 1 + fi + 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 < { - 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 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) } @@ -92,20 +60,37 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -const ovConf = 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 shared = loadConfig() as { + baseUrl: string + apiKey: string + account: string + user: string + agentId: string + timeoutMs: number + recallLimit: number + scoreThreshold: number + configPath: string | null +} 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")), - 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 { @@ -126,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) @@ -284,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"), @@ -310,7 +300,7 @@ server.tool( ) server.tool( - "remember", + "openviking_store", "Store information in OpenViking long-term memory.", { text: z.string().describe("Information to store"), @@ -352,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"), }, @@ -368,7 +358,7 @@ server.tool( ) server.tool( - "health", + "openviking_health", "Check whether the OpenViking server is reachable.", {}, async () => {