diff --git a/README.md b/README.md index 28a2b8f5..9c582850 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ mnemon setup --target codex --yes One command deploys the mnemon skill, prompt files, and Codex lifecycle hooks (`SessionStart`, `UserPromptSubmit`, `Stop`) in `.codex/hooks.json`. +For Codex desktop, the optional [memory panel](plugins/codex-memory-panel/README.md) +adds project/global memory browsing, automatic updates and explicit memory +tools through a local MCP App. It is installed separately from lifecycle hooks. + ### [Cursor](https://cursor.com/) ```bash diff --git a/plugins/codex-memory-panel/.codex-plugin/plugin.json b/plugins/codex-memory-panel/.codex-plugin/plugin.json new file mode 100644 index 00000000..d5f92ab7 --- /dev/null +++ b/plugins/codex-memory-panel/.codex-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "mnemon-memory-panel", + "version": "0.2.3", + "description": "Inspect project and global memories, associate projects and help models recall verified lessons during development.", + "skills": "./skills", + "mcpServers": "./.mcp.json", + "license": "Apache-2.0", + "interface": { + "displayName": "Mnemon", + "shortDescription": "本地记忆面板 · 自动同步", + "longDescription": "Inspect named Mnemon stores, search memories, read their sources and see new memories automatically. Read-only, local-only; does not change the global active store.", + "developerName": "Local development preview", + "category": "Developer Tools", + "capabilities": ["Interactive", "Read", "Write"], + "brandColor": "#8BC9AE", + "logo": "./assets/icon.svg", + "composerIcon": "./assets/icon.svg" + } +} diff --git a/plugins/codex-memory-panel/.gitignore b/plugins/codex-memory-panel/.gitignore new file mode 100644 index 00000000..6eedd7f4 --- /dev/null +++ b/plugins/codex-memory-panel/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +.local/ diff --git a/plugins/codex-memory-panel/.mcp.json b/plugins/codex-memory-panel/.mcp.json new file mode 100644 index 00000000..d2205e14 --- /dev/null +++ b/plugins/codex-memory-panel/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "mnemon-memory-panel": { + "command": "node", + "args": ["server/index.mjs"], + "cwd": ".", + "env_vars": ["MNEMON_UI_STORE", "MNEMON_UI_DATA_DIR", "MNEMON_UI_COMMAND", "MNEMON_UI_PYTHON"], + "tool_timeout_sec": 60 + } + } +} diff --git a/plugins/codex-memory-panel/README.md b/plugins/codex-memory-panel/README.md new file mode 100644 index 00000000..af9e71c4 --- /dev/null +++ b/plugins/codex-memory-panel/README.md @@ -0,0 +1,224 @@ +# Mnemon memory panel for Codex + +A local MCP App for inspecting Mnemon stores and associating projects. +Declares a native sidebar (`global`) entrypoint and a conversation-panel +(`thread`) entrypoint. New memories appear automatically; filtering, selection +and scroll positions survive data updates. No model calls are needed for sync. +Optional explicit writes and a bundled development skill complete the memory +loop: recall before work, save verified project lessons after work, and keep +transferable global experience in a separate library. + +## Requirements + +- Node.js 22 or newer on the desktop app's PATH. +- Mnemon CLI, currently verified with **0.2.10**. +- Python 3.9 or newer for SQLite online backup (standard library only). +- An MCP Apps host supporting OpenAI global/thread entrypoints. Installed Codex + desktop **26.1002.7124.0** recognizes both descriptors; native visible placement + depends on the host. The global page is confirmed visible; an old conversation + reported an unknown MCP server before loading its thread panel. Native thread + rendering was verified after the 0.2.3 repair. Entry placement remains + host-controlled. + +## Install locally + +From this directory: + +```sh +npm ci +npm run install:codex -- my-project-store +``` + +The installer preserves an existing personal marketplace, copies this package +to `~/.codex/plugins/local/mnemon-memory-panel`, and installs it with +`codex plugin add`. It writes machine-specific settings only into that local +copy. `CODEX_CLI_PATH` can select the desktop app's CLI executable. + +Reload plugins or restart the desktop app, find **Mnemon** in the local plugin +source, and open its sidebar entry or conversation-panel entry. A host determines +the exact entry placement; this package cannot force arbitrary desktop chrome. +Selecting a store in this panel never changes Mnemon's global active store. + +### Keeping a left sidebar shortcut + +The OpenAI extension contract defines `global` as a fullscreen app and `thread` +as a content tab. The installed Codex client places plugin thread entries inside +**More tools**. This plugin cannot promote itself below Terminal, remove that +host menu, or redirect a global navigation click into an existing chat's right +panel. These requested placements require host support; the package does not +patch Codex application files. + +The installed desktop client includes a **Pin to sidebar** action in the +right-hand content tab's context menu. Right-click the Mnemon **tab title**, not +the plugin settings page, and select that action if available. It creates a +reference to that exact tab in the sidebar's Pinned section. The shortcut's +availability depends on the host's feature settings and the owning conversation; +it is not a separate plugin preference. A removed or unavailable source tab may +leave an unavailable shortcut. This route depends on the host and must not be +described as an unconditional global app entry. + +### An old conversation says “Could not display Mnemon” + +The observed desktop log reports `unknown MCP server 'mnemon-memory-panel'` +for both resource and tool requests scoped to the old conversation. Global +navigation can succeed while that conversation retains an earlier MCP server +configuration. Reload plugin configuration, or restart Codex and reopen that +conversation, then retry. No thread/model messages need to be sent for this. +An SDK/resource roundtrip alone does not confirm that an old conversation has +refreshed its server configuration. + +A later retry also exposed an active older server reading UI files removed +by a package upgrade (`ENOENT` in the versioned cache). Version 0.2.3 loads +the complete MCP HTML resource at startup, and the local installer retains +previous version runtime files through upgrades. Existing affected processes +need their files restored or a restart. This repair does not register a missing +server in an old conversation; that remains a separate host configuration issue. +On Windows, reinstalling the same version can fail if the host holds its cache +directory open. Close/restart the host before that operation rather than deleting +the active cache. Retained old versions can be cleaned after old sessions end. + +## Projects and global experience + +- **A project:** its own store, selected by an explicit directory association. + Unassociated discovered projects get a stable path-based store proposal, + never a guess based on a same-named folder. New stores are created on the + first explicit write. Existing stores can be associated without migrating + records or changing Mnemon's active store. +- **Global experience:** the separate reserved `codex-global` store. Keep + genuinely transferable lessons here. Projects cannot bind to this name, + including case variants on Windows. +- **All projects:** a read-only aggregate of existing stores, with provenance + on each record. It is not a writable store or a copy of all projects. +- **Choose project:** inspect discovered projects or paste a complete directory, + then associate its store. Manual associations survive missing host discovery. + +Discovery first uses available MCP roots and an optional read-only Codex saved +project catalog, isolated in `server/projects.mjs`. That host file is not a +guaranteed public API: unsupported/malformed future layouts fall back to saved +associations and manual directory entry. Explicit associations live under +`/codex-panel/projects.json`, outside project source. +An explicitly associated ancestor also covers its nested checkouts unless a +more specific explicit association exists. + +The `mnemon-project-memory` skill accompanies the MCP tools `mnemon_context` +and `mnemon_remember`. The former recalls the current project and global +library; the latter writes to an explicit scope after verification. The panel's +small **Supplement memory** dialog is optional, not the primary workflow. +Hosts decide skill selection, so this is guidance rather than a guarantee of +automatic extraction every turn. Existing hooks remain separate and unchanged; +their configured store must agree with the association when used together. + +On Windows use PowerShell 7. If PATH aliases for Python or Mnemon are unusable, +configure the executable explicitly before installation: + +```powershell +$env:MNEMON_UI_PYTHON = 'C:\path\to\python.exe' +$env:MNEMON_UI_COMMAND = 'C:\path\to\mnemon.exe' +npm run install:codex -- my-project-store +``` + +## Local preview + +```sh +npm run preview +``` + +Open `http://127.0.0.1:43187`. This preview uses the same HTML and data service +as the MCP App, but does not itself create the native sidebar entry. +The HTTP listener accepts loopback requests only, checks Host/Origin, and is not +required by the installed stdio plugin. Explicit project association and memory +write endpoints require same-origin JSON POST requests; browsing stays read-only. + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `MNEMON_UI_STORE` | Initial named store; defaults to `MNEMON_STORE`, then `default` | +| `MNEMON_UI_DATA_DIR` | Data root; defaults to `MNEMON_DATA_DIR`, then `~/.mnemon` | +| `MNEMON_UI_COMMAND` | Native executable or npm launcher `.js` path | +| `MNEMON_UI_PYTHON` | Python 3 executable; Windows `python`, other systems `python3` | +| `MNEMON_UI_PORT` | Preview port; defaults to 43187 | +| `MNEMON_UI_HOST_STATE` | Optional saved Codex project catalog path; discovery-only | + +## Data safety and upgrade compatibility + +The UI consumes a versioned normalized snapshot. Only `server/adapter.mjs` +knows Mnemon command/output shapes. Extra JSON fields are tolerated; optional +fields have defaults; missing required fields fail visibly and retain the last +successful display. The executable version is checked periodically. Unknown +versions are interface-validated, not claimed to be fully release-tested. + +Mnemon 0.2.10's `--readonly` uses immutable SQLite access and must not observe a +database being actively changed. Therefore the adapter discovers the path via +the CLI, copies a consistent live snapshot using SQLite online backup, and +then runs public `--readonly status` and `--readonly recall --basic` commands +against that temporary copy. It never queries Mnemon tables directly. Access +counters, operation logs and source content are unchanged. The SQLite reader +may use SQLite-managed WAL/SHM bookkeeping while a writer is active. + +Snapshots are removed after reads. A future official live-read interface or +different storage backend needs an adapter change, not a UI rewrite. No package +can guarantee compatibility with arbitrary future breaking engine changes. +Browsing never upgrades Mnemon or migrates its source database. Explicit writes +use the official writable CLI and its normal store initialization behavior. +Cross-process locks serialize this plugin's project associations and same-store +writes; external clients retain their own concurrency semantics. + +## Automatic updates + +The service watches the discovered store directory and polls file metadata every +2 seconds as a fallback. It debounces changes and publishes a content revision. +The UI keeps one long-poll request open through the MCP Apps bridge (at most +20 seconds), receiving changed data immediately when the adapter refreshes. +This is **change detection plus long polling**, not a claim that Mnemon exposes +a native event stream. Preview HTTP follows the identical contract. + +Error states distinguish an empty store from a failed connection, preserve +previous data, and retry automatically. Results are bounded to 5000 active +memories, 8 cached stores, 15-second CLI timeouts and bounded output. Large +stores show a truncation notice; search applies to the loaded set. Aggregate +browsing covers at most 64 stores and shows at most 5000 memories. + +## Local verification and acceptance + +Run `npm test` in this directory for the portable MCP resource upgrade and +engine-output compatibility checks. They use synthetic records and temporary +files, without accessing or writing a Mnemon memory store. + +Verified locally on Windows, Node 22, Python 3.13 and Mnemon 0.2.10: + +- Real named-store count matches loaded memories; Chinese content renders. +- Empty store is displayed as empty, not a connection error. +- A committed WAL record is included in the online backup; source DB bytes unchanged. +- Separate CLI writes unblock a waiting revision request in approximately 1 second. +- Extra fields are accepted; missing required fields fail closed. +- MCP SDK roundtrip returns both global/thread descriptors, HTML resource, + real memory data and unchanged heartbeats. +- Codex app-server lists the installed, enabled plugin and its tools with no + Mnemon tool-discovery errors. +- A real memory addition updates the open preview without reload, preserving + the active search and selected memory. +- A simulated initial MCP bridge failure recovers on the second initialization + attempt and displays the real store. +- Reinstalling without a store argument preserves the previously selected store + and the installer carries forward configured executable/data paths. +- Project/global records stay separate; uninitialized scopes are empty without + creating stores; aggregate writes and project binding to global are rejected. +- Concurrent identical plugin writes are serialized and duplicates are skipped. +- An active MCP resource stays readable after its package UI directory is replaced. +- Version 0.2.3 installs successfully while retaining the older runtime UI files. + +Native right-panel rendering was verified after the 0.2.3 repair. Automatic +updates were verified in the equivalent local preview; native rendering alone +does not establish live-update behavior in every host session. The global +left-rail entry still opens fullscreen, and the thread launcher remains under +More tools. These are host-controlled limitations, not configuration options +provided by this package. The Go product core is not modified. + +## Contributing upstream + +This optional integration is proposed for maintainer discussion in +[issue #155](https://github.com/mnemon-dev/mnemon/issues/155). Follow the root +`CONTRIBUTING.md` and run the required upstream checks before submission. +Do not include local settings, real memories, databases, private screenshots, +logs or `node_modules` in a contribution. diff --git a/plugins/codex-memory-panel/assets/icon.svg b/plugins/codex-memory-panel/assets/icon.svg new file mode 100644 index 00000000..a4910e37 --- /dev/null +++ b/plugins/codex-memory-panel/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/codex-memory-panel/mcp.json b/plugins/codex-memory-panel/mcp.json new file mode 100644 index 00000000..f70c4bee --- /dev/null +++ b/plugins/codex-memory-panel/mcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "mnemon-memory-panel": { + "type": "stdio", + "command": "node", + "args": ["${PLUGIN_ROOT}/server/index.mjs"], + "cwd": "./" + } + } +} diff --git a/plugins/codex-memory-panel/package-lock.json b/plugins/codex-memory-panel/package-lock.json new file mode 100644 index 00000000..99969bab --- /dev/null +++ b/plugins/codex-memory-panel/package-lock.json @@ -0,0 +1,1206 @@ +{ + "name": "@mnemon-dev/codex-memory-panel", + "version": "0.2.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mnemon-dev/codex-memory-panel", + "version": "0.2.3", + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1", + "zod": "3.25.76" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "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/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/plugins/codex-memory-panel/package.json b/plugins/codex-memory-panel/package.json new file mode 100644 index 00000000..2f2cdd22 --- /dev/null +++ b/plugins/codex-memory-panel/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mnemon-dev/codex-memory-panel", + "version": "0.2.3", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test", + "start": "node server/index.mjs", + "preview": "node server/index.mjs --preview", + "install:codex": "node scripts/install.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1", + "zod": "3.25.76" + } +} diff --git a/plugins/codex-memory-panel/plugin.json b/plugins/codex-memory-panel/plugin.json new file mode 100644 index 00000000..da7d471a --- /dev/null +++ b/plugins/codex-memory-panel/plugin.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "mnemon-memory-panel", + "version": "0.2.3", + "description": "Local Mnemon project and global memory panel, automatic updates, and verified model memory workflow.", + "license": "Apache-2.0" +} diff --git a/plugins/codex-memory-panel/scripts/install.mjs b/plugins/codex-memory-panel/scripts/install.mjs new file mode 100644 index 00000000..f69ca55b --- /dev/null +++ b/plugins/codex-memory-panel/scripts/install.mjs @@ -0,0 +1,77 @@ +// Install a personal local plugin source. GitHub publication is a separate step. +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const run = promisify(execFile); +const source = fileURLToPath(new URL('../', import.meta.url)); +const installRoot = path.join(homedir(), '.codex', 'plugins', 'local', 'mnemon-memory-panel'); +const catalogPath = path.join(homedir(), '.agents', 'plugins', 'marketplace.json'); +const codex = process.env.CODEX_CLI_PATH || 'codex'; +// Read settings before copying the distributable manifests over the local copy. +let previousEnv = {}; +for (const name of ['mcp.json', '.mcp.json']) { + try { + const previous = JSON.parse(await readFile(path.join(installRoot, name), 'utf8')); + previousEnv = previous.mcpServers?.['mnemon-memory-panel']?.env || {}; + break; + } catch (error) { if (error.code !== 'ENOENT') throw error; } +} +const store = process.argv[2] || process.env.MNEMON_UI_STORE || previousEnv.MNEMON_UI_STORE || 'default'; +if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(store)) throw new Error('Invalid store name.'); +const settings = { MNEMON_UI_STORE: store }; +for (const name of ['MNEMON_UI_PYTHON', 'MNEMON_UI_COMMAND', 'MNEMON_UI_DATA_DIR', 'MNEMON_UI_HOST_STATE']) { + const value = process.env[name] !== undefined ? process.env[name] : previousEnv[name]; + if (typeof value === 'string') settings[name] = value; +} + +await mkdir(installRoot, { recursive: true }); +await cp(source, installRoot, { recursive: true, filter: file => !file.split(path.sep).some(part => ['.local', 'build'].includes(part)) }); +// Resolve the source once; installed startup never relies on the active project cwd. +const mcp = { mcpServers: { 'mnemon-memory-panel': { + command: process.execPath, args: [path.join(installRoot, 'server', 'index.mjs')], cwd: installRoot, + env: settings, + env_vars: ['MNEMON_UI_DATA_DIR', 'MNEMON_UI_COMMAND', 'MNEMON_UI_PYTHON'], tool_timeout_sec: 60, +} } }; +await writeFile(path.join(installRoot, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n'); +await writeFile(path.join(installRoot, 'mcp.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { 'mnemon-memory-panel': { + type: 'stdio', command: 'node', args: ['${PLUGIN_ROOT}/server/index.mjs'], cwd: './', + env: mcp.mcpServers['mnemon-memory-panel'].env, + } }, +}, null, 2) + '\n'); +let catalog; +try { catalog = JSON.parse(await readFile(catalogPath, 'utf8')); } +catch (error) { if (error.code !== 'ENOENT') throw error; catalog = { name: 'personal-local', interface: { displayName: 'Personal Local Plugins' }, plugins: [] }; } +if (!Array.isArray(catalog.plugins)) throw new Error('Existing marketplace plugins field is invalid.'); +if (!catalog.plugins.some(item => item.name === 'mnemon-memory-panel')) catalog.plugins.push({ + name: 'mnemon-memory-panel', source: { source: 'local', path: './.codex/plugins/local/mnemon-memory-panel' }, + policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Developer Tools', +}); +await mkdir(path.dirname(catalogPath), { recursive: true }); +await writeFile(catalogPath, JSON.stringify(catalog, null, 2) + '\n'); +// The host may remove old cache files while an existing conversation still +// references that version. Retain those runtime files through local upgrades. +const cacheRoot = path.join(homedir(), '.codex', 'plugins', 'cache', catalog.name, 'mnemon-memory-panel'); +let versions = []; +try { versions = (await readdir(cacheRoot, { withFileTypes: true })).filter(item => item.isDirectory() && /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(item.name)).map(item => item.name); } +catch (error) { if (error.code !== 'ENOENT') throw error; } +const retained = versions.length ? await mkdtemp(path.join(tmpdir(), 'mnemon-panel-upgrade-')) : undefined; +try { + for (const version of versions) await cp(path.join(cacheRoot, version), path.join(retained, version), { recursive: true }); + try { + const { stdout } = await run(codex, ['plugin', 'add', `mnemon-memory-panel@${catalog.name}`, '--json'], { encoding: 'utf8', timeout: 45000, windowsHide: true }); + process.stdout.write(stdout); + } finally { + for (const version of versions) await cp(path.join(retained, version), path.join(cacheRoot, version), { recursive: true, force: false, errorOnExist: false }); + } +} catch (error) { + if (retained) process.stderr.write(`Retained previous runtime files at ${retained}\n`); + throw error; +} +if (retained) await rm(retained, { recursive: true, force: true }); +process.stdout.write(`Installed Mnemon for store '${store}'. Reload plugins or restart the desktop app to discover entrypoints.\n`); diff --git a/plugins/codex-memory-panel/server/adapter.mjs b/plugins/codex-memory-panel/server/adapter.mjs new file mode 100644 index 00000000..3a04944c --- /dev/null +++ b/plugins/codex-memory-panel/server/adapter.mjs @@ -0,0 +1,150 @@ +import { execFile, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { withFileLock } from './lock.mjs'; + +const execute = promisify(execFile); +const helper = fileURLToPath(new URL('./snapshot.py', import.meta.url)); +const MAX_MEMORIES = 5000; +const opts = { encoding: 'utf8', timeout: 15000, maxBuffer: 24 * 1024 * 1024, windowsHide: true }; +const text = (v, fallback = '') => typeof v === 'string' ? v : fallback; +const strings = v => Array.isArray(v) ? v.filter(x => typeof x === 'string') : []; + +export function validStore(store) { + if (typeof store !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(store)) throw new Error('Invalid memory store name.'); + return store; +} + +export function normalize(rows, stats, version, store, stores) { + if (!Array.isArray(rows) || !stats || !Number.isInteger(stats.total_insights) || stats.total_insights < 0) { + throw new Error('Mnemon JSON interface changed: expected memory array and total_insights.'); + } + const memories = rows.slice(0, MAX_MEMORIES).map(row => { + if (!row || typeof row.id !== 'string' || typeof row.content !== 'string') throw new Error('Mnemon memory interface changed: missing id/content.'); + return { id: row.id, content: row.content, category: text(row.category, 'unknown'), importance: Number.isFinite(row.importance) ? row.importance : null, + tags: strings(row.tags), entities: strings(row.entities), source: text(row.source), createdAt: text(row.created_at), updatedAt: text(row.updated_at) }; + }).sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id)); + return { schemaVersion: 1, store, stores, version, memories, + stats: { total: stats.total_insights, edges: Number.isFinite(stats.edge_count) ? stats.edge_count : null, + deleted: Number.isFinite(stats.deleted_insights) ? stats.deleted_insights : null, bytes: Number.isFinite(stats.db_size_bytes) ? stats.db_size_bytes : null }, + truncated: stats.total_insights > memories.length, limit: MAX_MEMORIES, + compatibility: version === '0.2.10' ? 'validated-version' : 'interface-validated', + syncMethod: 'sqlite-online-backup', dbPath: text(stats.db_path) }; +} + +export class MnemonAdapter { + constructor(env = process.env) { + this.env = env; + this.dataDir = env.MNEMON_UI_DATA_DIR || env.MNEMON_DATA_DIR || path.join(homedir(), '.mnemon'); + this.defaultStore = validStore(env.MNEMON_UI_STORE || env.MNEMON_STORE || 'default'); + this.paths = new Map(); + this.versionChecked = 0; + const configured = env.MNEMON_UI_COMMAND; + const npmCli = process.platform === 'win32' ? path.join(env.APPDATA || path.join(homedir(), 'AppData', 'Roaming'), 'npm', 'node_modules', '@mnemon-dev', 'mnemon', 'bin', 'mnemon.js') : ''; + const command = configured || (npmCli && existsSync(npmCli) ? npmCli : 'mnemon'); + this.launch = /\.[cm]?js$/i.test(command) ? { command: process.execPath, prefix: [command] } : { command, prefix: [] }; + this.python = env.MNEMON_UI_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + } + + async cli(args, dataDir = this.dataDir, store) { + const flags = ['--data-dir', dataDir, ...(store ? ['--store', validStore(store)] : [])]; + try { + return (await execute(this.launch.command, [...this.launch.prefix, ...flags, ...args], opts)).stdout.trim(); + } catch (error) { + const detail = text(error.stderr).trim().split('\n')[0] || error.message; + throw new Error(`Mnemon: ${detail}`); + } + } + + async detect() { + if (Date.now() - this.versionChecked < 60000) return this.version; + const version = (await this.cli(['--version'])).match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/)?.[0]; + if (!version) throw new Error('Cannot detect Mnemon version.'); + if (this.version !== version) { + const help = await this.cli(['--help']); + if (!help.includes('--readonly')) throw new Error('This Mnemon version has no safe readonly CLI. Adapter update required.'); + this.paths.clear(); + } + this.version = version; + this.versionChecked = Date.now(); + return version; + } + + async stores() { + const raw = await this.cli(['--readonly', 'store', 'list']); + // store list is a human-readable public command in 0.2.x; keep this parser isolated. + const stores = raw.split(/\r?\n/).map(line => line.trim().replace(/^\*\s*/, '')).filter(line => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(line)); + if (!stores.length && !raw.includes('no stores yet')) throw new Error('Memory store list format changed.'); + return stores; + } + + async remember(store, content, category = 'insight', source = 'user') { + validStore(store); + if (typeof content !== 'string' || !content.trim() || Buffer.byteLength(content, 'utf8') > 8000) throw new Error('记忆内容须为 1–8000 UTF-8 字节。'); + if (!['insight', 'decision', 'preference', 'context', 'fact', 'general'].includes(category)) throw new Error('记忆分类无效。'); + if (!['user', 'agent'].includes(source)) throw new Error('记忆来源无效。'); + await this.detect(); + const lockName = process.platform === 'win32' ? store.toLowerCase() : store; + const output = await withFileLock(path.join(this.dataDir, 'codex-panel', `write-${lockName}.lock`), async () => + JSON.parse(await this.cli(['remember', '--cat', category, '--source', source, '--', content.trim()], this.dataDir, store))); + if (typeof output.id !== 'string' && typeof output.action !== 'string') throw new Error('写入返回格式变化,请先核对 Mnemon,勿重复提交。'); + this.paths.delete(store); + return output; + } + + async databasePath(store) { + if (this.paths.has(store)) return this.paths.get(store); + // Used only for path discovery. Counts from immutable live-store access are never displayed. + const data = JSON.parse(await this.cli(['--readonly', 'status'], this.dataDir, store)); + if (typeof data.db_path !== 'string' || !path.isAbsolute(data.db_path)) throw new Error('Mnemon no longer exposes a local SQLite path. Adapter update required.'); + this.paths.set(store, data.db_path); + return data.db_path; + } + + async fingerprint(store) { + await this.detect(); + const db = await this.databasePath(store); + const values = await Promise.all([db, `${db}-wal`].map(async file => { + try { const s = await stat(file, { bigint: true }); return `${s.size}:${s.mtimeNs}`; } + catch (e) { if (e.code === 'ENOENT') return 'missing'; throw e; } + })); + return [this.version, ...values].join('|'); + } + + async backup(source, target) { + await new Promise((resolve, reject) => { + const child = spawn(this.python, [helper], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error('Live snapshot timed out.')); }, 12000); + child.stdout.resume(); + child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-2000); }); + child.stdin.on('error', () => {}); + child.on('error', error => { clearTimeout(timer); reject(new Error(`Snapshot requires Python 3: ${error.message}`)); }); + child.on('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`Cannot read live snapshot: ${stderr.trim() || code}`)); }); + child.stdin.end(JSON.stringify({ source, target })); + }); + } + + async read(store, query = '') { + validStore(store); + const version = await this.detect(); + const stores = await this.stores(); + if (!stores.includes(store)) throw new Error(`Memory store '${store}' is unavailable.`); + const dbPath = await this.databasePath(store); + const folder = await mkdtemp(path.join(tmpdir(), 'mnemon-ui-')); + try { + await this.backup(dbPath, path.join(folder, 'data', 'default', 'mnemon.db')); + const [stats, rows] = await Promise.all([ + this.cli(['--readonly', 'status'], folder, 'default'), + this.cli(['--readonly', 'recall', query, '--basic', '--limit', String(query ? 8 : MAX_MEMORIES + 1)], folder, 'default'), + ]); + const snapshot = normalize(JSON.parse(rows), JSON.parse(stats), version, store, stores); + snapshot.dbPath = dbPath; + return snapshot; + } finally { await rm(folder, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } + } +} diff --git a/plugins/codex-memory-panel/server/index.mjs b/plugins/codex-memory-panel/server/index.mjs new file mode 100644 index 00000000..e999f9c1 --- /dev/null +++ b/plugins/codex-memory-panel/server/index.mjs @@ -0,0 +1,125 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { MnemonAdapter } from './adapter.mjs'; +import { LiveMemoryService } from './live.mjs'; +import { MemoryScopes, validScope } from './scopes.mjs'; +import { GLOBAL_STORE } from './projects.mjs'; +import { fileURLToPath } from 'node:url'; + +const resourceUri = 'ui://mnemon/memory-panel/v1'; +const uiDir = new URL('../ui/', import.meta.url); +const adapter = new MnemonAdapter(); +const scopes = new MemoryScopes(adapter); +const service = new LiveMemoryService(scopes); +const icon = 'data:image/svg+xml;base64,' + Buffer.from('').toString('base64'); + +async function html() { + const [template, css, js] = await Promise.all(['index.html', 'style.css', 'app.js'].map(file => readFile(new URL(file, uiDir), 'utf8'))); + return template.replace('', ``).replace('', ``); +} + +// An installed package may be replaced while an existing host session still +// uses this process. Keep its UI resource available without reopening files. +const packagedHtml = await html(); + +function result(snapshot) { + return { content: [{ type: 'text', text: snapshot.connection?.message || `Mnemon ${snapshot.store}: ${snapshot.stats?.total ?? 'unchanged'} memories.` }], structuredContent: snapshot }; +} + +const server = new McpServer({ name: 'mnemon-memory-panel', title: 'Mnemon', version: '0.2.3', icons: [{ src: icon, mimeType: 'image/svg+xml' }] }); +scopes.registry.roots = async () => { + if (!server.server.getClientCapabilities()?.roots) return []; + const value = await server.server.listRoots({}, { timeout: 2000 }); + return value.roots.filter(root => root.uri.startsWith('file:')).map(root => ({ name: root.name, path: fileURLToPath(root.uri) })); +}; +const scopeSchema = z.string().refine(validScope, 'Invalid memory scope'); +async function context({ directory, query }) { + const stores = await adapter.stores(); + const project = await scopes.registry.resolve(directory, stores); + const targets = [...new Set([project?.store, GLOBAL_STORE].filter(Boolean))]; + const memories = []; + for (const store of targets) if (stores.includes(store)) { + const value = await adapter.read(store, query); + memories.push(...value.memories.map(memory => ({ ...memory, store, scope: store === GLOBAL_STORE ? 'global' : 'project' }))); + } + return { project, globalStore: GLOBAL_STORE, memories, unassociated: !project?.associated }; +} +server.registerResource('memory-panel', resourceUri, { mimeType: 'text/html;profile=mcp-app' }, async () => ({ contents: [{ + uri: resourceUri, mimeType: 'text/html;profile=mcp-app', text: packagedHtml, + _meta: { ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [] } } }, +}] })); +server.registerTool('open_mnemon', { + title: 'Mnemon', description: 'Open the local Mnemon memory panel. Inspect real memories, search and see updates automatically.', inputSchema: {}, + icons: [{ src: icon, mimeType: 'image/svg+xml' }], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + _meta: { ui: { resourceUri, visibility: ['app', 'model'] }, 'openai/outputTemplate': resourceUri, 'openai/widgetAccessible': true, + 'openai/ui': { entrypoints: [{ type: 'global' }, { type: 'thread' }] } }, +}, async (_, extra) => result(await service.snapshot({}, extra.signal))); +server.registerTool('mnemon_snapshot', { + title: 'Read live Mnemon memories', description: 'Read a normalized memory snapshot, or wait for its revision to change. Read-only; no recall counters or source data are modified.', + inputSchema: { store: scopeSchema.optional(), revision: z.string().max(128).optional(), waitMs: z.number().int().min(0).max(20000).optional() }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + _meta: { ui: { visibility: ['app', 'model'] }, 'openai/widgetAccessible': true }, +}, async (args, extra) => result(await service.snapshot(args, extra.signal))); +server.registerTool('mnemon_projects', { + title: 'List available memory projects', description: 'Discover local projects, project-store associations, global memory and existing Mnemon stores.', inputSchema: {}, + annotations: { readOnlyHint: true }, _meta: { ui: { visibility: ['app', 'model'] }, 'openai/widgetAccessible': true }, +}, async () => result(await scopes.catalog())); +server.registerTool('mnemon_associate_project', { + title: 'Associate a project with memory', description: 'Save a local project-directory association. Does not migrate memories, change hooks or switch the engine active store.', + inputSchema: { directory: z.string().min(1).max(4096), name: z.string().min(1).max(100), store: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/).optional() }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, _meta: { ui: { visibility: ['app', 'model'] }, 'openai/widgetAccessible': true }, +}, async args => result(await scopes.registry.associate(args))); +server.registerTool('mnemon_context', { + title: 'Recall project and global experience', description: 'Before development, recall relevant verified experience for the current directory and the global library. Returns project/store information for explicit later writes.', + inputSchema: { directory: z.string().min(1).max(4096), query: z.string().min(1).max(500) }, + annotations: { readOnlyHint: true, openWorldHint: false }, +}, async args => result(await context(args))); +server.registerTool('mnemon_remember', { + title: 'Remember verified experience', description: 'Explicitly save verified knowledge to a selected project or global library after development. Project-specific pitfalls stay in that project; transferable lessons may go to global. Inspect action=skipped for duplicates; never write to an aggregate view.', + inputSchema: { scope: scopeSchema, content: z.string().min(1).max(8000), category: z.enum(['insight', 'decision', 'preference', 'context', 'fact', 'general']).optional(), source: z.enum(['user', 'agent']).optional() }, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, _meta: { ui: { visibility: ['app', 'model'] }, 'openai/widgetAccessible': true }, +}, async args => result(await scopes.remember(args))); + +let preview; +if (process.argv.includes('--preview')) { + const port = Number(process.env.MNEMON_UI_PORT || 43187); + preview = createServer(async (req, res) => { + const base = `http://127.0.0.1:${port}`; + if (req.headers.host !== `127.0.0.1:${port}` || (req.headers.origin && req.headers.origin !== base)) { res.writeHead(403).end(); return; } + const url = new URL(req.url, base); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + try { + if (req.method === 'POST' && ['/api/associate', '/api/remember'].includes(url.pathname)) { + if (req.headers.origin !== base || req.headers['content-type'] !== 'application/json') { res.writeHead(403).end(); return; } + const chunks = []; let bytes = 0; + for await (const chunk of req) { bytes += chunk.length; if (bytes > 20000) throw new Error('Request too large.'); chunks.push(chunk); } + const args = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const data = url.pathname === '/api/associate' ? await scopes.registry.associate(args) : await scopes.remember(args); + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); + } else if (req.method !== 'GET') { res.writeHead(405).end(); return; } + else if (url.pathname === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:; base-uri 'none'; frame-ancestors 'none'"); + res.end(await html()); + } else if (url.pathname === '/api/projects') { + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(await scopes.catalog())); + } else if (url.pathname === '/api/snapshot') { + const cancellation = new AbortController(); + res.on('close', () => cancellation.abort()); + const waitMs = Math.min(20000, Math.max(0, Number(url.searchParams.get('waitMs')) || 0)); + const data = await service.snapshot({ store: url.searchParams.get('store') || undefined, revision: url.searchParams.get('revision') || undefined, waitMs }, cancellation.signal); + if (!res.destroyed) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(data)); } + } else res.writeHead(404).end(); + } catch (error) { res.writeHead(503, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: error.message })); } + }); + preview.listen(port, '127.0.0.1', () => process.stderr.write(`Mnemon preview: http://127.0.0.1:${port}\n`)); +} else await server.connect(new StdioServerTransport()); + +async function shutdown() { service.close(); preview?.close(); await server.close(); } +process.once('SIGINT', () => shutdown().finally(() => process.exit(0))); +process.once('SIGTERM', () => shutdown().finally(() => process.exit(0))); +if (!preview) process.stdin.once('end', () => shutdown().catch(() => {})); diff --git a/plugins/codex-memory-panel/server/live.mjs b/plugins/codex-memory-panel/server/live.mjs new file mode 100644 index 00000000..de8af8b6 --- /dev/null +++ b/plugins/codex-memory-panel/server/live.mjs @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; +import { watch } from 'node:fs'; +import path from 'node:path'; + +const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 24); + +export class LiveMemoryService { + constructor(adapter) { this.adapter = adapter; this.entries = new Map(); } + + async entry(store) { + let entry = this.entries.get(store); + if (entry) { entry.usedAt = Date.now(); return entry; } + if (this.entries.size >= 8) { + const idle = [...this.entries.values()].filter(x => !x.listeners.size && !x.loading).sort((a, b) => a.usedAt - b.usedAt)[0]; + if (!idle) throw new Error('Too many open memory stores. Close an unused panel.'); + this.release(idle); + } + entry = { store, usedAt: Date.now(), listeners: new Set(), fingerprint: null, last: null, error: null, revision: null, readAt: 0 }; + this.entries.set(store, entry); + entry.timer = setInterval(() => { + if (Date.now() - entry.usedAt > 60000 && !entry.listeners.size && !entry.loading) this.release(entry); + else this.refresh(entry).catch(() => {}); + }, 2000); + entry.timer.unref(); + await this.refresh(entry); + return entry; + } + + publish(entry, snapshot, error) { + if (snapshot) entry.last = snapshot; + entry.error = error; + const revision = hash({ data: entry.last, error }); + if (revision === entry.revision) return; + entry.revision = revision; + entry.changedAt = new Date().toISOString(); + for (const listener of [...entry.listeners]) listener(); + } + + async refresh(entry) { + if (entry.loading) return entry.loading; + entry.loading = (async () => { + try { + const fingerprint = await this.adapter.fingerprint(entry.store); + if (fingerprint === entry.fingerprint && !entry.error && Date.now() - entry.readAt < 30000) return; + const snapshot = await this.adapter.read(entry.store); + entry.fingerprint = fingerprint; + entry.readAt = Date.now(); + const directory = snapshot.dbPath ? path.dirname(snapshot.dbPath) : null; + if (directory && entry.watchPath !== directory) { + entry.watcher?.close(); + entry.watchPath = directory; + try { + entry.watcher = watch(directory, (_, file) => { + if (file && ![path.basename(snapshot.dbPath), `${path.basename(snapshot.dbPath)}-wal`].includes(String(file))) return; + clearTimeout(entry.debounce); + entry.debounce = setTimeout(() => this.refresh(entry).catch(() => {}), 180); + entry.debounce.unref(); + }); + entry.watcher.on('error', () => { entry.watcher?.close(); entry.watcher = null; entry.watchPath = null; }); + entry.watcher.unref(); + } catch { entry.watchPath = null; } + } + this.publish(entry, snapshot, null); + } catch (error) { this.publish(entry, null, error.message); } + })().finally(() => { entry.loading = null; }); + return entry.loading; + } + + async snapshot({ store = this.adapter.defaultStore, revision, waitMs = 0 } = {}, signal) { + const entry = await this.entry(store); + await this.refresh(entry); + if (revision === entry.revision && waitMs > 0 && !signal?.aborted) { + await new Promise(resolve => { + let timer; + const done = () => { clearTimeout(timer); entry.listeners.delete(done); signal?.removeEventListener('abort', done); resolve(); }; + entry.listeners.add(done); + signal?.addEventListener('abort', done, { once: true }); + timer = setTimeout(done, Math.min(waitMs, 20000)); + if (entry.revision !== revision || signal?.aborted) done(); + }); + } + const connection = { state: entry.error ? (entry.last ? 'stale' : 'error') : 'live', message: entry.error, updatedAt: entry.changedAt }; + if (revision === entry.revision) return { unchanged: true, revision: entry.revision, store, connection }; + return { ...(entry.last || { schemaVersion: 1, store }), revision: entry.revision, connection }; + } + + release(entry) { + clearInterval(entry.timer); clearTimeout(entry.debounce); entry.watcher?.close(); + for (const listener of [...entry.listeners]) listener(); + this.entries.delete(entry.store); + } + close() { for (const entry of [...this.entries.values()]) this.release(entry); } +} diff --git a/plugins/codex-memory-panel/server/lock.mjs b/plugins/codex-memory-panel/server/lock.mjs new file mode 100644 index 00000000..c962be91 --- /dev/null +++ b/plugins/codex-memory-panel/server/lock.mjs @@ -0,0 +1,47 @@ +import { mkdir, readFile, writeFile, unlink, rmdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +// Serializes plugin processes, without locking or altering engine database files. +export async function withFileLock(directory, operation) { + await mkdir(path.dirname(directory), { recursive: true }); + const file = path.join(directory, 'owner.json'), token = randomUUID(); + let acquired = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { await mkdir(directory); acquired = true; break; } + catch (error) { + if (error.code !== 'EEXIST') throw error; + // Serialize stale-owner recovery too: two reclaimers must not remove a + // fresh owner's file after the first reclaimer released the old lock. + const recovery = `${directory}.recovery`; + let reclaiming = false; + try { + await mkdir(recovery); reclaiming = true; + try { + const owner = JSON.parse(await readFile(file, 'utf8')); + if (Number.isInteger(owner.pid) && owner.pid > 0) { + try { process.kill(owner.pid, 0); } + catch (probe) { if (probe.code === 'ESRCH') { await unlink(file); await rmdir(directory); } } + } + } catch (probe) { + // Recover a crash between mkdir and writing the owner file. + if (probe.code === 'ENOENT') { + try { if (Date.now() - (await stat(directory)).mtimeMs > 30000) await rmdir(directory); } catch {} + } + } + } catch { /* Another reclaimer owns recovery, or recovery is unavailable. */ } + finally { if (reclaiming) await rmdir(recovery).catch(() => {}); } + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + if (!acquired) throw new Error('其他会话正在保存,请稍后核对结果再重试。'); + try { + await writeFile(file, JSON.stringify({ pid: process.pid, token }), { flag: 'wx', mode: 0o600 }); + return await operation(); + } finally { + try { + const owner = JSON.parse(await readFile(file, 'utf8')); + if (owner.token === token) { await unlink(file); await rmdir(directory); } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + } +} diff --git a/plugins/codex-memory-panel/server/projects.mjs b/plugins/codex-memory-panel/server/projects.mjs new file mode 100644 index 00000000..b1f25b48 --- /dev/null +++ b/plugins/codex-memory-panel/server/projects.mjs @@ -0,0 +1,89 @@ +// Optional host discovery is isolated here; manual associations remain portable. +import { readFile, writeFile, mkdir, rename, stat, realpath } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { withFileLock } from './lock.mjs'; + +const storePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +export const GLOBAL_STORE = 'codex-global'; +export function projectKey(directory) { + const value = path.resolve(directory); + return createHash('sha256').update(process.platform === 'win32' ? value.toLowerCase() : value).digest('hex').slice(0, 16); +} +export class ProjectRegistry { + constructor(dataDir, env = process.env) { + this.file = path.join(dataDir, 'codex-panel', 'projects.json'); + this.hostFile = env.MNEMON_UI_HOST_STATE || path.join(env.CODEX_HOME || path.join(homedir(), '.codex'), '.codex-global-state.json'); + this.roots = async () => []; + this.queue = Promise.resolve(); + } + async saved() { + try { + const value = JSON.parse(await readFile(this.file, 'utf8')); + if (value.schemaVersion !== 1 || !Array.isArray(value.projects)) throw new Error('项目关联文件格式已变化。'); + if (value.projects.some(p => !p || typeof p.path !== 'string' || !path.isAbsolute(p.path) || typeof p.name !== 'string' || typeof p.store !== 'string' || !storePattern.test(p.store) || p.store.toLowerCase() === GLOBAL_STORE)) throw new Error('项目关联无效,项目不能绑定全局经验库。'); + return value.projects; + } catch (error) { if (error.code === 'ENOENT') return []; throw error; } + } + async projects(stores = []) { + const found = new Map(), saved = await this.saved(); + const add = (directory, name, source) => { + if (typeof directory !== 'string' || !path.isAbsolute(directory)) return; + const id = projectKey(directory), basename = path.basename(directory); + found.set(id, { id, path: path.resolve(directory), name: typeof name === 'string' && name.trim() ? name.trim() : basename, + store: `project-${id}`, source, associated: false }); + }; + try { + // Codex's saved local-project catalog is optional, version-tolerant, read-only. + const info = await stat(this.hostFile); + if (info.size <= 8 * 1024 * 1024) { + const host = JSON.parse(await readFile(this.hostFile, 'utf8')); + const projects = host['local-projects']; + for (const value of Array.isArray(projects) ? projects : Object.values(projects || {})) { + if (!value || typeof value !== 'object') continue; + for (const directory of Array.isArray(value.rootPaths) ? value.rootPaths : []) add(directory, value.name, 'codex'); + } + for (const directory of host['electron-saved-workspace-roots'] || []) add(directory, undefined, 'codex'); + } + } catch { /* Unsupported/missing host catalog falls back to roots and saved paths. */ } + for (const root of await this.roots().catch(() => [])) add(root.path, root.name, 'mcp-roots'); + for (const value of saved) { + const id = projectKey(value.path); + found.set(id, { ...value, id, source: 'associated', associated: true }); + } + return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + async associate({ directory, name, store }) { + if (typeof directory !== 'string' || typeof name !== 'string') throw new Error('请提供项目名称和目录。'); + if (!path.isAbsolute(directory)) throw new Error('请输入完整项目目录。'); + const resolved = await realpath(directory); + if (!(await stat(resolved)).isDirectory()) throw new Error('选定路径不是项目目录。'); + if (!name.trim() || name.length > 100) throw new Error('项目名称长度应为 1–100。'); + if (store !== undefined && (typeof store !== 'string' || !storePattern.test(store))) throw new Error('记忆库名称无效。'); + if (store?.toLowerCase() === GLOBAL_STORE) throw new Error('项目不能绑定全局经验库,请使用项目专属库。'); + const operation = this.queue.then(async () => { + return withFileLock(`${this.file}.lock`, async () => { + const projects = await this.saved(), id = projectKey(resolved); + const previous = projects.find(p => projectKey(p.path) === id); + const project = { path: resolved, name: name.trim(), store: store || previous?.store || `project-${id}` }; + const next = [...projects.filter(p => projectKey(p.path) !== id), project]; + await mkdir(path.dirname(this.file), { recursive: true }); + const temp = `${this.file}.${randomUUID()}.tmp`; + await writeFile(temp, JSON.stringify({ schemaVersion: 1, projects: next }, null, 2) + '\n', { mode: 0o600 }); + await rename(temp, this.file); + return { ...project, id, associated: true }; + }); + }); + this.queue = operation.catch(() => {}); + return operation; + } + async resolve(directory, stores = []) { + if (!path.isAbsolute(directory)) throw new Error('召回需要完整项目目录。'); + const current = await realpath(directory), projects = await this.projects(stores); + return projects.filter(p => { + const relative = path.relative(p.path, current); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); + }).sort((a, b) => Number(b.associated) - Number(a.associated) || b.path.length - a.path.length)[0] || null; + } +} diff --git a/plugins/codex-memory-panel/server/scopes.mjs b/plugins/codex-memory-panel/server/scopes.mjs new file mode 100644 index 00000000..17a9838d --- /dev/null +++ b/plugins/codex-memory-panel/server/scopes.mjs @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto'; +import { ProjectRegistry, GLOBAL_STORE } from './projects.mjs'; + +export const validScope = value => typeof value === 'string' && /^(?:[A-Za-z0-9][A-Za-z0-9_-]{0,127}|@global|@all|@project:[a-f0-9]{16})$/.test(value); +export class MemoryScopes { + constructor(adapter) { + this.adapter = adapter; + this.registry = new ProjectRegistry(adapter.dataDir, adapter.env); + this.defaultStore = adapter.defaultStore === 'default' ? '@global' : adapter.defaultStore; + } + async catalog() { + const stores = await this.adapter.stores(); + const projects = await this.registry.projects(stores); + return { stores, projects, scopes: [ + { id: '@global', name: '全局经验', group: '通用', store: GLOBAL_STORE }, + { id: '@all', name: '全部项目 · 汇总浏览', group: '通用' }, + ...projects.map(p => ({ id: `@project:${p.id}`, name: p.name, group: '项目', store: p.store, path: p.path, associated: p.associated })), + ...stores.filter(store => store.toLowerCase() !== GLOBAL_STORE).map(store => ({ id: store, name: `${store} · ${projects.some(p => p.store === store) ? '已关联项目' : '未关联项目'}`, group: '已有记忆库', store })), + ] }; + } + async target(scope, catalog) { + catalog ||= await this.catalog(); + if (!validScope(scope)) throw new Error('记忆范围无效。'); + if (scope === '@all') throw new Error('汇总视图不能写入,请选定项目或全局经验。'); + const target = catalog.scopes.find(s => s.id === scope); + if (!target) throw new Error('项目或记忆库已不可用,请重新选择。'); + return target; + } + async fingerprint(scope) { + const catalog = await this.catalog(); + const stores = scope === '@all' ? catalog.stores : [(await this.target(scope, catalog)).store]; + const fingerprints = []; + for (const store of stores) fingerprints.push(catalog.stores.includes(store) ? await this.adapter.fingerprint(store) : 'empty'); + return createHash('sha256').update(JSON.stringify([scope, catalog, fingerprints])).digest('hex'); + } + async read(scope) { + const catalog = await this.catalog(); + if (scope === '@all') { + const results = [], errors = []; + for (const store of catalog.stores.slice(0, 64)) { + try { results.push(await this.adapter.read(store)); } + catch (error) { errors.push(`${store}: ${error.message}`); } + } + if (errors.length) throw new Error(`汇总未完成,保留原显示。${errors.join(';')}`); + const memories = results.flatMap(result => result.memories.map(memory => ({ ...memory, originalId: memory.id, id: `${result.store}:${memory.id}`, + store: result.store, project: catalog.projects.filter(p => p.store === result.store).map(p => p.name).join(' / ') || (result.store === GLOBAL_STORE ? '全局经验' : result.store) }))) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id)); + const total = results.reduce((sum, result) => sum + result.stats.total, 0); + const bounded = catalog.stores.length > 64 || total > 5000; + return { schemaVersion: 1, store: scope, ...catalog, version: await this.adapter.detect(), memories: memories.slice(0, 5000), + stats: { total, edges: results.every(r => r.stats.edges !== null) ? results.reduce((sum, r) => sum + r.stats.edges, 0) : null }, + truncated: bounded, limit: 5000, storeLimit: 64, dbPath: '', writable: false }; + } + const target = await this.target(scope, catalog); + const value = catalog.stores.includes(target.store) ? await this.adapter.read(target.store) : { + schemaVersion: 1, memories: [], stats: { total: 0, edges: 0 }, version: await this.adapter.detect(), truncated: false, limit: 5000, dbPath: '', + }; + return { ...value, ...catalog, store: scope, targetStore: target.store, scopeName: target.name, writable: true, + uninitialized: !catalog.stores.includes(target.store) }; + } + async remember({ scope, content, category = 'insight', source = 'user' }) { + const target = await this.target(scope); + const saved = await this.adapter.remember(target.store, content, category, source); + return { ...saved, targetStore: target.store, scopeName: target.name }; + } +} diff --git a/plugins/codex-memory-panel/server/snapshot.py b/plugins/codex-memory-panel/server/snapshot.py new file mode 100644 index 00000000..0508cd09 --- /dev/null +++ b/plugins/codex-memory-panel/server/snapshot.py @@ -0,0 +1,16 @@ +"""Copy a live SQLite store using online backup, without querying Mnemon tables.""" +import json +from pathlib import Path +import sqlite3 +import sys +from contextlib import closing + +request = json.load(sys.stdin) +source = Path(request["source"]).resolve(strict=True) +target = Path(request["target"]).resolve() +target.parent.mkdir(parents=True, exist_ok=True) +with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True, timeout=3)) as reader: + with closing(sqlite3.connect(str(target))) as writer: + with writer: + reader.backup(writer, pages=256, sleep=0.03) +print(json.dumps({"ok": True})) diff --git a/plugins/codex-memory-panel/skills/mnemon-project-memory/SKILL.md b/plugins/codex-memory-panel/skills/mnemon-project-memory/SKILL.md new file mode 100644 index 00000000..79326b1a --- /dev/null +++ b/plugins/codex-memory-panel/skills/mnemon-project-memory/SKILL.md @@ -0,0 +1,41 @@ +--- +name: mnemon-project-memory +description: Recall and save verified project lessons with Mnemon during development, debugging, reviews, and iteration in projects using the Mnemon memory panel. Keeps project-specific memory separate from transferable global experience. +--- + +# Project memory loop + +Use this workflow when developing a project with the Mnemon memory panel plugin +enabled. Memory is supporting evidence; current instructions, source and observed +results take precedence. + +Before substantial work, call `mnemon_context` with the actual current project +directory and a concise query about the task. It recalls the matching project's +memory and the separate global experience library. Inspect the returned project +path and store, especially with multiple checkouts. An unassociated directory is +not permission to put its project-specific records into global memory: associate +it with `mnemon_associate_project`, or ask the user to select it in the panel if +the intended project is unclear. Do not switch Mnemon's global active store. + +After a meaningful fix or verified decision, call `mnemon_remember` with +`scope: "@project:"` using the returned project id, and `source: "agent"`. +Capture the trigger, root cause, effective fix, evidence and limitations, and a +source path/commit when relevant. Include failed attempts only if they prevent a +repeat mistake. Do not claim unperformed tests or user acceptance. Routine edits +and repeated observations do not need new records. + +Use `scope: "@global"` for lessons that actually transfer between projects, +such as a demonstrated tool incompatibility or an explicit general preference. +Write a concise general lesson without project-specific payloads. Global memory +is a separate library, not a copy of all project records; `@all` is read-only. + +Read `action` in the write result: `skipped` means a duplicate was not added. +An uncertain write error must be checked by recall before retrying. An unavailable +memory service does not justify inventing a successful write or blocking unrelated +development. Briefly report the relevant limitation and continue the task. + +These tools run locally and use the project's saved directory association. +The panel reflects later changes automatically; it does not make model calls +or guarantee that every host selects this skill on every turn. Existing project +hooks are separate and must use the same store when enabled; do not replace or +reconfigure them without an explicit project setup request. diff --git a/plugins/codex-memory-panel/test/compatibility.test.mjs b/plugins/codex-memory-panel/test/compatibility.test.mjs new file mode 100644 index 00000000..330ab76a --- /dev/null +++ b/plugins/codex-memory-panel/test/compatibility.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalize } from '../server/adapter.mjs'; + +test('optional and additional engine fields remain compatible', () => { + const result = normalize([{ id: 'fixture', content: 'Example knowledge', future_field: true }], { total_insights: 1, future_metric: 2 }, '0.2.99', 'example', ['example']); + assert.equal(result.compatibility, 'interface-validated'); + assert.equal(result.memories[0].category, 'unknown'); + assert.deepEqual(result.memories[0].tags, []); + assert.equal(result.stats.edges, null); +}); + +test('missing required engine fields are rejected rather than rendered empty', () => { + assert.throws(() => normalize([], {}, '0.2.10', 'example', ['example']), /interface changed/); + assert.throws(() => normalize([{ id: 'fixture' }], { total_insights: 1 }, '0.2.10', 'example', ['example']), /missing id\/content/); +}); diff --git a/plugins/codex-memory-panel/test/resource.test.mjs b/plugins/codex-memory-panel/test/resource.test.mjs new file mode 100644 index 00000000..406aaf4b --- /dev/null +++ b/plugins/codex-memory-panel/test/resource.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cp, mkdtemp, rename, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +test('an active MCP resource survives replacement of its UI directory', async () => { + const fixture = await mkdtemp(path.join(tmpdir(), 'mnemon-panel-resource-')); + const client = new Client({ name: 'resource-check', version: '1' }); + try { + for (const directory of ['server', 'ui']) await cp(path.join(packageRoot, directory), path.join(fixture, directory), { recursive: true }); + await symlink(path.join(packageRoot, 'node_modules'), path.join(fixture, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); + await client.connect(new StdioClientTransport({ command: process.execPath, args: [path.join(fixture, 'server', 'index.mjs')], stderr: 'pipe' })); + const tools = await client.listTools(); + const open = tools.tools.find(tool => tool.name === 'open_mnemon'); + assert.deepEqual(open._meta['openai/ui'].entrypoints, [{ type: 'global' }, { type: 'thread' }]); + const uri = open._meta.ui.resourceUri; + const before = await client.readResource({ uri }); + assert.equal(before.contents[0].mimeType, 'text/html;profile=mcp-app'); + assert.match(before.contents[0].text, /ui\/initialize/); + await rename(path.join(fixture, 'ui'), path.join(fixture, 'retired-ui')); + const after = await client.readResource({ uri }); + assert.equal(after.contents[0].text, before.contents[0].text); + } finally { + await client.close(); + await rm(fixture, { recursive: true, force: true }); + } +}); diff --git a/plugins/codex-memory-panel/ui/app.js b/plugins/codex-memory-panel/ui/app.js new file mode 100644 index 00000000..80f8d577 --- /dev/null +++ b/plugins/codex-memory-panel/ui/app.js @@ -0,0 +1,237 @@ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + const labels = { all: '全部', insight: '经验', decision: '决策', preference: '偏好', context: '上下文', fact: '事实' }; + let snapshot = null, selected = null, category = 'all', generation = 0, currentStore, controller, nextId = 1; + const pending = new Map(); + const preview = window.parent === window; + let ready; + let catalog = null, writingScope; + const formatTime = value => { const date = new Date(value); return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false }); }; + function el(tag, className, text) { const e = document.createElement(tag); if (className) e.className = className; if (text !== undefined) e.textContent = text; return e; } + + function request(method, params, timeout = 35000) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error('Codex 面板连接超时,正在重连。')); }, timeout); + pending.set(id, { resolve, reject, timer }); + window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*'); + }); + } + function initializeBridge() { + if (preview) return Promise.resolve(); + if (!ready) { + ready = request('ui/initialize', { appInfo: { name: 'Mnemon Memory Panel', version: '0.2.3' }, appCapabilities: {}, protocolVersion: '2026-01-26' }, 15000).then(() => { + window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/notifications/initialized' }, '*'); + }).catch(error => { ready = undefined; throw error; }); + } + return ready; + } + window.addEventListener('message', event => { + if (event.source !== window.parent || !event.data || event.data.jsonrpc !== '2.0') return; + const message = event.data; + if (message.id !== undefined && pending.has(message.id)) { + const handler = pending.get(message.id); pending.delete(message.id); clearTimeout(handler.timer); + message.error ? handler.reject(new Error(message.error.message || 'Codex bridge error')) : handler.resolve(message.result); + } else if (message.method === 'ui/notifications/tool-result' && message.params?.structuredContent?.schemaVersion === 1) { + const value = message.params.structuredContent; + if (!currentStore) { currentStore = value.store; render(value); } + } else if (message.method === 'ui/notifications/host-context-changed') { + const theme = message.params?.theme; + if (theme === 'light' || theme === 'dark') document.documentElement.style.colorScheme = theme; + } + }); + + function setConnection(state, message) { + const node = $('connection'); node.className = `connection ${state}`; + node.lastElementChild.textContent = ({ live: '实时同步', stale: '连接中断 · 保留已有内容', error: '暂时无法读取' })[state] || '正在连接'; + $('notice').hidden = !message; $('notice').textContent = message || ''; + } + + function render(value) { + if (value.schemaVersion !== 1) { setConnection('stale', '面板数据接口版本已变化,需要更新适配层。'); return; } + if (!Array.isArray(value.memories)) { + setConnection(value.connection?.state || 'error', value.connection?.message || '尚未取得记忆数据。'); + if (!snapshot) $('list').replaceChildren(el('div', 'empty', '连接恢复后,记忆会自动显示。')); + return; + } + const previousIds = new Set(snapshot?.store === value.store ? snapshot.memories.map(x => x.id) : value.memories.map(x => x.id)); + const added = value.memories.filter(x => !previousIds.has(x.id)).map(x => x.id); + snapshot = value; + $('total').textContent = String(value.stats.total); $('edges').textContent = value.stats.edges ?? '—'; + $('version').textContent = `Mnemon ${value.version}`; + $('synced').textContent = new Date(value.connection.updatedAt).toLocaleTimeString('zh-CN', { hour12: false }); + if (value.scopes) { catalog = value; renderScopes(); } + $('store').value = value.store; + $('add-memory').disabled = value.writable === false; + $('add-memory').title = value.writable === false ? '请先选择一个项目或全局经验。' : ''; + $('data-path').textContent = value.dbPath ? `本地 · ${value.dbPath}` : '本地记忆'; + $('data-path').title = $('data-path').textContent; + $('update-note').textContent = added.length ? `新增 ${added.length} 条记忆` : ''; + const warning = value.truncated ? `已显示前 ${value.limit} 条记忆${value.storeLimit ? `,最多汇总 ${value.storeLimit} 个库` : ''},已统计 ${value.stats.total} 条。` : ''; + setConnection(value.connection.state, value.connection.message || warning); + if (selected && !value.memories.some(x => x.id === selected)) selected = null; + renderCategories(); renderList(new Set(added)); renderDetail(); + } + + function renderCategories() { + const keys = ['all', ...new Set(snapshot.memories.map(x => x.category))]; + if (!keys.includes(category)) category = 'all'; + $('categories').replaceChildren(...keys.map(key => { + const count = key === 'all' ? snapshot.memories.length : snapshot.memories.filter(x => x.category === key).length; + const button = el('button', key === category ? 'active' : '', `${labels[key] || key} ${count}`); + button.type = 'button'; button.setAttribute('aria-pressed', String(key === category)); + button.onclick = () => { category = key; renderCategories(); renderList(); }; + return button; + })); + } + + function renderList(added = new Set()) { + if (!snapshot) return; + const query = $('search').value.trim().toLocaleLowerCase(); + const rows = snapshot.memories.filter(x => (category === 'all' || category === x.category) && [x.content, x.source, ...x.tags, ...x.entities].join(' ').toLocaleLowerCase().includes(query)); + $('matched').textContent = `${rows.length} 条`; + const scroll = $('list').scrollTop; + const nodes = rows.map(memory => { + const card = el('button', `memory-card${selected === memory.id ? ' selected' : ''}${added.has(memory.id) ? ' new-memory' : ''}`); + card.type = 'button'; card.setAttribute('aria-pressed', String(selected === memory.id)); + const top = el('div', 'card-top'); top.append(el('span', 'category-badge', labels[memory.category] || memory.category), el('span', '', memory.project || formatTime(memory.createdAt).split(' ')[0])); + const tags = el('div', 'card-tags'); memory.tags.slice(0, 3).forEach(tag => tags.append(el('span', '', `#${tag}`))); + card.append(top, el('div', 'card-content', memory.content), tags); + card.onclick = () => { selected = memory.id; $('detail').scrollTop = 0; renderList(); renderDetail(); }; + return card; + }); + if (!nodes.length) nodes.push(el('div', 'empty', snapshot.memories.length ? '没有匹配的记忆,试试其他关键词。' : '这里还没有记忆,新经验写入后会自动出现。')); + $('list').replaceChildren(...nodes); $('list').scrollTop = scroll; + } + + function renderDetail() { + const memory = snapshot?.memories.find(x => x.id === selected); + if (!memory) { + const empty = el('div', 'detail-empty'); empty.append(el('span', 'detail-symbol', '◎'), el('h2', '', '每条经验,都有来处'), el('p', '', '选择一条记忆,查看完整内容和来源。')); + $('detail').replaceChildren(empty); return; + } + const scroll = $('detail').scrollTop, header = el('div', 'detail-header'), copy = el('button', 'copy', '复制内容'); + copy.type = 'button'; copy.onclick = async () => { + try { await navigator.clipboard.writeText(memory.content); copy.textContent = '已复制'; } + catch { copy.textContent = '请选中文字复制'; } + }; + header.append(el('span', 'category-badge', labels[memory.category] || memory.category), copy); + const chips = el('div', 'chips'); [...new Set([...memory.tags, ...memory.entities])].forEach(tag => chips.append(el('span', 'chip', tag))); + const meta = el('dl', 'metadata'); + for (const [name, content] of [['记忆范围', memory.project || snapshot.scopeName || snapshot.targetStore || snapshot.store], ['来源', memory.source || '未提供'], ['重要性', memory.importance === null ? '未提供' : `${memory.importance} / 5`], ['创建时间', formatTime(memory.createdAt)], ['更新时间', formatTime(memory.updatedAt)], ['记忆 ID', memory.originalId || memory.id]]) { + meta.append(el('dt', '', name), el('dd', name === '记忆 ID' ? 'memory-id' : '', content)); + } + $('detail').replaceChildren(header, el('h2', 'detail-title', '完整记忆'), el('div', 'full-content', memory.content), chips, meta); + $('detail').scrollTop = scroll; + } + + async function getSnapshot(args) { + if (preview) { + controller = new AbortController(); + const response = await fetch(`/api/snapshot?${new URLSearchParams(Object.entries(args).filter(([, v]) => v !== undefined))}`, { signal: controller.signal }); + const value = await response.json(); if (!response.ok) throw new Error(value.error || '连接失败'); return value; + } + await initializeBridge(); + const result = await request('tools/call', { name: 'mnemon_snapshot', arguments: args }); + if (result.isError) throw new Error(result.content?.find(x => x.type === 'text')?.text || '读取失败'); + return result.structuredContent; + } + + function renderScopes() { + const signature = JSON.stringify(catalog.scopes); + if ($('store').dataset.options === signature) return; + const groups = new Map(); + for (const scope of catalog.scopes) { + if (!groups.has(scope.group)) { const group = el('optgroup'); group.label = scope.group; groups.set(scope.group, group); } + const option = el('option', '', scope.name); option.value = scope.id; groups.get(scope.group).append(option); + } + const choose = el('option', '', '+ 选定项目…'); choose.value = '@choose'; + $('store').replaceChildren(...groups.values(), choose); + $('store').dataset.options = signature; + } + async function callTool(name, args = {}) { + if (preview) { + const routes = { mnemon_projects: '/api/projects', mnemon_associate_project: '/api/associate', mnemon_remember: '/api/remember' }; + const read = name === 'mnemon_projects'; + const response = await fetch(routes[name], read ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }); + const value = await response.json(); if (!response.ok) throw new Error(value.error || '操作失败'); return value; + } + await initializeBridge(); + const value = await request('tools/call', { name, arguments: args }); + if (value.isError) throw new Error(value.content?.find(x => x.type === 'text')?.text || '操作失败'); + return value.structuredContent; + } + async function openProjects() { + $('project-error').textContent = ''; $('project-dialog').showModal(); + try { + catalog = await callTool('mnemon_projects'); renderScopes(); $('store').value = currentStore; + $('project-choice').replaceChildren(el('option', '', '手动添加项目目录'), ...catalog.projects.map(p => { const option = el('option', '', p.name); option.value = p.id; return option; })); + $('project-choice').firstElementChild.value = ''; + $('project-store').replaceChildren(el('option', '', '自动创建项目专属库'), ...catalog.stores.filter(store => store.toLowerCase() !== 'codex-global').map(store => { const option = el('option', '', store); option.value = store; return option; })); + $('project-store').firstElementChild.value = ''; + } catch (error) { $('project-error').textContent = error.message; } + } + $('project-choice').onchange = () => { + const project = catalog.projects.find(p => p.id === $('project-choice').value); + $('project-name').value = project?.name || ''; $('project-path').value = project?.path || ''; + $('project-store').value = project && catalog.stores.includes(project.store) ? project.store : ''; + }; + $('choose-project').onclick = openProjects; + $('close-project').onclick = () => $('project-dialog').close(); + $('project-form').onsubmit = async event => { + event.preventDefault(); $('save-project').disabled = true; $('project-error').textContent = '正在保存项目关联…'; + try { + const value = await callTool('mnemon_associate_project', { directory: $('project-path').value.trim(), name: $('project-name').value.trim(), store: $('project-store').value || undefined }); + catalog = await callTool('mnemon_projects'); renderScopes(); $('project-dialog').close(); switchStore(`@project:${value.id}`); + } catch (error) { $('project-error').textContent = error.message; } + finally { $('save-project').disabled = false; } + }; + $('add-memory').onclick = () => { + if (!snapshot || snapshot.writable === false) return; + writingScope = currentStore; $('memory-target').textContent = `保存到:${snapshot.scopeName || snapshot.targetStore || currentStore}`; + $('memory-error').textContent = ''; $('memory-content').value = ''; $('memory-dialog').showModal(); + }; + $('close-memory').onclick = () => $('memory-dialog').close(); + $('memory-form').onsubmit = async event => { + event.preventDefault(); $('save-memory').disabled = true; $('memory-error').textContent = '正在保存,请勿重复提交…'; + try { + const value = await callTool('mnemon_remember', { scope: writingScope, content: $('memory-content').value, category: $('memory-category').value, source: 'user' }); + $('memory-error').textContent = value.action === 'skipped' ? '这条内容已经存在,未重复保存。' : `已保存到 ${value.scopeName}`; + $('memory-content').value = ''; + } catch (error) { $('memory-error').textContent = `${error.message} 请核对记忆列表后再决定是否重试。`; } + finally { $('save-memory').disabled = false; } + }; + + async function synchronize(token) { + let revision, failures = 0; + while (token === generation) { + try { + const value = await getSnapshot({ store: currentStore, revision, waitMs: revision ? 20000 : 0 }); + if (token !== generation) return; + if (!value || (currentStore && value.store !== currentStore)) throw new Error('记忆体响应不匹配。'); + currentStore = value.store; revision = value.revision; failures = 0; + if (!value.unchanged) render(value); else setConnection(value.connection.state, value.connection.message || (snapshot?.truncated ? `已显示前 ${snapshot.limit} 条记忆。` : '')); + if (value.connection.state !== 'live') await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (error) { + if (token !== generation) return; + setConnection(snapshot ? 'stale' : 'error', `${error.message} 自动重连中…`); + await new Promise(resolve => setTimeout(resolve, Math.min(10000, 1000 * 2 ** Math.min(failures++, 4)))); + } + } + } + $('search').addEventListener('input', () => renderList()); + function switchStore(store) { + currentStore = store; $('store').value = store; generation++; controller?.abort(); + snapshot = null; selected = null; category = 'all'; $('search').value = ''; + $('total').textContent = '—'; $('edges').textContent = '—'; $('synced').textContent = '—'; + $('categories').replaceChildren(); $('list').replaceChildren(el('div', 'empty', '正在切换记忆体…')); renderDetail(); + setConnection('connecting'); synchronize(generation); + } + $('store').addEventListener('change', () => { + if ($('store').value === '@choose') { $('store').value = currentStore; openProjects(); } + else switchStore($('store').value); + }); + window.addEventListener('pagehide', () => { generation++; controller?.abort(); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Panel closed')); } pending.clear(); }); + synchronize(generation); +})(); diff --git a/plugins/codex-memory-panel/ui/index.html b/plugins/codex-memory-panel/ui/index.html new file mode 100644 index 00000000..d5b8edce --- /dev/null +++ b/plugins/codex-memory-panel/ui/index.html @@ -0,0 +1,54 @@ + + +Mnemon · 记忆系统 + +
+
+

