diff --git a/examples/codex-memory-plugin/.codex-plugin/plugin.json b/examples/codex-memory-plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..204730969 --- /dev/null +++ b/examples/codex-memory-plugin/.codex-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "openviking-memory", + "description": "Long-term OpenViking memory for Codex. Transparent recall and capture via hooks, plus explicit OpenViking memory tools via MCP.", + "interface": { + "displayName": "OpenViking Memory", + "shortDescription": "Transparent long-term memory for Codex", + "longDescription": "Adds transparent OpenViking recall and capture hooks for Codex, plus explicit OpenViking memory tools for manual store, recall, forget, and health operations.", + "developerName": "OpenViking", + "category": "productivity", + "capabilities": [ + "Long-term memory recall", + "Automatic memory capture", + "Manual memory inspection and deletion" + ], + "websiteURL": "https://github.com/volcengine/OpenViking" + } +} diff --git a/examples/codex-memory-plugin/.mcp.json b/examples/codex-memory-plugin/.mcp.json new file mode 100644 index 000000000..9ddbafde3 --- /dev/null +++ b/examples/codex-memory-plugin/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "openviking-memory": { + "command": "node", + "args": [ + "scripts/start-memory-server.mjs" + ], + "cwd": ".", + "env": { + "OPENVIKING_CONFIG_FILE": "${OPENVIKING_CONFIG_FILE:-}" + } + } + } +} diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md new file mode 100644 index 000000000..0a081493d --- /dev/null +++ b/examples/codex-memory-plugin/README.md @@ -0,0 +1,419 @@ +# OpenViking Memory Plugin for Codex + +OpenViking wants long-term memory to feel automatic. + +This Codex example now follows that model: + +- transparent recall on `UserPromptSubmit` +- transparent capture on `Stop` +- explicit MCP tools only for optional manual OpenViking control + +The default mode is `full`. A configurable `recall_only` mode is also +supported for setups where another system populates OpenViking and the Codex +plugin should act only as the recall/query layer. + +This is still a repo example, not a fully auto-installed Codex marketplace +plugin. Codex currently discovers lifecycle hooks from `hooks.json`, not from a +plugin manifest, so this example ships both the plugin bundle shape and a +one-time hook installer. + +## Architecture + +- `.codex-plugin/plugin.json` + - plugin metadata for future Codex plugin flows +- `.mcp.json` + - optional explicit OpenViking MCP server wiring +- `scripts/auto-recall.mjs` + - transparent recall hook +- `scripts/auto-capture.mjs` + - transparent capture hook that enqueues background writes +- `scripts/capture-worker.mjs` + - background worker for OpenViking writes +- `scripts/start-memory-server.mjs` + - launches the built MCP server from the bootstrapped runtime +- `scripts/install-codex-hooks.mjs` + - writes managed hook entries into `~/.codex/hooks.json` +- `src/memory-server.ts` + - explicit/manual OpenViking tools for store, recall, forget, and health + +## How It Works + +### 1. Transparent recall + +On every `UserPromptSubmit`, the hook: + +- skips obvious write/update prompts +- checks the local latest-fact index first +- checks OpenViking health +- searches `viking://user/memories` and `viking://agent/memories` +- optionally searches `viking://agent/skills` +- reranks and dedupes results +- injects at most one compact memory sentence into Codex context +- marks returned URIs as `used()` and commits that feedback asynchronously + +### 2. Transparent capture + +In `full` mode, on every `Stop`, the hook: + +- parses the Codex transcript +- captures only new turns since the last successful pass +- captures user turns only by default +- strips previously injected memory context +- keeps only durable-fact style captures by default +- updates a local latest-fact index immediately +- enqueues the OpenViking write into a background worker +- fails open if OpenViking is down or slow + +### 3. Manual MCP tools + +The MCP server remains available for explicit/manual operations, but it is not +part of the default happy path: + +- `openviking_recall` +- `openviking_store` +- `openviking_forget` +- `openviking_health` + +These are explicitly OpenViking operations. They are not Codex-native local +memory. + +### 4. Plugin modes + +- `full` + - transparent recall enabled + - transparent capture enabled + - manual store/delete tools enabled +- `recall_only` + - transparent recall enabled + - transparent capture disabled + - manual `openviking_store` and `openviking_forget` disabled + - manual `openviking_recall` and `openviking_health` still available + +## Install + +Prerequisites: + +- Codex CLI +- OpenViking server +- Node.js 22+ + +Build the example: + +```bash +cd examples/codex-memory-plugin +npm ci +npm run build +``` + +Install the lifecycle hooks: + +```bash +cd examples/codex-memory-plugin +npm run install:hooks +``` + +The installer writes managed entries into the active `CODEX_HOME`. Set +`CODEX_HOME=/path/to/test-home` first if you want to test without touching your +default Codex home. Rerun the installer after changing plugin mode so the +managed hook set matches the current config. + +Verify the hook-first path in a new Codex session: + +```bash +codex --no-alt-screen -C /tmp +``` + +Then say something like: + +```text +For future reference, my weird constellation codeword is comet-saffron-demo-20260408. +``` + +Exit, start a fresh session, and ask: + +```text +what is my weird constellation codeword? +``` + +The second session should answer with the stored codeword directly. + +Optional manual MCP install: + +```bash +codex mcp add openviking-memory -- \ + node /ABS/PATH/TO/OpenViking/examples/codex-memory-plugin/scripts/start-memory-server.mjs +``` + +Remove the example later if needed: + +```bash +cd examples/codex-memory-plugin +npm run uninstall:hooks +``` + +If you installed the optional manual MCP layer, also remove it: + +```bash +codex mcp remove openviking-memory +``` + +## Current Codex Limitation + +The repo now includes a real Codex plugin bundle shape: + +- `.codex-plugin/plugin.json` +- `.mcp.json` +- `hooks/hooks.json` + +But Codex does not yet auto-discover lifecycle hooks from the plugin manifest in +this example flow. Hook discovery still happens from `~/.codex/hooks.json`, so +the hook installer is the practical bridge today. + +That means setup is currently: + +1. install the hooks for the default experience +2. optionally add the MCP server for manual inspection/control + +The product intent is still transparent memory first. + +## Config + +The example reads OpenViking connection details from `~/.openviking/ov.conf`. +Codex-specific overrides live in a sidecar plugin config file at: + +`~/.openviking/codex-memory-plugin/config.json` + +Example: + +```json +{ + "mode": "full", + "agentId": "codex", + "timeoutMs": 15000, + "autoRecall": true, + "recallLimit": 1, + "scoreThreshold": 0.01, + "minQueryLength": 3, + "searchAgentSkills": false, + "skipRecallOnWritePrompts": true, + "maxInjectedMemories": 1, + "preferPromptLanguage": true, + "autoCapture": true, + "captureMode": "durable-facts", + "captureDispatch": "background", + "captureMaxLength": 24000, + "captureTimeoutMs": 30000, + "captureAssistantTurns": false, + "debug": false, + "logRankingDetails": false +} +``` + +`mode` is the canonical lifecycle setting: + +- `full` + - installs `UserPromptSubmit` and, when capture is enabled, `Stop` + - allows plugin-originated capture and manual store/delete +- `recall_only` + - installs `UserPromptSubmit` only + - disables plugin-originated capture and manual store/delete + +`autoCapture` can still be used as an extra local override inside `full`, but +`recall_only` always disables write paths. + +This is separate from `ov.conf` because the current OpenViking server config +schema rejects unknown top-level plugin sections. + +Useful env overrides: + +- `OPENVIKING_CONFIG_FILE` +- `OPENVIKING_CODEX_CONFIG_FILE` +- `OPENVIKING_AGENT_ID` +- `OPENVIKING_TIMEOUT_MS` +- `OPENVIKING_RECALL_LIMIT` +- `OPENVIKING_SCORE_THRESHOLD` +- `OPENVIKING_CODEX_PLUGIN_HOME` +- `OPENVIKING_DEBUG` +- `OPENVIKING_DEBUG_LOG` + +Keep the `UserPromptSubmit` hook timeout above your effective OpenViking recall +budget. With `thinking` plus rerank enabled, a 30 second hook timeout is a +safer default than a single-digit timeout. + +Runtime and state paths: + +- runtime: `~/.openviking/codex-memory-plugin/runtime` +- queue: `~/.openviking/codex-memory-plugin/queue` +- capture state: `~/.openviking/codex-memory-plugin/state` +- debug log: `~/.openviking/logs/codex-hooks.log` + +## Flow + +```mermaid +sequenceDiagram + participant C as Codex + participant H as Codex hooks + participant O as OpenViking + participant W as Capture worker + + rect rgb(245, 248, 255) + Note over C,H: default path + C->>H: UserPromptSubmit + H->>H: skip recall on write/update prompts + H->>H: check latest local fact index + H->>O: search memories + O-->>H: ranked candidates + H-->>C: compact additional_context sentence + end + + Note over H,O: fire-and-forget recall feedback + H->>O: create session + H->>O: used(context URIs) + H->>O: commit + H->>O: delete session + + C->>H: Stop + H->>H: parse transcript incrementally + H->>H: durable-fact capture check + H->>H: update local latest-fact index + H->>W: enqueue background job + W->>O: create session + W->>O: add messages + W->>O: commit with task polling + W->>O: delete session + + participant M as Optional MCP server + C->>M: openviking_store / recall / forget / health + M->>O: explicit manual OpenViking operations +``` + +## Manual Tool Semantics + +`openviking_recall` + +- manual inspection or forced recall +- returns compact recalled memory text plus scored summaries + +`openviking_store` + +- explicit durable write into OpenViking +- uses `session.commit()` plus task polling +- returns extracted count and, when recoverable, likely stored memory URIs +- disabled in `recall_only` + +`openviking_forget` + +- explicit deletion or correction path +- direct URI delete is supported +- query mode auto-deletes only a single strong match +- otherwise it returns candidate URIs for confirmation +- disabled in `recall_only` + +`openviking_health` + +- direct connectivity probe for the OpenViking server + +## Native Codex Memory + +This example is compatible with Codex native memory staying enabled. + +The intended boundary is: + +- Codex native memory: local implicit adaptation +- OpenViking plugin: transparent long-term recall/capture plus explicit manual memory control + +If the model needs to inspect, store, or delete persistent OpenViking memory +explicitly, it should use the OpenViking MCP tools, not rely on ambiguous +native-memory phrasing. + +## Current UX Limitation + +This plugin-only example can remove plugin-branded labels and raw memory dumps, +but Codex still shows generic hook start/completion rows in the TUI. That is a +Codex UI constraint, not an OpenViking plugin choice. + +## Verification + +Minimum end-to-end check: + +```bash +codex --no-alt-screen -C /tmp +``` + +Inside Codex: + +```text +For future reference, my weird constellation codeword is opal-squid-radar-demo-20260408. +``` + +Exit, start a fresh session, and ask: + +```text +what is my weird constellation codeword? +``` + +Expected result: + +1. the first session stores the codeword without showing raw hook context +2. the second session answers `opal-squid-radar-demo-20260408` +3. Codex may still show generic `hook: UserPromptSubmit` and `hook: Stop` rows +4. Codex should not show plugin-branded labels, XML memory wrappers, or raw memory dumps + +For deterministic QA, prefer a unique nonsense phrase such as +`opal-squid-radar-demo-20260408` over broad phrases like "amber tea". + +`recall_only` verification: + +1. set `"mode": "recall_only"` in `~/.openviking/codex-memory-plugin/config.json` +2. rerun `npm run install:hooks` +3. confirm `~/.codex/hooks.json` contains `UserPromptSubmit` but not `Stop` +4. verify a preexisting OpenViking memory is recalled in a fresh Codex session +5. verify `openviking_store` and `openviking_forget` return a mode-based rejection + +Optional manual MCP verification: + +```bash +codex mcp add openviking-memory -- \ + node /ABS/PATH/TO/OpenViking/examples/codex-memory-plugin/scripts/start-memory-server.mjs +``` + +Then in Codex: + +```text +Use the external MCP tool `openviking_health` exactly once right now, then return only the tool result. +``` + +If `openviking_store` returns `0` extracted memories or times out, the MCP and +hook wiring may still be correct. That result usually means the backing +OpenViking extraction pipeline is unhealthy, overloaded, or misconfigured. + +## Known Limitations + +- Hook install is still explicit because Codex currently reads lifecycle hooks + from `~/.codex/hooks.json`, not from the plugin manifest in this example flow. +- The MCP server install is still explicit via `codex mcp add`. +- `recall_only` is intentionally strict: the plugin will not create, mutate, or + delete OpenViking memory in that mode, even through manual store/delete tools. +- `openviking_store` returns likely stored URIs when it can recover them from a + follow-up search, but OpenViking does not currently return created memory URIs + directly from `session.commit()`. +- Successful extraction still depends on the backing OpenViking server. On a + slow or unhealthy server, explicit store can return `0` extracted memories or + a timeout even when the Codex integration itself is working. +- `openviking_forget` query mode intentionally falls back to candidate URIs when + there is not exactly one strong match. +- If OpenViking is unavailable, the hooks fail open and Codex remains usable, + but automatic memory behavior is skipped for that turn. + +## Troubleshooting + +- `OpenViking is unreachable` + - verify `openviking-server` is running at the host and port in `ov.conf` +- transparent recall/capture not happening + - run `npm run install:hooks` again + - inspect `~/.codex/hooks.json` + - enable `OPENVIKING_DEBUG=1` or `codex.debug: true` + - inspect `~/.openviking/logs/codex-hooks.log` +- MCP server startup fails + - rebuild with `npm ci && npm run build` + - remove and re-add the optional MCP server diff --git a/examples/codex-memory-plugin/hooks/hooks.json b/examples/codex-memory-plugin/hooks/hooks.json new file mode 100644 index 000000000..858a9133d --- /dev/null +++ b/examples/codex-memory-plugin/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node '__OPENVIKING_CODEX_PLUGIN_ROOT__/scripts/auto-recall.mjs'", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node '__OPENVIKING_CODEX_PLUGIN_ROOT__/scripts/auto-capture.mjs'", + "timeout": 45 + } + ] + } + ] + } +} diff --git a/examples/codex-memory-plugin/package-lock.json b/examples/codex-memory-plugin/package-lock.json new file mode 100644 index 000000000..75ce986d8 --- /dev/null +++ b/examples/codex-memory-plugin/package-lock.json @@ -0,0 +1,1174 @@ +{ + "name": "codex-openviking-memory", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-openviking-memory", + "version": "0.1.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.11", + "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "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.15", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.18.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.0.1", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.6", + "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/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.3.1", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.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.npmmirror.com/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.0", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.3.0", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/examples/codex-memory-plugin/package.json b/examples/codex-memory-plugin/package.json new file mode 100644 index 000000000..0f8ce6a3d --- /dev/null +++ b/examples/codex-memory-plugin/package.json @@ -0,0 +1,20 @@ +{ + "name": "codex-openviking-memory", + "version": "0.1.0", + "description": "OpenViking memory plugin for Codex — semantic long-term memory via MCP", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "install:hooks": "node ./scripts/install-codex-hooks.mjs", + "uninstall:hooks": "node ./scripts/uninstall-codex-hooks.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} 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 000000000..dc2753588 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node + +import { readFile, writeFile, mkdir } from "node:fs/promises" +import { join } from "node:path" +import { loadConfig } from "./config.mjs" +import { createLogger } from "./debug-log.mjs" +import { enqueueCaptureJob, kickCaptureWorker, seedFactIndex, drainCaptureQueue } from "./capture-queue.mjs" +import { inferFactFromText } from "./fact-index.mjs" +import { buildHookDedupeKey, claimHookInvocation } from "./hook-dedupe.mjs" + +const cfg = loadConfig() +const { log, logError } = createLogger("auto-capture") + +function output(value) { + process.stdout.write(`${JSON.stringify(value)}\n`) +} + +function approve(systemMessage) { + const out = { continue: true } + output(out) +} + +function stateFilePath(sessionId) { + const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_") + return join(cfg.captureStateDir, `${safe}.json`) +} + +async function loadState(sessionId) { + try { + return JSON.parse(await readFile(stateFilePath(sessionId), "utf-8")) + } catch { + return { capturedTurnCount: 0 } + } +} + +async function saveState(sessionId, state) { + try { + await mkdir(cfg.captureStateDir, { recursive: true }) + await writeFile(stateFilePath(sessionId), JSON.stringify(state)) + } catch {} +} + +const MEMORY_TRIGGERS = [ + /for future reference/i, + /remember this/i, + /remember|preference|prefer|important|decision|decided|always|never/i, + /记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i, + /[\w.-]+@[\w.-]+\.\w+/, + /\+\d{10,}/, + /(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i, + /(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i, + /(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i, +] + +const RELEVANT_MEMORIES_BLOCK_RE = /[\s\S]*?<\/relevant-memories>/gi +const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i +const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u +const CJK_CHAR_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/ + +function sanitize(text) { + return text + .replace(RELEVANT_MEMORIES_BLOCK_RE, " ") + .replace(/\u0000/g, "") + .replace(/\s+/g, " ") + .trim() +} + +function shouldCapture(text) { + const normalized = sanitize(text) + if (!normalized) return { capture: false, reason: "empty", text: "" } + + const compact = normalized.replace(/\s+/g, "") + const minLen = CJK_CHAR_RE.test(compact) ? 4 : 10 + if (compact.length < minLen || normalized.length > cfg.captureMaxLength) { + return { capture: false, reason: "length_out_of_range", text: normalized } + } + + if (COMMAND_TEXT_RE.test(normalized)) { + return { capture: false, reason: "command", text: normalized } + } + + if (NON_CONTENT_TEXT_RE.test(normalized)) { + return { capture: false, reason: "non_content", text: normalized } + } + + const inferredFact = inferFactFromText(normalized) + + if (cfg.captureMode === "keyword") { + for (const trigger of MEMORY_TRIGGERS) { + if (trigger.test(normalized)) { + return { capture: true, reason: `trigger:${trigger}`, text: normalized, fact: inferredFact } + } + } + return { capture: false, reason: "no_trigger", text: normalized } + } + + if (cfg.captureMode === "semantic") { + return { capture: true, reason: "semantic", text: normalized, fact: inferredFact } + } + + if (inferredFact) { + return { capture: true, reason: "fact_pattern", text: normalized, fact: inferredFact } + } + + for (const trigger of MEMORY_TRIGGERS) { + if (trigger.test(normalized)) { + return { capture: true, reason: `durable_trigger:${trigger}`, text: normalized, fact: inferredFact } + } + } + + return { capture: false, reason: "not_durable", text: normalized } +} + +function parseTranscript(content) { + try { + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) return parsed + } catch {} + + const lines = content.split("\n").filter((line) => line.trim()) + const messages = [] + for (const line of lines) { + try { + messages.push(JSON.parse(line)) + } catch {} + } + return messages +} + +function extractAllTurns(messages) { + const turns = [] + for (const message of messages) { + if (!message || typeof message !== "object") continue + + if (message.type === "response_item" && message.payload && typeof message.payload === "object") { + const payload = message.payload + if (payload.type === "message" && payload.role === "assistant") { + const content = Array.isArray(payload.content) ? payload.content : [] + const text = content + .filter((block) => + (block?.type === "input_text" || block?.type === "output_text") && typeof block.text === "string") + .map((block) => block.text) + .join("\n") + .trim() + if (text) turns.push({ role: payload.role, text }) + } + continue + } + + if (message.type === "event_msg" && message.payload && typeof message.payload === "object") { + const payload = message.payload + if (payload.type === "user_message" && typeof payload.message === "string" && payload.message.trim()) { + turns.push({ role: "user", text: payload.message.trim() }) + } + if (payload.type === "agent_message" && typeof payload.message === "string" && payload.message.trim()) { + turns.push({ role: "assistant", text: payload.message.trim() }) + } + continue + } + + let role = message.role + let text = "" + + if (typeof message.content === "string") { + text = message.content + } else if (Array.isArray(message.content)) { + text = message.content + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n") + } else if (typeof message.message === "object" && message.message) { + role = message.message.role || role + if (typeof message.message.content === "string") { + text = message.message.content + } else if (Array.isArray(message.message.content)) { + text = message.message.content + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n") + } + } + + if ((role === "user" || role === "assistant") && text.trim()) { + turns.push({ role, text: text.trim() }) + } + } + return turns +} + +async function main() { + if (!cfg.autoCapture) { + log("skip", { + stage: "init", + reason: cfg.mode === "recall_only" ? "recall_only mode" : "autoCapture disabled", + }) + approve() + 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" }) + approve() + return + } + + const transcriptPath = input.transcript_path + const sessionId = input.session_id || "unknown" + const turnId = input.turn_id || "" + const dedupeKey = buildHookDedupeKey("Stop", input) + log("start", { sessionId, turnId, transcriptPath }) + + let claimed + try { + claimed = await claimHookInvocation(cfg.hookDedupeDir, dedupeKey, { + eventName: "Stop", + sessionId, + turnId, + transcriptPath, + }) + } catch (err) { + logError("dedupe_claim", err) + approve() + return + } + if (!claimed) { + log("skip", { stage: "dedupe", reason: "already_processed", sessionId, turnId, dedupeKey }) + return + } + + if (!transcriptPath) { + log("skip", { stage: "input_check", reason: "no transcript_path" }) + approve() + return + } + + let transcriptContent + try { + transcriptContent = await readFile(transcriptPath, "utf-8") + } catch (err) { + logError("transcript_read", err) + approve() + return + } + + if (!transcriptContent.trim()) { + log("skip", { stage: "transcript_read", reason: "empty transcript" }) + approve() + return + } + + const messages = parseTranscript(transcriptContent) + const allTurns = extractAllTurns(messages) + if (allTurns.length === 0) { + log("skip", { stage: "transcript_parse", reason: "no user/assistant turns found" }) + approve() + return + } + + const state = await loadState(sessionId) + const newTurns = allTurns.slice(state.capturedTurnCount) + const captureTurns = cfg.captureAssistantTurns + ? newTurns + : newTurns.filter((turn) => turn.role === "user") + + log("transcript_parse", { + totalTurns: allTurns.length, + previouslyCaptured: state.capturedTurnCount, + newTurns: newTurns.length, + captureTurns: captureTurns.length, + assistantTurnsSkipped: newTurns.length - captureTurns.length, + }) + + if (newTurns.length === 0) { + log("skip", { stage: "incremental_check", reason: "no new turns" }) + approve() + return + } + + if (captureTurns.length === 0) { + await saveState(sessionId, { capturedTurnCount: allTurns.length }) + approve() + return + } + + const turnText = cfg.captureAssistantTurns + ? captureTurns.map((turn) => `[${turn.role}]: ${turn.text}`).join("\n") + : captureTurns.map((turn) => turn.text).join("\n\n") + const decision = shouldCapture(turnText) + log("should_capture", { + capture: decision.capture, + reason: decision.reason, + textPreview: decision.text.slice(0, 100), + factFamily: decision.fact?.familyKey || null, + }) + + if (!decision.capture) { + await saveState(sessionId, { capturedTurnCount: allTurns.length }) + approve() + return + } + + const fact = decision.fact + ? { + ...decision.fact, + sourceText: decision.text, + sessionId, + } + : null + + if (fact) { + await seedFactIndex(cfg, fact).catch((err) => logError("fact_seed", err)) + } + + const job = { + sessionId, + text: decision.text, + fact, + capturedTurnCount: allTurns.length, + } + await enqueueCaptureJob(cfg, job) + await saveState(sessionId, { capturedTurnCount: allTurns.length }) + + if (cfg.captureDispatch === "inline") { + await drainCaptureQueue(cfg, log, logError) + } else { + kickCaptureWorker() + } + + approve() +} + +main().catch((err) => { + logError("uncaught", err) + approve() +}) 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 000000000..0ea7a348c --- /dev/null +++ b/examples/codex-memory-plugin/scripts/auto-recall.mjs @@ -0,0 +1,487 @@ +#!/usr/bin/env node + +import { loadConfig } from "./config.mjs" +import { createLogger } from "./debug-log.mjs" +import { findFactForPrompt } from "./fact-index.mjs" +import { buildHookDedupeKey, claimHookInvocation } from "./hook-dedupe.mjs" + +const cfg = loadConfig() +const { log, logError } = createLogger("auto-recall") + +function output(value) { + process.stdout.write(`${JSON.stringify(value)}\n`) +} + +function approve(additionalContext) { + const out = { continue: true } + if (additionalContext) { + out.hookSpecificOutput = { + hookEventName: "UserPromptSubmit", + additionalContext, + } + } + output(out) +} + +async function fetchJSON(path, init = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), cfg.timeoutMs) + try { + const headers = { "Content-Type": "application/json" } + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId + const response = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }) + const body = await response.json().catch(() => null) + if (!response.ok || !body || body.status === "error") return null + return body.result ?? body + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +function clampScore(value) { + if (typeof value !== "number" || Number.isNaN(value)) return 0 + return Math.max(0, Math.min(1, value)) +} + +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\u4e00-\u9fa5]{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", "my", "do", "not", "use", "any", + "just", "answer", "reply", "directly", "future", "reference", "please", + "tool", "tools", "without", "external", "once", "right", "now", +]) +const MAX_INJECTED_ITEMS = 3 +const MAX_SUMMARY_CHARS = 220 +const OVERVIEW_URI_RE = /\/\.(?:overview|abstract)\.md$/i +const MEMORY_DOC_URI_RE = /\/mem_[^/]+\.md$/i +const CJK_CHAR_RE = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/ +const WRITE_LIKE_PROMPT_RE = /for future reference|remember this|store this|save this|my [a-z0-9 _-]{2,80} is |update (?:my|the)|correct (?:my|the)|replace (?:my|the)|from now on/i + +function buildQueryProfile(query) { + const text = query + .replace(/^do not use any tools?\.\s*/i, "") + .replace(/^just (?:answer|reply) directly[:.]?\s*/i, "") + .replace(/^for future reference,?\s*/i, "") + .trim() + const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || [] + const tokens = allTokens.filter((token) => !STOPWORDS.has(token)) + 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 baseScore = clampScore(item.score) + const abstract = (item.abstract || item.overview || "").trim() + const category = (item.category || "").toLowerCase() + const uri = item.uri.toLowerCase() + const leafBoost = (item.level === 2 || uri.endsWith(".md")) ? 0.12 : 0 + const eventBoost = profile.wantsTemporal && (category === "events" || uri.includes("/events/")) ? 0.1 : 0 + const preferenceBoost = profile.wantsPreference && (category === "preferences" || uri.includes("/preferences/")) ? 0.08 : 0 + const overlapBoost = lexicalOverlapBoost(profile.tokens, `${item.uri} ${abstract}`) + return { + baseScore, + leafBoost, + eventBoost, + preferenceBoost, + overlapBoost, + finalScore: baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost, + } +} + +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 isLeafMemoryItem(item) { + if (!item?.uri) return false + if (OVERVIEW_URI_RE.test(item.uri)) return false + return item.level === 2 && MEMORY_DOC_URI_RE.test(item.uri) +} + +function trimSummary(text) { + const normalized = String(text || "") + .replace(/\s+/g, " ") + .trim() + if (!normalized) return "" + if (normalized.length <= MAX_SUMMARY_CHARS) return normalized + return `${normalized.slice(0, MAX_SUMMARY_CHARS - 1).trim()}…` +} + +function mostlyCjk(text) { + const sample = String(text || "").replace(/\s+/g, "") + if (!sample) return false + let cjk = 0 + for (const char of sample) { + if (CJK_CHAR_RE.test(char)) cjk += 1 + } + return cjk / sample.length >= 0.4 +} + +function promptPrefersEnglish(prompt) { + return !CJK_CHAR_RE.test(prompt) +} + +function isWriteLikePrompt(prompt) { + const normalized = String(prompt || "").trim() + if (!normalized) return false + if (/\?$/.test(normalized)) return false + return WRITE_LIKE_PROMPT_RE.test(normalized) +} + +function filterNearTop(items) { + if (items.length === 0) return [] + const sorted = [...items].sort((left, right) => clampScore(right.score) - clampScore(left.score)) + const topScore = clampScore(sorted[0]?.score) + const cutoff = topScore >= 0.6 ? topScore - 0.18 : Math.max(0.2, topScore * 0.75) + return sorted.filter((item) => clampScore(item.score) >= cutoff) +} + +function hasMeaningfulOverlap(item, profile) { + if (!item) return false + if (profile.tokens.length === 0) return true + return lexicalOverlapBoost(profile.tokens, `${item.uri} ${item.abstract || item.overview || ""}`) > 0 +} + +function postProcess(items, limit, threshold) { + const seen = new Set() + const sorted = [...items].sort((left, right) => clampScore(right.score) - clampScore(left.score)) + const results = [] + for (const item of sorted) { + if (clampScore(item.score) < threshold) continue + const category = (item.category || "").toLowerCase() || "unknown" + const abstract = (item.abstract || item.overview || "").trim().toLowerCase() + const key = abstract ? `${category}:${abstract}` : `uri:${item.uri}` + if (seen.has(key)) continue + seen.add(key) + results.push(item) + if (results.length >= limit) break + } + return results +} + +function pickItemsForInjection(items, limit, queryText) { + if (items.length === 0 || limit <= 0) return [] + const profile = buildQueryProfile(queryText) + const sorted = [...items] + .map((item) => ({ item, breakdown: getRankingBreakdown(item, profile) })) + .sort((left, right) => right.breakdown.finalScore - left.breakdown.finalScore) + .map((entry) => entry.item) + const deduped = dedupeByAbstract(sorted) + const leafCandidates = filterNearTop(deduped.filter((item) => isLeafMemoryItem(item))) + const overlappingLeaves = leafCandidates.filter((item) => hasMeaningfulOverlap(item, profile)) + const leaves = overlappingLeaves.length > 0 ? overlappingLeaves : leafCandidates.slice(0, 1) + if (leaves.length >= limit) return leaves.slice(0, limit) + + const picked = [...leaves] + const used = new Set(picked.map((item) => item.uri)) + for (const item of deduped) { + if (picked.length >= limit) break + if (used.has(item.uri)) continue + if (!isLeafMemoryItem(item)) continue + if (!hasMeaningfulOverlap(item, profile)) continue + picked.push(item) + } + return picked +} + +const USER_RESERVED_DIRS = new Set(["memories"]) +const AGENT_RESERVED_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]) +const resolvedSpaces = {} + +async function resolveScopeSpace(scope) { + if (resolvedSpaces[scope]) return resolvedSpaces[scope] + + let fallbackSpace = "default" + const status = await fetchJSON("/api/v1/system/status") + if (status && typeof status.user === "string" && status.user.trim()) { + fallbackSpace = status.user.trim() + } + + const reserved = scope === "user" ? USER_RESERVED_DIRS : AGENT_RESERVED_DIRS + const entries = await fetchJSON(`/api/v1/fs/ls?uri=${encodeURIComponent(`viking://${scope}`)}&output=original`) + if (Array.isArray(entries)) { + const spaces = entries + .filter((entry) => entry?.isDir) + .map((entry) => typeof entry.name === "string" ? entry.name.trim() : "") + .filter((name) => name && !name.startsWith(".") && !reserved.has(name)) + + if (spaces.length > 0) { + if (spaces.includes(fallbackSpace)) { + resolvedSpaces[scope] = fallbackSpace + return fallbackSpace + } + if (scope === "user" && spaces.includes("default")) { + resolvedSpaces[scope] = "default" + return "default" + } + if (spaces.length === 1) { + resolvedSpaces[scope] = spaces[0] + return spaces[0] + } + } + } + + resolvedSpaces[scope] = fallbackSpace + return fallbackSpace +} + +async function resolveTargetUri(targetUri) { + const trimmed = targetUri.trim().replace(/\/+$/, "") + const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/) + if (!match) return trimmed + + const scope = match[1] + const rawRest = (match[2] || "").trim() + if (!rawRest) return trimmed + + const parts = rawRest.split("/").filter(Boolean) + if (parts.length === 0) return trimmed + + const reserved = scope === "user" ? USER_RESERVED_DIRS : AGENT_RESERVED_DIRS + if (!reserved.has(parts[0])) return trimmed + + const space = await resolveScopeSpace(scope) + return `viking://${scope}/${space}/${parts.join("/")}` +} + +async function searchScope(query, targetUri, limit) { + const resolvedUri = await resolveTargetUri(targetUri) + const result = await fetchJSON("/api/v1/search/find", { + method: "POST", + body: JSON.stringify({ + query, + target_uri: resolvedUri, + limit, + score_threshold: 0, + }), + }) + + if (!result) return [] + const collections = [result.memories, result.resources, result.skills] + return collections.flatMap((items) => Array.isArray(items) ? items : []) +} + +async function searchOpenViking(query, limit) { + const targets = [ + "viking://user/memories", + "viking://agent/memories", + ] + + if (cfg.searchAgentSkills) { + targets.push("viking://agent/skills") + } + + const settled = await Promise.allSettled(targets.map((target) => searchScope(query, target, limit))) + const all = settled + .filter((result) => result.status === "fulfilled") + .flatMap((result) => result.value) + + const seen = new Set() + return all.filter((item) => { + if (!item?.uri || seen.has(item.uri)) return false + seen.add(item.uri) + return true + }) +} + +async function readContent(uri) { + const result = await fetchJSON(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`) + if (typeof result === "string" && result.trim()) return result.trim() + return null +} + +async function markUsed(contexts) { + const unique = [...new Set(contexts.filter((uri) => typeof uri === "string" && uri))] + if (unique.length === 0) return + + try { + const created = await fetchJSON("/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }) + const sessionId = created?.session_id + if (!sessionId) return + + try { + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(sessionId)}/used`, { + method: "POST", + body: JSON.stringify({ contexts: unique }), + }) + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { + method: "POST", + body: JSON.stringify({}), + }) + } finally { + await fetchJSON(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }).catch(() => {}) + } + } catch {} +} + +async function main() { + if (!cfg.autoRecall) { + log("skip", { stage: "init", reason: "autoRecall disabled" }) + approve() + 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" }) + approve() + return + } + + const prompt = (input.prompt || "").trim() + const sessionId = input.session_id || "unknown" + const turnId = input.turn_id || "" + const dedupeKey = buildHookDedupeKey("UserPromptSubmit", input) + log("start", { + sessionId, + turnId, + query: prompt.slice(0, 200), + queryLength: prompt.length, + config: { + recallLimit: cfg.recallLimit, + scoreThreshold: cfg.scoreThreshold, + searchAgentSkills: cfg.searchAgentSkills, + }, + }) + + let claimed + try { + claimed = await claimHookInvocation(cfg.hookDedupeDir, dedupeKey, { + eventName: "UserPromptSubmit", + sessionId, + turnId, + prompt: prompt.slice(0, 500), + }) + } catch (err) { + logError("dedupe_claim", err) + approve() + return + } + if (!claimed) { + log("skip", { stage: "dedupe", reason: "already_processed", sessionId, turnId, dedupeKey }) + return + } + + if (!prompt || prompt.length < cfg.minQueryLength) { + log("skip", { stage: "query_check", reason: "query too short or empty" }) + approve() + return + } + + if (cfg.skipRecallOnWritePrompts && isWriteLikePrompt(prompt)) { + log("skip", { stage: "query_check", reason: "write_like_prompt" }) + approve() + return + } + + const indexedFact = await findFactForPrompt(cfg.factsPath, prompt).catch(() => null) + if (indexedFact?.sentence) { + log("done", { + selectedCount: 1, + injectedCount: 1, + source: "fact_index", + familyKey: indexedFact.familyKey, + }) + approve(`Relevant OpenViking memory: ${indexedFact.sentence}.`) + return + } + + const health = await fetchJSON("/health") + if (!health) { + logError("health_check", "server unreachable or unhealthy") + approve() + return + } + + const candidateLimit = Math.max(cfg.recallLimit * 4, 20) + const allItems = await searchOpenViking(prompt, candidateLimit) + if (allItems.length === 0) { + log("skip", { stage: "search", reason: "no results from any scope" }) + approve() + return + } + + const processed = postProcess(allItems, candidateLimit, cfg.scoreThreshold) + const picked = pickItemsForInjection( + processed, + Math.min(cfg.recallLimit, cfg.maxInjectedMemories, MAX_INJECTED_ITEMS), + prompt, + ) + if (picked.length === 0) { + log("skip", { stage: "post_process", reason: "no items survived thresholding" }) + approve() + return + } + + const englishOnly = promptPrefersEnglish(prompt) + const rawLines = await Promise.all( + picked.map(async (item) => { + const full = await readContent(item.uri).catch(() => null) + const summary = trimSummary(full || item.abstract || item.overview || "") + if (!summary) return null + if (cfg.preferPromptLanguage && englishOnly && mostlyCjk(summary)) return null + return summary + }), + ) + + const lines = rawLines + .filter(Boolean) + + if (lines.length === 0) { + log("skip", { stage: "format", reason: "no concise summaries survived filtering" }) + approve() + return + } + + void markUsed(picked.map((item) => item.uri)) + + const additionalContext = lines.length === 1 + ? `Relevant OpenViking memory: ${lines[0]}` + : `Relevant OpenViking memories:\n${lines.map((line) => `- ${line}`).join("\n")}` + + log("done", { + selectedCount: picked.length, + injectedCount: lines.length, + uris: picked.map((item) => item.uri), + }) + approve(additionalContext) +} + +main().catch((err) => { + logError("uncaught", err) + approve() +}) 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 000000000..67c300c88 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs @@ -0,0 +1,25 @@ +import { + computeSourceState, + ensureRuntimeInstalled, + getRuntimePaths, +} from "./runtime-common.mjs" + +async function main() { + const paths = getRuntimePaths() + const expectedState = await computeSourceState(paths) + + try { + await ensureRuntimeInstalled(paths, expectedState) + } catch (err) { + process.stderr.write( + `[openviking-memory] Failed to prepare Codex 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/capture-queue.mjs b/examples/codex-memory-plugin/scripts/capture-queue.mjs new file mode 100644 index 000000000..4d390d005 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/capture-queue.mjs @@ -0,0 +1,224 @@ +import { spawn } from "node:child_process" +import { mkdir, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises" +import { join, resolve as resolvePath } from "node:path" +import { fileURLToPath } from "node:url" +import { upsertFact } from "./fact-index.mjs" + +const LOCK_STALE_MS = 10 * 60 * 1000 + +function queueJobsDir(cfg) { + return join(cfg.captureQueueDir, "jobs") +} + +function workerLockDir(cfg) { + return join(cfg.captureQueueDir, "worker.lock") +} + +function workerScriptPath() { + return resolvePath(fileURLToPath(new URL("./capture-worker.mjs", import.meta.url))) +} + +export async function enqueueCaptureJob(cfg, job) { + await mkdir(queueJobsDir(cfg), { recursive: true }) + const id = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}` + const path = join(queueJobsDir(cfg), `${id}.json`) + await writeFile(path, `${JSON.stringify({ ...job, id }, null, 2)}\n`) + return path +} + +export async function seedFactIndex(cfg, fact) { + if (!fact) return + await upsertFact(cfg.factsPath, { + ...fact, + status: "pending", + updatedAt: new Date().toISOString(), + }) +} + +export function kickCaptureWorker() { + const child = spawn(process.execPath, [workerScriptPath()], { + detached: true, + stdio: "ignore", + env: process.env, + }) + child.unref() +} + +export async function drainCaptureQueue(cfg, log = () => {}, logError = () => {}) { + const release = await acquireWorkerLock(cfg) + if (!release) return false + + try { + while (true) { + const next = await nextJobPath(cfg) + if (!next) break + await processJobFile(cfg, next, log, logError) + } + return true + } finally { + await release() + } +} + +async function nextJobPath(cfg) { + try { + const dir = queueJobsDir(cfg) + const files = (await readdir(dir)) + .filter((name) => name.endsWith(".json")) + .sort() + if (files.length === 0) return null + return join(dir, files[0]) + } catch { + return null + } +} + +async function processJobFile(cfg, jobPath, log, logError) { + let job + try { + job = JSON.parse(await readFile(jobPath, "utf-8")) + } catch (err) { + logError("queue_read", err) + await unlink(jobPath).catch(() => {}) + return + } + + if (!cfg.allowMemoryWrites) { + log("queue_skip", { + id: job.id, + sessionId: job.sessionId, + reason: `mode=${cfg.mode}`, + }) + await unlink(jobPath).catch(() => {}) + return + } + + const result = await captureToOpenViking(cfg, job.text) + log("queue_capture", { + id: job.id, + sessionId: job.sessionId, + result, + }) + + if (job.fact) { + await upsertFact(cfg.factsPath, { + ...job.fact, + status: result.ok ? "confirmed" : "failed", + updatedAt: new Date().toISOString(), + }).catch((err) => logError("fact_upsert", err)) + } + + await unlink(jobPath).catch(() => {}) +} + +async function acquireWorkerLock(cfg) { + const lockDir = workerLockDir(cfg) + await mkdir(cfg.captureQueueDir, { recursive: true }) + + try { + await mkdir(lockDir) + return async () => { + await rm(lockDir, { recursive: true, force: true }) + } + } catch (err) { + if (err?.code !== "EEXIST") throw err + if (await isStale(lockDir)) { + await rm(lockDir, { recursive: true, force: true }) + return acquireWorkerLock(cfg) + } + return null + } +} + +async function isStale(path) { + try { + const info = await stat(path) + return Date.now() - info.mtimeMs > LOCK_STALE_MS + } catch { + return false + } +} + +async function fetchJSON(cfg, path, init = {}, timeoutMs = cfg.captureTimeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const headers = { "Content-Type": "application/json" } + if (cfg.apiKey) headers["X-API-Key"] = cfg.apiKey + if (cfg.agentId) headers["X-OpenViking-Agent"] = cfg.agentId + const response = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal }) + const body = await response.json().catch(() => null) + if (!response.ok || !body || body.status === "error") return null + return body.result ?? body + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +async function getTask(cfg, taskId) { + return fetchJSON(cfg, `/api/v1/tasks/${encodeURIComponent(taskId)}`, { method: "GET" }, cfg.captureTimeoutMs) +} + +async function commitSession(cfg, sessionId) { + const result = await fetchJSON(cfg, `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { + method: "POST", + body: JSON.stringify({}), + }, cfg.captureTimeoutMs) + + if (!result?.task_id) { + return { + status: result?.status || "completed", + memoriesExtracted: Object.values(result?.memories_extracted || {}).reduce((sum, count) => sum + count, 0), + taskId: result?.task_id || null, + } + } + + const deadline = Date.now() + cfg.captureTimeoutMs + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const task = await getTask(cfg, result.task_id) + if (!task) break + if (task.status === "completed") { + const memoriesExtracted = Object.values(task.result?.memories_extracted || {}).reduce((sum, count) => sum + count, 0) + return { status: "completed", memoriesExtracted, taskId: result.task_id } + } + if (task.status === "failed") { + return { status: "failed", memoriesExtracted: 0, taskId: result.task_id, error: task.error } + } + } + + return { status: "timeout", memoriesExtracted: 0, taskId: result.task_id } +} + +async function captureToOpenViking(cfg, text) { + const created = await fetchJSON(cfg, "/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }) + if (!created?.session_id) return { ok: false, reason: "session_create_failed" } + + const sessionId = created.session_id + try { + await fetchJSON(cfg, `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role: "user", content: text }), + }) + + const commit = await commitSession(cfg, sessionId) + if (commit.status === "failed") { + return { ok: false, reason: "commit_failed", error: commit.error } + } + return { + ok: true, + status: commit.status, + count: commit.memoriesExtracted, + taskId: commit.taskId, + } + } finally { + await fetchJSON(cfg, `/api/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }).catch(() => {}) + } +} diff --git a/examples/codex-memory-plugin/scripts/capture-worker.mjs b/examples/codex-memory-plugin/scripts/capture-worker.mjs new file mode 100644 index 000000000..cab6b8aff --- /dev/null +++ b/examples/codex-memory-plugin/scripts/capture-worker.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { loadConfig } from "./config.mjs" +import { createLogger } from "./debug-log.mjs" +import { drainCaptureQueue } from "./capture-queue.mjs" + +const cfg = loadConfig() +const { log, logError } = createLogger("capture-worker") + +async function main() { + await drainCaptureQueue(cfg, log, logError) +} + +main().catch((err) => { + logError("uncaught", err) + 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 000000000..c3e44d63e --- /dev/null +++ b/examples/codex-memory-plugin/scripts/config.mjs @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join, resolve as resolvePath } from "node:path" + +const DEFAULT_CONFIG_PATH = join(homedir(), ".openviking", "ov.conf") +const DEFAULT_PLUGIN_HOME = join(homedir(), ".openviking", "codex-memory-plugin") +const DEFAULT_PLUGIN_CONFIG_PATH = join(DEFAULT_PLUGIN_HOME, "config.json") + +function num(value, fallback) { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return fallback +} + +function str(value, fallback) { + if (typeof value === "string" && value.trim()) return value.trim() + return fallback +} + +function normalizeMode(value) { + const mode = str(value, "full") + return mode === "recall_only" ? "recall_only" : "full" +} + +export function loadConfig() { + const configPath = resolvePath( + (process.env.OPENVIKING_CONFIG_FILE || DEFAULT_CONFIG_PATH).replace(/^~/, homedir()), + ) + + let raw + try { + raw = readFileSync(configPath, "utf-8") + } catch (err) { + const msg = err?.code === "ENOENT" + ? `Config file not found: ${configPath}\n Create it from the example: cp ov.conf.example ~/.openviking/ov.conf` + : `Failed to read config file: ${configPath} — ${err?.message || err}` + process.stderr.write(`[openviking-memory] ${msg}\n`) + process.exit(1) + } + + let file + try { + file = JSON.parse(raw) + } catch (err) { + process.stderr.write(`[openviking-memory] Invalid JSON in ${configPath}: ${err?.message || err}\n`) + process.exit(1) + } + + const pluginConfigPath = resolvePath( + str(process.env.OPENVIKING_CODEX_CONFIG_FILE, DEFAULT_PLUGIN_CONFIG_PATH).replace(/^~/, homedir()), + ) + let pluginFile = {} + try { + pluginFile = JSON.parse(readFileSync(pluginConfigPath, "utf-8")) + } catch (err) { + if (err?.code !== "ENOENT") { + process.stderr.write(`[openviking-memory] Invalid Codex plugin config in ${pluginConfigPath}: ${err?.message || err}\n`) + process.exit(1) + } + } + + const server = file.server || {} + const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1") + const port = Math.floor(num(server.port, 1933)) + const baseUrl = `http://${host}:${port}` + const apiKey = str(server.root_api_key, "") + + const codex = pluginFile || {} + const pluginHome = resolvePath( + str(process.env.OPENVIKING_CODEX_PLUGIN_HOME, DEFAULT_PLUGIN_HOME).replace(/^~/, homedir()), + ) + const mode = normalizeMode(process.env.OPENVIKING_CODEX_MODE || codex.mode) + const allowMemoryWrites = mode !== "recall_only" + + const timeoutMs = Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, num(codex.timeoutMs, 15000)))) + const captureTimeoutMs = Math.max( + 1000, + Math.floor(num(process.env.OPENVIKING_CAPTURE_TIMEOUT_MS, num(codex.captureTimeoutMs, Math.max(timeoutMs * 2, 30000)))), + ) + + const debug = codex.debug === true || process.env.OPENVIKING_DEBUG === "1" + const debugLogPath = str( + process.env.OPENVIKING_DEBUG_LOG, + join(homedir(), ".openviking", "logs", "codex-hooks.log"), + ) + + return { + configPath, + pluginConfigPath, + pluginHome, + captureStateDir: join(pluginHome, "state"), + captureQueueDir: join(pluginHome, "queue"), + hookDedupeDir: join(pluginHome, "state", "hook-dedupe"), + factsPath: join(pluginHome, "state", "facts.json"), + mode, + allowMemoryWrites, + baseUrl, + apiKey, + agentId: str(process.env.OPENVIKING_AGENT_ID, str(codex.agentId, "codex")), + timeoutMs, + autoRecall: codex.autoRecall !== false, + recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, num(codex.recallLimit, 1)))), + scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, num(codex.scoreThreshold, 0.01)))), + minQueryLength: Math.max(1, Math.floor(num(process.env.OPENVIKING_MIN_QUERY_LENGTH, num(codex.minQueryLength, 3)))), + logRankingDetails: codex.logRankingDetails === true, + searchAgentSkills: codex.searchAgentSkills === true, + skipRecallOnWritePrompts: codex.skipRecallOnWritePrompts !== false, + maxInjectedMemories: Math.max(1, Math.floor(num(codex.maxInjectedMemories, 1))), + preferPromptLanguage: codex.preferPromptLanguage !== false, + autoCapture: allowMemoryWrites && codex.autoCapture !== false, + captureMode: codex.captureMode === "keyword" + ? "keyword" + : codex.captureMode === "semantic" + ? "semantic" + : "durable-facts", + captureDispatch: str(codex.captureDispatch, "background") === "inline" ? "inline" : "background", + captureMaxLength: Math.max(200, Math.floor(num(process.env.OPENVIKING_CAPTURE_MAX_LENGTH, num(codex.captureMaxLength, 24000)))), + captureTimeoutMs, + captureAssistantTurns: codex.captureAssistantTurns === true, + 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 000000000..0fb1c1d92 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/debug-log.mjs @@ -0,0 +1,56 @@ +import { appendFileSync, mkdirSync } from "node:fs" +import { dirname } from "node:path" +import { loadConfig } from "./config.mjs" + +let cachedConfig + +function cfg() { + if (!cachedConfig) cachedConfig = loadConfig() + return cachedConfig +} + +function ensureDir(filePath) { + try { + mkdirSync(dirname(filePath), { recursive: true }) + } catch {} +} + +function writeLine(filePath, value) { + try { + appendFileSync(filePath, `${JSON.stringify(value)}\n`) + } catch {} +} + +function localISO() { + const now = new Date() + const offset = now.getTimezoneOffset() + const sign = offset <= 0 ? "+" : "-" + const absolute = Math.abs(offset) + const local = new Date(now.getTime() - offset * 60_000) + return local.toISOString().replace( + "Z", + `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`, + ) +} + +const noop = () => {} + +export function createLogger(hookName, overrideConfig) { + const current = overrideConfig || cfg() + if (!current.debug) return { log: noop, logError: noop } + + ensureDir(current.debugLogPath) + + function log(stage, data) { + writeLine(current.debugLogPath, { ts: localISO(), hook: hookName, stage, data }) + } + + function logError(stage, err) { + const error = err instanceof Error + ? { message: err.message, stack: err.stack } + : String(err) + writeLine(current.debugLogPath, { ts: localISO(), hook: hookName, stage, error }) + } + + return { log, logError } +} diff --git a/examples/codex-memory-plugin/scripts/fact-index.mjs b/examples/codex-memory-plugin/scripts/fact-index.mjs new file mode 100644 index 000000000..f1ae198b1 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/fact-index.mjs @@ -0,0 +1,100 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname } from "node:path" + +const FACT_QUERY_PATTERNS = [ + /what(?:'s| is)? my (?[a-z0-9][a-z0-9 _-]{1,80})(?:\?|$)/i, + /do you remember (?:what )?my (?[a-z0-9][a-z0-9 _-]{1,80})(?:\?|$)/i, +] + +const FACT_STATEMENT_PATTERNS = [ + /for future reference,?\s*my (?[a-z0-9][a-z0-9 _-]{1,80}) is (?[^.!?\n]{1,200})/i, + /remember (?:that )?my (?[a-z0-9][a-z0-9 _-]{1,80}) is (?[^.!?\n]{1,200})/i, + /my (?[a-z0-9][a-z0-9 _-]{1,80}) is (?[^.!?\n]{1,200})/i, +] + +function normalizeSpace(value) { + return String(value || "").replace(/\s+/g, " ").trim() +} + +function normalizeFamily(value) { + return normalizeSpace(value) + .toLowerCase() + .replace(/^(the|a|an)\s+/i, "") + .replace(/[?.!,:;]+$/g, "") +} + +function normalizeValue(value) { + return normalizeSpace(value).replace(/^["']|["']$/g, "") +} + +export function inferFactFromText(text) { + const normalized = normalizeSpace(text) + if (!normalized) return null + + for (const pattern of FACT_STATEMENT_PATTERNS) { + const match = normalized.match(pattern) + const family = normalizeFamily(match?.groups?.family || "") + const value = normalizeValue(match?.groups?.value || "") + if (family && value) { + return { + familyKey: family, + familyLabel: family, + value, + sentence: `your ${family} is ${value}`, + } + } + } + + return null +} + +export function inferFactQuery(prompt) { + const normalized = normalizeSpace(prompt) + if (!normalized) return null + + for (const pattern of FACT_QUERY_PATTERNS) { + const match = normalized.match(pattern) + const family = normalizeFamily(match?.groups?.family || "") + if (family) return family + } + + return null +} + +export async function loadFacts(factsPath) { + try { + const raw = await readFile(factsPath, "utf-8") + const parsed = JSON.parse(raw) + return parsed && typeof parsed === "object" && parsed.facts && typeof parsed.facts === "object" + ? parsed + : { facts: {} } + } catch { + return { facts: {} } + } +} + +export async function upsertFact(factsPath, fact) { + if (!fact?.familyKey || !fact?.value) return + const current = await loadFacts(factsPath) + current.facts[fact.familyKey] = { + familyKey: fact.familyKey, + familyLabel: fact.familyLabel || fact.familyKey, + value: fact.value, + sentence: fact.sentence || `your ${fact.familyLabel || fact.familyKey} is ${fact.value}`, + sourceText: fact.sourceText || "", + sessionId: fact.sessionId || "", + status: fact.status || "pending", + updatedAt: fact.updatedAt || new Date().toISOString(), + } + await mkdir(dirname(factsPath), { recursive: true }) + await writeFile(factsPath, `${JSON.stringify(current, null, 2)}\n`) +} + +export async function findFactForPrompt(factsPath, prompt) { + const familyKey = inferFactQuery(prompt) + if (!familyKey) return null + const current = await loadFacts(factsPath) + const fact = current.facts[familyKey] + if (!fact?.value) return null + return fact +} diff --git a/examples/codex-memory-plugin/scripts/hook-dedupe.mjs b/examples/codex-memory-plugin/scripts/hook-dedupe.mjs new file mode 100644 index 000000000..dc92d5ee1 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/hook-dedupe.mjs @@ -0,0 +1,81 @@ +import { mkdir, opendir, open, stat, unlink } from "node:fs/promises" +import { createHash } from "node:crypto" +import { join } from "node:path" + +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +function normalizePart(value, fallback = "unknown") { + const text = String(value || "").trim() + if (!text) return fallback + return text.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || fallback +} + +function stableHash(parts) { + const hash = createHash("sha256") + for (const part of parts) hash.update(String(part || "")) + return hash.digest("hex").slice(0, 16) +} + +export function buildHookDedupeKey(eventName, input = {}) { + const sessionId = normalizePart(input.session_id) + const turnId = normalizePart(input.turn_id, "") + if (turnId) return `${normalizePart(eventName)}__${sessionId}__${turnId}` + + if (eventName === "UserPromptSubmit") { + return `${normalizePart(eventName)}__${sessionId}__${stableHash([ + input.prompt, + input.cwd, + ])}` + } + + return `${normalizePart(eventName)}__${sessionId}__${stableHash([ + input.transcript_path, + input.last_assistant_message, + input.cwd, + ])}` +} + +function markerPath(dir, key) { + return join(dir, `${key}.json`) +} + +async function pruneOldMarkers(dir, now) { + try { + const handle = await opendir(dir) + for await (const entry of handle) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue + const path = join(dir, entry.name) + try { + const info = await stat(path) + if (now - info.mtimeMs > DEFAULT_TTL_MS) await unlink(path) + } catch {} + } + } catch {} +} + +export async function claimHookInvocation(dir, key, metadata = {}) { + const now = Date.now() + await mkdir(dir, { recursive: true }) + void pruneOldMarkers(dir, now) + + const path = markerPath(dir, key) + let file + try { + file = await open(path, "wx") + } catch (err) { + if (err?.code === "EEXIST") return false + throw err + } + + try { + await file.writeFile(JSON.stringify({ + key, + createdAt: new Date(now).toISOString(), + ...metadata, + })) + } finally { + await file.close() + } + + return true +} diff --git a/examples/codex-memory-plugin/scripts/install-codex-hooks.mjs b/examples/codex-memory-plugin/scripts/install-codex-hooks.mjs new file mode 100644 index 000000000..7689e792f --- /dev/null +++ b/examples/codex-memory-plugin/scripts/install-codex-hooks.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join, resolve as resolvePath } from "node:path" +import { fileURLToPath } from "node:url" +import { loadConfig } from "./config.mjs" + +const HOOK_TAG = "openviking-codex-memory-plugin" +const pluginRoot = resolvePath(dirname(fileURLToPath(import.meta.url)), "..") +const codexHome = resolvePath((process.env.CODEX_HOME || join(homedir(), ".codex")).replace(/^~/, homedir())) +const hooksPath = join(codexHome, "hooks.json") +const cfg = loadConfig() + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +function buildCommand(scriptName) { + return `node ${shellQuote(join(pluginRoot, "scripts", scriptName))}` +} + +function loadHooksFile() { + try { + return JSON.parse(readFileSync(hooksPath, "utf-8")) + } catch { + return { hooks: {} } + } +} + +function ensureEvent(hooksFile, eventName) { + if (!Array.isArray(hooksFile.hooks[eventName])) hooksFile.hooks[eventName] = [] + return hooksFile.hooks[eventName] +} + +function managedCommands() { + return new Set([ + buildCommand("bootstrap-runtime.mjs"), + buildCommand("auto-recall.mjs"), + buildCommand("auto-capture.mjs"), + ]) +} + +function isManagedHook(hook) { + if (typeof hook?.command === "string" && managedCommands().has(hook.command)) return true + return typeof hook?.statusMessage === "string" && hook.statusMessage.startsWith(HOOK_TAG) +} + +function stripManagedHooks(groups) { + return groups + .map((group) => ({ + ...group, + hooks: Array.isArray(group.hooks) + ? group.hooks.filter((hook) => !isManagedHook(hook)) + : [], + })) + .filter((group) => group.hooks.length > 0) +} + +function upsertHook(hooksFile, eventName, hook) { + const existing = stripManagedHooks(ensureEvent(hooksFile, eventName)) + existing.push({ hooks: [hook] }) + hooksFile.hooks[eventName] = existing +} + +const hooksFile = loadHooksFile() +if (!hooksFile.hooks || typeof hooksFile.hooks !== "object") hooksFile.hooks = {} + +hooksFile.hooks.SessionStart = stripManagedHooks(Array.isArray(hooksFile.hooks.SessionStart) ? hooksFile.hooks.SessionStart : []) +if (hooksFile.hooks.SessionStart.length === 0) { + delete hooksFile.hooks.SessionStart +} + +upsertHook(hooksFile, "UserPromptSubmit", { + type: "command", + command: buildCommand("auto-recall.mjs"), + timeout: 30, +}) + +hooksFile.hooks.Stop = stripManagedHooks(Array.isArray(hooksFile.hooks.Stop) ? hooksFile.hooks.Stop : []) +if (hooksFile.hooks.Stop.length === 0) { + delete hooksFile.hooks.Stop +} + +if (cfg.autoCapture) { + upsertHook(hooksFile, "Stop", { + type: "command", + command: buildCommand("auto-capture.mjs"), + timeout: 45, + }) +} + +mkdirSync(dirname(hooksPath), { recursive: true }) +writeFileSync(hooksPath, `${JSON.stringify(hooksFile, null, 2)}\n`) +process.stdout.write(`Installed OpenViking Codex hooks into ${hooksPath} (mode=${cfg.mode})\n`) 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 000000000..39d9bc45f --- /dev/null +++ b/examples/codex-memory-plugin/scripts/runtime-common.mjs @@ -0,0 +1,230 @@ +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 { fileURLToPath } from "node:url" +import { dirname, join, resolve as resolvePath } from "node:path" +import { homedir } from "node:os" + +export const INSTALL_TIMEOUT_MS = 120_000 + +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" + +function pluginRootFromScript() { + return resolvePath(dirname(fileURLToPath(import.meta.url)), "..") +} + +export function getRuntimePaths() { + const pluginRoot = pluginRootFromScript() + const pluginDataRoot = resolvePath( + (process.env.OPENVIKING_CODEX_PLUGIN_HOME || FALLBACK_PLUGIN_DATA_ROOT).replace(/^~/, homedir()), + ) + const runtimeRoot = join(pluginDataRoot, "runtime") + + return { + pluginRoot, + pluginDataRoot, + runtimeRoot, + sourcePackagePath: join(pluginRoot, "package.json"), + sourceLockPath: join(pluginRoot, "package-lock.json"), + sourceServerPath: join(pluginRoot, "servers", "memory-server.js"), + runtimePackagePath: join(runtimeRoot, "package.json"), + runtimeLockPath: join(runtimeRoot, "package-lock.json"), + runtimeServerPath: join(runtimeRoot, "servers", "memory-server.js"), + runtimeNodeModulesPath: join(runtimeRoot, "node_modules"), + statePath: join(runtimeRoot, "install-state.json"), + lockDir: join(runtimeRoot, ".install-lock"), + envMetaPath: join(runtimeRoot, RUNTIME_ENV_META_PATH), + } +} + +export async function computeSourceState(paths) { + const [pkgRaw, lockRaw, serverRaw] = await Promise.all([ + readFile(paths.sourcePackagePath), + readFile(paths.sourceLockPath), + readFile(paths.sourceServerPath), + ]) + + const pkg = JSON.parse(pkgRaw.toString("utf8")) + + return { + pluginVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + manifestHash: sha256(pkgRaw, lockRaw), + serverHash: sha256(serverRaw), + } +} + +export async function loadInstallState(paths) { + try { + return JSON.parse(await readFile(paths.statePath, "utf8")) + } catch { + return null + } +} + +export async function writeInstallState(paths, state) { + await mkdir(paths.runtimeRoot, { recursive: true }) + await writeFile( + paths.statePath, + `${JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2)}\n`, + ) +} + +export async function writeRuntimeEnvMeta(paths) { + await mkdir(paths.runtimeRoot, { recursive: true }) + await writeFile( + paths.envMetaPath, + `${JSON.stringify({ + pluginRoot: paths.pluginRoot, + pluginDataRoot: paths.pluginDataRoot, + runtimeRoot: paths.runtimeRoot, + updatedAt: new Date().toISOString(), + }, null, 2)}\n`, + ) +} + +export async function runtimeIsReady(paths, expectedState) { + const state = await loadInstallState(paths) + if (!state || state.status !== "ready") return false + if (state.manifestHash !== expectedState.manifestHash) return false + if (state.serverHash !== expectedState.serverHash) return false + + for (const target of [ + paths.runtimePackagePath, + paths.runtimeLockPath, + paths.runtimeServerPath, + paths.runtimeNodeModulesPath, + ]) { + if (!(await pathExists(target))) return false + } + + return true +} + +export async function syncRuntimeFiles(paths) { + await mkdir(join(paths.runtimeRoot, "servers"), { recursive: true }) + await copyFile(paths.sourcePackagePath, paths.runtimePackagePath) + await copyFile(paths.sourceLockPath, paths.runtimeLockPath) + await copyFile(paths.sourceServerPath, paths.runtimeServerPath) + await writeRuntimeEnvMeta(paths) +} + +export async function acquireInstallLock(paths, timeoutMs = INSTALL_TIMEOUT_MS) { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + await mkdir(paths.runtimeRoot, { recursive: true }) + + try { + await mkdir(paths.lockDir) + await writeFile( + join(paths.lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, + ) + return async () => { + await rm(paths.lockDir, { recursive: true, force: true }) + } + } catch (err) { + if (err?.code !== "EEXIST") throw err + + if (await isStaleLock(paths.lockDir)) { + await rm(paths.lockDir, { recursive: true, force: true }) + continue + } + + await wait(500) + } + } + + throw new Error(`Timed out waiting for install lock in ${paths.runtimeRoot}`) +} + +export async function 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", + }) + + 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, + }) + + 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, + 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 buffer of buffers) hash.update(buffer) + 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 failed with exit code ${result.status ?? "unknown"}`, + result.stdout?.trim() ? `stdout:\n${result.stdout.trim()}` : "", + result.stderr?.trim() ? `stderr:\n${result.stderr.trim()}` : "", + ].filter(Boolean) + return lines.join("\n\n") +} + +export function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} 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 000000000..2b0ad15b2 --- /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 (err) { + 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/scripts/uninstall-codex-hooks.mjs b/examples/codex-memory-plugin/scripts/uninstall-codex-hooks.mjs new file mode 100644 index 000000000..a29046316 --- /dev/null +++ b/examples/codex-memory-plugin/scripts/uninstall-codex-hooks.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join, resolve as resolvePath } from "node:path" +import { fileURLToPath } from "node:url" + +const HOOK_TAG = "openviking-codex-memory-plugin" +const pluginRoot = resolvePath(dirname(fileURLToPath(import.meta.url)), "..") +const codexHome = resolvePath((process.env.CODEX_HOME || join(homedir(), ".codex")).replace(/^~/, homedir())) +const hooksPath = join(codexHome, "hooks.json") + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +function buildCommand(scriptName) { + return `node ${shellQuote(join(pluginRoot, "scripts", scriptName))}` +} + +const managedCommands = new Set([ + buildCommand("bootstrap-runtime.mjs"), + buildCommand("auto-recall.mjs"), + buildCommand("auto-capture.mjs"), +]) + +function isManagedHook(hook) { + if (typeof hook?.command === "string" && managedCommands.has(hook.command)) return true + return typeof hook?.statusMessage === "string" && hook.statusMessage.startsWith(HOOK_TAG) +} + +if (!existsSync(hooksPath)) { + process.stdout.write(`No hooks file found at ${hooksPath}\n`) + process.exit(0) +} + +let hooksFile +try { + hooksFile = JSON.parse(readFileSync(hooksPath, "utf-8")) +} catch (err) { + process.stderr.write(`Failed to parse ${hooksPath}: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +} + +if (!hooksFile.hooks || typeof hooksFile.hooks !== "object") hooksFile.hooks = {} + +for (const eventName of ["SessionStart", "UserPromptSubmit", "Stop"]) { + const groups = Array.isArray(hooksFile.hooks[eventName]) ? hooksFile.hooks[eventName] : [] + hooksFile.hooks[eventName] = groups + .map((group) => ({ + ...group, + hooks: Array.isArray(group.hooks) + ? group.hooks.filter((hook) => !isManagedHook(hook)) + : [], + })) + .filter((group) => group.hooks.length > 0) +} + +writeFileSync(hooksPath, `${JSON.stringify(hooksFile, null, 2)}\n`) +process.stdout.write(`Removed OpenViking Codex hooks from ${hooksPath}\n`) 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 000000000..47de9f503 --- /dev/null +++ b/examples/codex-memory-plugin/servers/memory-server.js @@ -0,0 +1,694 @@ +/** + * OpenViking Memory MCP Server for Codex + * + * Exposes explicit OpenViking long-term memory tools for Codex: + * - openviking_recall : inspect persisted OpenViking memories on demand + * - openviking_store : persist new memories into OpenViking explicitly + * - openviking_forget : delete memories by URI or query + * - openviking_health : connectivity and config checks + * + * Ported from the OpenClaw context-engine plugin (openclaw-plugin/). + * Adapted for Codex's MCP server interface (stdio transport). + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { createHash } from "node:crypto"; +// --------------------------------------------------------------------------- +// Configuration — loaded from ov.conf. +// Env var: OPENVIKING_CONFIG_FILE (default: ~/.openviking/ov.conf) +// Optional runtime overrides can be supplied via environment variables. +// --------------------------------------------------------------------------- +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +const DEFAULT_PLUGIN_CONFIG_PATH = join(homedir(), ".openviking", "codex-memory-plugin", "config.json"); +function loadOvConf() { + const defaultPath = join(homedir(), ".openviking", "ov.conf"); + const configPath = resolvePath((process.env.OPENVIKING_CONFIG_FILE || defaultPath).replace(/^~/, homedir())); + try { + return JSON.parse(readFileSync(configPath, "utf-8")); + } + catch (err) { + const code = err?.code; + const msg = code === "ENOENT" + ? `Config file not found: ${configPath}` + : `Failed to read config: ${configPath}`; + process.stderr.write(`[openviking-memory] ${msg}\n`); + process.exit(1); + } +} +function loadPluginConf() { + const configPath = resolvePath((process.env.OPENVIKING_CODEX_CONFIG_FILE || DEFAULT_PLUGIN_CONFIG_PATH).replace(/^~/, homedir())); + try { + return JSON.parse(readFileSync(configPath, "utf-8")); + } + catch (err) { + const code = err?.code; + if (code === "ENOENT") + return {}; + process.stderr.write(`[openviking-memory] Invalid Codex plugin config: ${configPath}\n`); + process.exit(1); + } +} +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 normalizeMode(val) { + return str(val, "full") === "recall_only" ? "recall_only" : "full"; +} +const file = loadOvConf(); +const pluginFile = loadPluginConf(); +const serverCfg = (file.server ?? {}); +const codexCfg = pluginFile; +const host = str(serverCfg.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); +const port = Math.floor(num(serverCfg.port, 1933)); +const config = { + baseUrl: `http://${host}:${port}`, + apiKey: str(serverCfg.root_api_key, ""), + agentId: str(process.env.OPENVIKING_AGENT_ID, str(codexCfg.agentId, "codex")), + timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, num(codexCfg.timeoutMs, 15000)))), + recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, num(codexCfg.recallLimit, 6)))), + scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, num(codexCfg.scoreThreshold, 0.01)))), + mode: normalizeMode(process.env.OPENVIKING_CODEX_MODE || codexCfg.mode), +}; +function storeAndForgetDisabledMessage() { + return `OpenViking Codex plugin is in ${config.mode} mode. Manual store/delete operations are disabled.`; +} +// --------------------------------------------------------------------------- +// OpenViking HTTP Client (ported from openclaw-plugin/client.ts) +// --------------------------------------------------------------------------- +const MEMORY_URI_PATTERNS = [ + /^viking:\/\/user\/(?:[^/]+\/)?memories(?:\/|$)/, + /^viking:\/\/agent\/(?:[^/]+\/)?memories(?:\/|$)/, +]; +const USER_STRUCTURE_DIRS = new Set(["memories"]); +const AGENT_STRUCTURE_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]); +function md5Short(input) { + return createHash("md5").update(input).digest("hex").slice(0, 12); +} +function isMemoryUri(uri) { + return MEMORY_URI_PATTERNS.some((p) => p.test(uri)); +} +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +class OpenVikingClient { + baseUrl; + apiKey; + agentId; + timeoutMs; + resolvedSpaceByScope = {}; + runtimeIdentity = null; + constructor(baseUrl, apiKey, agentId, timeoutMs) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.agentId = agentId; + this.timeoutMs = timeoutMs; + } + async request(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers = new Headers(init.headers ?? {}); + if (this.apiKey) + headers.set("X-API-Key", this.apiKey); + if (this.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 ls(uri) { + return this.request(`/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`); + } + async getRuntimeIdentity() { + if (this.runtimeIdentity) + return this.runtimeIdentity; + const fallback = { 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() : "default"; + this.runtimeIdentity = { userId, agentId: this.agentId || "default" }; + return this.runtimeIdentity; + } + catch { + this.runtimeIdentity = fallback; + return fallback; + } + } + async resolveScopeSpace(scope) { + const cached = this.resolvedSpaceByScope[scope]; + if (cached) + return cached; + const identity = await this.getRuntimeIdentity(); + const fallbackSpace = scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`); + const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS; + try { + const entries = await this.ls(`viking://${scope}`); + const spaces = entries + .filter((e) => e?.isDir === true) + .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)) { + this.resolvedSpaceByScope[scope] = fallbackSpace; + return fallbackSpace; + } + if (scope === "user" && spaces.includes("default")) { + this.resolvedSpaceByScope[scope] = "default"; + return "default"; + } + if (spaces.length === 1) { + this.resolvedSpaceByScope[scope] = spaces[0]; + return spaces[0]; + } + } + } + catch { /* fall through */ } + this.resolvedSpaceByScope[scope] = fallbackSpace; + return fallbackSpace; + } + async normalizeTargetUri(targetUri) { + const trimmed = targetUri.trim().replace(/\/+$/, ""); + const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); + if (!match) + return trimmed; + const scope = match[1]; + const rawRest = (match[2] ?? "").trim(); + if (!rawRest) + return trimmed; + const parts = rawRest.split("/").filter(Boolean); + if (parts.length === 0) + return trimmed; + const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS; + if (!reservedDirs.has(parts[0])) + return trimmed; + const space = await this.resolveScopeSpace(scope); + return `viking://${scope}/${space}/${parts.join("/")}`; + } + async find(query, options) { + const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri); + return this.request("/api/v1/search/find", { + method: "POST", + body: JSON.stringify({ + query, + target_uri: normalizedTargetUri, + limit: options.limit, + score_threshold: options.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 sessionUsed(sessionId, contexts) { + if (contexts.length === 0) + return; + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/used`, { + method: "POST", + body: JSON.stringify({ contexts }), + }); + } + async commitSession(sessionId, options) { + const result = await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { method: "POST", body: JSON.stringify({}) }); + if (!options?.wait || !result.task_id) { + return result; + } + const deadline = Date.now() + (options.timeoutMs ?? 120_000); + 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 ?? {}); + result.status = "completed"; + result.memories_extracted = (taskResult.memories_extracted ?? {}); + return result; + } + if (task.status === "failed") { + result.status = "failed"; + result.error = task.error; + return result; + } + } + result.status = "timeout"; + return result; + } + 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 isNotFoundError(err) { + const message = err instanceof Error ? err.message : String(err); + return message.includes("NOT_FOUND") || message.includes("File not found"); +} +async function waitForMemoryDeletion(client, uri, timeoutMs = 6_000, intervalMs = 250) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + try { + await client.read(uri); + } + catch (err) { + if (isNotFoundError(err)) { + return; + } + throw err; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`OpenViking delete for ${uri} did not settle within ${timeoutMs}ms`); +} +function totalCommitMemories(result) { + return Object.values(result.memories_extracted ?? {}).reduce((sum, count) => sum + count, 0); +} +// --------------------------------------------------------------------------- +// Memory ranking helpers (ported from openclaw-plugin/memory-ranking.ts) +// --------------------------------------------------------------------------- +function clampScore(value) { + if (typeof value !== "number" || Number.isNaN(value)) + return 0; + return Math.max(0, Math.min(1, value)); +} +function normalizeDedupeText(text) { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} +function getMemoryDedupeKey(item) { + const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? ""); + const category = (item.category ?? "").toLowerCase() || "unknown"; + if (abstract) + return `abstract:${category}:${abstract}`; + return `uri:${item.uri}`; +} +function postProcessMemories(items, options) { + const deduped = []; + const seen = new Set(); + const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score)); + for (const item of sorted) { + if (options.leafOnly && item.level !== 2) + continue; + if (clampScore(item.score) < options.scoreThreshold) + continue; + const key = getMemoryDedupeKey(item); + if (seen.has(key)) + continue; + seen.add(key); + deduped.push(item); + if (deduped.length >= options.limit) + break; + } + return deduped; +} +function formatMemoryLines(items) { + return items + .map((item, i) => { + const score = clampScore(item.score); + const abstract = item.abstract?.trim() || item.overview?.trim() || item.uri; + const category = item.category ?? "memory"; + return `${i + 1}. [${category}] ${abstract} (${(score * 100).toFixed(0)}%)`; + }) + .join("\n"); +} +function formatStoredMemoryMatches(items) { + return items + .map((item) => { + const summary = item.abstract?.trim() || item.overview?.trim() || item.uri; + return `- ${item.uri} — ${summary}`; + }) + .join("\n"); +} +function filterNearTopMatches(items, relativeGap, minimumScore) { + if (items.length === 0) + return []; + const topScore = clampScore(items[0].score); + const cutoff = Math.max(minimumScore, topScore >= 0.5 ? topScore - relativeGap : topScore * 0.8); + return items.filter((item) => clampScore(item.score) >= cutoff); +} +// Query-aware ranking (ported from openclaw-plugin/memory-ranking.ts) +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 QUERY_TOKEN_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) => !QUERY_TOKEN_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 rankForInjection(item, query) { + const baseScore = clampScore(item.score); + const abstract = (item.abstract ?? item.overview ?? "").trim(); + const leafBoost = item.level === 2 ? 0.12 : 0; + const cat = (item.category ?? "").toLowerCase(); + const eventBoost = query.wantsTemporal && (cat === "events" || item.uri.includes("/events/")) ? 0.1 : 0; + const prefBoost = query.wantsPreference && (cat === "preferences" || item.uri.includes("/preferences/")) ? 0.08 : 0; + const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`); + return baseScore + leafBoost + eventBoost + prefBoost + overlapBoost; +} +function pickMemoriesForInjection(items, limit, queryText) { + if (items.length === 0 || limit <= 0) + return []; + const query = buildQueryProfile(queryText); + const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query)); + const deduped = []; + const seen = new Set(); + for (const item of sorted) { + const key = (item.abstract ?? item.overview ?? "").trim().toLowerCase() || item.uri; + if (seen.has(key)) + continue; + seen.add(key); + deduped.push(item); + } + const leaves = deduped.filter((item) => item.level === 2); + if (leaves.length >= limit) + return leaves.slice(0, limit); + const picked = [...leaves]; + const used = new Set(leaves.map((item) => item.uri)); + for (const item of deduped) { + if (picked.length >= limit) + break; + if (used.has(item.uri)) + continue; + picked.push(item); + } + return picked; +} +// --------------------------------------------------------------------------- +// Shared search helpers +// --------------------------------------------------------------------------- +async function searchBothScopes(client, query, limit) { + const [userSettled, agentSettled] = await Promise.allSettled([ + client.find(query, { targetUri: "viking://user/memories", limit, scoreThreshold: 0 }), + client.find(query, { targetUri: "viking://agent/memories", limit, scoreThreshold: 0 }), + ]); + const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; + const agentResult = agentSettled.status === "fulfilled" ? agentSettled.value : { memories: [] }; + const all = [...(userResult.memories ?? []), ...(agentResult.memories ?? [])]; + // Deduplicate by URI and keep only leaf memories + const unique = all.filter((m, i, self) => i === self.findIndex((o) => o.uri === m.uri)); + return unique.filter((m) => m.level === 2); +} +async function findStoredMemories(client, text, displayLimit = 6) { + const candidateLimit = Math.max(displayLimit * 4, 12); + const leafMemories = await searchBothScopes(client, text.slice(0, 500), candidateLimit); + const processed = postProcessMemories(leafMemories, { + limit: candidateLimit, + scoreThreshold: 0, + leafOnly: true, + }); + const picked = pickMemoriesForInjection(processed, candidateLimit, text); + const overlapping = picked.filter((item) => lexicalOverlapBoost(buildQueryProfile(text).tokens, `${item.uri} ${item.abstract ?? item.overview ?? ""}`) > 0); + return filterNearTopMatches(overlapping, 0.15, 0); +} +function markRecalledMemoriesUsed(client, contexts) { + const uniqueContexts = [...new Set(contexts.filter((uri) => typeof uri === "string" && uri.length > 0))]; + if (uniqueContexts.length === 0) + return; + void (async () => { + let sessionId; + try { + sessionId = await client.createSession(); + await client.sessionUsed(sessionId, uniqueContexts); + await client.commitSession(sessionId); + } + catch { + // Fire-and-forget usage tracking must never block or fail the caller. + } + finally { + if (sessionId) { + await client.deleteSession(sessionId).catch(() => { }); + } + } + })(); +} +// --------------------------------------------------------------------------- +// MCP Server +// --------------------------------------------------------------------------- +const client = new OpenVikingClient(config.baseUrl, config.apiKey, config.agentId, config.timeoutMs); +const server = new McpServer({ + name: "openviking-memory-codex", + version: "0.1.0", +}); +// -- Tool: memory_recall -------------------------------------------------- +server.tool("openviking_recall", "Manually inspect OpenViking long-term memory. Use only for explicit OpenViking recall or inspection requests. Normal background recall is handled by hooks, not this tool.", { + query: z.string().describe("Search query — describe what you want to recall"), + limit: z.number().optional().describe("Max results to return (default: 6)"), + score_threshold: z.number().optional().describe("Min relevance score 0-1 (default: 0.01)"), + target_uri: z.string().optional().describe("Search scope URI, e.g. viking://user/memories"), +}, async ({ query, limit, score_threshold, target_uri }) => { + const recallLimit = limit ?? config.recallLimit; + const threshold = score_threshold ?? config.scoreThreshold; + const candidateLimit = Math.max(recallLimit * 4, 20); + let leafMemories; + if (target_uri) { + const result = await client.find(query, { targetUri: target_uri, limit: candidateLimit, scoreThreshold: 0 }); + leafMemories = (result.memories ?? []).filter((m) => m.level === 2); + } + else { + leafMemories = await searchBothScopes(client, query, candidateLimit); + } + const processed = postProcessMemories(leafMemories, { limit: candidateLimit, scoreThreshold: threshold }); + const memories = pickMemoriesForInjection(processed, recallLimit, query); + if (memories.length === 0) { + return { content: [{ type: "text", text: "No relevant memories found in OpenViking." }] }; + } + markRecalledMemoriesUsed(client, memories.map((memory) => memory.uri)); + // Read full content for leaf memories + const lines = await Promise.all(memories.map(async (item) => { + if (item.level === 2) { + try { + const content = await client.read(item.uri); + if (content?.trim()) + return `- [${item.category ?? "memory"}] ${content.trim()}`; + } + catch { /* fallback */ } + } + return `- [${item.category ?? "memory"}] ${item.abstract ?? item.uri}`; + })); + return { + content: [{ + type: "text", + text: `Found ${memories.length} relevant memories:\n\n${lines.join("\n")}\n\n---\n${formatMemoryLines(memories)}`, + }], + }; +}); +// -- Tool: memory_store --------------------------------------------------- +server.tool("openviking_store", "Manually persist information into OpenViking long-term memory. Use only for explicit OpenViking save requests or direct memory control. Normal background capture is handled by hooks, not this tool.", { + text: z.string().describe("The information to store as memory"), + role: z.string().optional().describe("Message role: 'user' (default) or 'assistant'"), +}, async ({ text, role }) => { + if (config.mode === "recall_only") { + return { + content: [{ type: "text", text: storeAndForgetDisabledMessage() }], + }; + } + const msgRole = role || "user"; + let sessionId; + try { + sessionId = await client.createSession(); + await client.addSessionMessage(sessionId, msgRole, text); + const commitResult = await client.commitSession(sessionId, { + wait: true, + timeoutMs: 180_000, + }); + const memoriesCount = totalCommitMemories(commitResult); + if (commitResult.status === "failed") { + return { + content: [{ + type: "text", + text: `Memory extraction failed: ${String(commitResult.error ?? "unknown error")}`, + }], + }; + } + if (commitResult.status === "timeout") { + return { + content: [{ + type: "text", + text: `Memory extraction timed out. It may still complete in the background (task_id=${commitResult.task_id ?? "none"}).`, + }], + }; + } + if (memoriesCount === 0) { + return { + content: [{ + type: "text", + text: "Memory stored but extraction returned 0 memories. The text may be too short or not contain extractable information. Check OpenViking server logs for details.", + }], + }; + } + const storedMemories = await findStoredMemories(client, text).catch(() => []); + const storedSuffix = storedMemories.length > 0 + ? `\n\nLikely stored memories:\n${formatStoredMemoryMatches(storedMemories)}` + : ""; + return { + content: [{ + type: "text", + text: `OpenViking reported ${memoriesCount} extracted memory item(s).${storedSuffix}`, + }], + }; + } + finally { + if (sessionId) { + await client.deleteSession(sessionId).catch(() => { }); + } + } +}); +// -- Tool: memory_forget -------------------------------------------------- +server.tool("openviking_forget", "Manually delete OpenViking long-term memories. Use for explicit correction or deletion requests. Provide an exact URI for direct deletion, or a search query to find matching memories.", { + uri: z.string().optional().describe("Exact viking:// memory URI to delete"), + query: z.string().optional().describe("Search query to find the memory to delete"), + target_uri: z.string().optional().describe("Search scope URI (default: viking://user/memories)"), +}, async ({ uri, query, target_uri }) => { + if (config.mode === "recall_only") { + return { + content: [{ type: "text", text: storeAndForgetDisabledMessage() }], + }; + } + // Direct URI deletion + if (uri) { + if (!isMemoryUri(uri)) { + return { content: [{ type: "text", text: `Refusing to delete non-memory URI: ${uri}` }] }; + } + await client.deleteUri(uri); + await waitForMemoryDeletion(client, uri); + return { content: [{ type: "text", text: `Deleted memory: ${uri}` }] }; + } + if (!query) { + return { content: [{ type: "text", text: "Please provide either a uri or query parameter." }] }; + } + // Search then delete + const candidateLimit = 20; + let candidates; + if (target_uri) { + const result = await client.find(query, { targetUri: target_uri, limit: candidateLimit, scoreThreshold: 0 }); + candidates = postProcessMemories(result.memories ?? [], { + limit: candidateLimit, + scoreThreshold: config.scoreThreshold, + leafOnly: true, + }).filter((item) => isMemoryUri(item.uri)); + } + else { + const leafMemories = await searchBothScopes(client, query, candidateLimit); + candidates = postProcessMemories(leafMemories, { + limit: candidateLimit, + scoreThreshold: config.scoreThreshold, + leafOnly: true, + }).filter((item) => isMemoryUri(item.uri)); + } + candidates = filterNearTopMatches(candidates, 0.15, config.scoreThreshold); + if (candidates.length === 0) { + return { content: [{ type: "text", text: "No matching memories found. Try a more specific query." }] }; + } + // Auto-delete if single strong match + const top = candidates[0]; + if (candidates.length === 1 && clampScore(top.score) >= 0.7) { + await client.deleteUri(top.uri); + await waitForMemoryDeletion(client, top.uri); + return { content: [{ type: "text", text: `Deleted memory: ${top.uri}` }] }; + } + // List candidates for confirmation + const list = candidates + .map((item) => `- ${item.uri} — ${item.abstract?.trim() || "?"} (${(clampScore(item.score) * 100).toFixed(0)}%)`) + .join("\n"); + return { + content: [{ + type: "text", + text: `Found ${candidates.length} candidate memories. Please specify the exact URI to delete:\n\n${list}`, + }], + }; +}); +// -- Tool: memory_health -------------------------------------------------- +server.tool("openviking_health", "Check whether the OpenViking memory server is reachable and healthy.", {}, async () => { + const ok = await client.healthCheck(); + return { + content: [{ + type: "text", + text: ok + ? `OpenViking is healthy (${config.baseUrl})` + : `OpenViking is unreachable at ${config.baseUrl}. Please check if the server is running.`, + }], + }; +}); +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/codex-memory-plugin/src/memory-server.ts b/examples/codex-memory-plugin/src/memory-server.ts new file mode 100644 index 000000000..4f48c4ed5 --- /dev/null +++ b/examples/codex-memory-plugin/src/memory-server.ts @@ -0,0 +1,860 @@ +/** + * OpenViking Memory MCP Server for Codex + * + * Exposes explicit OpenViking long-term memory tools for Codex: + * - openviking_recall : inspect persisted OpenViking memories on demand + * - openviking_store : persist new memories into OpenViking explicitly + * - openviking_forget : delete memories by URI or query + * - openviking_health : connectivity and config checks + * + * Ported from the OpenClaw context-engine plugin (openclaw-plugin/). + * Adapted for Codex's MCP server interface (stdio transport). + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { createHash } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Types (ported from openclaw-plugin/client.ts) +// --------------------------------------------------------------------------- + +type FindResultItem = { + uri: string; + level?: number; + abstract?: string; + overview?: string; + category?: string; + score?: number; + match_reason?: string; +}; + +type FindResult = { + memories?: FindResultItem[]; + resources?: FindResultItem[]; + skills?: FindResultItem[]; + total?: number; +}; + +type CommitSessionResult = { + task_id?: string; + status?: string; + memories_extracted?: Record; + active_count_updated?: number; + error?: unknown; +}; + +type TaskResult = { + status?: string; + result?: Record; + error?: unknown; +}; + +type ScopeName = "user" | "agent"; + +// --------------------------------------------------------------------------- +// Configuration — loaded from ov.conf. +// Env var: OPENVIKING_CONFIG_FILE (default: ~/.openviking/ov.conf) +// Optional runtime overrides can be supplied via environment variables. +// --------------------------------------------------------------------------- + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; + +const DEFAULT_PLUGIN_CONFIG_PATH = join(homedir(), ".openviking", "codex-memory-plugin", "config.json"); + +function loadOvConf(): Record { + const defaultPath = join(homedir(), ".openviking", "ov.conf"); + const configPath = resolvePath( + (process.env.OPENVIKING_CONFIG_FILE || defaultPath).replace(/^~/, homedir()), + ); + try { + return JSON.parse(readFileSync(configPath, "utf-8")); + } catch (err: unknown) { + const code = (err as { code?: string })?.code; + const msg = code === "ENOENT" + ? `Config file not found: ${configPath}` + : `Failed to read config: ${configPath}`; + process.stderr.write(`[openviking-memory] ${msg}\n`); + process.exit(1); + } +} + +function loadPluginConf(): Record { + const configPath = resolvePath( + (process.env.OPENVIKING_CODEX_CONFIG_FILE || DEFAULT_PLUGIN_CONFIG_PATH).replace(/^~/, homedir()), + ); + try { + return JSON.parse(readFileSync(configPath, "utf-8")); + } catch (err: unknown) { + const code = (err as { code?: string })?.code; + if (code === "ENOENT") return {}; + process.stderr.write(`[openviking-memory] Invalid Codex plugin config: ${configPath}\n`); + process.exit(1); + } +} + +function num(val: unknown, fallback: number): number { + 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: unknown, fallback: string): string { + if (typeof val === "string" && val.trim()) return val.trim(); + return fallback; +} + +function normalizeMode(val: unknown): "full" | "recall_only" { + return str(val, "full") === "recall_only" ? "recall_only" : "full"; +} + +const file = loadOvConf(); +const pluginFile = loadPluginConf(); +const serverCfg = (file.server ?? {}) as Record; +const codexCfg = pluginFile as Record; + +const host = str(serverCfg.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1"); +const port = Math.floor(num(serverCfg.port, 1933)); + +const config = { + baseUrl: `http://${host}:${port}`, + apiKey: str(serverCfg.root_api_key, ""), + agentId: str(process.env.OPENVIKING_AGENT_ID, str(codexCfg.agentId, "codex")), + timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, num(codexCfg.timeoutMs, 15000)))), + recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, num(codexCfg.recallLimit, 6)))), + scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, num(codexCfg.scoreThreshold, 0.01)))), + mode: normalizeMode(process.env.OPENVIKING_CODEX_MODE || codexCfg.mode), +}; + +function storeAndForgetDisabledMessage(): string { + return `OpenViking Codex plugin is in ${config.mode} mode. Manual store/delete operations are disabled.`; +} + +// --------------------------------------------------------------------------- +// OpenViking HTTP Client (ported from openclaw-plugin/client.ts) +// --------------------------------------------------------------------------- + +const MEMORY_URI_PATTERNS = [ + /^viking:\/\/user\/(?:[^/]+\/)?memories(?:\/|$)/, + /^viking:\/\/agent\/(?:[^/]+\/)?memories(?:\/|$)/, +]; +const USER_STRUCTURE_DIRS = new Set(["memories"]); +const AGENT_STRUCTURE_DIRS = new Set(["memories", "skills", "instructions", "workspaces"]); + +function md5Short(input: string): string { + return createHash("md5").update(input).digest("hex").slice(0, 12); +} + +function isMemoryUri(uri: string): boolean { + return MEMORY_URI_PATTERNS.some((p) => p.test(uri)); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class OpenVikingClient { + private resolvedSpaceByScope: Partial> = {}; + private runtimeIdentity: { userId: string; agentId: string } | null = null; + + constructor( + private readonly baseUrl: string, + private readonly apiKey: string, + private readonly agentId: string, + private readonly timeoutMs: number, + ) {} + + private async request(path: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers = new Headers(init.headers ?? {}); + if (this.apiKey) headers.set("X-API-Key", this.apiKey); + if (this.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(() => ({}))) as { + status?: string; + result?: T; + error?: { code?: string; message?: string }; + }; + + 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) as T; + } finally { + clearTimeout(timer); + } + } + + async healthCheck(): Promise { + try { + await this.request<{ status: string }>("/health"); + return true; + } catch { + return false; + } + } + + private async ls(uri: string): Promise>> { + return this.request>>( + `/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`, + ); + } + + private async getRuntimeIdentity(): Promise<{ userId: string; agentId: string }> { + if (this.runtimeIdentity) return this.runtimeIdentity; + const fallback = { userId: "default", agentId: this.agentId || "default" }; + try { + const status = await this.request<{ user?: unknown }>("/api/v1/system/status"); + const userId = + typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default"; + this.runtimeIdentity = { userId, agentId: this.agentId || "default" }; + return this.runtimeIdentity; + } catch { + this.runtimeIdentity = fallback; + return fallback; + } + } + + private async resolveScopeSpace(scope: ScopeName): Promise { + const cached = this.resolvedSpaceByScope[scope]; + if (cached) return cached; + + const identity = await this.getRuntimeIdentity(); + const fallbackSpace = + scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`); + const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS; + + try { + const entries = await this.ls(`viking://${scope}`); + const spaces = entries + .filter((e) => e?.isDir === true) + .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)) { + this.resolvedSpaceByScope[scope] = fallbackSpace; + return fallbackSpace; + } + if (scope === "user" && spaces.includes("default")) { + this.resolvedSpaceByScope[scope] = "default"; + return "default"; + } + if (spaces.length === 1) { + this.resolvedSpaceByScope[scope] = spaces[0]!; + return spaces[0]!; + } + } + } catch { /* fall through */ } + + this.resolvedSpaceByScope[scope] = fallbackSpace; + return fallbackSpace; + } + + private async normalizeTargetUri(targetUri: string): Promise { + const trimmed = targetUri.trim().replace(/\/+$/, ""); + const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); + if (!match) return trimmed; + + const scope = match[1] as ScopeName; + const rawRest = (match[2] ?? "").trim(); + if (!rawRest) return trimmed; + + const parts = rawRest.split("/").filter(Boolean); + if (parts.length === 0) return trimmed; + + const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS; + if (!reservedDirs.has(parts[0]!)) return trimmed; + + const space = await this.resolveScopeSpace(scope); + return `viking://${scope}/${space}/${parts.join("/")}`; + } + + async find( + query: string, + options: { targetUri: string; limit: number; scoreThreshold?: number }, + ): Promise { + const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri); + return this.request("/api/v1/search/find", { + method: "POST", + body: JSON.stringify({ + query, + target_uri: normalizedTargetUri, + limit: options.limit, + score_threshold: options.scoreThreshold, + }), + }); + } + + async read(uri: string): Promise { + return this.request(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`); + } + + async createSession(): Promise { + const result = await this.request<{ session_id: string }>("/api/v1/sessions", { + method: "POST", + body: JSON.stringify({}), + }); + return result.session_id; + } + + async addSessionMessage(sessionId: string, role: string, content: string): Promise { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ role, content }), + }); + } + + async sessionUsed(sessionId: string, contexts: string[]): Promise { + if (contexts.length === 0) return; + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/used`, { + method: "POST", + body: JSON.stringify({ contexts }), + }); + } + + async commitSession( + sessionId: string, + options?: { wait?: boolean; timeoutMs?: number }, + ): Promise { + const result = await this.request( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, + { method: "POST", body: JSON.stringify({}) }, + ); + + if (!options?.wait || !result.task_id) { + return result; + } + + const deadline = Date.now() + (options.timeoutMs ?? 120_000); + 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 ?? {}) as Record; + result.status = "completed"; + result.memories_extracted = (taskResult.memories_extracted ?? {}) as Record; + return result; + } + if (task.status === "failed") { + result.status = "failed"; + result.error = task.error; + return result; + } + } + + result.status = "timeout"; + return result; + } + + async getTask(taskId: string): Promise { + return this.request(`/api/v1/tasks/${encodeURIComponent(taskId)}`, { + method: "GET", + }); + } + + async deleteSession(sessionId: string): Promise { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); + } + + async deleteUri(uri: string): Promise { + await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { + method: "DELETE", + }); + } +} + +function isNotFoundError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes("NOT_FOUND") || message.includes("File not found"); +} + +async function waitForMemoryDeletion( + client: OpenVikingClient, + uri: string, + timeoutMs = 6_000, + intervalMs = 250, +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt <= timeoutMs) { + try { + await client.read(uri); + } catch (err) { + if (isNotFoundError(err)) { + return; + } + throw err; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`OpenViking delete for ${uri} did not settle within ${timeoutMs}ms`); +} + +function totalCommitMemories(result: CommitSessionResult): number { + return Object.values(result.memories_extracted ?? {}).reduce((sum, count) => sum + count, 0); +} + +// --------------------------------------------------------------------------- +// Memory ranking helpers (ported from openclaw-plugin/memory-ranking.ts) +// --------------------------------------------------------------------------- + +function clampScore(value: number | undefined): number { + if (typeof value !== "number" || Number.isNaN(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + +function normalizeDedupeText(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function getMemoryDedupeKey(item: FindResultItem): string { + const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? ""); + const category = (item.category ?? "").toLowerCase() || "unknown"; + if (abstract) return `abstract:${category}:${abstract}`; + return `uri:${item.uri}`; +} + +function postProcessMemories( + items: FindResultItem[], + options: { limit: number; scoreThreshold: number; leafOnly?: boolean }, +): FindResultItem[] { + const deduped: FindResultItem[] = []; + const seen = new Set(); + const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score)); + for (const item of sorted) { + if (options.leafOnly && item.level !== 2) continue; + if (clampScore(item.score) < options.scoreThreshold) continue; + const key = getMemoryDedupeKey(item); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + if (deduped.length >= options.limit) break; + } + return deduped; +} + +function formatMemoryLines(items: FindResultItem[]): string { + return items + .map((item, i) => { + const score = clampScore(item.score); + const abstract = item.abstract?.trim() || item.overview?.trim() || item.uri; + const category = item.category ?? "memory"; + return `${i + 1}. [${category}] ${abstract} (${(score * 100).toFixed(0)}%)`; + }) + .join("\n"); +} + +function formatStoredMemoryMatches(items: FindResultItem[]): string { + return items + .map((item) => { + const summary = item.abstract?.trim() || item.overview?.trim() || item.uri; + return `- ${item.uri} — ${summary}`; + }) + .join("\n"); +} + +function filterNearTopMatches( + items: FindResultItem[], + relativeGap: number, + minimumScore: number, +): FindResultItem[] { + if (items.length === 0) return []; + const topScore = clampScore(items[0]!.score); + const cutoff = Math.max(minimumScore, topScore >= 0.5 ? topScore - relativeGap : topScore * 0.8); + return items.filter((item) => clampScore(item.score) >= cutoff); +} + +// Query-aware ranking (ported from openclaw-plugin/memory-ranking.ts) +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 QUERY_TOKEN_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: string) { + const text = query.trim(); + const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) ?? []; + const tokens = allTokens.filter((t) => !QUERY_TOKEN_STOPWORDS.has(t)); + return { + tokens, + wantsPreference: PREFERENCE_QUERY_RE.test(text), + wantsTemporal: TEMPORAL_QUERY_RE.test(text), + }; +} + +function lexicalOverlapBoost(tokens: string[], text: string): number { + 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 rankForInjection(item: FindResultItem, query: ReturnType): number { + const baseScore = clampScore(item.score); + const abstract = (item.abstract ?? item.overview ?? "").trim(); + const leafBoost = item.level === 2 ? 0.12 : 0; + const cat = (item.category ?? "").toLowerCase(); + const eventBoost = query.wantsTemporal && (cat === "events" || item.uri.includes("/events/")) ? 0.1 : 0; + const prefBoost = query.wantsPreference && (cat === "preferences" || item.uri.includes("/preferences/")) ? 0.08 : 0; + const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`); + return baseScore + leafBoost + eventBoost + prefBoost + overlapBoost; +} + +function pickMemoriesForInjection(items: FindResultItem[], limit: number, queryText: string): FindResultItem[] { + if (items.length === 0 || limit <= 0) return []; + const query = buildQueryProfile(queryText); + const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query)); + + const deduped: FindResultItem[] = []; + const seen = new Set(); + for (const item of sorted) { + const key = (item.abstract ?? item.overview ?? "").trim().toLowerCase() || item.uri; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + + const leaves = deduped.filter((item) => item.level === 2); + if (leaves.length >= limit) return leaves.slice(0, limit); + + const picked = [...leaves]; + const used = new Set(leaves.map((item) => item.uri)); + for (const item of deduped) { + if (picked.length >= limit) break; + if (used.has(item.uri)) continue; + picked.push(item); + } + return picked; +} + +// --------------------------------------------------------------------------- +// Shared search helpers +// --------------------------------------------------------------------------- + +async function searchBothScopes( + client: OpenVikingClient, + query: string, + limit: number, +): Promise { + const [userSettled, agentSettled] = await Promise.allSettled([ + client.find(query, { targetUri: "viking://user/memories", limit, scoreThreshold: 0 }), + client.find(query, { targetUri: "viking://agent/memories", limit, scoreThreshold: 0 }), + ]); + + const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; + const agentResult = agentSettled.status === "fulfilled" ? agentSettled.value : { memories: [] }; + + const all = [...(userResult.memories ?? []), ...(agentResult.memories ?? [])]; + // Deduplicate by URI and keep only leaf memories + const unique = all.filter((m, i, self) => i === self.findIndex((o) => o.uri === m.uri)); + return unique.filter((m) => m.level === 2); +} + +async function findStoredMemories( + client: OpenVikingClient, + text: string, + displayLimit = 6, +): Promise { + const candidateLimit = Math.max(displayLimit * 4, 12); + const leafMemories = await searchBothScopes(client, text.slice(0, 500), candidateLimit); + const processed = postProcessMemories(leafMemories, { + limit: candidateLimit, + scoreThreshold: 0, + leafOnly: true, + }); + const picked = pickMemoriesForInjection(processed, candidateLimit, text); + const overlapping = picked.filter((item) => + lexicalOverlapBoost(buildQueryProfile(text).tokens, `${item.uri} ${item.abstract ?? item.overview ?? ""}`) > 0); + return filterNearTopMatches(overlapping, 0.15, 0); +} + +function markRecalledMemoriesUsed(client: OpenVikingClient, contexts: string[]): void { + const uniqueContexts = [...new Set(contexts.filter((uri) => typeof uri === "string" && uri.length > 0))]; + if (uniqueContexts.length === 0) return; + + void (async () => { + let sessionId: string | undefined; + try { + sessionId = await client.createSession(); + await client.sessionUsed(sessionId, uniqueContexts); + await client.commitSession(sessionId); + } catch { + // Fire-and-forget usage tracking must never block or fail the caller. + } finally { + if (sessionId) { + await client.deleteSession(sessionId).catch(() => {}); + } + } + })(); +} + +// --------------------------------------------------------------------------- +// MCP Server +// --------------------------------------------------------------------------- + +const client = new OpenVikingClient(config.baseUrl, config.apiKey, config.agentId, config.timeoutMs); + +const server = new McpServer({ + name: "openviking-memory-codex", + version: "0.1.0", +}); + +// -- Tool: memory_recall -------------------------------------------------- + +server.tool( + "openviking_recall", + "Manually inspect OpenViking long-term memory. Use only for explicit OpenViking recall or inspection requests. Normal background recall is handled by hooks, not this tool.", + { + query: z.string().describe("Search query — describe what you want to recall"), + limit: z.number().optional().describe("Max results to return (default: 6)"), + score_threshold: z.number().optional().describe("Min relevance score 0-1 (default: 0.01)"), + target_uri: z.string().optional().describe("Search scope URI, e.g. viking://user/memories"), + }, + async ({ query, limit, score_threshold, target_uri }) => { + const recallLimit = limit ?? config.recallLimit; + const threshold = score_threshold ?? config.scoreThreshold; + const candidateLimit = Math.max(recallLimit * 4, 20); + + let leafMemories: FindResultItem[]; + if (target_uri) { + const result = await client.find(query, { targetUri: target_uri, limit: candidateLimit, scoreThreshold: 0 }); + leafMemories = (result.memories ?? []).filter((m) => m.level === 2); + } else { + leafMemories = await searchBothScopes(client, query, candidateLimit); + } + + const processed = postProcessMemories(leafMemories, { limit: candidateLimit, scoreThreshold: threshold }); + const memories = pickMemoriesForInjection(processed, recallLimit, query); + + if (memories.length === 0) { + return { content: [{ type: "text" as const, text: "No relevant memories found in OpenViking." }] }; + } + + markRecalledMemoriesUsed(client, memories.map((memory) => memory.uri)); + + // Read full content for leaf memories + const lines = await Promise.all( + memories.map(async (item) => { + if (item.level === 2) { + try { + const content = await client.read(item.uri); + if (content?.trim()) return `- [${item.category ?? "memory"}] ${content.trim()}`; + } catch { /* fallback */ } + } + return `- [${item.category ?? "memory"}] ${item.abstract ?? item.uri}`; + }), + ); + + return { + content: [{ + type: "text" as const, + text: `Found ${memories.length} relevant memories:\n\n${lines.join("\n")}\n\n---\n${formatMemoryLines(memories)}`, + }], + }; + }, +); + +// -- Tool: memory_store --------------------------------------------------- + +server.tool( + "openviking_store", + "Manually persist information into OpenViking long-term memory. Use only for explicit OpenViking save requests or direct memory control. Normal background capture is handled by hooks, not this tool.", + { + text: z.string().describe("The information to store as memory"), + role: z.string().optional().describe("Message role: 'user' (default) or 'assistant'"), + }, + async ({ text, role }) => { + if (config.mode === "recall_only") { + return { + content: [{ type: "text" as const, text: storeAndForgetDisabledMessage() }], + }; + } + + const msgRole = role || "user"; + let sessionId: string | undefined; + try { + sessionId = await client.createSession(); + await client.addSessionMessage(sessionId, msgRole, text); + const commitResult = await client.commitSession(sessionId, { + wait: true, + timeoutMs: 180_000, + }); + const memoriesCount = totalCommitMemories(commitResult); + + if (commitResult.status === "failed") { + return { + content: [{ + type: "text" as const, + text: `Memory extraction failed: ${String(commitResult.error ?? "unknown error")}`, + }], + }; + } + + if (commitResult.status === "timeout") { + return { + content: [{ + type: "text" as const, + text: `Memory extraction timed out. It may still complete in the background (task_id=${commitResult.task_id ?? "none"}).`, + }], + }; + } + + if (memoriesCount === 0) { + return { + content: [{ + type: "text" as const, + text: "Memory stored but extraction returned 0 memories. The text may be too short or not contain extractable information. Check OpenViking server logs for details.", + }], + }; + } + + const storedMemories = await findStoredMemories(client, text).catch(() => []); + const storedSuffix = storedMemories.length > 0 + ? `\n\nLikely stored memories:\n${formatStoredMemoryMatches(storedMemories)}` + : ""; + + return { + content: [{ + type: "text" as const, + text: `OpenViking reported ${memoriesCount} extracted memory item(s).${storedSuffix}`, + }], + }; + } finally { + if (sessionId) { + await client.deleteSession(sessionId).catch(() => {}); + } + } + }, +); + +// -- Tool: memory_forget -------------------------------------------------- + +server.tool( + "openviking_forget", + "Manually delete OpenViking long-term memories. Use for explicit correction or deletion requests. Provide an exact URI for direct deletion, or a search query to find matching memories.", + { + uri: z.string().optional().describe("Exact viking:// memory URI to delete"), + query: z.string().optional().describe("Search query to find the memory to delete"), + target_uri: z.string().optional().describe("Search scope URI (default: viking://user/memories)"), + }, + async ({ uri, query, target_uri }) => { + if (config.mode === "recall_only") { + return { + content: [{ type: "text" as const, text: storeAndForgetDisabledMessage() }], + }; + } + + // Direct URI deletion + if (uri) { + if (!isMemoryUri(uri)) { + return { content: [{ type: "text" as const, text: `Refusing to delete non-memory URI: ${uri}` }] }; + } + await client.deleteUri(uri); + await waitForMemoryDeletion(client, uri); + return { content: [{ type: "text" as const, text: `Deleted memory: ${uri}` }] }; + } + + if (!query) { + return { content: [{ type: "text" as const, text: "Please provide either a uri or query parameter." }] }; + } + + // Search then delete + const candidateLimit = 20; + let candidates: FindResultItem[]; + + if (target_uri) { + const result = await client.find(query, { targetUri: target_uri, limit: candidateLimit, scoreThreshold: 0 }); + candidates = postProcessMemories(result.memories ?? [], { + limit: candidateLimit, + scoreThreshold: config.scoreThreshold, + leafOnly: true, + }).filter((item) => isMemoryUri(item.uri)); + } else { + const leafMemories = await searchBothScopes(client, query, candidateLimit); + candidates = postProcessMemories(leafMemories, { + limit: candidateLimit, + scoreThreshold: config.scoreThreshold, + leafOnly: true, + }).filter((item) => isMemoryUri(item.uri)); + } + + candidates = filterNearTopMatches(candidates, 0.15, config.scoreThreshold); + + if (candidates.length === 0) { + return { content: [{ type: "text" as const, text: "No matching memories found. Try a more specific query." }] }; + } + + // Auto-delete if single strong match + const top = candidates[0]!; + if (candidates.length === 1 && clampScore(top.score) >= 0.7) { + await client.deleteUri(top.uri); + await waitForMemoryDeletion(client, top.uri); + return { content: [{ type: "text" as const, text: `Deleted memory: ${top.uri}` }] }; + } + + // List candidates for confirmation + const list = candidates + .map((item) => `- ${item.uri} — ${item.abstract?.trim() || "?"} (${(clampScore(item.score) * 100).toFixed(0)}%)`) + .join("\n"); + + return { + content: [{ + type: "text" as const, + text: `Found ${candidates.length} candidate memories. Please specify the exact URI to delete:\n\n${list}`, + }], + }; + }, +); + +// -- Tool: memory_health -------------------------------------------------- + +server.tool( + "openviking_health", + "Check whether the OpenViking memory server is reachable and healthy.", + {}, + async () => { + const ok = await client.healthCheck(); + return { + content: [{ + type: "text" as const, + text: ok + ? `OpenViking is healthy (${config.baseUrl})` + : `OpenViking is unreachable at ${config.baseUrl}. Please check if the server is running.`, + }], + }; + }, +); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/codex-memory-plugin/tsconfig.json b/examples/codex-memory-plugin/tsconfig.json new file mode 100644 index 000000000..7f284c9a1 --- /dev/null +++ b/examples/codex-memory-plugin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./servers", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts"] +} diff --git a/openviking/storage/viking_fs.py b/openviking/storage/viking_fs.py index 0bf15ea51..932460caf 100644 --- a/openviking/storage/viking_fs.py +++ b/openviking/storage/viking_fs.py @@ -795,7 +795,10 @@ async def abstract( if not info.get("isDir"): raise ValueError(f"{uri} is not a directory") file_path = f"{path}/.abstract.md" - content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + try: + content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + except Exception: + raise NotFoundError(f"{uri}/.abstract.md", "file") if self._encryptor: real_ctx = self._ctx_or_default(ctx) @@ -815,7 +818,10 @@ async def overview( if not info.get("isDir"): raise ValueError(f"{uri} is not a directory") file_path = f"{path}/.overview.md" - content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + try: + content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + except Exception: + raise NotFoundError(f"{uri}/.overview.md", "file") if self._encryptor: real_ctx = self._ctx_or_default(ctx) diff --git a/tests/api_test/filesystem/test_fs_read_write.py b/tests/api_test/filesystem/test_fs_read_write.py index 53cca7c23..36b3203eb 100644 --- a/tests/api_test/filesystem/test_fs_read_write.py +++ b/tests/api_test/filesystem/test_fs_read_write.py @@ -3,22 +3,23 @@ class TestFsReadWrite: def test_fs_read(self, api_client): - try: - response = api_client.fs_ls("viking://") - print(f"\nList root directory API status code: {response.status_code}") - assert response.status_code == 200, ( - f"Failed to list root directory: {response.status_code}" - ) + session_id = None + try: + response = api_client.create_session() + assert response.status_code == 200, "Create session failed" data = response.json() assert data.get("status") == "ok", f"Expected status 'ok', got {data.get('status')}" assert data.get("error") is None, f"Expected error to be null, got {data.get('error')}" + session_id = data["result"]["session_id"] - result = data.get("result", []) - assert len(result) > 0, "No files found in root" + response = api_client.add_message(session_id, "user", "Hello, file read test!") + assert response.status_code == 200, "Add message failed" + data = response.json() + assert data.get("status") == "ok", f"Expected status 'ok', got {data.get('status')}" + assert data.get("error") is None, f"Expected error to be null, got {data.get('error')}" - test_file_path = result[0].get("uri") - assert test_file_path is not None, "No suitable file found" + test_file_path = f"viking://session/default/{session_id}/messages.jsonl" response = api_client.fs_read(test_file_path) print(f"\nFS read API status code: {response.status_code}") @@ -37,3 +38,6 @@ def test_fs_read(self, api_client): except Exception as e: print(f"Error: {e}") raise + finally: + if session_id: + api_client.delete_session(session_id) diff --git a/tests/integration/test_quick_start_lite.py b/tests/integration/test_quick_start_lite.py index b7a393a91..5c9d70074 100644 --- a/tests/integration/test_quick_start_lite.py +++ b/tests/integration/test_quick_start_lite.py @@ -14,6 +14,56 @@ from openviking.models.embedder.base import EmbedResult # noqa: E402 +class _FakeEmbedder: + def __init__(self, dimension: int): + self._dimension = dimension + self.is_sparse = False + self.is_hybrid = False + + def get_dimension(self): + return self._dimension + + def _generate_pseudo_embedding(self, text: str): + """ + Generate a deterministic pseudo-embedding based on text content. + Features: + 1. Deterministic: Same text -> Same vector (using hash seed) + 2. Semantic Simulation: If text contains 'openviking', boost dimension 0. + This allows "what is openviking" query to match "OpenViking" docs better. + 3. Length Feature: Encode length in dimension 1 (as requested by user). + """ + import hashlib + import math + import random + + text_lower = text.lower() + hash_object = hashlib.md5(text_lower.encode("utf-8")) + seed = int(hash_object.hexdigest(), 16) + rng = random.Random(seed) + + vector = [rng.uniform(-0.1, 0.1) for _ in range(self._dimension)] + + if "openviking" in text_lower: + vector[0] = 1.0 + + length_feature = min(len(text) / 10000.0, 1.0) + vector[1] = length_feature + + norm = math.sqrt(sum(x**2 for x in vector)) + if norm > 0: + vector = [x / norm for x in vector] + else: + vector = [0.0] * self._dimension + + return vector + + def embed(self, text: str, is_query: bool = False) -> EmbedResult: + return EmbedResult(dense_vector=self._generate_pseudo_embedding(text)) + + def embed_batch(self, texts, is_query: bool = False): + return [self.embed(text, is_query=is_query) for text in texts] + + class TestQuickStartLite(unittest.TestCase): def setUp(self): # Clean up data directory if exists to ensure fresh start @@ -140,64 +190,9 @@ def generate_pseudo_completion(prompt: str, images=None, **kwargs): ) # --- 2. Mock Embedder --- - mock_embedder = MagicMock() # Default config usually uses 2048 dimension unless overridden DIMENSION = 2048 - mock_embedder.get_dimension.return_value = DIMENSION - mock_embedder.is_sparse = False - mock_embedder.is_hybrid = False - - def generate_pseudo_embedding(text: str): - """ - Generate a deterministic pseudo-embedding based on text content. - Features: - 1. Deterministic: Same text -> Same vector (using hash seed) - 2. Semantic Simulation: If text contains 'openviking', boost dimension 0. - This allows "what is openviking" query to match "OpenViking" docs better. - 3. Length Feature: Encode length in dimension 1 (as requested by user). - """ - import hashlib - import math - import random - - # 1. Deterministic Randomness based on text content - text_lower = text.lower() - hash_object = hashlib.md5(text_lower.encode("utf-8")) - seed = int(hash_object.hexdigest(), 16) - rng = random.Random(seed) - - # Initialize random vector [-0.1, 0.1] - vector = [rng.uniform(-0.1, 0.1) for _ in range(DIMENSION)] - - # 2. Semantic Simulation (Keyword Boosting) - # If text is relevant to "openviking", boost the first dimension significantly - if "openviking" in text_lower: - vector[0] = 1.0 # Strong signal - - # 3. Length Feature (as requested) - # Map length to [0, 1] range roughly - length_feature = min(len(text) / 10000.0, 1.0) - vector[1] = length_feature - - # 4. L2 Normalization (Crucial for Cosine Similarity) - norm = math.sqrt(sum(x**2 for x in vector)) - if norm > 0: - vector = [x / norm for x in vector] - else: - vector = [0.0] * DIMENSION - - return vector - - # Mock embed_batch - def side_effect_embed_batch(texts): - return [EmbedResult(dense_vector=generate_pseudo_embedding(t)) for t in texts] - - # Mock single embed - def side_effect_embed(text): - return EmbedResult(dense_vector=generate_pseudo_embedding(text)) - - mock_embedder.embed_batch.side_effect = side_effect_embed_batch - mock_embedder.embed.side_effect = side_effect_embed + fake_embedder = _FakeEmbedder(DIMENSION) # --- 3. Patch Factories --- # We STILL need to patch get_embedder/get_vlm_instance because we don't want to use the REAL factories @@ -216,7 +211,7 @@ def side_effect_embed(text): patch.dict(os.environ, env_override), patch( "openviking_cli.utils.config.EmbeddingConfig.get_embedder", - return_value=mock_embedder, + return_value=fake_embedder, ), patch("openviking_cli.utils.config.VLMConfig.get_vlm_instance", return_value=mock_vlm), ):