Mnemon 记忆系统

让踩过的坑,成为下一次的经验。

+ 正在连接 +
+
+

开发前召回项目与全局经验,验证后由模型记下新经验。全局保存通用知识;全部项目仅用于汇总浏览。

+ +
+
活跃记忆—项目经验与长期知识
+
知识关联—记忆之间的连接
+
最后同步—有变化时自动同步
+
+
+
+
+ +
记忆列表—
+
正在读取你的记忆…
+
+ +
+
本地记忆 · 内容不会发送到其他服务
+
+ +
+

选定记忆项目

+

关联目录与记忆库,让模型按项目召回和保存经验。已有记忆保留在原库。

+ + + + +

新项目库在首次保存记忆时创建。模型还会召回全局经验,不会将全部项目内容混写到一个库。

+

+ +
+
+ +
+

补充一条记忆

+

主要由模型在开发中积累经验。也可以在这里补充你确认过的知识。

+

+ + +

+ +
+
+ + diff --git a/plugins/codex-memory-panel/ui/style.css b/plugins/codex-memory-panel/ui/style.css new file mode 100644 index 00000000..6af878c9 --- /dev/null +++ b/plugins/codex-memory-panel/ui/style.css @@ -0,0 +1,4 @@ +:root{color-scheme:dark light;--bg:#0f1216;--surface:#171b20;--raised:#1f252c;--border:#2a313a;--text:#e9edf1;--muted:#929eac;--accent:#8bc9ae;--selected:#1b3028;--danger:#e5b67a;--shadow:0 10px 40px #0002} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.6 system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}main{max-width:1440px;margin:auto;padding:28px 30px 16px}button,input,select{font:inherit}button,select{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button{color:inherit}h1,h2,p{margin:0}h1{font-size:22px;letter-spacing:-.4px;font-weight:650}h1 span{font-size:13px;font-weight:400;color:var(--muted);margin-left:8px}.header{display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;gap:13px;align-items:center}.brand-mark{display:grid;place-items:center;width:42px;height:42px;border:1px solid #8bc9ae55;border-radius:13px;background:var(--selected);color:var(--accent);font-size:30px;font-family:Georgia,serif;font-style:italic}.brand p{color:var(--muted);font-size:12px;margin-top:3px}.connection{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);white-space:nowrap}.connection i{width:7px;height:7px;border-radius:50%;background:var(--muted)}.connection.live{color:var(--accent)}.connection.live i{background:var(--accent);box-shadow:0 0 0 4px #8bc9ae10}.connection.stale,.connection.error{color:var(--danger)}.connection.stale i,.connection.error i{background:var(--danger)}.toolbar{display:flex;align-items:center;gap:12px;margin:24px 0 16px}.store-label{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--muted)}select{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);padding:6px 32px 6px 10px;max-width:210px}.readonly{border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:10px;color:var(--muted)}.version{margin-left:auto;font-size:11px;color:var(--muted)}.notice{margin:8px 0 16px;padding:10px 13px;border:1px solid #e5b67a44;border-radius:8px;color:var(--danger);background:#e5b67a08;overflow-wrap:anywhere;font-size:12px}.stats{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--border);border-radius:12px;background:var(--surface);overflow:hidden;margin-bottom:22px}.stats article{padding:16px 20px}.stats article+article{border-left:1px solid var(--border)}.stats span,.stats small{display:block;color:var(--muted);font-size:11px}.stats strong{display:block;font-size:29px;line-height:1.5;font-weight:550;font-variant-numeric:tabular-nums}.stats .time{font-size:20px;line-height:2.175}.stats small{font-size:10px}.workspace{display:grid;grid-template-columns:minmax(250px,.9fr) minmax(280px,1.1fr);border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface);height:min(650px,calc(100vh - 285px));min-height:350px}.memories{display:flex;flex-direction:column;min-width:0;overflow:hidden}.search-wrap{display:flex;align-items:center;gap:9px;margin:16px 16px 10px;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px}.search-wrap span{color:var(--muted);font-size:22px;line-height:1}input{min-width:0;flex:1;border:0;background:transparent;color:var(--text);outline:0;font-size:12px}input::placeholder{color:var(--muted)}.categories{display:flex;flex-wrap:wrap;gap:5px;margin:0 16px 12px}.categories button{background:transparent;border:1px solid transparent;border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted)}.categories button.active{background:var(--selected);border-color:#8bc9ae30;color:var(--accent)}.list-heading{display:flex;justify-content:space-between;margin:0 18px 9px;font-size:10px;color:var(--muted)}.list{overflow-y:auto;padding:0 9px 10px;scrollbar-width:thin;scrollbar-color:var(--border) transparent}.memory-card{display:block;text-align:left;width:100%;border:1px solid transparent;background:transparent;border-radius:9px;padding:11px 12px;margin:0 0 3px;cursor:pointer}.memory-card:hover{background:var(--raised)}.memory-card.selected{border-color:#8bc9ae38;background:var(--selected)}.card-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:7px;font-size:10px;color:var(--muted)}.category-badge{font-size:10px;color:var(--accent);background:#8bc9ae0d;padding:1px 6px;border-radius:4px}.card-content{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;line-height:1.65;font-size:12px;overflow-wrap:anywhere}.card-tags{display:flex;gap:6px;margin-top:8px;color:var(--muted);font-size:10px;white-space:nowrap;overflow:hidden}.new-memory{animation:arrive 1s ease-out}.detail{border-left:1px solid var(--border);overflow-y:auto;padding:24px;scrollbar-width:thin;scrollbar-color:var(--border) transparent}.detail-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--muted);gap:8px}.detail-empty h2{font-size:14px;color:var(--text);font-weight:500}.detail-empty p{font-size:12px}.detail-symbol{font-size:46px;opacity:.4;margin-bottom:4px}.detail-header{display:flex;justify-content:space-between;align-items:center;gap:10px}.copy{background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:6px;font-size:11px;padding:3px 8px}.detail-title{font-size:14px;margin-top:20px;color:var(--muted);font-weight:400}.full-content{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.95;margin:10px 0 24px}.chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:20px}.chip{font-size:10px;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:2px 7px}.metadata{border-top:1px solid var(--border);padding-top:14px;display:grid;grid-template-columns:60px 1fr;gap:9px 12px;font-size:11px}.metadata dt{color:var(--muted)}.metadata dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.memory-id{font-family:ui-monospace,monospace;font-size:10px;color:var(--muted)}.empty{padding:50px 20px;text-align:center;font-size:12px;color:var(--muted)}footer{display:flex;justify-content:space-between;gap:12px;padding-top:12px;color:var(--muted);font-size:10px}#data-path{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:75%}@keyframes arrive{from{box-shadow:inset 3px 0 var(--accent)}to{box-shadow:inset 3px 0 transparent}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}@media(prefers-color-scheme:light){:root{--bg:#f6f7f5;--surface:#fff;--raised:#f0f3ef;--border:#e1e6de;--text:#26312b;--muted:#748075;--accent:#3b8665;--selected:#edf6ef;--danger:#926025}} +@media(max-width:700px){main{padding:18px 16px 12px}.header{align-items:flex-start}.brand-mark{width:34px;height:34px;font-size:25px}h1{font-size:19px}h1 span{display:block;margin-left:0;font-size:10px}.brand p{display:none}.connection{font-size:10px;padding-top:6px}.toolbar{margin-top:18px}.stats article{padding:12px}.stats strong{font-size:25px}.stats .time{font-size:16px;line-height:2.3}.stats small{display:none}.workspace{grid-template-columns:1fr;height:auto;min-height:0}.list{max-height:42vh;min-height:180px}.detail{border-left:0;border-top:1px solid var(--border);padding:18px;min-height:210px;max-height:55vh}.detail-empty{min-height:180px}.version{font-size:10px}.stats{margin-bottom:16px}footer{flex-wrap:wrap}#data-path{max-width:100%}} +.toolbar{flex-wrap:wrap}.workflow-hint{font-size:11px;color:var(--muted);margin:-4px 0 16px}.store-label{min-width:0}.store-label select{max-width:min(300px,55vw)}dialog{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);width:min(500px,calc(100vw - 32px));max-height:90vh;padding:24px;overflow:auto}dialog::backdrop{background:#0008}dialog label{display:flex;flex-direction:column;gap:6px;margin:15px 0;font-size:12px;color:var(--muted)}dialog input,dialog textarea{font:inherit;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text);padding:9px 11px;width:100%;flex:none}dialog textarea{resize:vertical}dialog select{max-width:100%;width:100%}.dialog-heading{display:flex;align-items:center;justify-content:space-between}.dialog-heading h2{font-size:17px}.dialog-hint{font-size:12px;color:var(--muted);margin:12px 0;line-height:1.8}.form-status{font-size:12px;color:var(--accent);min-height:20px;margin:12px 0;overflow-wrap:anywhere}.primary{border:1px solid #8bc9ae55;border-radius:7px;background:var(--selected);padding:9px 13px;color:var(--accent);width:100%}.target-label{padding:9px 12px;background:var(--selected);border-radius:6px;color:var(--accent);font-size:12px}button:disabled{opacity:.5;cursor:default}