From d83a29bc0ded8313d516cfc50394ba98d343dda2 Mon Sep 17 00:00:00 2001 From: OcenasCreative Date: Mon, 1 Jun 2026 21:08:04 +0900 Subject: [PATCH 1/9] feat: expose captures to Claude Code on-demand via MCP server (v0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an MCP delivery path as an alternative to appending to CLAUDE.md. Anthropic's own guidance is to keep CLAUDE.md small (bloated context files degrade instruction-following), so this lets captures live as individual files that the agent pulls in only when it asks. - mcp-server/: standalone npx-distributable stdio MCP server (SDK 1.29) with list_contexts / search_contexts / get_context / delete_context. Reads a plain directory of Markdown files — no native-messaging host, which is the most common failure mode of similar bridges. 12 tests. - extension: new "mcp-store" output mode writes each capture as a standalone .md file into a linked directory via the existing File System Access offscreen pipeline; options UI to link/re-grant the directory and setup instructions; stable slug generation. - Reuses the existing high-fidelity claude.ai parser (thinking / tool_use / branches) and frontmatter format unchanged. Co-Authored-By: Claude Opus 4.8 --- .eslintrc.cjs | 2 +- README.md | 2 + mcp-server/README.md | 130 ++ mcp-server/package-lock.json | 2580 ++++++++++++++++++++++++++++++ mcp-server/package.json | 48 + mcp-server/src/index.ts | 246 +++ mcp-server/src/search.ts | 124 ++ mcp-server/src/store.ts | 301 ++++ mcp-server/test/store.test.ts | 140 ++ mcp-server/tsconfig.json | 17 + package.json | 2 +- src/background/service-worker.ts | 28 + src/manifest.config.ts | 2 +- src/offscreen/index.ts | 47 +- src/options/App.tsx | 150 ++ src/shared/handle-store.ts | 24 + src/shared/slug.ts | 48 + src/shared/types.ts | 28 +- tests/slug.test.ts | 66 + 19 files changed, 3980 insertions(+), 5 deletions(-) create mode 100644 mcp-server/README.md create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/search.ts create mode 100644 mcp-server/src/store.ts create mode 100644 mcp-server/test/store.test.ts create mode 100644 mcp-server/tsconfig.json create mode 100644 src/shared/slug.ts create mode 100644 tests/slug.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b141d2d..35bc6c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -23,5 +23,5 @@ module.exports = { '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', }, - ignorePatterns: ['dist', 'node_modules', '*.config.js', '*.config.ts'], + ignorePatterns: ['dist', 'node_modules', '*.config.js', '*.config.ts', 'mcp-server'], }; diff --git a/README.md b/README.md index f421661..8ddd8f5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ I see the same. - **CLAUDE.md への直書き** *(v0.2.0+)*:プロジェクトの `CLAUDE.md` を一度ピックすれば、以降のキャプチャは File System Access API 経由で自動 append。コピペ不要 - **マルチプロジェクト振り分け** *(v0.3.0+)*:URL パターン(glob)で複数の CLAUDE.md を使い分け。例:`github.com/anthropic/*` → anthropic 用、`zenn.dev/*` → 個人メモ、無マッチは default ルート - **claude.ai 会話のキャプチャ** *(v0.4.0+)*:claude.ai でブレストした会話をワンクリックで CLAUDE.md に流し込み。share リンクの 403 問題、手コピペの労力を解消。内部 API を叩いて thinking blocks・tool_use・branch 構造まで保持 +- **MCP 経由でオンデマンド参照** *(v0.5.0+)*:キャプチャを `CLAUDE.md` に追記する代わりに、専用ディレクトリへ 1 件 1 ファイルで保存し、付属の [MCP サーバ](./mcp-server) が Claude Code に**必要なときだけ**渡します。Anthropic 公式の「`CLAUDE.md` は小さく保て(肥大化すると指示が無視される)」ガイドと整合し、コンテキストを汚しません。`list_contexts` / `search_contexts` / `get_context` で検索・取得できます。→ [セットアップ](./mcp-server/README.md) - **キャプチャバッファ**:複数ページをまとめて溜めて、後から一括エクスポート - **キーボードショートカット**: - `Ctrl+Shift+L`(macOS: `Cmd+Shift+L`):ページ全体 @@ -257,6 +258,7 @@ In short: a clipper purpose-built for AI agent context files. If you want a gene - **Direct CLAUDE.md write** *(v0.2.0+)* — Link a `CLAUDE.md` once via the File System Access API; subsequent captures append directly, no copy/paste - **Multi-project routing** *(v0.3.0+)* — Link multiple `CLAUDE.md` files with URL glob patterns. Captures from `github.com/anthropic/*` go to one file, `zenn.dev/*` to another, unmatched URLs to a default route - **claude.ai conversation capture** *(v0.4.0+)* — Capture your claude.ai brainstorm conversation in one click. Bypasses the 403-on-share-link issue and the manual copy-paste loop. Hits claude.ai's internal API to preserve thinking blocks, tool_use entries, and branch structure that DOM scraping would miss +- **On-demand access via MCP** *(v0.5.0+)* — Instead of appending to `CLAUDE.md`, save each capture as a standalone file in a directory, and let the bundled [MCP server](./mcp-server) hand them to Claude Code **only when it asks**. This aligns with Anthropic's own guidance to keep `CLAUDE.md` small (bloated context files make Claude ignore your instructions) — your research stays available without polluting context. Searchable via `list_contexts` / `search_contexts` / `get_context`. → [Setup](./mcp-server/README.md) - **Buffer mode** — Stack multiple captures and export all at once - **Keyboard shortcuts**: - `Ctrl+Shift+L` (Cmd+Shift+L on macOS) — capture page diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..6b6988d --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,130 @@ +# Claude Code Context Capturer — MCP server + +Expose the web pages and **claude.ai conversations** you capture with the +[Claude Code Context Capturer](https://github.com/OceansCreative/claude-code-context-capturer) +browser extension to **Claude Code — on demand**, over MCP. + +## Why on-demand instead of CLAUDE.md? + +Anthropic's own guidance is to keep `CLAUDE.md` small (target < 200 lines) — +[bloated context files make Claude ignore your instructions](https://code.claude.com/docs/en/memory). +Dumping every captured article and chat into `CLAUDE.md` works against that. + +This MCP server takes the opposite approach: your captures live as individual +Markdown files in a directory, and Claude Code pulls in **only the ones it needs, +only when it needs them** — via `list_contexts`, `search_contexts`, and +`get_context`. Nothing is loaded into context until the agent asks. No silos, +no bloat. + +## How it fits together + +``` + ┌─────────────┐ capture (FSA write) ┌──────────────────┐ read ┌─────────────┐ + │ browser │ ───────────────────────▶ │ contexts dir/ │ ◀──────── │ this MCP │ + │ extension │ .md per capture │ *.md files │ │ server │ + └─────────────┘ └──────────────────┘ └──────┬──────┘ + │ stdio (MCP) + ┌──────▼──────┐ + │ Claude Code │ + └─────────────┘ +``` + +**No native-messaging host.** The extension writes plain files via the File +System Access API; this server just reads that directory. That's the single +biggest reliability difference from similar bridges — there's nothing fragile to +register. + +## Install + +Requires Node.js ≥ 18. No global install needed — `npx` fetches it on demand. + +```bash +# From inside your project, register the server pointed at your captures dir: +claude mcp add ccc-contexts \ + -- npx -y claude-code-context-capturer-mcp ./.ccc-contexts +``` + +Or add it to your MCP config manually: + +```json +{ + "mcpServers": { + "ccc-contexts": { + "command": "npx", + "args": ["-y", "claude-code-context-capturer-mcp", "./.ccc-contexts"] + } + } +} +``` + +The captures directory is resolved in this order: + +1. the first CLI argument (`./.ccc-contexts` above) +2. the `CCC_CONTEXTS_DIR` environment variable +3. `/.ccc-contexts` + +`~` is expanded to your home directory. + +## Connect the extension + +1. Open the extension's options page. +2. Under **MCP contexts directory**, click **Link directory** and pick the same + directory you pointed the server at (e.g. `.ccc-contexts` in your project). +3. Set **Default action** to **“Save to MCP contexts directory.”** + +Now every page or claude.ai conversation you capture lands as a `.md` file the +server can serve to Claude Code. + +## Tools + +| Tool | Description | +|------|-------------| +| `list_contexts` | List captures (metadata only). Optional `parser` filter, `limit`. | +| `search_contexts` | Ranked keyword search across title, URL, tags, and body, with snippets. | +| `get_context` | Fetch one capture's full Markdown by `slug` (fuzzy matching). | +| `delete_context` | Remove a capture by `slug` once it's been incorporated. | + +### Example + +In Claude Code: + +> *“Search my captured contexts for the OAuth migration plan and use it.”* + +Claude calls `search_contexts({ query: "oauth migration" })`, finds the +`claude-ai` conversation you captured, then `get_context` to read the full plan — +without you pasting anything. + +## File format + +The server reads the Markdown + YAML frontmatter the extension emits: + +```markdown +--- +url: https://claude.ai/chat/abc-123 +title: "Auth refactor plan" +captured_at: 2026-05-31T14:00:00.000Z +parser: claude-ai +tags: ["model:claude-opus-4"] +--- + +# Auth refactor plan +... +``` + +Files without frontmatter are still served (the whole file becomes the body, and +the title falls back to the first heading or the filename). + +## Develop + +```bash +npm install +npm run build # tsc -> dist/, makes bin executable +npm test # vitest +npm start # run against ./.ccc-contexts (or $CCC_CONTEXTS_DIR) +``` + +Logs go to **stderr** — stdout is the JSON-RPC channel and must stay clean. + +## License + +MIT diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 0000000..820938a --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,2580 @@ +{ + "name": "claude-code-context-capturer-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-code-context-capturer-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.23.8" + }, + "bin": { + "ccc-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.3", + "vitest": "^2.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..92e4588 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,48 @@ +{ + "name": "claude-code-context-capturer-mcp", + "version": "0.1.0", + "description": "MCP server that exposes web pages and claude.ai conversations captured by the Claude Code Context Capturer extension to Claude Code — on demand, without bloating CLAUDE.md.", + "type": "module", + "bin": { + "ccc-mcp": "./dist/index.js" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc && chmod 755 dist/index.js", + "dev": "tsc --watch", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "claude-code", + "claude-ai", + "anthropic", + "context-engineering", + "ai-coding" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/OceansCreative/claude-code-context-capturer.git", + "directory": "mcp-server" + }, + "author": "Kazushi Ikeda ", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.3", + "vitest": "^2.0.2" + } +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..ab9bc34 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Claude Code Context Capturer — MCP server. + * + * Exposes web pages and claude.ai conversations captured by the browser + * extension to Claude Code, on demand, over stdio. Unlike appending to + * CLAUDE.md (which Anthropic explicitly warns bloats context and degrades + * instruction-following), captures stay out of the always-loaded context and + * are pulled in only when the agent asks for them. + * + * Design note: this server reads a plain directory of Markdown files written + * by the extension via the File System Access API. It deliberately uses NO + * native-messaging host — the single most common failure mode of similar + * tools — so installation is just `npx` + one line in the MCP config. + * + * Tools: + * - list_contexts : list captured contexts (metadata only) + * - get_context : fetch one capture's full Markdown by slug + * - search_contexts : ranked substring search across captures + * - delete_context : remove a capture by slug + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { ContextStore, resolveContextsDir, toSummary } from './store.js'; +import { searchContexts } from './search.js'; + +const VERSION = '0.1.0'; + +const contextsDir = resolveContextsDir(); +const store = new ContextStore(contextsDir); + +const server = new McpServer({ + name: 'claude-code-context-capturer', + version: VERSION, +}); + +// --------------------------------------------------------------------------- +// list_contexts +// --------------------------------------------------------------------------- + +server.registerTool( + 'list_contexts', + { + description: + 'List web pages and claude.ai conversations captured into this project ' + + 'by the Claude Code Context Capturer browser extension. Returns metadata ' + + 'only (slug, title, url, source, captured_at, tags) — call get_context ' + + 'with a slug to read the full content. Use this to discover what research ' + + 'or planning context is available before answering.', + inputSchema: { + parser: z + .string() + .optional() + .describe( + 'Optional filter by capture source, e.g. "claude-ai", "github", "generic".' + ), + limit: z + .number() + .int() + .positive() + .max(200) + .optional() + .describe('Maximum number of entries to return (default 50).'), + }, + }, + async ({ parser, limit }) => { + const all = await store.readAll(); + let filtered = all; + if (parser) { + const p = parser.toLowerCase(); + filtered = all.filter((e) => (e.parser ?? '').toLowerCase() === p); + } + const summaries = filtered.slice(0, limit ?? 50).map(toSummary); + + if (summaries.length === 0) { + return textResult(emptyMessage(parser)); + } + + const header = `${summaries.length} captured context(s) in ${contextsDir}:\n`; + const lines = summaries.map(formatSummaryLine); + return textResult(header + '\n' + lines.join('\n')); + } +); + +// --------------------------------------------------------------------------- +// get_context +// --------------------------------------------------------------------------- + +server.registerTool( + 'get_context', + { + description: + 'Fetch the full Markdown of one captured context by its slug (from ' + + 'list_contexts or search_contexts). Returns the complete captured page ' + + 'or claude.ai conversation, including frontmatter metadata. Matching is ' + + 'fuzzy: an exact filename, a slug, or a unique substring all work.', + inputSchema: { + slug: z + .string() + .describe('The context slug, filename, or a unique substring of it.'), + }, + }, + async ({ slug }) => { + const entry = await store.readBySlug(slug); + if (!entry) { + return textResult( + `No captured context matches "${slug}". Run list_contexts to see available slugs.`, + true + ); + } + return textResult(entry.raw); + } +); + +// --------------------------------------------------------------------------- +// search_contexts +// --------------------------------------------------------------------------- + +server.registerTool( + 'search_contexts', + { + description: + 'Search captured contexts by keyword(s), ranked by relevance. Matches ' + + 'titles, URLs, tags, and body text, and returns short snippets around ' + + 'each match plus the slug to fetch with get_context. Prefer this over ' + + 'reading every context when you are looking for something specific.', + inputSchema: { + query: z + .string() + .min(1) + .describe('One or more space-separated keywords to search for.'), + limit: z + .number() + .int() + .positive() + .max(50) + .optional() + .describe('Maximum number of hits to return (default 10).'), + }, + }, + async ({ query, limit }) => { + const all = await store.readAll(); + const hits = searchContexts(all, query, limit ?? 10); + if (hits.length === 0) { + return textResult(`No captured contexts match "${query}".`); + } + const blocks = hits.map((h) => { + const meta = [ + `### ${h.title}`, + `- slug: \`${h.slug}\``, + h.url ? `- url: ${h.url}` : undefined, + h.parser ? `- source: ${h.parser}` : undefined, + h.capturedAt ? `- captured_at: ${h.capturedAt}` : undefined, + h.snippets.length > 0 ? `- match: ${h.snippets.join(' ')}` : undefined, + ] + .filter(Boolean) + .join('\n'); + return meta; + }); + return textResult( + `${hits.length} match(es) for "${query}":\n\n` + blocks.join('\n\n') + ); + } +); + +// --------------------------------------------------------------------------- +// delete_context +// --------------------------------------------------------------------------- + +server.registerTool( + 'delete_context', + { + description: + 'Delete a captured context by slug once it is no longer needed (e.g. ' + + 'after its content has been incorporated into the code or notes). This ' + + 'removes the Markdown file from the captures directory.', + inputSchema: { + slug: z.string().describe('The context slug to delete.'), + }, + }, + async ({ slug }) => { + const deleted = await store.deleteBySlug(slug); + if (!deleted) { + return textResult( + `No captured context matches "${slug}"; nothing deleted.`, + true + ); + } + return textResult(`Deleted captured context "${slug}".`); + } +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function textResult(text: string, isError = false) { + return { + content: [{ type: 'text' as const, text }], + isError, + }; +} + +function formatSummaryLine(s: ReturnType): string { + const parts = [`- \`${s.slug}\` — ${s.title}`]; + const meta: string[] = []; + if (s.parser) meta.push(s.parser); + if (s.capturedAt) meta.push(s.capturedAt); + if (s.tags.length > 0) meta.push(`tags: ${s.tags.join(', ')}`); + if (meta.length > 0) parts.push(` (${meta.join(' · ')})`); + if (s.url) parts.push(`\n ${s.url}`); + return parts.join(''); +} + +function emptyMessage(parser?: string): string { + const where = `Captures directory: ${contextsDir}`; + if (parser) { + return `No captured contexts with source "${parser}".\n${where}`; + } + return ( + `No captured contexts found yet.\n${where}\n\n` + + 'Capture pages or claude.ai conversations with the Claude Code Context ' + + 'Capturer browser extension (set its output to this directory), then they ' + + 'will appear here.' + ); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + // stdout is the JSON-RPC channel — log only to stderr. + console.error( + `[ccc-mcp] v${VERSION} ready. Watching captures in: ${contextsDir}` + ); +} + +main().catch((error) => { + console.error('[ccc-mcp] fatal:', error); + process.exit(1); +}); diff --git a/mcp-server/src/search.ts b/mcp-server/src/search.ts new file mode 100644 index 0000000..64a71b9 --- /dev/null +++ b/mcp-server/src/search.ts @@ -0,0 +1,124 @@ +/** + * Ranked substring search over captured contexts. + * + * Deliberately dependency-free: a small scoring function beats the competitor's + * "first 3 matching lines" grep without pulling in a search engine. Matches in + * the title/url/tags weigh more than body matches, and we return short snippets + * with the query highlighted by context. + */ + +import type { ContextEntry } from './store.js'; + +export interface SearchHit { + slug: string; + title: string; + url?: string; + parser?: string; + capturedAt?: string; + score: number; + /** Up to a few short excerpts around the match. */ + snippets: string[]; +} + +export function searchContexts( + entries: ContextEntry[], + query: string, + limit = 10 +): SearchHit[] { + const terms = tokenize(query); + if (terms.length === 0) return []; + + const hits: SearchHit[] = []; + + for (const entry of entries) { + const haystackTitle = entry.title.toLowerCase(); + const haystackUrl = (entry.url ?? '').toLowerCase(); + const haystackTags = entry.tags.join(' ').toLowerCase(); + const haystackBody = entry.body.toLowerCase(); + + let score = 0; + let matchedTerms = 0; + + for (const term of terms) { + let termMatched = false; + if (haystackTitle.includes(term)) { + score += 10; + termMatched = true; + } + if (haystackTags.includes(term)) { + score += 6; + termMatched = true; + } + if (haystackUrl.includes(term)) { + score += 4; + termMatched = true; + } + const bodyCount = countOccurrences(haystackBody, term); + if (bodyCount > 0) { + score += Math.min(bodyCount, 5); // cap body weight + termMatched = true; + } + if (termMatched) matchedTerms++; + } + + if (matchedTerms === 0) continue; + + // Reward entries that match more of the distinct query terms. + score += matchedTerms * 2; + + hits.push({ + slug: entry.slug, + title: entry.title, + url: entry.url, + parser: entry.parser, + capturedAt: entry.capturedAt, + score, + snippets: buildSnippets(entry.body, terms), + }); + } + + return hits.sort((a, b) => b.score - a.score).slice(0, limit); +} + +function tokenize(query: string): string[] { + return query + .toLowerCase() + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let idx = haystack.indexOf(needle); + while (idx !== -1) { + count++; + idx = haystack.indexOf(needle, idx + needle.length); + } + return count; +} + +function buildSnippets(body: string, terms: string[], max = 3): string[] { + const lower = body.toLowerCase(); + const snippets: string[] = []; + const seen = new Set(); + + for (const term of terms) { + if (snippets.length >= max) break; + const idx = lower.indexOf(term); + if (idx === -1) continue; + + const start = Math.max(0, idx - 60); + const end = Math.min(body.length, idx + term.length + 60); + if (seen.has(start)) continue; + seen.add(start); + + let snippet = body.slice(start, end).replace(/\s+/g, ' ').trim(); + if (start > 0) snippet = '…' + snippet; + if (end < body.length) snippet = snippet + '…'; + snippets.push(snippet); + } + + return snippets; +} diff --git a/mcp-server/src/store.ts b/mcp-server/src/store.ts new file mode 100644 index 0000000..5e6c1f6 --- /dev/null +++ b/mcp-server/src/store.ts @@ -0,0 +1,301 @@ +/** + * Capture store reader. + * + * The Claude Code Context Capturer browser extension writes each captured web + * page or claude.ai conversation as a standalone Markdown file (with YAML + * frontmatter) into a "contexts" directory the user picks. This module reads + * that directory so the MCP server can expose the captures to Claude Code on + * demand — without ever appending to (and bloating) CLAUDE.md. + * + * The format mirrors what the extension's frontmatter-builder produces: + * + * --- + * url: https://github.com/owner/repo/issues/42 + * title: "Bug: something is broken" + * captured_at: 2026-04-30T12:00:00.000Z + * parser: github + * author: alice + * tags: ["bug", "priority:high"] + * --- + * + * # Bug: something is broken + * ...body... + */ + +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; + +/** Parsed metadata + body for a single captured context file. */ +export interface ContextEntry { + /** Filename without extension — the stable id used by get/delete tools. */ + slug: string; + /** Absolute path on disk. */ + filePath: string; + /** Title from frontmatter, falling back to the slug. */ + title: string; + /** Source URL, if present. */ + url?: string; + /** Which extension parser produced it (github, claude-ai, generic, …). */ + parser?: string; + /** Capture timestamp (ISO 8601), if present. */ + capturedAt?: string; + /** Detected author/site, if present. */ + author?: string; + /** Tags/labels, if present. */ + tags: string[]; + /** The Markdown body (everything after the frontmatter block). */ + body: string; + /** The full file contents, frontmatter included. */ + raw: string; + /** File size in bytes. */ + bytes: number; +} + +/** A lightweight summary used by list/search (no body, to stay cheap). */ +export interface ContextSummary { + slug: string; + title: string; + url?: string; + parser?: string; + capturedAt?: string; + author?: string; + tags: string[]; + bytes: number; +} + +export function toSummary(entry: ContextEntry): ContextSummary { + return { + slug: entry.slug, + title: entry.title, + url: entry.url, + parser: entry.parser, + capturedAt: entry.capturedAt, + author: entry.author, + tags: entry.tags, + bytes: entry.bytes, + }; +} + +const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); + +/** + * Resolve the contexts directory from (in priority order): + * 1. an explicit argument + * 2. the CCC_CONTEXTS_DIR environment variable + * 3. the first non-flag CLI argument + * 4. /.ccc-contexts (project-local default) + * + * Tilde (~) is expanded to the user's home directory. + */ +export function resolveContextsDir(explicit?: string): string { + const fromCli = process.argv.slice(2).find((a) => !a.startsWith('-')); + const raw = + explicit || + process.env.CCC_CONTEXTS_DIR || + fromCli || + path.join(process.cwd(), '.ccc-contexts'); + return expandHome(raw); +} + +function expandHome(p: string): string { + if (p === '~' || p.startsWith('~/')) { + const home = process.env.HOME || process.env.USERPROFILE || ''; + return path.join(home, p.slice(1)); + } + return path.resolve(p); +} + +export class ContextStore { + constructor(public readonly dir: string) {} + + /** True if the contexts directory exists and is a directory. */ + async exists(): Promise { + try { + const stat = await fs.stat(this.dir); + return stat.isDirectory(); + } catch { + return false; + } + } + + /** Read and parse every Markdown file in the directory (non-recursive). */ + async readAll(): Promise { + let names: string[]; + try { + names = await fs.readdir(this.dir); + } catch { + return []; + } + + const mdNames = names.filter((n) => + MARKDOWN_EXTENSIONS.has(path.extname(n).toLowerCase()) + ); + + const entries = await Promise.all( + mdNames.map((name) => this.readOne(name).catch(() => undefined)) + ); + + return entries + .filter((e): e is ContextEntry => e !== undefined) + .sort(byCapturedAtDesc); + } + + /** Read a single file by its name (with or without extension) or slug. */ + async readBySlug(slug: string): Promise { + const candidates = [slug, `${slug}.md`, `${slug}.markdown`]; + for (const name of candidates) { + try { + const stat = await fs.stat(path.join(this.dir, name)); + if (stat.isFile()) return this.readOne(name); + } catch { + // try next candidate + } + } + + // Fuzzy fallback: case-insensitive slug match across the directory. + const all = await this.readAll(); + const lower = slug.toLowerCase(); + return ( + all.find((e) => e.slug.toLowerCase() === lower) ?? + all.find((e) => e.slug.toLowerCase().includes(lower)) + ); + } + + /** Delete a capture by slug. Returns true if a file was removed. */ + async deleteBySlug(slug: string): Promise { + const entry = await this.readBySlug(slug); + if (!entry) return false; + await fs.rm(entry.filePath); + return true; + } + + private async readOne(name: string): Promise { + const filePath = path.join(this.dir, name); + const raw = await fs.readFile(filePath, 'utf8'); + const stat = await fs.stat(filePath); + const slug = name.replace(/\.(md|markdown)$/i, ''); + return parseContextFile(slug, filePath, raw, stat.size); + } +} + +function byCapturedAtDesc(a: ContextEntry, b: ContextEntry): number { + const ta = a.capturedAt ? Date.parse(a.capturedAt) : 0; + const tb = b.capturedAt ? Date.parse(b.capturedAt) : 0; + if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; + return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta); +} + +/** + * Parse a captured Markdown file into structured metadata + body. + * Tolerant of files with no frontmatter (treats the whole file as body). + */ +export function parseContextFile( + slug: string, + filePath: string, + raw: string, + bytes: number +): ContextEntry { + const { frontmatter, body } = splitFrontmatter(raw); + + const title = + frontmatter.title || + firstHeading(body) || + slug; + + return { + slug, + filePath, + title, + url: frontmatter.url, + parser: frontmatter.parser, + capturedAt: frontmatter.captured_at, + author: frontmatter.author, + tags: parseTags(frontmatter.tags), + body: body.trim(), + raw, + bytes, + }; +} + +interface RawFrontmatter { + [key: string]: string | undefined; + title?: string; + url?: string; + parser?: string; + captured_at?: string; + author?: string; + tags?: string; +} + +/** + * Split a `---`-delimited YAML frontmatter block from the body. + * This is a deliberately small parser that understands the flat `key: value` + * shape the extension emits — not a full YAML implementation. + */ +export function splitFrontmatter(raw: string): { + frontmatter: RawFrontmatter; + body: string; +} { + const normalized = raw.replace(/^\uFEFF/, ''); // strip BOM + if (!normalized.startsWith('---')) { + return { frontmatter: {}, body: normalized }; + } + + // Find the closing delimiter on its own line. + const lines = normalized.split('\n'); + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + end = i; + break; + } + } + if (end === -1) { + return { frontmatter: {}, body: normalized }; + } + + const fmLines = lines.slice(1, end); + const body = lines.slice(end + 1).join('\n'); + const frontmatter: RawFrontmatter = {}; + + for (const line of fmLines) { + const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!m) continue; + const key = m[1]; + frontmatter[key] = unquote(m[2].trim()); + } + + return { frontmatter, body }; +} + +function unquote(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value + .slice(1, -1) + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + } + return value; +} + +/** Parse a `tags: ["a", "b"]` line into a string array. */ +export function parseTags(rawTags?: string): string[] { + if (!rawTags) return []; + const trimmed = rawTags.trim(); + if (!trimmed) return []; + + // Bracketed list form: ["a", "b"] or [a, b] + const inner = trimmed.startsWith('[') && trimmed.endsWith(']') + ? trimmed.slice(1, -1) + : trimmed; + + return inner + .split(',') + .map((t) => unquote(t.trim())) + .filter((t) => t.length > 0); +} + +function firstHeading(body: string): string | undefined { + const m = body.match(/^#{1,6}\s+(.+)$/m); + return m?.[1]?.trim(); +} diff --git a/mcp-server/test/store.test.ts b/mcp-server/test/store.test.ts new file mode 100644 index 0000000..9b1f9d5 --- /dev/null +++ b/mcp-server/test/store.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { + parseContextFile, + splitFrontmatter, + parseTags, +} from '../src/store.js'; +import { searchContexts } from '../src/search.js'; +import type { ContextEntry } from '../src/store.js'; + +const SAMPLE = `--- +url: https://github.com/owner/repo/issues/42 +title: "Bug: something is broken" +captured_at: 2026-04-30T12:00:00.000Z +parser: github +author: alice +tags: ["bug", "priority:high"] +--- + +# Bug: something is broken + +The thing crashes when I call \`foo()\`. +`; + +describe('splitFrontmatter', () => { + it('separates frontmatter from body', () => { + const { frontmatter, body } = splitFrontmatter(SAMPLE); + expect(frontmatter.url).toBe('https://github.com/owner/repo/issues/42'); + expect(frontmatter.title).toBe('Bug: something is broken'); + expect(frontmatter.parser).toBe('github'); + expect(body).toContain('# Bug: something is broken'); + expect(body).not.toContain('captured_at'); + }); + + it('treats a file with no frontmatter as all body', () => { + const { frontmatter, body } = splitFrontmatter('# Just a heading\n\ntext'); + expect(Object.keys(frontmatter)).toHaveLength(0); + expect(body).toContain('Just a heading'); + }); + + it('handles an unterminated frontmatter block gracefully', () => { + const { frontmatter, body } = splitFrontmatter('---\nurl: x\nno closing'); + expect(Object.keys(frontmatter)).toHaveLength(0); + expect(body).toContain('no closing'); + }); +}); + +describe('parseTags', () => { + it('parses a bracketed quoted list', () => { + expect(parseTags('["bug", "priority:high"]')).toEqual(['bug', 'priority:high']); + }); + it('parses an unbracketed list', () => { + expect(parseTags('a, b, c')).toEqual(['a', 'b', 'c']); + }); + it('returns empty for undefined/empty', () => { + expect(parseTags(undefined)).toEqual([]); + expect(parseTags('')).toEqual([]); + expect(parseTags('[]')).toEqual([]); + }); +}); + +describe('parseContextFile', () => { + it('produces a structured entry', () => { + const entry = parseContextFile('issue-42', '/x/issue-42.md', SAMPLE, SAMPLE.length); + expect(entry.slug).toBe('issue-42'); + expect(entry.title).toBe('Bug: something is broken'); + expect(entry.url).toBe('https://github.com/owner/repo/issues/42'); + expect(entry.parser).toBe('github'); + expect(entry.author).toBe('alice'); + expect(entry.tags).toEqual(['bug', 'priority:high']); + expect(entry.body).toContain('crashes when I call'); + }); + + it('falls back to first heading then slug for title', () => { + const noTitle = '# Heading Title\n\nbody'; + const entry = parseContextFile('my-slug', '/x/my-slug.md', noTitle, noTitle.length); + expect(entry.title).toBe('Heading Title'); + + const bare = parseContextFile('bare-slug', '/x/bare-slug.md', 'plain text', 10); + expect(bare.title).toBe('bare-slug'); + }); +}); + +function entry(p: Partial): ContextEntry { + return { + slug: p.slug ?? 's', + filePath: p.filePath ?? '/x/s.md', + title: p.title ?? 'Title', + url: p.url, + parser: p.parser, + capturedAt: p.capturedAt, + author: p.author, + tags: p.tags ?? [], + body: p.body ?? '', + raw: p.raw ?? '', + bytes: p.bytes ?? 0, + }; +} + +describe('searchContexts', () => { + const entries = [ + entry({ + slug: 'react-hooks', + title: 'React Hooks deep dive', + url: 'https://react.dev/hooks', + tags: ['react'], + body: 'useEffect and useState explained with examples.', + }), + entry({ + slug: 'pg-indexes', + title: 'Postgres indexing', + body: 'B-tree indexes speed up queries. useState is not mentioned beyond this.', + }), + entry({ + slug: 'unrelated', + title: 'Cooking pasta', + body: 'Boil water, add salt.', + }), + ]; + + it('ranks title matches above body matches', () => { + const hits = searchContexts(entries, 'react'); + expect(hits[0].slug).toBe('react-hooks'); + }); + + it('matches body text and returns snippets', () => { + const hits = searchContexts(entries, 'useState'); + const slugs = hits.map((h) => h.slug); + expect(slugs).toContain('react-hooks'); + expect(slugs).toContain('pg-indexes'); + expect(hits[0].snippets.length).toBeGreaterThan(0); + }); + + it('returns nothing for a non-matching query', () => { + expect(searchContexts(entries, 'kubernetes')).toHaveLength(0); + }); + + it('respects the limit', () => { + expect(searchContexts(entries, 'a', 1)).toHaveLength(1); + }); +}); diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..4bd85b0 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/package.json b/package.json index 9649e96..7c09d32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-context-capturer", - "version": "0.4.3", + "version": "0.5.0", "description": "Convert any web page into Claude Code-friendly Markdown context with one click.", "type": "module", "scripts": { diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index ea497f3..8edb413 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -4,10 +4,12 @@ import { appendToBuffer } from '@/shared/buffer-storage'; import { buildEntryHeading } from '@/shared/file-appender'; import { listRoutes } from '@/shared/handle-store'; import { resolveRoute } from '@/shared/route-matcher'; +import { buildContextSlug } from '@/shared/slug'; import type { CapturedContext, OffscreenAppendResult, OffscreenClipboardResult, + OffscreenMcpStoreResult, OffscreenMessage, RuntimeMessage, UserOptions, @@ -166,6 +168,32 @@ async function deliver( if (options.defaultMode === 'claude-md') { await appendToClaudeMd(ctx, markdown); } + if (options.defaultMode === 'mcp-store') { + await writeToMcpStore(ctx, markdown); + } +} + +/** + * Write the capture as a standalone `.md` file into the linked MCP + * contexts directory (via the offscreen document's FSA access). The companion + * MCP server reads that directory to expose captures to Claude Code on demand. + */ +async function writeToMcpStore( + ctx: CapturedContext, + markdown: string +): Promise { + const fileName = `${buildContextSlug(ctx)}.md`; + const result = await sendToOffscreenWithRetry({ + target: 'offscreen', + type: 'WRITE_TO_MCP_STORE', + fileName, + content: markdown, + }); + if (!result?.ok) { + const reason = result?.reason ?? 'write-failed'; + const detail = result?.message ?? 'Unknown error'; + throw new Error(`${reason}: ${detail}`); + } } /** diff --git a/src/manifest.config.ts b/src/manifest.config.ts index f5beb37..a89724b 100644 --- a/src/manifest.config.ts +++ b/src/manifest.config.ts @@ -4,7 +4,7 @@ export default defineManifest({ manifest_version: 3, name: 'Claude Code Context Capturer', short_name: 'CCC', - version: '0.4.3', + version: '0.5.0', description: 'Convert any web page into Claude Code-friendly Markdown context with one click.', permissions: [ diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts index ca1fd6a..bfc5d4a 100644 --- a/src/offscreen/index.ts +++ b/src/offscreen/index.ts @@ -1,8 +1,9 @@ -import { getRoute } from '@/shared/handle-store'; +import { getRoute, getMcpDir } from '@/shared/handle-store'; import { buildAppendBlock } from '@/shared/file-appender'; import type { OffscreenAppendResult, OffscreenClipboardResult, + OffscreenMcpStoreResult, OffscreenMessage, } from '@/shared/types'; @@ -35,6 +36,10 @@ chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) = ); return true; // async response } + if (message.type === 'WRITE_TO_MCP_STORE') { + void writeToMcpStore(message.fileName, message.content).then(sendResponse); + return true; // async response + } if (message.type === 'WRITE_TO_CLIPBOARD') { void writeToClipboard(message.content).then(sendResponse); return true; @@ -82,6 +87,46 @@ async function appendToRoute( } } +/** + * Write one capture as a standalone `.md` file into the linked MCP + * contexts directory. Each capture is its own file (not appended), so the + * companion MCP server can list/get/search them individually — and CLAUDE.md + * never bloats. + */ +async function writeToMcpStore( + fileName: string, + content: string +): Promise { + const dir = await getMcpDir(); + if (!dir) { + return { + ok: false, + reason: 'no-handle', + message: 'No MCP contexts directory is linked. Link one in the options page.', + }; + } + + const perm = await dir.queryPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + return { + ok: false, + reason: 'permission-denied', + message: 'Re-link the MCP contexts directory from the options page to grant write access.', + }; + } + + try { + const fileHandle = await dir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable({ keepExistingData: false }); + await writable.write(content); + await writable.close(); + return { ok: true, fileName }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, reason: 'write-failed', message: msg }; + } +} + async function writeToClipboard(content: string): Promise { // We deliberately use the legacy textarea + document.execCommand('copy') // dance instead of navigator.clipboard.writeText. diff --git a/src/options/App.tsx b/src/options/App.tsx index d09e11f..a6da108 100644 --- a/src/options/App.tsx +++ b/src/options/App.tsx @@ -15,6 +15,9 @@ import { listRoutes, saveRoute, deleteRoute, + saveMcpDir, + getMcpDir, + clearMcpDir, } from '@/shared/handle-store'; import { DEFAULT_OPTIONS, @@ -32,13 +35,69 @@ export default function App() { const [savedAt, setSavedAt] = useState(null); const [buffer, setBuffer] = useState([]); const [routes, setRoutes] = useState([]); + const [mcpDirName, setMcpDirName] = useState(null); + const [mcpDirPermission, setMcpDirPermission] = useState(null); useEffect(() => { void loadOptions().then(setOptions); void readBuffer().then(setBuffer); void refreshRoutes(); + void refreshMcpDir(); }, []); + async function refreshMcpDir() { + const dir = await getMcpDir(); + if (!dir) { + setMcpDirName(null); + setMcpDirPermission(null); + return; + } + setMcpDirName(dir.name); + setMcpDirPermission(await dir.queryPermission({ mode: 'readwrite' })); + } + + async function handleLinkMcpDir() { + if (!('showDirectoryPicker' in window)) { + alert('Your browser does not support the File System Access directory picker.'); + return; + } + let dir: FileSystemDirectoryHandle; + try { + // @ts-expect-error showDirectoryPicker is not in older lib.dom typings + dir = await window.showDirectoryPicker({ mode: 'readwrite' }); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + alert(`Could not pick directory: ${err instanceof Error ? err.message : String(err)}`); + return; + } + const perm = await dir.requestPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + alert('Write permission was not granted.'); + return; + } + await saveMcpDir(dir); + await refreshMcpDir(); + } + + async function handleRegrantMcpDir() { + const dir = await getMcpDir(); + if (!dir) return; + try { + await dir.requestPermission({ mode: 'readwrite' }); + await refreshMcpDir(); + } catch (err) { + alert(`Could not re-grant: ${err instanceof Error ? err.message : String(err)}`); + } + } + + async function handleUnlinkMcpDir() { + if (!confirm('Unlink the MCP contexts directory? Captured files on disk are not deleted.')) { + return; + } + await clearMcpDir(); + await refreshMcpDir(); + } + async function refreshRoutes() { const list = await listRoutes(); const enriched: RouteRow[] = await Promise.all( @@ -192,6 +251,9 @@ export default function App() { + @@ -355,6 +417,94 @@ export default function App() { )} +
+
+

MCP contexts directory

+ {mcpDirName ? ( + + ) : ( + + )} +
+

+ With the “Save to MCP contexts directory”{' '} + output mode, each capture is written as a standalone{' '} + .md file here. The + companion MCP server then exposes these to Claude Code{' '} + on demand — so your research and claude.ai + conversations are available without bloating{' '} + CLAUDE.md. +

+ + {mcpDirName ? ( +
+ + {mcpDirName}/ + + + {mcpDirPermission === 'granted' ? 'write granted' : 'needs re-grant'} + + {mcpDirPermission !== 'granted' && ( + + )} +
+ ) : ( +

+ No directory linked yet. Captures in “Save to MCP contexts + directory” mode will fail until you link one. +

+ )} + +
+ + How to connect this to Claude Code + +
    +
  1. + Pick a directory inside your project (e.g.{' '} + .ccc-contexts). +
  2. +
  3. + Register the MCP server, pointing it at that directory: +
    +{`claude mcp add ccc-contexts \\
    +  -- npx -y claude-code-context-capturer-mcp \\
    +  ./.ccc-contexts`}
    +              
    +
  4. +
  5. + In Claude Code, ask it to{' '} + list_contexts or search_contexts. +
  6. +
+
+
+

diff --git a/src/shared/handle-store.ts b/src/shared/handle-store.ts index 9ddeba4..c00314b 100644 --- a/src/shared/handle-store.ts +++ b/src/shared/handle-store.ts @@ -13,6 +13,7 @@ const DB_VERSION = 2; const STORE_KV = 'kv'; const STORE_ROUTES = 'routes'; const LEGACY_KEY = 'claudeMdHandle'; +const MCP_DIR_KEY = 'mcpContextsDir'; function openDb(): Promise { return new Promise((resolve, reject) => { @@ -71,6 +72,29 @@ export async function deleteRoute(id: string): Promise { await tx(STORE_ROUTES, 'readwrite', (s) => s.delete(id)); } +/** + * Persist the directory handle for the MCP contexts store. Captures in + * `mcp-store` output mode are written here as standalone `.md` files, + * which the companion MCP server reads to expose them to Claude Code. + */ +export async function saveMcpDir(handle: FileSystemDirectoryHandle): Promise { + await tx(STORE_KV, 'readwrite', (s) => s.put(handle, MCP_DIR_KEY)); +} + +/** Read the saved MCP contexts directory handle, if any. */ +export async function getMcpDir(): Promise { + return tx( + STORE_KV, + 'readonly', + (s) => s.get(MCP_DIR_KEY) + ); +} + +/** Forget the linked MCP contexts directory. */ +export async function clearMcpDir(): Promise { + await tx(STORE_KV, 'readwrite', (s) => s.delete(MCP_DIR_KEY)); +} + /** * One-time migration from v0.2.0's single-handle schema. If a legacy * `claudeMdHandle` value exists in the kv store and no routes yet exist, diff --git a/src/shared/slug.ts b/src/shared/slug.ts new file mode 100644 index 0000000..6f50c3d --- /dev/null +++ b/src/shared/slug.ts @@ -0,0 +1,48 @@ +import type { CapturedContext } from './types'; + +/** + * Build a stable, filesystem-safe slug for a captured context. Used as the + * filename (`.md`) in the MCP contexts directory, and as the `slug` + * identifier the MCP server's get/delete tools accept. + * + * Shape: `--` so files sort chronologically, + * stay human-readable, and never collide (the hash disambiguates same-title + * captures). + */ +export function buildContextSlug(ctx: CapturedContext): string { + const date = (ctx.capturedAt || new Date().toISOString()).slice(0, 10); // YYYY-MM-DD + const base = slugify(ctx.title) || slugify(hostOf(ctx.url)) || 'capture'; + const hash = shortHash(`${ctx.url}\n${ctx.capturedAt}`); + return `${date}-${truncate(base, 60)}-${hash}`; +} + +function slugify(input: string): string { + return input + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max).replace(/-+$/g, ''); +} + +/** Small deterministic hash (djb2) rendered as base36, 6 chars. */ +export function shortHash(input: string): string { + let h = 5381; + for (let i = 0; i < input.length; i++) { + h = (h * 33) ^ input.charCodeAt(i); + } + // >>> 0 coerces to unsigned 32-bit before base36. + return (h >>> 0).toString(36).padStart(6, '0').slice(0, 6); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 992ba1f..e5daef7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -59,6 +59,12 @@ export type OutputMode = | 'append-buffer' /** Append to the user-linked CLAUDE.md file on disk. */ | 'claude-md' + /** + * Write each capture as a standalone Markdown file into the linked MCP + * contexts directory, where the companion MCP server exposes it to Claude + * Code on demand (without bloating CLAUDE.md). + */ + | 'mcp-store' /** Clipboard + buffer. */ | 'both'; @@ -121,6 +127,14 @@ export type OffscreenMessage = /** Heading prefix line, e.g. `## 2026-04-30 22:35 — `. */ heading: string; } + | { + target: 'offscreen'; + type: 'WRITE_TO_MCP_STORE'; + /** Filename (without directory), e.g. `2026-05-31-auth-plan-1a2b3c.md`. */ + fileName: string; + /** Full file contents (frontmatter + body + footer). */ + content: string; + } | { target: 'offscreen'; type: 'WRITE_TO_CLIPBOARD'; @@ -147,6 +161,15 @@ export type OffscreenAppendResult = message: string; }; +/** Result returned from the offscreen document for MCP-store writes. */ +export type OffscreenMcpStoreResult = + | { ok: true; fileName: string } + | { + ok: false; + reason: 'no-handle' | 'permission-denied' | 'write-failed'; + message: string; + }; + /** Result returned from the offscreen document for clipboard writes. */ export type OffscreenClipboardResult = | { ok: true } @@ -156,4 +179,7 @@ export type OffscreenClipboardResult = * Generic offscreen response — the union of all offscreen-handled message * results. Callers narrow by which message they sent. */ -export type OffscreenResult = OffscreenAppendResult | OffscreenClipboardResult; +export type OffscreenResult = + | OffscreenAppendResult + | OffscreenMcpStoreResult + | OffscreenClipboardResult; diff --git a/tests/slug.test.ts b/tests/slug.test.ts new file mode 100644 index 0000000..c6cf646 --- /dev/null +++ b/tests/slug.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { buildContextSlug, shortHash } from '@/shared/slug'; +import type { CapturedContext } from '@/shared/types'; + +function ctx(p: Partial<CapturedContext>): CapturedContext { + return { + url: p.url ?? 'https://example.com/page', + title: p.title ?? 'Example Title', + body: p.body ?? 'body', + capturedAt: p.capturedAt ?? '2026-05-31T14:30:00.000Z', + parser: p.parser ?? 'generic', + fromSelection: p.fromSelection ?? false, + author: p.author, + publishedAt: p.publishedAt, + tags: p.tags, + }; +} + +describe('buildContextSlug', () => { + it('starts with the capture date', () => { + expect(buildContextSlug(ctx({}))).toMatch(/^2026-05-31-/); + }); + + it('slugifies the title', () => { + const slug = buildContextSlug(ctx({ title: 'Auth Refactor: OAuth Plan!' })); + expect(slug).toContain('auth-refactor-oauth-plan'); + }); + + it('falls back to the host when title is empty', () => { + const slug = buildContextSlug(ctx({ title: '', url: 'https://www.react.dev/hooks' })); + // host dots are slugified to dashes, www. is stripped + expect(slug).toContain('react-dev'); + }); + + it('is filesystem-safe (no slashes, spaces, or quotes)', () => { + const slug = buildContextSlug( + ctx({ title: 'a/b c"d\\e', url: 'https://x.com/a b' }) + ); + expect(slug).not.toMatch(/[/\\\s"]/); + }); + + it('produces different slugs for different URLs with the same title/date', () => { + const a = buildContextSlug(ctx({ url: 'https://x.com/1' })); + const b = buildContextSlug(ctx({ url: 'https://x.com/2' })); + expect(a).not.toBe(b); + }); + + it('is deterministic for the same input', () => { + expect(buildContextSlug(ctx({}))).toBe(buildContextSlug(ctx({}))); + }); + + it('truncates very long titles', () => { + const slug = buildContextSlug(ctx({ title: 'word '.repeat(100) })); + // date(10) + dash + base(<=60) + dash + hash(6) + expect(slug.length).toBeLessThanOrEqual(10 + 1 + 60 + 1 + 6); + }); +}); + +describe('shortHash', () => { + it('returns a 6-char base36 string', () => { + expect(shortHash('hello')).toMatch(/^[0-9a-z]{6}$/); + }); + it('differs for different inputs', () => { + expect(shortHash('a')).not.toBe(shortHash('b')); + }); +}); From 45db51e74267ddf5ea6a4e8d2b9fe00b1280e740 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 21:09:34 +0900 Subject: [PATCH 2/9] docs: draft launch comment for claude-code#13843 Includes posting checklist: do not post the npx install line until the MCP server is published to npm, to avoid the broken-install failure mode that sank the closest competitor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/issue-13843-comment.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/issue-13843-comment.md diff --git a/docs/issue-13843-comment.md b/docs/issue-13843-comment.md new file mode 100644 index 0000000..ccce4b7 --- /dev/null +++ b/docs/issue-13843-comment.md @@ -0,0 +1,35 @@ +# Draft comment for anthropics/claude-code#13843 + +> "Share conversation context from Claude.ai to Claude Code" +> https://github.com/anthropics/claude-code/issues/13843 + +--- + +For anyone who wants this **today** without waiting on a first-party fix, I built an open-source pair of tools that bridges it — and deliberately avoids the native-messaging-host setup that trips up most attempts at this. + +**How it works:** +1. A Chrome extension captures your claude.ai conversation — preserving thinking blocks, `tool_use` steps, and branch structure (it reads claude.ai's conversation API rather than scraping the rendered DOM, so artifacts and code survive). +2. It saves each conversation as a Markdown file in a directory you pick (via the File System Access API — **no native messaging host**, which is where similar tools usually break). +3. A small MCP server exposes those files to Claude Code on demand: `list_contexts`, `search_contexts`, `get_context`. + +**Why on-demand instead of dumping into `CLAUDE.md`:** Anthropic's own [memory guidance](https://code.claude.com/docs/en/memory) is to keep `CLAUDE.md` small — bloated context files make Claude ignore instructions. So the agent pulls in a captured conversation only when it needs it, instead of permanently inflating context. (No silos either — it's a single directory you can inspect, search, or delete from.) + +Setup is `npx` + one line: + +```bash +claude mcp add ccc-contexts -- npx -y claude-code-context-capturer-mcp ./.ccc-contexts +``` + +Repo: https://github.com/OceansCreative/claude-code-context-capturer +It also captures general web pages (GitHub issues, docs, Stack Overflow, etc.) the same way, if that's useful for research → code workflows. + +Feedback very welcome — especially on whether on-demand MCP access fits how you actually move from planning in claude.ai to building in Claude Code. + +--- + +## Posting notes (for me, not part of the comment) + +- **Don't post until** the MCP server is actually published to npm (the `npx` line must work for a stranger) OR reword to point at the manual install. Posting a broken install command is exactly what sank the competitor. +- Tone: helpful, not spammy. Lead with "here's a thing that works today," not "check out my project." +- The thread has a vocal "no export/import, I want live read access / single source of truth" camp (powerobject, +10). The "single directory you can inspect/search/delete, agent reads on demand" framing is aimed at them — keep it. +- Consider also replying to geovaniprodata, who hit "native messaging host not found" with the competitor — explicitly note this design has no native host. From 554b952d6aafeea1340ba1dd127d0f05471ea4f0 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:19:22 +0900 Subject: [PATCH 3/9] fix: surface MCP captures-directory misconfig instead of failing silently The #1 silent failure for this feature is "I captured something but Claude Code can't see it", which almost always means the server's resolved directory differs from where the extension writes (typically a relative path resolving against an unexpected cwd). - ContextStore.status() diagnoses missing / not-a-directory / unreadable / ok(+file count); describeStatus() renders a one-line explanation with the resolved absolute path. - Server logs the resolved path and status on startup (stderr), with a hint when it's not ok. - list_contexts returns the same diagnostic to the agent when empty, so Claude Code can relay the actual fix instead of "nothing found". - README troubleshooting section. +3 tests (15 total in mcp-server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- mcp-server/README.md | 17 +++++++++++ mcp-server/src/index.ts | 39 +++++++++++++++++++----- mcp-server/src/store.ts | 56 +++++++++++++++++++++++++++++++++++ mcp-server/test/store.test.ts | 47 ++++++++++++++++++++++++++++- 4 files changed, 151 insertions(+), 8 deletions(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index 6b6988d..4a508a3 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -114,6 +114,23 @@ tags: ["model:claude-opus-4"] Files without frontmatter are still served (the whole file becomes the body, and the title falls back to the first heading or the filename). +## Troubleshooting + +**"I captured something but Claude Code can't see it."** This almost always +means the server and the extension disagree on *which directory* to use. The +server logs the exact absolute path it resolved on startup (to stderr): + +``` +[ccc-mcp] Watching 3 capture(s) in: /Users/you/project/.ccc-contexts +``` + +If you instead see `does not exist`, `is a file, not a directory`, or a count of +`0` when you expect captures, compare that absolute path against the directory +you linked in the extension's options page — they must resolve to the **same +folder**. The most common cause is a relative path (`./.ccc-contexts`) resolving +against an unexpected working directory; use an absolute path in the MCP config +if in doubt. The `list_contexts` tool surfaces the same diagnostic to the agent. + ## Develop ```bash diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index ab9bc34..cc130ba 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -23,7 +23,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { ContextStore, resolveContextsDir, toSummary } from './store.js'; +import { + ContextStore, + resolveContextsDir, + toSummary, + describeStatus, +} from './store.js'; import { searchContexts } from './search.js'; const VERSION = '0.1.0'; @@ -75,7 +80,7 @@ server.registerTool( const summaries = filtered.slice(0, limit ?? 50).map(toSummary); if (summaries.length === 0) { - return textResult(emptyMessage(parser)); + return textResult(await emptyMessage(parser)); } const header = `${summaries.length} captured context(s) in ${contextsDir}:\n`; @@ -214,8 +219,17 @@ function formatSummaryLine(s: ReturnType<typeof toSummary>): string { return parts.join(''); } -function emptyMessage(parser?: string): string { - const where = `Captures directory: ${contextsDir}`; +async function emptyMessage(parser?: string): Promise<string> { + const status = await store.status(); + const where = describeStatus(contextsDir, status); + + // A misconfigured directory (missing / file / unreadable) is the real + // problem far more often than "no captures yet" — surface it directly so + // the agent can relay the fix instead of saying "nothing found". + if (status.kind !== 'ok') { + return `No captured contexts available.\n${where}`; + } + if (parser) { return `No captured contexts with source "${parser}".\n${where}`; } @@ -235,9 +249,20 @@ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); // stdout is the JSON-RPC channel — log only to stderr. - console.error( - `[ccc-mcp] v${VERSION} ready. Watching captures in: ${contextsDir}` - ); + // Emit a clear startup diagnostic so the #1 silent failure mode ("I + // captured something but Claude Code can't see it") is immediately + // visible: it almost always means this resolved path differs from where + // the extension writes. + const status = await store.status(); + console.error(`[ccc-mcp] v${VERSION} ready.`); + console.error(`[ccc-mcp] ${describeStatus(contextsDir, status)}`); + if (status.kind !== 'ok') { + console.error( + `[ccc-mcp] hint: this is the absolute path the server resolved from ` + + `its arguments and working directory. If captures don't appear, the ` + + `extension is likely writing to a different folder.` + ); + } } main().catch((error) => { diff --git a/mcp-server/src/store.ts b/mcp-server/src/store.ts index 5e6c1f6..3b21acb 100644 --- a/mcp-server/src/store.ts +++ b/mcp-server/src/store.ts @@ -105,6 +105,12 @@ function expandHome(p: string): string { return path.resolve(p); } +export type DirStatus = + | { kind: 'ok'; fileCount: number } + | { kind: 'missing' } + | { kind: 'not-a-directory' } + | { kind: 'unreadable'; message: string }; + export class ContextStore { constructor(public readonly dir: string) {} @@ -118,6 +124,33 @@ export class ContextStore { } } + /** + * Diagnose the contexts directory for startup logging. Distinguishes the + * failure modes that otherwise produce silent "I captured something but + * Claude Code can't see it" confusion: the path doesn't exist, points at a + * file, or can't be read. + */ + async status(): Promise<DirStatus> { + let stat; + try { + stat = await fs.stat(this.dir); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'ENOENT') return { kind: 'missing' }; + return { kind: 'unreadable', message: e.message }; + } + if (!stat.isDirectory()) return { kind: 'not-a-directory' }; + try { + const names = await fs.readdir(this.dir); + const fileCount = names.filter((n) => + MARKDOWN_EXTENSIONS.has(path.extname(n).toLowerCase()) + ).length; + return { kind: 'ok', fileCount }; + } catch (err) { + return { kind: 'unreadable', message: (err as Error).message }; + } + } + /** Read and parse every Markdown file in the directory (non-recursive). */ async readAll(): Promise<ContextEntry[]> { let names: string[]; @@ -178,6 +211,29 @@ export class ContextStore { } } +/** + * Human-readable one-line summary of a DirStatus, for stderr boot logging and + * for the "no contexts" guidance returned to the agent. `dir` is the resolved + * absolute path so the user can see exactly where the server is looking. + */ +export function describeStatus(dir: string, status: DirStatus): string { + switch (status.kind) { + case 'ok': + return `Watching ${status.fileCount} capture(s) in: ${dir}`; + case 'missing': + return ( + `Captures directory does not exist yet: ${dir}\n` + + `It will be created by the browser extension on first capture. ` + + `Make sure the extension's linked MCP directory resolves to this same ` + + `absolute path.` + ); + case 'not-a-directory': + return `Configured captures path is a file, not a directory: ${dir}`; + case 'unreadable': + return `Cannot read captures directory: ${dir} (${status.message})`; + } +} + function byCapturedAtDesc(a: ContextEntry, b: ContextEntry): number { const ta = a.capturedAt ? Date.parse(a.capturedAt) : 0; const tb = b.capturedAt ? Date.parse(b.capturedAt) : 0; diff --git a/mcp-server/test/store.test.ts b/mcp-server/test/store.test.ts index 9b1f9d5..fa90852 100644 --- a/mcp-server/test/store.test.ts +++ b/mcp-server/test/store.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { parseContextFile, splitFrontmatter, parseTags, + describeStatus, + ContextStore, } from '../src/store.js'; import { searchContexts } from '../src/search.js'; import type { ContextEntry } from '../src/store.js'; @@ -96,6 +101,46 @@ function entry(p: Partial<ContextEntry>): ContextEntry { }; } +describe('ContextStore.status + describeStatus', () => { + let tmp: string; + + beforeAll(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ccc-status-')); + }); + + afterAll(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it('reports missing for a non-existent directory', async () => { + const store = new ContextStore(path.join(tmp, 'nope')); + const status = await store.status(); + expect(status.kind).toBe('missing'); + expect(describeStatus(store.dir, status)).toContain('does not exist'); + }); + + it('reports not-a-directory when the path is a file', async () => { + const filePath = path.join(tmp, 'afile.md'); + await fs.writeFile(filePath, '# hi'); + const store = new ContextStore(filePath); + const status = await store.status(); + expect(status.kind).toBe('not-a-directory'); + expect(describeStatus(store.dir, status)).toContain('not a directory'); + }); + + it('reports ok with a markdown file count', async () => { + const dir = path.join(tmp, 'good'); + await fs.mkdir(dir); + await fs.writeFile(path.join(dir, 'a.md'), '# a'); + await fs.writeFile(path.join(dir, 'b.markdown'), '# b'); + await fs.writeFile(path.join(dir, 'ignore.txt'), 'nope'); + const store = new ContextStore(dir); + const status = await store.status(); + expect(status).toEqual({ kind: 'ok', fileCount: 2 }); + expect(describeStatus(store.dir, status)).toContain('2 capture'); + }); +}); + describe('searchContexts', () => { const entries = [ entry({ From 89e7c2a0d2d60fde747a91a83860836eefb1b3e2 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:26:15 +0900 Subject: [PATCH 4/9] feat: claude.ai artifact extraction + range selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthens the highest-fidelity, Claude-specific capture path. - Artifacts (code/documents Claude authors via the `artifacts` tool) now render as clean fenced code blocks with title + language, instead of collapsing to raw JSON — both inline in full captures and in the new artifacts-only mode. - New capture options (UserOptions + forwarded to the in-page parser): - claudeAiArtifactsOnly: extract just the artifacts, drop the chat. Deduped by artifact id so an edited artifact is captured at its final version, not every intermediate edit. - claudeAiMaxMessages: keep only the last N messages of a long thread. - Options plumbed end to end: service worker loads + forwards a parser subset via RuntimeMessage -> content script -> dispatcher -> parser. - Options UI: "claude.ai conversations" controls. - Tests: claude-ai parser 9 -> 21 (artifactFromInput, collectArtifacts, artifacts-only, maxMessages, no-artifacts error). - vitest: exclude mcp-server from the root run (it has its own). - README: document the v0.5.0 capture options (EN + JA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- README.md | 2 + src/background/service-worker.ts | 12 +- src/content/index.ts | 2 +- src/content/parsers/claude-ai.ts | 153 +++++++++++++++++++++- src/content/parsers/dispatcher.ts | 8 +- src/options/App.tsx | 31 +++++ src/shared/types.ts | 28 +++- tests/parsers/claude-ai.test.ts | 205 ++++++++++++++++++++++++++++++ vite.config.ts | 2 + 9 files changed, 431 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8ddd8f5..4555668 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ I see the same. - **CLAUDE.md への直書き** *(v0.2.0+)*:プロジェクトの `CLAUDE.md` を一度ピックすれば、以降のキャプチャは File System Access API 経由で自動 append。コピペ不要 - **マルチプロジェクト振り分け** *(v0.3.0+)*:URL パターン(glob)で複数の CLAUDE.md を使い分け。例:`github.com/anthropic/*` → anthropic 用、`zenn.dev/*` → 個人メモ、無マッチは default ルート - **claude.ai 会話のキャプチャ** *(v0.4.0+)*:claude.ai でブレストした会話をワンクリックで CLAUDE.md に流し込み。share リンクの 403 問題、手コピペの労力を解消。内部 API を叩いて thinking blocks・tool_use・branch 構造まで保持 +- **アーティファクト抽出 & 範囲選択** *(v0.5.0+)*:claude.ai キャプチャで「**アーティファクトだけ**」(Claude が書いたコード/ドキュメントを整形済みコードブロックで抽出、会話は捨てる)や「**直近 N 件のみ**」を選択可能。長い設計会話から必要な部分だけを取り込めます。アーティファクトは同一 ID の更新を最新版に集約 - **MCP 経由でオンデマンド参照** *(v0.5.0+)*:キャプチャを `CLAUDE.md` に追記する代わりに、専用ディレクトリへ 1 件 1 ファイルで保存し、付属の [MCP サーバ](./mcp-server) が Claude Code に**必要なときだけ**渡します。Anthropic 公式の「`CLAUDE.md` は小さく保て(肥大化すると指示が無視される)」ガイドと整合し、コンテキストを汚しません。`list_contexts` / `search_contexts` / `get_context` で検索・取得できます。→ [セットアップ](./mcp-server/README.md) - **キャプチャバッファ**:複数ページをまとめて溜めて、後から一括エクスポート - **キーボードショートカット**: @@ -258,6 +259,7 @@ In short: a clipper purpose-built for AI agent context files. If you want a gene - **Direct CLAUDE.md write** *(v0.2.0+)* — Link a `CLAUDE.md` once via the File System Access API; subsequent captures append directly, no copy/paste - **Multi-project routing** *(v0.3.0+)* — Link multiple `CLAUDE.md` files with URL glob patterns. Captures from `github.com/anthropic/*` go to one file, `zenn.dev/*` to another, unmatched URLs to a default route - **claude.ai conversation capture** *(v0.4.0+)* — Capture your claude.ai brainstorm conversation in one click. Bypasses the 403-on-share-link issue and the manual copy-paste loop. Hits claude.ai's internal API to preserve thinking blocks, tool_use entries, and branch structure that DOM scraping would miss +- **Artifact extraction & range selection** *(v0.5.0+)* — For claude.ai captures, choose **artifacts only** (extract just the code/documents Claude wrote as clean fenced code blocks, dropping the conversation) or **keep the last N messages** (trim a long planning thread to what matters). Artifacts are deduped by id so an updated artifact is captured at its final version, not every intermediate edit - **On-demand access via MCP** *(v0.5.0+)* — Instead of appending to `CLAUDE.md`, save each capture as a standalone file in a directory, and let the bundled [MCP server](./mcp-server) hand them to Claude Code **only when it asks**. This aligns with Anthropic's own guidance to keep `CLAUDE.md` small (bloated context files make Claude ignore your instructions) — your research stays available without polluting context. Searchable via `list_contexts` / `search_contexts` / `get_context`. → [Setup](./mcp-server/README.md) - **Buffer mode** — Stack multiple captures and export all at once - **Keyboard shortcuts**: diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 8edb413..daa77a4 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -96,9 +96,18 @@ async function requestCapture( type: 'CAPTURE_PAGE' | 'CAPTURE_SELECTION' ): Promise<RuntimeMessage> { try { + const options = await loadOptions(); const response = await chrome.tabs.sendMessage<RuntimeMessage, RuntimeMessage>( tabId, - { type } + { + type, + // Forward parser-relevant options so the content script (which can't + // read chrome.storage) can honor claude.ai capture preferences. + options: { + claudeAiArtifactsOnly: options.claudeAiArtifactsOnly, + claudeAiMaxMessages: options.claudeAiMaxMessages, + }, + } ); if (response.type === 'CAPTURE_ERROR') { @@ -110,7 +119,6 @@ async function requestCapture( return { type: 'CAPTURE_ERROR', error: 'Unexpected response from content script.' }; } - const options = await loadOptions(); const finalMarkdown = renderFinalMarkdown(response.payload, options); await deliver(finalMarkdown, response.payload, options, tabId); diff --git a/src/content/index.ts b/src/content/index.ts index 7cac884..2cdbbf6 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -15,7 +15,7 @@ chrome.runtime.onMessage.addListener( if (message.type === 'CAPTURE_PAGE') { void (async () => { try { - const ctx: CapturedContext = await dispatchPageParser(); + const ctx: CapturedContext = await dispatchPageParser(message.options); sendResponse({ type: 'CAPTURE_RESULT', payload: ctx }); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); diff --git a/src/content/parsers/claude-ai.ts b/src/content/parsers/claude-ai.ts index 63a3008..a718edf 100644 --- a/src/content/parsers/claude-ai.ts +++ b/src/content/parsers/claude-ai.ts @@ -1,4 +1,4 @@ -import type { CapturedContext } from '@/shared/types'; +import type { CaptureOptions, CapturedContext } from '@/shared/types'; /** * claude.ai conversation parser. @@ -28,7 +28,9 @@ export function canHandleClaudeAi(): boolean { ); } -export async function parseClaudeAi(): Promise<CapturedContext> { +export async function parseClaudeAi( + options: CaptureOptions = {} +): Promise<CapturedContext> { const url = window.location.href; const capturedAt = new Date().toISOString(); const conversationUuid = extractConversationUuid(window.location.pathname); @@ -46,8 +48,38 @@ export async function parseClaudeAi(): Promise<CapturedContext> { } const conversation = await fetchConversation(orgUuid, conversationUuid); - const messages = orderedMessages(conversation); + let messages = orderedMessages(conversation); + + // Range selection: keep only the last N messages of the (chronological) + // thread, so long planning conversations don't dump their whole history. + const maxMessages = options.claudeAiMaxMessages ?? 0; + if (maxMessages > 0 && messages.length > maxMessages) { + messages = messages.slice(-maxMessages); + } + const title = (conversation.name ?? '').trim() || 'Untitled Claude conversation'; + + // Artifacts-only: extract just the code/documents Claude authored, dropping + // the surrounding conversation entirely. + if (options.claudeAiArtifactsOnly) { + const artifacts = collectArtifacts(messages); + if (artifacts.length === 0) { + throw new Error( + 'No artifacts found in this conversation. Turn off "artifacts only" to capture the full chat.' + ); + } + return { + url, + title, + body: renderArtifactsMarkdown(title, artifacts), + capturedAt, + publishedAt: conversation.created_at, + parser: 'claude-ai', + fromSelection: false, + tags: tagsFor(conversation, artifacts), + }; + } + const body = renderConversationMarkdown(title, conversation, messages); return { @@ -231,6 +263,13 @@ function renderContentBlock(block: ConversationMessageContent): string { } case 'tool_use': { const name = (block as { name?: string }).name ?? 'tool_use'; + // The artifacts tool carries the code/documents Claude writes. Render it + // as a real fenced code block (with language + title) instead of raw + // JSON, so it's directly usable in CLAUDE.md / a project. + if (name === 'artifacts') { + const art = artifactFromInput((block as { input?: unknown }).input); + if (art) return renderArtifactBlock(art); + } const input = (block as { input?: unknown }).input; const inputStr = input ? '\n\n```json\n' + safeJson(input) + '\n```' : ''; return `> **tool_use**: \`${name}\`${inputStr}`; @@ -262,12 +301,118 @@ function safeJson(v: unknown): string { } } -function tagsFor(conv: Conversation): string[] | undefined { +function tagsFor(conv: Conversation, artifacts?: Artifact[]): string[] | undefined { const tags: string[] = []; if (conv.model) tags.push(`model:${conv.model}`); + if (artifacts && artifacts.length > 0) { + tags.push('artifacts-only'); + const langs = new Set( + artifacts.map((a) => a.language).filter((l): l is string => !!l) + ); + for (const lang of langs) tags.push(`lang:${lang}`); + } return tags.length > 0 ? tags : undefined; } +// --------------------------------------------------------------------------- +// Artifacts +// --------------------------------------------------------------------------- + +/** A code or document artifact Claude authored during the conversation. */ +export interface Artifact { + /** Stable artifact id (used to dedupe updates to the same artifact). */ + id?: string; + /** Human title, e.g. "Auth middleware". */ + title?: string; + /** Fence language hint, e.g. "ts", "python", "markdown". */ + language?: string; + /** Artifact MIME-ish type, e.g. "application/vnd.ant.code", "text/markdown". */ + type?: string; + /** The artifact body. */ + content: string; +} + +/** + * Parse the `input` of an `artifacts` tool_use block into an Artifact. + * claude.ai's artifact tool uses fields like: + * { command: "create"|"update"|"rewrite", id, type, title, language, content } + * Updates may carry only a diff (old_str/new_str); those have no full content + * and are skipped (the create/rewrite that follows carries the full text). + */ +export function artifactFromInput(input: unknown): Artifact | undefined { + if (!input || typeof input !== 'object') return undefined; + const o = input as Record<string, unknown>; + const content = typeof o.content === 'string' ? o.content : undefined; + if (!content || content.trim() === '') return undefined; + return { + id: typeof o.id === 'string' ? o.id : undefined, + title: typeof o.title === 'string' ? o.title : undefined, + language: typeof o.language === 'string' ? o.language : undefined, + type: typeof o.type === 'string' ? o.type : undefined, + content, + }; +} + +/** + * Collect all artifacts across the given messages, in order. When the same + * artifact id is created then updated/rewritten, the LATER (most complete) + * version wins — so we capture the final state, not every intermediate edit. + */ +export function collectArtifacts(messages: ConversationMessage[]): Artifact[] { + const byId = new Map<string, Artifact>(); + const anon: Artifact[] = []; + + for (const msg of messages) { + const blocks = Array.isArray(msg.content) ? msg.content : []; + for (const block of blocks) { + if (block.type !== 'tool_use') continue; + if ((block as { name?: string }).name !== 'artifacts') continue; + const art = artifactFromInput((block as { input?: unknown }).input); + if (!art) continue; + if (art.id) { + byId.set(art.id, art); // later create/rewrite replaces earlier + } else { + anon.push(art); + } + } + } + + return [...byId.values(), ...anon]; +} + +/** Render a single artifact as a titled, fenced code block. */ +function renderArtifactBlock(art: Artifact): string { + const lines: string[] = []; + if (art.title) lines.push(`#### ${art.title}`); + const fence = fenceLangFor(art); + lines.push('```' + fence); + lines.push(art.content.replace(/\n+$/, '')); + lines.push('```'); + return lines.join('\n'); +} + +/** Render an artifacts-only capture: title + each artifact as a code block. */ +function renderArtifactsMarkdown(title: string, artifacts: Artifact[]): string { + const lines: string[] = [`# ${title} — artifacts`, '']; + artifacts.forEach((art, i) => { + lines.push(renderArtifactBlock(art)); + if (i < artifacts.length - 1) lines.push(''); + }); + return lines.join('\n').trimEnd() + '\n'; +} + +/** Choose a fenced-code language label from the artifact's language/type. */ +function fenceLangFor(art: Artifact): string { + if (art.language) return art.language; + if (art.type) { + if (art.type.includes('markdown')) return 'markdown'; + if (art.type.includes('html')) return 'html'; + if (art.type.includes('svg')) return 'svg'; + if (art.type.includes('mermaid')) return 'mermaid'; + } + return ''; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/content/parsers/dispatcher.ts b/src/content/parsers/dispatcher.ts index 973ee0e..e747e97 100644 --- a/src/content/parsers/dispatcher.ts +++ b/src/content/parsers/dispatcher.ts @@ -5,7 +5,7 @@ import { canHandleQiita, parseQiita } from './qiita'; import { canHandleMdn, parseMdn } from './mdn'; import { canHandleClaudeAi, parseClaudeAi } from './claude-ai'; import { parseGenericPage } from './generic'; -import type { CapturedContext } from '@/shared/types'; +import type { CaptureOptions, CapturedContext } from '@/shared/types'; /** * Try each site-specific parser in priority order, falling back to generic. @@ -15,8 +15,10 @@ import type { CapturedContext } from '@/shared/types'; * * The order matters: more specific URL/host checks come first. */ -export async function dispatchPageParser(): Promise<CapturedContext> { - if (canHandleClaudeAi()) return parseClaudeAi(); +export async function dispatchPageParser( + options: CaptureOptions = {} +): Promise<CapturedContext> { + if (canHandleClaudeAi()) return parseClaudeAi(options); if (canHandleGitHub()) return parseGitHub(); if (canHandleStackOverflow()) return parseStackOverflow(); if (canHandleZenn()) return parseZenn(); diff --git a/src/options/App.tsx b/src/options/App.tsx index a6da108..ddd77c6 100644 --- a/src/options/App.tsx +++ b/src/options/App.tsx @@ -308,6 +308,37 @@ export default function App() { </select> </Field> + <div className="mt-6 border-t border-slate-100 pt-5"> + <h3 className="mb-1 text-sm font-semibold text-slate-700"> + claude.ai conversations + </h3> + <p className="mb-3 text-xs text-slate-500"> + Controls applied when capturing a claude.ai chat. + </p> + + <Toggle + label="Artifacts only" + description="Capture just the code/documents Claude wrote (as clean code blocks), dropping the surrounding conversation." + checked={options.claudeAiArtifactsOnly} + onChange={(v) => setOptions({ ...options, claudeAiArtifactsOnly: v })} + /> + + <Field label="Keep only the last N messages (0 = whole conversation)"> + <input + type="number" + min={0} + value={options.claudeAiMaxMessages} + onChange={(e) => + setOptions({ + ...options, + claudeAiMaxMessages: Math.max(0, Number(e.target.value)), + }) + } + className="w-full rounded border border-slate-300 px-3 py-2 text-sm" + /> + </Field> + </div> + <div className="mt-6 flex items-center gap-3"> <button type="button" diff --git a/src/shared/types.ts b/src/shared/types.ts index e5daef7..e641a0e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -49,6 +49,28 @@ export interface UserOptions { defaultMode: OutputMode; /** Locale for date formatting and built-in messages. */ locale: 'en' | 'ja'; + /** + * claude.ai capture: extract only artifacts (code/documents Claude wrote), + * dropping the surrounding conversation. Ideal for pulling "the code from + * that chat" into a project without the brainstorming noise. + */ + claudeAiArtifactsOnly: boolean; + /** + * claude.ai capture: keep only the last N messages of the conversation + * (0 = the whole thing). Trims long planning threads down to the part that + * matters before it lands in your context. + */ + claudeAiMaxMessages: number; +} + +/** + * Capture-time options forwarded from the service worker to the content-script + * parsers. A small, parser-relevant subset of UserOptions — the parser runs in + * the page and shouldn't reach into chrome.storage itself. + */ +export interface CaptureOptions { + claudeAiArtifactsOnly?: boolean; + claudeAiMaxMessages?: number; } /** Where the captured Markdown should go. */ @@ -75,12 +97,14 @@ export const DEFAULT_OPTIONS: UserOptions = { maxBodyLength: 0, defaultMode: 'clipboard', locale: 'en', + claudeAiArtifactsOnly: false, + claudeAiMaxMessages: 0, }; /** Messages exchanged between background and content scripts. */ export type RuntimeMessage = - | { type: 'CAPTURE_PAGE' } - | { type: 'CAPTURE_SELECTION' } + | { type: 'CAPTURE_PAGE'; options?: CaptureOptions } + | { type: 'CAPTURE_SELECTION'; options?: CaptureOptions } | { type: 'CAPTURE_RESULT'; payload: CapturedContext } | { type: 'CAPTURE_ERROR'; error: string }; diff --git a/tests/parsers/claude-ai.test.ts b/tests/parsers/claude-ai.test.ts index 67fc547..0c7bb42 100644 --- a/tests/parsers/claude-ai.test.ts +++ b/tests/parsers/claude-ai.test.ts @@ -3,6 +3,8 @@ import { canHandleClaudeAi, parseClaudeAi, orderedMessages, + collectArtifacts, + artifactFromInput, } from '@/content/parsers/claude-ai'; const ORG_UUID = 'org-uuid-1'; @@ -227,4 +229,207 @@ describe('parseClaudeAi (with mocked fetch)', () => { setLocation('https://claude.ai/chats'); await expect(parseClaudeAi()).rejects.toThrow(/extract a conversation ID/i); }); + + // ------------------------------------------------------------------------- + // Artifact extraction + capture options + // ------------------------------------------------------------------------- + + function mockArtifactConversation() { + fetchMock.mockImplementation(async (input: string) => { + if (input === '/api/organizations') return jsonResponse([{ uuid: ORG_UUID }]); + if (input.endsWith(`/chat_conversations/${CONVERSATION_UUID}`)) return jsonResponse({}); + if (input.includes('?tree=True')) { + return jsonResponse({ + uuid: CONVERSATION_UUID, + name: 'Build auth middleware', + model: 'claude-opus-4', + current_leaf_message_uuid: 'm4', + chat_messages: [ + { + uuid: 'm1', + sender: 'human', + content: [{ type: 'text', text: 'Write auth middleware.' }], + }, + { + uuid: 'm2', + sender: 'assistant', + parent_message_uuid: 'm1', + content: [ + { type: 'text', text: 'Here you go.' }, + { + type: 'tool_use', + name: 'artifacts', + input: { + command: 'create', + id: 'auth-mw', + type: 'application/vnd.ant.code', + language: 'ts', + title: 'Auth middleware', + content: 'export const auth = () => {};', + }, + }, + ], + }, + { + uuid: 'm3', + sender: 'human', + parent_message_uuid: 'm2', + content: [{ type: 'text', text: 'Add logging.' }], + }, + { + uuid: 'm4', + sender: 'assistant', + parent_message_uuid: 'm3', + content: [ + { + type: 'tool_use', + name: 'artifacts', + input: { + command: 'rewrite', + id: 'auth-mw', + type: 'application/vnd.ant.code', + language: 'ts', + title: 'Auth middleware', + content: 'export const auth = () => { log(); };', + }, + }, + ], + }, + ], + }); + } + throw new Error(`unexpected: ${input}`); + }); + } + + it('renders artifacts as clean code blocks inside a full capture', async () => { + mockArtifactConversation(); + const ctx = await parseClaudeAi(); + expect(ctx.body).toContain('#### Auth middleware'); + expect(ctx.body).toContain('```ts'); + expect(ctx.body).toContain('export const auth'); + // Not dumped as raw JSON. + expect(ctx.body).not.toContain('"command": "create"'); + }); + + it('artifacts-only mode keeps only the final artifact version, no chat', async () => { + mockArtifactConversation(); + const ctx = await parseClaudeAi({ claudeAiArtifactsOnly: true }); + expect(ctx.title).toBe('Build auth middleware'); + expect(ctx.body).toContain('# Build auth middleware — artifacts'); + // Final (rewrite) version wins; intermediate create is gone. + expect(ctx.body).toContain('export const auth = () => { log(); };'); + expect(ctx.body).not.toContain('export const auth = () => {};'); + // Conversation text is dropped. + expect(ctx.body).not.toContain('Add logging.'); + expect(ctx.body).not.toContain('## Human'); + expect(ctx.tags).toContain('artifacts-only'); + expect(ctx.tags).toContain('lang:ts'); + }); + + it('artifacts-only throws a friendly error when there are no artifacts', async () => { + fetchMock.mockImplementation(async (input: string) => { + if (input === '/api/organizations') return jsonResponse([{ uuid: ORG_UUID }]); + if (input.endsWith(`/chat_conversations/${CONVERSATION_UUID}`)) return jsonResponse({}); + if (input.includes('?tree=True')) { + return jsonResponse({ + uuid: CONVERSATION_UUID, + name: 'No artifacts here', + chat_messages: [ + { uuid: 'm1', sender: 'human', content: [{ type: 'text', text: 'hi' }] }, + ], + }); + } + throw new Error(`unexpected: ${input}`); + }); + await expect(parseClaudeAi({ claudeAiArtifactsOnly: true })).rejects.toThrow( + /No artifacts found/i + ); + }); + + it('maxMessages keeps only the last N messages', async () => { + mockArtifactConversation(); + const ctx = await parseClaudeAi({ claudeAiMaxMessages: 1 }); + // Only m4 (the last assistant turn with the rewrite) remains. + expect(ctx.body).toContain('export const auth = () => { log(); };'); + expect(ctx.body).not.toContain('Write auth middleware.'); + expect(ctx.body).not.toContain('Add logging.'); + }); +}); + +describe('artifactFromInput', () => { + it('parses a well-formed artifact input', () => { + const art = artifactFromInput({ + id: 'x', + title: 'T', + language: 'python', + type: 'application/vnd.ant.code', + content: 'print(1)', + }); + expect(art).toEqual({ + id: 'x', + title: 'T', + language: 'python', + type: 'application/vnd.ant.code', + content: 'print(1)', + }); + }); + + it('returns undefined for diff-only updates (no content)', () => { + expect(artifactFromInput({ id: 'x', old_str: 'a', new_str: 'b' })).toBeUndefined(); + expect(artifactFromInput({ content: ' ' })).toBeUndefined(); + expect(artifactFromInput(null)).toBeUndefined(); + }); +}); + +describe('collectArtifacts', () => { + it('dedupes by id, keeping the latest version, then anon artifacts', () => { + const messages = [ + { + uuid: 'a', + sender: 'assistant' as const, + content: [ + { + type: 'tool_use', + name: 'artifacts', + input: { id: 'k', title: 'K', content: 'v1' }, + }, + ], + }, + { + uuid: 'b', + sender: 'assistant' as const, + content: [ + { + type: 'tool_use', + name: 'artifacts', + input: { id: 'k', title: 'K', content: 'v2' }, + }, + { + type: 'tool_use', + name: 'artifacts', + input: { title: 'Anon', content: 'anon-body' }, + }, + ], + }, + ]; + const arts = collectArtifacts(messages); + expect(arts).toHaveLength(2); + expect(arts[0].content).toBe('v2'); + expect(arts[1].content).toBe('anon-body'); + }); + + it('ignores non-artifact tool_use blocks', () => { + const messages = [ + { + uuid: 'a', + sender: 'assistant' as const, + content: [ + { type: 'tool_use', name: 'repl', input: { code: 'x' } }, + { type: 'text', text: 'hi' }, + ], + }, + ]; + expect(collectArtifacts(messages)).toHaveLength(0); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 3f0200d..a3a3acc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,5 +24,7 @@ export default defineConfig({ test: { environment: 'happy-dom', globals: true, + // mcp-server is a separate Node package with its own vitest run. + exclude: ['node_modules', 'dist', 'mcp-server/**'], }, }); From 55ac1a5c93ed3416ecb83249ec90a597cd82720a Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:32:18 +0900 Subject: [PATCH 5/9] feat(mcp): tag/date filtering on list+search, plus stats_contexts tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the on-demand context store actually navigable as it grows. - filter.ts: shared filtering by source, tags (case-insensitive substring; AND by default, OR via tagMatch), and a captured-at date range (since/until; bare `until` date = end of day). Invalid date bounds are reported as warnings instead of failing the call. - list_contexts + search_contexts gain parser/tags/tagMatch/since/until. search applies the filter before ranking, so you can scope a keyword search to e.g. claude-ai TS artifacts from this month. - New stats_contexts tool: total count + size, breakdown by source, top tags, and captured-at date range — an at-a-glance store overview. - stats.ts: computeStats + formatBytes. - Tests: +14 (filter AND/OR, substring, date window, end-of-day, no-date exclusion, invalid-bound warning, stats aggregation). 29 total. - README: tool table, filtering docs, worked example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- mcp-server/README.md | 21 ++++- mcp-server/src/filter.ts | 94 +++++++++++++++++++++ mcp-server/src/index.ts | 147 +++++++++++++++++++++++++++------ mcp-server/src/stats.ts | 79 ++++++++++++++++++ mcp-server/test/filter.test.ts | 143 ++++++++++++++++++++++++++++++++ 5 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 mcp-server/src/filter.ts create mode 100644 mcp-server/src/stats.ts create mode 100644 mcp-server/test/filter.test.ts diff --git a/mcp-server/README.md b/mcp-server/README.md index 4a508a3..05e8e56 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -79,11 +79,28 @@ server can serve to Claude Code. | Tool | Description | |------|-------------| -| `list_contexts` | List captures (metadata only). Optional `parser` filter, `limit`. | -| `search_contexts` | Ranked keyword search across title, URL, tags, and body, with snippets. | +| `list_contexts` | List captures (metadata only). Filter by `parser`, `tags` (AND/OR via `tagMatch`), `since`/`until` date range; `limit`. | +| `search_contexts` | Ranked keyword search across title, URL, tags, and body, with snippets. Accepts the same `parser`/`tags`/`since`/`until` filters to scope the search first. | +| `stats_contexts` | Aggregate overview: total count + size, breakdown by source, top tags, captured-at date range. | | `get_context` | Fetch one capture's full Markdown by `slug` (fuzzy matching). | | `delete_context` | Remove a capture by `slug` once it's been incorporated. | +### Filtering + +`list_contexts` and `search_contexts` share these optional filters: + +- `parser` — exact source, e.g. `"claude-ai"`, `"github"`, `"generic"`. +- `tags` — case-insensitive substring match. `["lang:ts"]` matches the + `lang:ts` tag; `["lang:"]` matches any language tag. Multiple tags are ANDed + by default; pass `tagMatch: "any"` to OR them. +- `since` / `until` — ISO date (`"2026-05-01"`) or datetime. A bare `until` date + covers the whole day. An unparseable bound is ignored with a warning rather + than failing the call. + +Example: *"search my claude.ai TypeScript artifacts from this month for the auth +helper"* → +`search_contexts({ query: "auth helper", parser: "claude-ai", tags: ["lang:ts"], since: "2026-06-01" })`. + ### Example In Claude Code: diff --git a/mcp-server/src/filter.ts b/mcp-server/src/filter.ts new file mode 100644 index 0000000..bababd4 --- /dev/null +++ b/mcp-server/src/filter.ts @@ -0,0 +1,94 @@ +/** + * Shared filtering over captured contexts: by source (parser), tags, and a + * captured-at date range. Used by both list_contexts and search_contexts so + * the two tools behave consistently. + */ + +import type { ContextEntry } from './store.js'; + +export interface FilterCriteria { + /** Exact source/parser, case-insensitive (e.g. "claude-ai"). */ + parser?: string; + /** + * Tags the entry must carry. Matching is case-insensitive and substring-based + * so `lang:ts` matches and `ts` also matches `lang:ts`. By default an entry + * must match ALL given tags (AND); pass tagMatch:'any' for OR. + */ + tags?: string[]; + /** 'all' (AND, default) or 'any' (OR) over the `tags` list. */ + tagMatch?: 'all' | 'any'; + /** Inclusive lower bound on captured_at (ISO date or datetime). */ + since?: string; + /** Inclusive upper bound on captured_at (ISO date or datetime). */ + until?: string; +} + +export interface FilterResult { + entries: ContextEntry[]; + /** Non-fatal notes, e.g. an unparseable date bound that was ignored. */ + warnings: string[]; +} + +export function filterEntries( + entries: ContextEntry[], + criteria: FilterCriteria +): FilterResult { + const warnings: string[] = []; + + const sinceMs = parseBound(criteria.since, 'since', warnings, false); + const untilMs = parseBound(criteria.until, 'until', warnings, true); + + const wantTags = (criteria.tags ?? []) + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0); + const tagMatch = criteria.tagMatch ?? 'all'; + const wantParser = criteria.parser?.trim().toLowerCase(); + + const filtered = entries.filter((e) => { + if (wantParser && (e.parser ?? '').toLowerCase() !== wantParser) return false; + + if (wantTags.length > 0) { + const have = e.tags.map((t) => t.toLowerCase()); + const matches = (want: string) => have.some((h) => h.includes(want)); + const ok = tagMatch === 'any' ? wantTags.some(matches) : wantTags.every(matches); + if (!ok) return false; + } + + if (sinceMs !== undefined || untilMs !== undefined) { + const t = e.capturedAt ? Date.parse(e.capturedAt) : NaN; + if (Number.isNaN(t)) return false; // can't satisfy a date filter without a date + if (sinceMs !== undefined && t < sinceMs) return false; + if (untilMs !== undefined && t > untilMs) return false; + } + + return true; + }); + + return { entries: filtered, warnings }; +} + +/** + * Parse a date bound. A bare date ("2026-05-01") is treated as the start of + * that day; for an `until` bound it's pushed to the end of the day so the bound + * is inclusive of the whole calendar day. + */ +function parseBound( + raw: string | undefined, + label: string, + warnings: string[], + endOfDay: boolean +): number | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + const ms = Date.parse(trimmed); + if (Number.isNaN(ms)) { + warnings.push(`Ignored ${label}: "${raw}" is not a valid ISO date.`); + return undefined; + } + // Date-only string (no time component) → optionally extend to end of day. + const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed); + if (isDateOnly && endOfDay) { + return ms + 24 * 60 * 60 * 1000 - 1; + } + return ms; +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index cc130ba..c4bf1a7 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -14,9 +14,10 @@ * tools — so installation is just `npx` + one line in the MCP config. * * Tools: - * - list_contexts : list captured contexts (metadata only) + * - list_contexts : list captured contexts (metadata only), filterable * - get_context : fetch one capture's full Markdown by slug - * - search_contexts : ranked substring search across captures + * - search_contexts : ranked substring search across captures, filterable + * - stats_contexts : aggregate stats (counts by source/tag, date range) * - delete_context : remove a capture by slug */ @@ -30,6 +31,8 @@ import { describeStatus, } from './store.js'; import { searchContexts } from './search.js'; +import { filterEntries, type FilterCriteria } from './filter.js'; +import { computeStats, formatBytes } from './stats.js'; const VERSION = '0.1.0'; @@ -41,6 +44,49 @@ const server = new McpServer({ version: VERSION, }); +// Reusable filter fields shared by list_contexts and search_contexts. +const FILTER_FIELDS = { + parser: z + .string() + .optional() + .describe('Filter by capture source, e.g. "claude-ai", "github", "generic".'), + tags: z + .array(z.string()) + .optional() + .describe( + 'Filter by tags (case-insensitive, substring). e.g. ["lang:ts"] or ' + + '["artifacts-only"]. By default an entry must match ALL given tags.' + ), + tagMatch: z + .enum(['all', 'any']) + .optional() + .describe('Whether entries must match ALL tags (default) or ANY of them.'), + since: z + .string() + .optional() + .describe('Only captures on/after this ISO date or datetime, e.g. "2026-05-01".'), + until: z + .string() + .optional() + .describe('Only captures on/before this ISO date or datetime (date = whole day).'), +}; + +function criteriaFrom(args: { + parser?: string; + tags?: string[]; + tagMatch?: 'all' | 'any'; + since?: string; + until?: string; +}): FilterCriteria { + return { + parser: args.parser, + tags: args.tags, + tagMatch: args.tagMatch, + since: args.since, + until: args.until, + }; +} + // --------------------------------------------------------------------------- // list_contexts // --------------------------------------------------------------------------- @@ -52,15 +98,11 @@ server.registerTool( 'List web pages and claude.ai conversations captured into this project ' + 'by the Claude Code Context Capturer browser extension. Returns metadata ' + 'only (slug, title, url, source, captured_at, tags) — call get_context ' + - 'with a slug to read the full content. Use this to discover what research ' + - 'or planning context is available before answering.', + 'with a slug to read the full content. Filter by source, tags, and date ' + + 'range. Use this to discover what research or planning context is ' + + 'available before answering.', inputSchema: { - parser: z - .string() - .optional() - .describe( - 'Optional filter by capture source, e.g. "claude-ai", "github", "generic".' - ), + ...FILTER_FIELDS, limit: z .number() .int() @@ -70,22 +112,21 @@ server.registerTool( .describe('Maximum number of entries to return (default 50).'), }, }, - async ({ parser, limit }) => { + async ({ parser, tags, tagMatch, since, until, limit }) => { const all = await store.readAll(); - let filtered = all; - if (parser) { - const p = parser.toLowerCase(); - filtered = all.filter((e) => (e.parser ?? '').toLowerCase() === p); - } - const summaries = filtered.slice(0, limit ?? 50).map(toSummary); + const { entries, warnings } = filterEntries( + all, + criteriaFrom({ parser, tags, tagMatch, since, until }) + ); + const summaries = entries.slice(0, limit ?? 50).map(toSummary); if (summaries.length === 0) { - return textResult(await emptyMessage(parser)); + return textResult(prependWarnings(warnings, await emptyMessage(parser))); } const header = `${summaries.length} captured context(s) in ${contextsDir}:\n`; const lines = summaries.map(formatSummaryLine); - return textResult(header + '\n' + lines.join('\n')); + return textResult(prependWarnings(warnings, header + '\n' + lines.join('\n'))); } ); @@ -129,13 +170,15 @@ server.registerTool( description: 'Search captured contexts by keyword(s), ranked by relevance. Matches ' + 'titles, URLs, tags, and body text, and returns short snippets around ' + - 'each match plus the slug to fetch with get_context. Prefer this over ' + + 'each match plus the slug to fetch with get_context. Optionally restrict ' + + 'the search to a source, tags, or date range first. Prefer this over ' + 'reading every context when you are looking for something specific.', inputSchema: { query: z .string() .min(1) .describe('One or more space-separated keywords to search for.'), + ...FILTER_FIELDS, limit: z .number() .int() @@ -145,11 +188,17 @@ server.registerTool( .describe('Maximum number of hits to return (default 10).'), }, }, - async ({ query, limit }) => { + async ({ query, parser, tags, tagMatch, since, until, limit }) => { const all = await store.readAll(); - const hits = searchContexts(all, query, limit ?? 10); + const { entries, warnings } = filterEntries( + all, + criteriaFrom({ parser, tags, tagMatch, since, until }) + ); + const hits = searchContexts(entries, query, limit ?? 10); if (hits.length === 0) { - return textResult(`No captured contexts match "${query}".`); + return textResult( + prependWarnings(warnings, `No captured contexts match "${query}".`) + ); } const blocks = hits.map((h) => { const meta = [ @@ -165,11 +214,55 @@ server.registerTool( return meta; }); return textResult( - `${hits.length} match(es) for "${query}":\n\n` + blocks.join('\n\n') + prependWarnings( + warnings, + `${hits.length} match(es) for "${query}":\n\n` + blocks.join('\n\n') + ) ); } ); +// --------------------------------------------------------------------------- +// stats_contexts +// --------------------------------------------------------------------------- + +server.registerTool( + 'stats_contexts', + { + description: + 'Summarize the capture store: total count and size, a breakdown by ' + + 'source (claude-ai / github / generic / …), the most common tags, and ' + + 'the captured-at date range. Use this for an at-a-glance picture of what ' + + 'research/planning context exists without listing every entry.', + inputSchema: {}, + }, + async () => { + const all = await store.readAll(); + if (all.length === 0) { + return textResult(await emptyMessage()); + } + const stats = computeStats(all); + const lines: string[] = []; + lines.push(`**${stats.total}** captured context(s), ${formatBytes(stats.totalBytes)} total.`); + if (stats.earliest && stats.latest) { + lines.push(`Date range: ${stats.earliest} → ${stats.latest}`); + } + lines.push(''); + lines.push('By source:'); + for (const { source, count } of stats.bySource) { + lines.push(`- ${source}: ${count}`); + } + if (stats.topTags.length > 0) { + lines.push(''); + lines.push('Top tags:'); + for (const { tag, count } of stats.topTags) { + lines.push(`- ${tag}: ${count}`); + } + } + return textResult(lines.join('\n')); + } +); + // --------------------------------------------------------------------------- // delete_context // --------------------------------------------------------------------------- @@ -208,6 +301,12 @@ function textResult(text: string, isError = false) { }; } +/** Prefix any non-fatal filter warnings (e.g. a bad date bound) to the output. */ +function prependWarnings(warnings: string[], text: string): string { + if (warnings.length === 0) return text; + return warnings.map((w) => `⚠️ ${w}`).join('\n') + '\n\n' + text; +} + function formatSummaryLine(s: ReturnType<typeof toSummary>): string { const parts = [`- \`${s.slug}\` — ${s.title}`]; const meta: string[] = []; diff --git a/mcp-server/src/stats.ts b/mcp-server/src/stats.ts new file mode 100644 index 0000000..b227f6f --- /dev/null +++ b/mcp-server/src/stats.ts @@ -0,0 +1,79 @@ +/** + * Aggregate statistics over captured contexts — for the stats_contexts tool, + * which lets the agent (and the user) get an at-a-glance picture of what's in + * the capture store without listing every entry. + */ + +import type { ContextEntry } from './store.js'; + +export interface ContextStats { + total: number; + totalBytes: number; + /** Count per source/parser, descending. */ + bySource: Array<{ source: string; count: number }>; + /** Count per tag, descending, top N only. */ + topTags: Array<{ tag: string; count: number }>; + /** Earliest captured_at (ISO), if any have dates. */ + earliest?: string; + /** Latest captured_at (ISO), if any have dates. */ + latest?: string; +} + +export function computeStats(entries: ContextEntry[], topTagsN = 15): ContextStats { + let totalBytes = 0; + const sourceCounts = new Map<string, number>(); + const tagCounts = new Map<string, number>(); + let earliestMs = Infinity; + let latestMs = -Infinity; + let earliest: string | undefined; + let latest: string | undefined; + + for (const e of entries) { + totalBytes += e.bytes; + + const source = e.parser || 'unknown'; + sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1); + + for (const tag of e.tags) { + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); + } + + if (e.capturedAt) { + const t = Date.parse(e.capturedAt); + if (!Number.isNaN(t)) { + if (t < earliestMs) { + earliestMs = t; + earliest = e.capturedAt; + } + if (t > latestMs) { + latestMs = t; + latest = e.capturedAt; + } + } + } + } + + return { + total: entries.length, + totalBytes, + bySource: sortedCounts(sourceCounts).map(([source, count]) => ({ source, count })), + topTags: sortedCounts(tagCounts) + .slice(0, topTagsN) + .map(([tag, count]) => ({ tag, count })), + earliest, + latest, + }; +} + +/** Sort a count map by count desc, then key asc for stable output. */ +function sortedCounts(m: Map<string, number>): Array<[string, number]> { + return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +/** Human-readable byte size, e.g. "12.3 KB". */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + return `${(kb / 1024).toFixed(1)} MB`; +} diff --git a/mcp-server/test/filter.test.ts b/mcp-server/test/filter.test.ts new file mode 100644 index 0000000..e41f724 --- /dev/null +++ b/mcp-server/test/filter.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { filterEntries } from '../src/filter.js'; +import { computeStats, formatBytes } from '../src/stats.js'; +import type { ContextEntry } from '../src/store.js'; + +function entry(p: Partial<ContextEntry>): ContextEntry { + return { + slug: p.slug ?? 's', + filePath: p.filePath ?? '/x/s.md', + title: p.title ?? 'Title', + url: p.url, + parser: p.parser, + capturedAt: p.capturedAt, + author: p.author, + tags: p.tags ?? [], + body: p.body ?? '', + raw: p.raw ?? '', + bytes: p.bytes ?? 0, + }; +} + +const SAMPLE: ContextEntry[] = [ + entry({ + slug: 'a', + parser: 'claude-ai', + tags: ['model:claude-opus-4', 'artifacts-only', 'lang:ts'], + capturedAt: '2026-05-01T10:00:00.000Z', + bytes: 1000, + }), + entry({ + slug: 'b', + parser: 'github', + tags: ['bug'], + capturedAt: '2026-05-15T10:00:00.000Z', + bytes: 2000, + }), + entry({ + slug: 'c', + parser: 'claude-ai', + tags: ['lang:python'], + capturedAt: '2026-05-30T10:00:00.000Z', + bytes: 3000, + }), + entry({ slug: 'd', parser: 'generic', tags: [], capturedAt: undefined, bytes: 500 }), +]; + +describe('filterEntries — parser', () => { + it('filters by exact source, case-insensitive', () => { + const { entries } = filterEntries(SAMPLE, { parser: 'Claude-AI' }); + expect(entries.map((e) => e.slug)).toEqual(['a', 'c']); + }); +}); + +describe('filterEntries — tags', () => { + it('matches a tag by substring', () => { + const { entries } = filterEntries(SAMPLE, { tags: ['lang:ts'] }); + expect(entries.map((e) => e.slug)).toEqual(['a']); + }); + + it('ANDs multiple tags by default', () => { + const { entries } = filterEntries(SAMPLE, { + tags: ['artifacts-only', 'lang:ts'], + }); + expect(entries.map((e) => e.slug)).toEqual(['a']); + }); + + it('returns nothing when AND is unsatisfiable', () => { + const { entries } = filterEntries(SAMPLE, { tags: ['lang:ts', 'bug'] }); + expect(entries).toHaveLength(0); + }); + + it('ORs tags with tagMatch:any', () => { + const { entries } = filterEntries(SAMPLE, { + tags: ['lang:ts', 'bug'], + tagMatch: 'any', + }); + expect(entries.map((e) => e.slug)).toEqual(['a', 'b']); + }); + + it('substring matches the tag prefix, e.g. "lang:"', () => { + const { entries } = filterEntries(SAMPLE, { tags: ['lang:'] }); + expect(entries.map((e) => e.slug)).toEqual(['a', 'c']); + }); +}); + +describe('filterEntries — date range', () => { + it('filters since (inclusive)', () => { + const { entries } = filterEntries(SAMPLE, { since: '2026-05-15' }); + expect(entries.map((e) => e.slug)).toEqual(['b', 'c']); + }); + + it('filters until as end-of-day for a bare date', () => { + const { entries } = filterEntries(SAMPLE, { until: '2026-05-15' }); + // b is on 2026-05-15T10:00 — must be included by the end-of-day rule. + expect(entries.map((e) => e.slug)).toEqual(['a', 'b']); + }); + + it('combines since and until into a window', () => { + const { entries } = filterEntries(SAMPLE, { + since: '2026-05-10', + until: '2026-05-20', + }); + expect(entries.map((e) => e.slug)).toEqual(['b']); + }); + + it('excludes entries without a date when a date filter is set', () => { + const { entries } = filterEntries(SAMPLE, { since: '2026-01-01' }); + expect(entries.map((e) => e.slug)).not.toContain('d'); + }); + + it('warns and ignores an invalid date bound', () => { + const { entries, warnings } = filterEntries(SAMPLE, { since: 'not-a-date' }); + expect(warnings.join(' ')).toMatch(/not a valid ISO date/); + expect(entries).toHaveLength(SAMPLE.length); // bound ignored + }); +}); + +describe('computeStats', () => { + it('aggregates totals, sources, tags, and range', () => { + const stats = computeStats(SAMPLE); + expect(stats.total).toBe(4); + expect(stats.totalBytes).toBe(6500); + expect(stats.bySource[0]).toEqual({ source: 'claude-ai', count: 2 }); + expect(stats.bySource.map((s) => s.source)).toContain('generic'); + // lang:ts and others each appear once; claude-ai entries dominate tag set. + expect(stats.topTags.find((t) => t.tag === 'lang:ts')?.count).toBe(1); + expect(stats.earliest).toBe('2026-05-01T10:00:00.000Z'); + expect(stats.latest).toBe('2026-05-30T10:00:00.000Z'); + }); + + it('labels missing parser as unknown', () => { + const stats = computeStats([entry({ slug: 'x', parser: undefined })]); + expect(stats.bySource[0]).toEqual({ source: 'unknown', count: 1 }); + }); +}); + +describe('formatBytes', () => { + it('formats B / KB / MB', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2.0 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); From ec0a64ff82cbf7e9a088439dda9fe6ea06b81669 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:38:22 +0900 Subject: [PATCH 6/9] feat: split claude.ai artifacts into one file each (get_context format:code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the "one code = one file" path: a claude.ai capture's code/ document artifacts become individually addressable in the MCP store. Extension (mcp-store mode): - CapturedContext now carries `artifacts`; the claude.ai parser exposes them even for full-conversation captures. - artifact-file.ts: builds one standalone store file per artifact — frontmatter (parser: claude-ai-artifact, artifact_of, language, tags artifact + lang:<x>) + a single fenced code block. Filenames share the parent slug and disambiguate same-title artifacts. - service worker writes the main capture file plus one file per artifact. MCP server: - get_context gains format:"code" — returns raw code with frontmatter and fences stripped, ready to write to a file. extractCode() in store.ts. - Artifact files are first-class: filterable via tags/parser, fetchable as code. Tests: extension +6 (buildArtifactFiles), mcp +2 (extractCode). 80 + 31. README: artifact-as-files docs + worked example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- mcp-server/README.md | 17 +++++- mcp-server/src/index.ts | 22 ++++++-- mcp-server/src/store.ts | 16 ++++++ mcp-server/test/store.test.ts | 32 ++++++++++++ src/background/service-worker.ts | 15 +++++- src/content/parsers/claude-ai.ts | 6 ++- src/shared/artifact-file.ts | 89 ++++++++++++++++++++++++++++++++ src/shared/types.ts | 25 +++++++++ tests/artifact-file.test.ts | 80 ++++++++++++++++++++++++++++ 9 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 src/shared/artifact-file.ts create mode 100644 tests/artifact-file.test.ts diff --git a/mcp-server/README.md b/mcp-server/README.md index 05e8e56..b48ce50 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -82,9 +82,24 @@ server can serve to Claude Code. | `list_contexts` | List captures (metadata only). Filter by `parser`, `tags` (AND/OR via `tagMatch`), `since`/`until` date range; `limit`. | | `search_contexts` | Ranked keyword search across title, URL, tags, and body, with snippets. Accepts the same `parser`/`tags`/`since`/`until` filters to scope the search first. | | `stats_contexts` | Aggregate overview: total count + size, breakdown by source, top tags, captured-at date range. | -| `get_context` | Fetch one capture's full Markdown by `slug` (fuzzy matching). | +| `get_context` | Fetch one capture by `slug` (fuzzy matching). `format:"code"` returns just the raw code (fences + frontmatter stripped) — ideal for single artifacts. | | `delete_context` | Remove a capture by `slug` once it's been incorporated. | +### Artifacts as individual files + +When you capture a claude.ai conversation in **mcp-store** mode, each code or +document **artifact** Claude wrote is also saved as its own file (`parser: +claude-ai-artifact`, tagged `artifact` + `lang:<x>`). So you can: + +``` +list_contexts({ tags: ["artifact"], parser: "claude-ai-artifact" }) +get_context({ slug: "...--auth-middleware", format: "code" }) // → raw code, ready to write to a file +``` + +That's the "one code = one file" path: ask Claude Code to pull a specific +artifact and it gets exactly that file's contents, with no conversation or +Markdown wrapping. + ### Filtering `list_contexts` and `search_contexts` share these optional filters: diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index c4bf1a7..faa7fb3 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -29,6 +29,7 @@ import { resolveContextsDir, toSummary, describeStatus, + extractCode, } from './store.js'; import { searchContexts } from './search.js'; import { filterEntries, type FilterCriteria } from './filter.js'; @@ -138,17 +139,25 @@ server.registerTool( 'get_context', { description: - 'Fetch the full Markdown of one captured context by its slug (from ' + - 'list_contexts or search_contexts). Returns the complete captured page ' + - 'or claude.ai conversation, including frontmatter metadata. Matching is ' + - 'fuzzy: an exact filename, a slug, or a unique substring all work.', + 'Fetch one captured context by its slug (from list_contexts or ' + + 'search_contexts). Matching is fuzzy: an exact filename, a slug, or a ' + + 'unique substring all work. With format:"code" (useful for artifacts — ' + + 'single code/document files), returns just the raw code with frontmatter ' + + 'and code fences stripped, ready to write to a file.', inputSchema: { slug: z .string() .describe('The context slug, filename, or a unique substring of it.'), + format: z + .enum(['markdown', 'code']) + .optional() + .describe( + 'markdown (default): full file with frontmatter. code: raw code only, ' + + 'fences and frontmatter stripped — best for single artifacts.' + ), }, }, - async ({ slug }) => { + async ({ slug, format }) => { const entry = await store.readBySlug(slug); if (!entry) { return textResult( @@ -156,6 +165,9 @@ server.registerTool( true ); } + if (format === 'code') { + return textResult(extractCode(entry)); + } return textResult(entry.raw); } ); diff --git a/mcp-server/src/store.ts b/mcp-server/src/store.ts index 3b21acb..c2bd838 100644 --- a/mcp-server/src/store.ts +++ b/mcp-server/src/store.ts @@ -355,3 +355,19 @@ function firstHeading(body: string): string | undefined { const m = body.match(/^#{1,6}\s+(.+)$/m); return m?.[1]?.trim(); } + +/** + * Extract raw code from an entry whose body is a single fenced code block + * (the shape `buildArtifactFiles` writes). Returns the code with the fence and + * frontmatter stripped. If the body isn't a single clean code block, returns + * the trimmed body unchanged so callers always get something usable. + */ +export function extractCode(entry: ContextEntry): string { + const body = entry.body.trim(); + // Match a leading ```lang ... ``` block, optionally preceded by a heading. + const fence = body.match(/```[^\n]*\n([\s\S]*?)\n?```/); + if (fence) { + return fence[1].replace(/\n+$/, '') + '\n'; + } + return body + '\n'; +} diff --git a/mcp-server/test/store.test.ts b/mcp-server/test/store.test.ts index fa90852..08637d2 100644 --- a/mcp-server/test/store.test.ts +++ b/mcp-server/test/store.test.ts @@ -7,6 +7,7 @@ import { splitFrontmatter, parseTags, describeStatus, + extractCode, ContextStore, } from '../src/store.js'; import { searchContexts } from '../src/search.js'; @@ -141,6 +142,37 @@ describe('ContextStore.status + describeStatus', () => { }); }); +describe('extractCode', () => { + const ARTIFACT = `--- +title: Auth middleware +parser: claude-ai-artifact +language: ts +tags: ["artifact", "lang:ts"] +--- + +\`\`\`ts +export const auth = () => { + return true; +}; +\`\`\` +`; + + it('returns raw code with frontmatter and fences stripped', () => { + const entry = parseContextFile('x', '/x/x.md', ARTIFACT, ARTIFACT.length); + const code = extractCode(entry); + expect(code).toBe('export const auth = () => {\n return true;\n};\n'); + expect(code).not.toContain('```'); + expect(code).not.toContain('parser:'); + }); + + it('falls back to the trimmed body when there is no code fence', () => { + const plain = '# Heading\n\njust prose, no fence'; + const entry = parseContextFile('y', '/y/y.md', plain, plain.length); + const code = extractCode(entry); + expect(code).toContain('just prose, no fence'); + }); +}); + describe('searchContexts', () => { const entries = [ entry({ diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index daa77a4..0edacc0 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -5,6 +5,7 @@ import { buildEntryHeading } from '@/shared/file-appender'; import { listRoutes } from '@/shared/handle-store'; import { resolveRoute } from '@/shared/route-matcher'; import { buildContextSlug } from '@/shared/slug'; +import { buildArtifactFiles } from '@/shared/artifact-file'; import type { CapturedContext, OffscreenAppendResult, @@ -191,11 +192,23 @@ async function writeToMcpStore( markdown: string ): Promise<void> { const fileName = `${buildContextSlug(ctx)}.md`; + await writeOneMcpFile(fileName, markdown); + + // If the capture carried code/document artifacts (claude.ai), also write one + // standalone file per artifact so `get_context` can return a single + // artifact's code directly — "one code = one file". + const artifactFiles = buildArtifactFiles(ctx); + for (const file of artifactFiles) { + await writeOneMcpFile(file.fileName, file.content); + } +} + +async function writeOneMcpFile(fileName: string, content: string): Promise<void> { const result = await sendToOffscreenWithRetry<OffscreenMcpStoreResult>({ target: 'offscreen', type: 'WRITE_TO_MCP_STORE', fileName, - content: markdown, + content, }); if (!result?.ok) { const reason = result?.reason ?? 'write-failed'; diff --git a/src/content/parsers/claude-ai.ts b/src/content/parsers/claude-ai.ts index a718edf..2ab1eea 100644 --- a/src/content/parsers/claude-ai.ts +++ b/src/content/parsers/claude-ai.ts @@ -58,11 +58,11 @@ export async function parseClaudeAi( } const title = (conversation.name ?? '').trim() || 'Untitled Claude conversation'; + const artifacts = collectArtifacts(messages); // Artifacts-only: extract just the code/documents Claude authored, dropping // the surrounding conversation entirely. if (options.claudeAiArtifactsOnly) { - const artifacts = collectArtifacts(messages); if (artifacts.length === 0) { throw new Error( 'No artifacts found in this conversation. Turn off "artifacts only" to capture the full chat.' @@ -77,6 +77,7 @@ export async function parseClaudeAi( parser: 'claude-ai', fromSelection: false, tags: tagsFor(conversation, artifacts), + artifacts, }; } @@ -91,6 +92,9 @@ export async function parseClaudeAi( parser: 'claude-ai', fromSelection: false, tags: tagsFor(conversation), + // Expose artifacts so mcp-store mode can split them into one file each, + // even when the body is the full conversation. + artifacts: artifacts.length > 0 ? artifacts : undefined, }; } diff --git a/src/shared/artifact-file.ts b/src/shared/artifact-file.ts new file mode 100644 index 0000000..15835d4 --- /dev/null +++ b/src/shared/artifact-file.ts @@ -0,0 +1,89 @@ +import type { CapturedArtifact, CapturedContext } from './types'; +import { buildContextSlug, shortHash } from './slug'; + +/** One artifact rendered as a standalone store file (name + contents). */ +export interface ArtifactFile { + fileName: string; + content: string; +} + +/** + * Split a capture's artifacts into one standalone store file each, so the MCP + * server's `get_context` can return a single artifact's code directly. + * + * Each file carries YAML frontmatter (so list/search/stats/filtering keep + * working) followed by exactly one fenced code block. The frontmatter records + * `artifact_of` (the parent capture slug) and `artifact_title`/`parser: + * claude-ai-artifact` so artifacts are filterable as their own source. + */ +export function buildArtifactFiles(ctx: CapturedContext): ArtifactFile[] { + const artifacts = ctx.artifacts ?? []; + if (artifacts.length === 0) return []; + + const parentSlug = buildContextSlug(ctx); + const usedNames = new Set<string>(); + + return artifacts.map((art, index) => { + const fileName = uniqueArtifactFileName(parentSlug, art, index, usedNames); + return { fileName, content: renderArtifactFile(ctx, art, parentSlug) }; + }); +} + +function uniqueArtifactFileName( + parentSlug: string, + art: CapturedArtifact, + index: number, + used: Set<string> +): string { + const titlePart = slugifyTitle(art.title) || `artifact-${index + 1}`; + // Keep artifact files grouped next to (but distinct from) the parent slug. + let base = `${parentSlug}--${titlePart}`; + if (used.has(base)) { + base = `${base}-${shortHash(art.id ?? art.content).slice(0, 4)}`; + } + used.add(base); + return `${base}.md`; +} + +function renderArtifactFile( + ctx: CapturedContext, + art: CapturedArtifact, + parentSlug: string +): string { + const fence = art.language || ''; + const title = art.title || ctx.title; + const lines: string[] = ['---']; + lines.push(`title: ${yamlValue(title)}`); + lines.push(`url: ${yamlValue(ctx.url)}`); + lines.push(`captured_at: ${ctx.capturedAt}`); + lines.push('parser: claude-ai-artifact'); + lines.push(`artifact_of: ${yamlValue(parentSlug)}`); + if (art.language) lines.push(`language: ${yamlValue(art.language)}`); + const tags = ['artifact']; + if (art.language) tags.push(`lang:${art.language}`); + lines.push(`tags: [${tags.map((t) => `"${t.replace(/"/g, '\\"')}"`).join(', ')}]`); + lines.push('---', ''); + lines.push('```' + fence); + lines.push(art.content.replace(/\n+$/, '')); + lines.push('```', ''); + return lines.join('\n'); +} + +function slugifyTitle(title?: string): string { + if (!title) return ''; + return title + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-') + .slice(0, 40) + .replace(/-+$/g, ''); +} + +function yamlValue(value: string): string { + if (/[:#\n"'\\]|^\s|\s$/.test(value)) { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return value; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e641a0e..94f1d55 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -22,6 +22,31 @@ export interface CapturedContext { parser: ParserName; /** True if this came from a user text selection (not the whole page). */ fromSelection: boolean; + /** + * Code/document artifacts detected in this capture (currently claude.ai + * only). In `mcp-store` output mode these are written as one file each, so + * `get_context` can return a single artifact's code directly. Other output + * modes ignore this (the artifacts are already rendered inside `body`). + */ + artifacts?: CapturedArtifact[]; + /** + * When this context IS a single extracted artifact (one file per artifact), + * the slug of the parent capture it came from. Surfaced in frontmatter as + * `artifact_of` so the conversation it belongs to is discoverable. + */ + artifactOf?: string; +} + +/** A single code or document artifact extracted from a capture. */ +export interface CapturedArtifact { + /** Stable artifact id from the source, when available. */ + id?: string; + /** Human title, e.g. "Auth middleware". */ + title?: string; + /** Fence language hint, e.g. "ts", "python", "markdown". */ + language?: string; + /** The artifact body (raw code/document text). */ + content: string; } /** Names of all built-in parsers. */ diff --git a/tests/artifact-file.test.ts b/tests/artifact-file.test.ts new file mode 100644 index 0000000..3f3f514 --- /dev/null +++ b/tests/artifact-file.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { buildArtifactFiles } from '@/shared/artifact-file'; +import type { CapturedContext } from '@/shared/types'; + +function ctx(p: Partial<CapturedContext>): CapturedContext { + return { + url: p.url ?? 'https://claude.ai/chat/abc-123', + title: p.title ?? 'Auth work', + body: p.body ?? 'body', + capturedAt: p.capturedAt ?? '2026-05-31T14:30:00.000Z', + parser: p.parser ?? 'claude-ai', + fromSelection: false, + artifacts: p.artifacts, + }; +} + +describe('buildArtifactFiles', () => { + it('returns nothing when there are no artifacts', () => { + expect(buildArtifactFiles(ctx({}))).toEqual([]); + expect(buildArtifactFiles(ctx({ artifacts: [] }))).toEqual([]); + }); + + it('writes one file per artifact with frontmatter + single code block', () => { + const files = buildArtifactFiles( + ctx({ + artifacts: [ + { id: '1', title: 'Auth middleware', language: 'ts', content: 'export const a = 1;' }, + { id: '2', title: 'Schema', language: 'sql', content: 'CREATE TABLE t (id int);' }, + ], + }) + ); + expect(files).toHaveLength(2); + + const f0 = files[0]; + expect(f0.fileName).toMatch(/--auth-middleware\.md$/); + expect(f0.content).toContain('parser: claude-ai-artifact'); + expect(f0.content).toContain('artifact_of:'); + expect(f0.content).toContain('language: ts'); + expect(f0.content).toContain('tags: ["artifact", "lang:ts"]'); + expect(f0.content).toContain('```ts'); + expect(f0.content).toContain('export const a = 1;'); + // title from the artifact, not the conversation + expect(f0.content).toContain('title: Auth middleware'); + }); + + it('shares the parent slug across artifact filenames', () => { + const files = buildArtifactFiles( + ctx({ + title: 'Project X', + artifacts: [ + { title: 'One', language: 'ts', content: 'a' }, + { title: 'Two', language: 'ts', content: 'b' }, + ], + }) + ); + const prefix0 = files[0].fileName.split('--')[0]; + const prefix1 = files[1].fileName.split('--')[0]; + expect(prefix0).toBe(prefix1); + expect(prefix0).toContain('project-x'); + }); + + it('disambiguates artifacts with the same title', () => { + const files = buildArtifactFiles( + ctx({ + artifacts: [ + { id: 'a', title: 'Same', language: 'ts', content: 'one' }, + { id: 'b', title: 'Same', language: 'ts', content: 'two' }, + ], + }) + ); + expect(files[0].fileName).not.toBe(files[1].fileName); + }); + + it('falls back to artifact-N when there is no title', () => { + const files = buildArtifactFiles( + ctx({ artifacts: [{ language: 'py', content: 'print(1)' }] }) + ); + expect(files[0].fileName).toMatch(/--artifact-1\.md$/); + }); +}); From 409ee471c9c23f70bb53c89ced4982428ad95f4d Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:42:10 +0900 Subject: [PATCH 7/9] feat(popup): quick artifacts/range toggle on claude.ai chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the active tab is a claude.ai chat, the popup shows an inline panel to toggle "artifacts only" and set "last N messages" without digging into the options page. Changes persist to the same chrome.storage options, so the next capture (button, shortcut, or context menu) picks them up — one source of truth, no divergence from the settings page. - popup detects claude.ai/chat/<uuid> via the active tab URL (same pattern as the parser's canHandleClaudeAi); panel is hidden elsewhere. - patchOption() persists each change immediately via saveOptions. - README: note the popup quick-toggle (EN + JA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- README.md | 4 +-- src/popup/App.tsx | 74 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4555668..dea57f2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ I see the same. - **CLAUDE.md への直書き** *(v0.2.0+)*:プロジェクトの `CLAUDE.md` を一度ピックすれば、以降のキャプチャは File System Access API 経由で自動 append。コピペ不要 - **マルチプロジェクト振り分け** *(v0.3.0+)*:URL パターン(glob)で複数の CLAUDE.md を使い分け。例:`github.com/anthropic/*` → anthropic 用、`zenn.dev/*` → 個人メモ、無マッチは default ルート - **claude.ai 会話のキャプチャ** *(v0.4.0+)*:claude.ai でブレストした会話をワンクリックで CLAUDE.md に流し込み。share リンクの 403 問題、手コピペの労力を解消。内部 API を叩いて thinking blocks・tool_use・branch 構造まで保持 -- **アーティファクト抽出 & 範囲選択** *(v0.5.0+)*:claude.ai キャプチャで「**アーティファクトだけ**」(Claude が書いたコード/ドキュメントを整形済みコードブロックで抽出、会話は捨てる)や「**直近 N 件のみ**」を選択可能。長い設計会話から必要な部分だけを取り込めます。アーティファクトは同一 ID の更新を最新版に集約 +- **アーティファクト抽出 & 範囲選択** *(v0.5.0+)*:claude.ai キャプチャで「**アーティファクトだけ**」(Claude が書いたコード/ドキュメントを整形済みコードブロックで抽出、会話は捨てる)や「**直近 N 件のみ**」を選択可能。長い設計会話から必要な部分だけを取り込めます。アーティファクトは同一 ID の更新を最新版に集約。claude.ai のチャットを開いているときは**ポップアップから直接トグル**できます - **MCP 経由でオンデマンド参照** *(v0.5.0+)*:キャプチャを `CLAUDE.md` に追記する代わりに、専用ディレクトリへ 1 件 1 ファイルで保存し、付属の [MCP サーバ](./mcp-server) が Claude Code に**必要なときだけ**渡します。Anthropic 公式の「`CLAUDE.md` は小さく保て(肥大化すると指示が無視される)」ガイドと整合し、コンテキストを汚しません。`list_contexts` / `search_contexts` / `get_context` で検索・取得できます。→ [セットアップ](./mcp-server/README.md) - **キャプチャバッファ**:複数ページをまとめて溜めて、後から一括エクスポート - **キーボードショートカット**: @@ -259,7 +259,7 @@ In short: a clipper purpose-built for AI agent context files. If you want a gene - **Direct CLAUDE.md write** *(v0.2.0+)* — Link a `CLAUDE.md` once via the File System Access API; subsequent captures append directly, no copy/paste - **Multi-project routing** *(v0.3.0+)* — Link multiple `CLAUDE.md` files with URL glob patterns. Captures from `github.com/anthropic/*` go to one file, `zenn.dev/*` to another, unmatched URLs to a default route - **claude.ai conversation capture** *(v0.4.0+)* — Capture your claude.ai brainstorm conversation in one click. Bypasses the 403-on-share-link issue and the manual copy-paste loop. Hits claude.ai's internal API to preserve thinking blocks, tool_use entries, and branch structure that DOM scraping would miss -- **Artifact extraction & range selection** *(v0.5.0+)* — For claude.ai captures, choose **artifacts only** (extract just the code/documents Claude wrote as clean fenced code blocks, dropping the conversation) or **keep the last N messages** (trim a long planning thread to what matters). Artifacts are deduped by id so an updated artifact is captured at its final version, not every intermediate edit +- **Artifact extraction & range selection** *(v0.5.0+)* — For claude.ai captures, choose **artifacts only** (extract just the code/documents Claude wrote as clean fenced code blocks, dropping the conversation) or **keep the last N messages** (trim a long planning thread to what matters). Artifacts are deduped by id so an updated artifact is captured at its final version, not every intermediate edit. When you're on a claude.ai chat, these are **toggleable right from the popup** - **On-demand access via MCP** *(v0.5.0+)* — Instead of appending to `CLAUDE.md`, save each capture as a standalone file in a directory, and let the bundled [MCP server](./mcp-server) hand them to Claude Code **only when it asks**. This aligns with Anthropic's own guidance to keep `CLAUDE.md` small (bloated context files make Claude ignore your instructions) — your research stays available without polluting context. Searchable via `list_contexts` / `search_contexts` / `get_context`. → [Setup](./mcp-server/README.md) - **Buffer mode** — Stack multiple captures and export all at once - **Keyboard shortcuts**: diff --git a/src/popup/App.tsx b/src/popup/App.tsx index cae44e1..50a233f 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; -import type { RuntimeMessage } from '@/shared/types'; +import type { RuntimeMessage, UserOptions } from '@/shared/types'; import { readBuffer, type BufferEntry } from '@/shared/buffer-storage'; +import { loadOptions, saveOptions } from '@/shared/options-storage'; type Status = 'idle' | 'capturing' | 'success' | 'error'; @@ -8,9 +9,13 @@ export default function App() { const [status, setStatus] = useState<Status>('idle'); const [errorMessage, setErrorMessage] = useState<string | null>(null); const [buffer, setBuffer] = useState<BufferEntry[]>([]); + const [options, setOptions] = useState<UserOptions | null>(null); + const [isClaudeAi, setIsClaudeAi] = useState(false); useEffect(() => { void refreshBuffer(); + void loadOptions().then(setOptions); + void detectClaudeAi(); }, []); async function refreshBuffer() { @@ -18,6 +23,27 @@ export default function App() { setBuffer(entries); } + async function detectClaudeAi() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = tab?.url ?? ''; + setIsClaudeAi(/^https:\/\/claude\.ai\/chat\/[0-9a-f-]{8,}/i.test(url)); + } catch { + setIsClaudeAi(false); + } + } + + /** Persist a single option change immediately (next capture picks it up). */ + async function patchOption<K extends keyof UserOptions>( + key: K, + value: UserOptions[K] + ) { + if (!options) return; + const next = { ...options, [key]: value }; + setOptions(next); + await saveOptions(next); + } + async function dispatch(type: 'CAPTURE_PAGE' | 'CAPTURE_SELECTION') { setStatus('capturing'); setErrorMessage(null); @@ -77,6 +103,52 @@ export default function App() { </button> </div> + {isClaudeAi && options && ( + <section className="mt-3 rounded border border-violet-200 bg-violet-50 p-2.5"> + <h2 className="mb-1.5 flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700"> + <span aria-hidden>✦</span> claude.ai chat + </h2> + + <label className="flex cursor-pointer items-center justify-between gap-2 text-xs text-slate-700"> + <span> + <span className="font-medium">Artifacts only</span> + <span className="block text-[10px] text-slate-500"> + Capture just the code/docs Claude wrote + </span> + </span> + <input + type="checkbox" + checked={options.claudeAiArtifactsOnly} + onChange={(e) => + void patchOption('claudeAiArtifactsOnly', e.target.checked) + } + className="h-4 w-4 shrink-0 rounded border-slate-300 text-violet-600 focus:ring-violet-500" + /> + </label> + + <label className="mt-2 flex items-center justify-between gap-2 text-xs text-slate-700"> + <span> + <span className="font-medium">Last N messages</span> + <span className="block text-[10px] text-slate-500"> + 0 = whole conversation + </span> + </span> + <input + type="number" + min={0} + value={options.claudeAiMaxMessages} + onChange={(e) => + void patchOption( + 'claudeAiMaxMessages', + Math.max(0, Number(e.target.value)) + ) + } + className="w-16 shrink-0 rounded border border-slate-300 px-2 py-1 text-xs" + /> + </label> + </section> + )} + {status === 'success' && ( <p className="mt-3 rounded bg-emerald-50 px-2 py-1 text-xs text-emerald-700"> Captured ✓ From 878079493259e3c9a2a2dae0df8a5b2b3bc8c032 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 22:55:50 +0900 Subject: [PATCH 8/9] feat: re-capturing a claude.ai conversation updates in place (no silos) Directly answers the strongest signal in #13843's thread: users want a single source of truth, not export/import silos. Re-capturing the same conversation now UPDATES its store files instead of accumulating duplicate snapshots. - CapturedContext.dedupeKey: stable identity for a capture's subject. The claude.ai parser sets it to the conversation UUID (and uuid:artifacts for artifacts-only mode, so the two modes stay independent). - buildContextSlug: with a dedupeKey, the slug leads with a stable `ccc-<hash>` prefix (title appended only for readability). The prefix is invariant across re-captures even when Claude renames the conversation. - WRITE_MCP_FILESET offscreen op: removes the prior version by stable prefix (main + derived artifact files), then writes the new set atomically per file. Non-deduped captures (web pages) are unaffected. - slugFileNamesToRemove: pure, prefix-based selection of files to replace, with a boundary check so ccc-ab12 doesn't match ccc-ab123. Tests: +dedupe slug stability (incl. title-change), +prefix removal boundary cases. 90 extension tests, 31 mcp. README EN+JA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- README.md | 2 + src/background/service-worker.ts | 32 ++++----- src/content/parsers/claude-ai.ts | 7 ++ src/offscreen/index.ts | 112 ++++++++++++++++++++++++++----- src/shared/artifact-file.ts | 24 +++++++ src/shared/slug.ts | 26 +++++-- src/shared/types.ts | 22 ++++++ tests/artifact-file.test.ts | 42 +++++++++++- tests/slug.test.ts | 51 +++++++++++++- 9 files changed, 281 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index dea57f2..c5dac25 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ I see the same. - **マルチプロジェクト振り分け** *(v0.3.0+)*:URL パターン(glob)で複数の CLAUDE.md を使い分け。例:`github.com/anthropic/*` → anthropic 用、`zenn.dev/*` → 個人メモ、無マッチは default ルート - **claude.ai 会話のキャプチャ** *(v0.4.0+)*:claude.ai でブレストした会話をワンクリックで CLAUDE.md に流し込み。share リンクの 403 問題、手コピペの労力を解消。内部 API を叩いて thinking blocks・tool_use・branch 構造まで保持 - **アーティファクト抽出 & 範囲選択** *(v0.5.0+)*:claude.ai キャプチャで「**アーティファクトだけ**」(Claude が書いたコード/ドキュメントを整形済みコードブロックで抽出、会話は捨てる)や「**直近 N 件のみ**」を選択可能。長い設計会話から必要な部分だけを取り込めます。アーティファクトは同一 ID の更新を最新版に集約。claude.ai のチャットを開いているときは**ポップアップから直接トグル**できます +- **同一会話は上書き更新(siloを作らない)** *(v0.5.0+)*:MCP ストアモードで同じ claude.ai 会話を再キャプチャすると、**新しいスナップショットを増やさず既存ファイルを上書き**します。会話タイトルが変わっても安定 ID で追跡するので、ストアは常に single source of truth に保たれます(古いアーティファクトファイルも掃除) - **MCP 経由でオンデマンド参照** *(v0.5.0+)*:キャプチャを `CLAUDE.md` に追記する代わりに、専用ディレクトリへ 1 件 1 ファイルで保存し、付属の [MCP サーバ](./mcp-server) が Claude Code に**必要なときだけ**渡します。Anthropic 公式の「`CLAUDE.md` は小さく保て(肥大化すると指示が無視される)」ガイドと整合し、コンテキストを汚しません。`list_contexts` / `search_contexts` / `get_context` で検索・取得できます。→ [セットアップ](./mcp-server/README.md) - **キャプチャバッファ**:複数ページをまとめて溜めて、後から一括エクスポート - **キーボードショートカット**: @@ -260,6 +261,7 @@ In short: a clipper purpose-built for AI agent context files. If you want a gene - **Multi-project routing** *(v0.3.0+)* — Link multiple `CLAUDE.md` files with URL glob patterns. Captures from `github.com/anthropic/*` go to one file, `zenn.dev/*` to another, unmatched URLs to a default route - **claude.ai conversation capture** *(v0.4.0+)* — Capture your claude.ai brainstorm conversation in one click. Bypasses the 403-on-share-link issue and the manual copy-paste loop. Hits claude.ai's internal API to preserve thinking blocks, tool_use entries, and branch structure that DOM scraping would miss - **Artifact extraction & range selection** *(v0.5.0+)* — For claude.ai captures, choose **artifacts only** (extract just the code/documents Claude wrote as clean fenced code blocks, dropping the conversation) or **keep the last N messages** (trim a long planning thread to what matters). Artifacts are deduped by id so an updated artifact is captured at its final version, not every intermediate edit. When you're on a claude.ai chat, these are **toggleable right from the popup** +- **Same conversation updates in place (no silos)** *(v0.5.0+)* — In MCP-store mode, re-capturing the same claude.ai conversation **overwrites the existing file instead of piling up new snapshots**. It's tracked by a stable id, so even if the conversation's title changes the store stays a single source of truth (stale artifact files are cleaned up too) - **On-demand access via MCP** *(v0.5.0+)* — Instead of appending to `CLAUDE.md`, save each capture as a standalone file in a directory, and let the bundled [MCP server](./mcp-server) hand them to Claude Code **only when it asks**. This aligns with Anthropic's own guidance to keep `CLAUDE.md` small (bloated context files make Claude ignore your instructions) — your research stays available without polluting context. Searchable via `list_contexts` / `search_contexts` / `get_context`. → [Setup](./mcp-server/README.md) - **Buffer mode** — Stack multiple captures and export all at once - **Keyboard shortcuts**: diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 0edacc0..cf4365f 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -4,7 +4,7 @@ import { appendToBuffer } from '@/shared/buffer-storage'; import { buildEntryHeading } from '@/shared/file-appender'; import { listRoutes } from '@/shared/handle-store'; import { resolveRoute } from '@/shared/route-matcher'; -import { buildContextSlug } from '@/shared/slug'; +import { buildContextSlug, dedupePrefix } from '@/shared/slug'; import { buildArtifactFiles } from '@/shared/artifact-file'; import type { CapturedContext, @@ -191,24 +191,26 @@ async function writeToMcpStore( ctx: CapturedContext, markdown: string ): Promise<void> { - const fileName = `${buildContextSlug(ctx)}.md`; - await writeOneMcpFile(fileName, markdown); + const baseSlug = buildContextSlug(ctx); - // If the capture carried code/document artifacts (claude.ai), also write one - // standalone file per artifact so `get_context` can return a single + // The complete set for this capture: the main file, plus one file per + // code/document artifact (claude.ai) so `get_context` can return a single // artifact's code directly — "one code = one file". - const artifactFiles = buildArtifactFiles(ctx); - for (const file of artifactFiles) { - await writeOneMcpFile(file.fileName, file.content); - } -} - -async function writeOneMcpFile(fileName: string, content: string): Promise<void> { + const files = [ + { fileName: `${baseSlug}.md`, content: markdown }, + ...buildArtifactFiles(ctx), + ]; + + // Write as a replace-set: when this capture has a stable identity + // (dedupeKey, e.g. a claude.ai conversation), any previous version is removed + // first by its stable prefix — so re-capturing UPDATES it instead of + // accumulating duplicate snapshots, even if the conversation title changed. + const cleanupPrefix = ctx.dedupeKey ? dedupePrefix(ctx.dedupeKey) : null; const result = await sendToOffscreenWithRetry<OffscreenMcpStoreResult>({ target: 'offscreen', - type: 'WRITE_TO_MCP_STORE', - fileName, - content, + type: 'WRITE_MCP_FILESET', + cleanupPrefix, + files, }); if (!result?.ok) { const reason = result?.reason ?? 'write-failed'; diff --git a/src/content/parsers/claude-ai.ts b/src/content/parsers/claude-ai.ts index 2ab1eea..a86f95d 100644 --- a/src/content/parsers/claude-ai.ts +++ b/src/content/parsers/claude-ai.ts @@ -78,6 +78,10 @@ export async function parseClaudeAi( fromSelection: false, tags: tagsFor(conversation, artifacts), artifacts, + // Stable per conversation+mode: re-capturing artifacts-only updates the + // same file rather than creating a new snapshot. A separate suffix keeps + // it distinct from a full capture of the same conversation. + dedupeKey: `${conversationUuid}:artifacts`, }; } @@ -95,6 +99,9 @@ export async function parseClaudeAi( // Expose artifacts so mcp-store mode can split them into one file each, // even when the body is the full conversation. artifacts: artifacts.length > 0 ? artifacts : undefined, + // Stable per conversation: re-capturing the same chat overwrites the + // existing store file instead of accumulating duplicate snapshots. + dedupeKey: conversationUuid, }; } diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts index bfc5d4a..4fa1505 100644 --- a/src/offscreen/index.ts +++ b/src/offscreen/index.ts @@ -1,5 +1,6 @@ import { getRoute, getMcpDir } from '@/shared/handle-store'; import { buildAppendBlock } from '@/shared/file-appender'; +import { slugFileNamesToRemove } from '@/shared/artifact-file'; import type { OffscreenAppendResult, OffscreenClipboardResult, @@ -40,6 +41,10 @@ chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) = void writeToMcpStore(message.fileName, message.content).then(sendResponse); return true; // async response } + if (message.type === 'WRITE_MCP_FILESET') { + void writeMcpFileset(message.cleanupPrefix, message.files).then(sendResponse); + return true; // async response + } if (message.type === 'WRITE_TO_CLIPBOARD') { void writeToClipboard(message.content).then(sendResponse); return true; @@ -97,33 +102,108 @@ async function writeToMcpStore( fileName: string, content: string ): Promise<OffscreenMcpStoreResult> { + const acquired = await acquireMcpDir(); + if ('error' in acquired) return acquired.error; + const dir = acquired.dir; + + try { + await writeFile(dir, fileName, content); + return { ok: true, fileName }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, reason: 'write-failed', message: msg }; + } +} + +/** + * Write a complete file set for one capture, replacing any previous version. + * + * Before writing, removes the existing main file (`<baseSlug>.md`) and any + * derived per-artifact files (`<baseSlug>--*.md`). This is what makes + * re-capturing the same claude.ai conversation an UPDATE rather than a pile of + * duplicate snapshots — the "no silos / single source of truth" behaviour. + */ +async function writeMcpFileset( + cleanupPrefix: string | null, + files: Array<{ fileName: string; content: string }> +): Promise<OffscreenMcpStoreResult> { + const acquired = await acquireMcpDir(); + if ('error' in acquired) return acquired.error; + const dir = acquired.dir; + + try { + if (cleanupPrefix) { + await removeSlugFiles(dir, cleanupPrefix); + } + for (const f of files) { + await writeFile(dir, f.fileName, f.content); + } + return { ok: true, fileName: files[0]?.fileName ?? '' }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, reason: 'write-failed', message: msg }; + } +} + +/** Resolve the linked MCP dir + verify write permission, or an error result. */ +async function acquireMcpDir(): Promise< + { dir: FileSystemDirectoryHandle } | { error: OffscreenMcpStoreResult } +> { const dir = await getMcpDir(); if (!dir) { return { - ok: false, - reason: 'no-handle', - message: 'No MCP contexts directory is linked. Link one in the options page.', + error: { + ok: false, + reason: 'no-handle', + message: 'No MCP contexts directory is linked. Link one in the options page.', + }, }; } - const perm = await dir.queryPermission({ mode: 'readwrite' }); if (perm !== 'granted') { return { - ok: false, - reason: 'permission-denied', - message: 'Re-link the MCP contexts directory from the options page to grant write access.', + error: { + ok: false, + reason: 'permission-denied', + message: + 'Re-link the MCP contexts directory from the options page to grant write access.', + }, }; } + return { dir }; +} - try { - const fileHandle = await dir.getFileHandle(fileName, { create: true }); - const writable = await fileHandle.createWritable({ keepExistingData: false }); - await writable.write(content); - await writable.close(); - return { ok: true, fileName }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, reason: 'write-failed', message: msg }; +async function writeFile( + dir: FileSystemDirectoryHandle, + fileName: string, + content: string +): Promise<void> { + const fileHandle = await dir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable({ keepExistingData: false }); + await writable.write(content); + await writable.close(); +} + +/** Remove all `<cleanupPrefix>*.md` files (the prior version of a capture). */ +async function removeSlugFiles( + dir: FileSystemDirectoryHandle, + cleanupPrefix: string +): Promise<void> { + const names: string[] = []; + // FileSystemDirectoryHandle is async-iterable over [name, handle] entries. + for await (const [name, handle] of ( + dir as unknown as AsyncIterable<[string, FileSystemHandle]> + )) { + if (handle.kind === 'file') names.push(name); + } + + for (const name of slugFileNamesToRemove(names, cleanupPrefix)) { + try { + await dir.removeEntry(name); + } catch { + // Best-effort: a file we couldn't remove will simply be overwritten if + // it's part of the new set, or left behind otherwise. + } } } diff --git a/src/shared/artifact-file.ts b/src/shared/artifact-file.ts index 15835d4..557a755 100644 --- a/src/shared/artifact-file.ts +++ b/src/shared/artifact-file.ts @@ -87,3 +87,27 @@ function yamlValue(value: string): string { } return value; } + +/** + * Given all filenames in the store directory, return those belonging to a + * deduped capture identified by its stable `cleanupPrefix` (`ccc-<hash>`): the + * main `<prefix>-<title>.md` and any derived `<prefix>-<title>--*.md` files. + * + * Matching by the stable prefix (not the full slug) means a re-capture whose + * title changed still finds and replaces the prior version. Pure (no FS + * access) so it's directly testable. + */ +export function slugFileNamesToRemove( + names: string[], + cleanupPrefix: string +): string[] { + if (!cleanupPrefix) return []; + // Match `<prefix>` followed by a slug boundary (`-` or `.`) so `ccc-ab12` + // doesn't accidentally match `ccc-ab123...`. + const re = new RegExp(`^${escapeRegExp(cleanupPrefix)}[-.]`); + return names.filter((name) => re.test(name) && /\.(md|markdown)$/i.test(name)); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/shared/slug.ts b/src/shared/slug.ts index 6f50c3d..cf525e7 100644 --- a/src/shared/slug.ts +++ b/src/shared/slug.ts @@ -5,17 +5,35 @@ import type { CapturedContext } from './types'; * filename (`<slug>.md`) in the MCP contexts directory, and as the `slug` * identifier the MCP server's get/delete tools accept. * - * Shape: `<date>-<title-or-host>-<short-hash>` so files sort chronologically, - * stay human-readable, and never collide (the hash disambiguates same-title - * captures). + * Two shapes: + * - With a `dedupeKey` (e.g. a claude.ai conversation UUID): the slug leads + * with a STABLE hash of the dedupeKey — `ccc-<hash>-<title>`. The hash + * prefix is invariant across re-captures even if the conversation title + * changes, so the MCP store can find and overwrite the previous version by + * prefix ("no silos"). The title is appended only for readability. + * - Without one: `<date>-<title-or-host>-<hash(url+capturedAt)>`, unique per + * capture, so distinct pages/research never collide. */ export function buildContextSlug(ctx: CapturedContext): string { - const date = (ctx.capturedAt || new Date().toISOString()).slice(0, 10); // YYYY-MM-DD const base = slugify(ctx.title) || slugify(hostOf(ctx.url)) || 'capture'; + if (ctx.dedupeKey) { + // Stable hash leads; title is cosmetic and may change between captures. + return `${dedupePrefix(ctx.dedupeKey)}-${truncate(base, 50)}`; + } + const date = (ctx.capturedAt || new Date().toISOString()).slice(0, 10); // YYYY-MM-DD const hash = shortHash(`${ctx.url}\n${ctx.capturedAt}`); return `${date}-${truncate(base, 60)}-${hash}`; } +/** + * The stable filename prefix for a dedupeKey: `ccc-<hash>`. Re-capturing the + * same subject yields the same prefix regardless of title, so the store can + * remove the prior version's files by matching this prefix. + */ +export function dedupePrefix(dedupeKey: string): string { + return `ccc-${shortHash(dedupeKey)}`; +} + function slugify(input: string): string { return input .normalize('NFKD') diff --git a/src/shared/types.ts b/src/shared/types.ts index 94f1d55..f059131 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -22,6 +22,14 @@ export interface CapturedContext { parser: ParserName; /** True if this came from a user text selection (not the whole page). */ fromSelection: boolean; + /** + * Stable identity for this capture's *subject* (not this capture event). + * When set, re-capturing the same subject produces the same store slug, so + * the MCP store UPDATES the existing file instead of accumulating silos. + * claude.ai sets this to the conversation UUID. Unset → every capture is a + * new file (date + content hash). + */ + dedupeKey?: string; /** * Code/document artifacts detected in this capture (currently claude.ai * only). In `mcp-store` output mode these are written as one file each, so @@ -184,6 +192,20 @@ export type OffscreenMessage = /** Full file contents (frontmatter + body + footer). */ content: string; } + | { + target: 'offscreen'; + type: 'WRITE_MCP_FILESET'; + /** + * Stable filename prefix identifying this capture's previous version, or + * null for non-deduped captures. When set, any existing `<prefix>*.md` + * files are removed before the new set is written, so re-capturing the + * same subject UPDATES it (no duplicate silos / stale artifact files) + * even if the title changed. + */ + cleanupPrefix: string | null; + /** The files to write: the main capture plus any per-artifact files. */ + files: Array<{ fileName: string; content: string }>; + } | { target: 'offscreen'; type: 'WRITE_TO_CLIPBOARD'; diff --git a/tests/artifact-file.test.ts b/tests/artifact-file.test.ts index 3f3f514..67eae1c 100644 --- a/tests/artifact-file.test.ts +++ b/tests/artifact-file.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildArtifactFiles } from '@/shared/artifact-file'; +import { buildArtifactFiles, slugFileNamesToRemove } from '@/shared/artifact-file'; import type { CapturedContext } from '@/shared/types'; function ctx(p: Partial<CapturedContext>): CapturedContext { @@ -78,3 +78,43 @@ describe('buildArtifactFiles', () => { expect(files[0].fileName).toMatch(/--artifact-1\.md$/); }); }); + +describe('slugFileNamesToRemove (silo prevention, prefix-based)', () => { + const dirNames = [ + 'ccc-1a2b3c-old-title.md', // main file (prior capture, old title) + 'ccc-1a2b3c-old-title--middleware.md', // derived artifact + 'ccc-1a2b3c-old-title--schema.md', // derived artifact + 'ccc-1a2b3cX-other.md', // NOT ours: prefix boundary differs + 'ccc-9z9z9z-other.md', // different conversation + 'ccc-1a2b3c-old-title--note.txt', // not markdown + 'README.md', + ]; + + it('selects all files for the prefix, even though the title differs', () => { + // We re-capture with a NEW title but the SAME conversation → same prefix. + expect(slugFileNamesToRemove(dirNames, 'ccc-1a2b3c').sort()).toEqual( + [ + 'ccc-1a2b3c-old-title.md', + 'ccc-1a2b3c-old-title--middleware.md', + 'ccc-1a2b3c-old-title--schema.md', + ].sort() + ); + }); + + it('respects the prefix boundary (does not match ccc-1a2b3cX)', () => { + const removed = slugFileNamesToRemove(dirNames, 'ccc-1a2b3c'); + expect(removed).not.toContain('ccc-1a2b3cX-other.md'); + }); + + it('does not touch other conversations or non-markdown files', () => { + const removed = slugFileNamesToRemove(dirNames, 'ccc-1a2b3c'); + expect(removed).not.toContain('ccc-9z9z9z-other.md'); + expect(removed).not.toContain('ccc-1a2b3c-old-title--note.txt'); + expect(removed).not.toContain('README.md'); + }); + + it('returns empty for a non-matching or empty prefix', () => { + expect(slugFileNamesToRemove(dirNames, 'ccc-nomatch')).toEqual([]); + expect(slugFileNamesToRemove(dirNames, '')).toEqual([]); + }); +}); diff --git a/tests/slug.test.ts b/tests/slug.test.ts index c6cf646..cf5e766 100644 --- a/tests/slug.test.ts +++ b/tests/slug.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildContextSlug, shortHash } from '@/shared/slug'; +import { buildContextSlug, shortHash, dedupePrefix } from '@/shared/slug'; import type { CapturedContext } from '@/shared/types'; function ctx(p: Partial<CapturedContext>): CapturedContext { @@ -13,6 +13,7 @@ function ctx(p: Partial<CapturedContext>): CapturedContext { author: p.author, publishedAt: p.publishedAt, tags: p.tags, + dedupeKey: p.dedupeKey, }; } @@ -54,6 +55,54 @@ describe('buildContextSlug', () => { // date(10) + dash + base(<=60) + dash + hash(6) expect(slug.length).toBeLessThanOrEqual(10 + 1 + 60 + 1 + 6); }); + + describe('with dedupeKey (stable slug)', () => { + it('leads with the stable ccc-<hash> prefix, not the date', () => { + const slug = buildContextSlug(ctx({ dedupeKey: 'conv-uuid-1' })); + expect(slug).not.toMatch(/^\d{4}-\d{2}-\d{2}-/); + expect(slug.startsWith(dedupePrefix('conv-uuid-1'))).toBe(true); + expect(slug).toContain('example-title'); + }); + + it('is identical for the same dedupeKey regardless of capturedAt', () => { + const a = buildContextSlug( + ctx({ dedupeKey: 'conv-uuid-1', capturedAt: '2026-05-31T10:00:00.000Z' }) + ); + const b = buildContextSlug( + ctx({ dedupeKey: 'conv-uuid-1', capturedAt: '2026-06-15T22:00:00.000Z' }) + ); + expect(a).toBe(b); + }); + + it('keeps the same prefix when only the title changes (title is cosmetic)', () => { + const a = buildContextSlug(ctx({ dedupeKey: 'conv-1', title: 'Old title' })); + const b = buildContextSlug(ctx({ dedupeKey: 'conv-1', title: 'Renamed by Claude' })); + expect(a).not.toBe(b); // full slug differs (title changed)... + const prefix = dedupePrefix('conv-1'); + expect(a.startsWith(prefix)).toBe(true); // ...but the stable prefix matches + expect(b.startsWith(prefix)).toBe(true); + }); + + it('differs for different dedupeKeys', () => { + const a = buildContextSlug(ctx({ dedupeKey: 'conv-A' })); + const b = buildContextSlug(ctx({ dedupeKey: 'conv-B' })); + expect(a).not.toBe(b); + }); + + it('separates artifacts-only from full capture of the same conversation', () => { + const full = buildContextSlug(ctx({ dedupeKey: 'conv-1' })); + const arts = buildContextSlug(ctx({ dedupeKey: 'conv-1:artifacts' })); + expect(full).not.toBe(arts); + expect(dedupePrefix('conv-1')).not.toBe(dedupePrefix('conv-1:artifacts')); + }); + }); +}); + +describe('dedupePrefix', () => { + it('is stable for a given key and shaped ccc-<hash>', () => { + expect(dedupePrefix('abc')).toBe(dedupePrefix('abc')); + expect(dedupePrefix('abc')).toMatch(/^ccc-[0-9a-z]{6}$/); + }); }); describe('shortHash', () => { From 68a74d02d9224ae3fb03ce72ffff7a15f4de4d35 Mon Sep 17 00:00:00 2001 From: OcenasCreative <kazushi_ikeda@oceans-creative.com> Date: Mon, 1 Jun 2026 23:06:10 +0900 Subject: [PATCH 9/9] fix: address self-review findings (path traversal, hash collision, CRLF, extractCode, artifact order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the PR surfaced a critical security bug and several correctness/data-loss issues; all fixed with regression tests. CRITICAL — path traversal (mcp-server): - readBySlug/deleteBySlug accepted slugs like "../../etc/passwd", allowing read and DELETE of files outside the contexts dir. Added isSafeSlugComponent() guard + resolveInside() containment check, and a refusal at the delete_context tool boundary. Verified the exploit is closed end-to-end. HIGH — hash truncation (extension): - shortHash did `.slice(0,6)` on a value that's 6–7 base36 chars, halving and skewing the dedupe space. Since the hash gates which files a re-capture deletes, a collision could delete an unrelated capture. Now returns the full 32-bit value. Artifact filenames use a collision- proof numeric counter instead of a (truncated) hash suffix. HIGH — CRLF frontmatter (mcp-server): - splitFrontmatter dropped all fields for Windows line endings; now normalizes CRLF/CR to LF first. MEDIUM — extractCode (mcp-server): - Only strips fences when the body is exactly one fenced block; otherwise returns the full body, so prose-around-fence or multi-fence content is never silently truncated. MEDIUM — collectArtifacts order (extension): - Preserves authoring order; an updated artifact keeps its original slot instead of all id'd artifacts jumping ahead of anonymous ones. Tests: extension 90 -> 91, mcp 31 -> 39 (traversal, CRLF, extractCode truncation, artifact ordering). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- mcp-server/src/index.ts | 11 +++++ mcp-server/src/store.ts | 83 +++++++++++++++++++++++++------ mcp-server/test/store.test.ts | 84 ++++++++++++++++++++++++++++++++ src/content/parsers/claude-ai.ts | 16 +++--- src/shared/artifact-file.ts | 18 ++++--- src/shared/slug.ts | 13 ++++- tests/parsers/claude-ai.test.ts | 31 ++++++++++++ 7 files changed, 226 insertions(+), 30 deletions(-) diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index faa7fb3..a5085e2 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -30,6 +30,7 @@ import { toSummary, describeStatus, extractCode, + isSafeSlugComponent, } from './store.js'; import { searchContexts } from './search.js'; import { filterEntries, type FilterCriteria } from './filter.js'; @@ -291,6 +292,16 @@ server.registerTool( }, }, async ({ slug }) => { + // Defense in depth at the destructive boundary: reject path-like slugs + // outright (store.deleteBySlug also contains the path, but a clear refusal + // here is safer and more legible than a silent "nothing deleted"). + if (!isSafeSlugComponent(slug)) { + return textResult( + `Refusing to delete: "${slug}" is not a valid context slug ` + + `(no paths or "..").`, + true + ); + } const deleted = await store.deleteBySlug(slug); if (!deleted) { return textResult( diff --git a/mcp-server/src/store.ts b/mcp-server/src/store.ts index c2bd838..ffc8942 100644 --- a/mcp-server/src/store.ts +++ b/mcp-server/src/store.ts @@ -78,6 +78,20 @@ export function toSummary(entry: ContextEntry): ContextSummary { const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); +/** + * True if `slug` is safe to use as a direct filename component: no path + * separators, no parent-dir traversal, not absolute. Untrusted slugs (e.g. + * derived from a captured page's title) must pass this before any fs access. + */ +export function isSafeSlugComponent(slug: string): boolean { + if (!slug || slug === '.' || slug === '..') return false; + if (/[/\\]/.test(slug)) return false; // no separators + if (slug.includes('..')) return false; // no traversal + if (path.isAbsolute(slug)) return false; // no absolute paths + if (slug.includes('\0')) return false; // no NUL + return true; +} + /** * Resolve the contexts directory from (in priority order): * 1. an explicit argument @@ -175,17 +189,27 @@ export class ContextStore { /** Read a single file by its name (with or without extension) or slug. */ async readBySlug(slug: string): Promise<ContextEntry | undefined> { - const candidates = [slug, `${slug}.md`, `${slug}.markdown`]; - for (const name of candidates) { - try { - const stat = await fs.stat(path.join(this.dir, name)); - if (stat.isFile()) return this.readOne(name); - } catch { - // try next candidate + // Security: a slug must name a file DIRECTLY inside the contexts dir. We + // reject anything with path separators or traversal up front, then verify + // the resolved path is still contained — so `../../etc/passwd`, absolute + // paths, and the like can never read outside the store. + if (isSafeSlugComponent(slug)) { + const candidates = [slug, `${slug}.md`, `${slug}.markdown`]; + for (const name of candidates) { + const full = this.resolveInside(name); + if (!full) continue; + try { + const stat = await fs.stat(full); + if (stat.isFile()) return this.readOne(name); + } catch { + // try next candidate + } } } - // Fuzzy fallback: case-insensitive slug match across the directory. + // Fuzzy fallback: case-insensitive slug match across the directory. This is + // inherently safe — it only matches entries that readAll() found inside the + // dir — so it also serves slugs we rejected above as direct lookups. const all = await this.readAll(); const lower = slug.toLowerCase(); return ( @@ -194,10 +218,24 @@ export class ContextStore { ); } + /** + * Resolve `name` against the contexts dir, returning the absolute path only + * if it stays strictly inside the dir. Returns undefined for any escape. + */ + private resolveInside(name: string): string | undefined { + const base = path.resolve(this.dir); + const full = path.resolve(base, name); + if (full !== base && !full.startsWith(base + path.sep)) return undefined; + return full; + } + /** Delete a capture by slug. Returns true if a file was removed. */ async deleteBySlug(slug: string): Promise<boolean> { const entry = await this.readBySlug(slug); if (!entry) return false; + // Defense in depth: never unlink anything outside the contexts dir, even if + // a future code path produced an entry with an out-of-dir filePath. + if (!this.resolveInside(path.basename(entry.filePath))) return false; await fs.rm(entry.filePath); return true; } @@ -292,7 +330,12 @@ export function splitFrontmatter(raw: string): { frontmatter: RawFrontmatter; body: string; } { - const normalized = raw.replace(/^\uFEFF/, ''); // strip BOM + // Strip BOM and normalize CRLF/CR \u2192 LF so Windows-authored files parse the + // same as Unix ones (otherwise trailing \r breaks every key: value match). + const normalized = raw + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); if (!normalized.startsWith('---')) { return { frontmatter: {}, body: normalized }; } @@ -358,16 +401,24 @@ function firstHeading(body: string): string | undefined { /** * Extract raw code from an entry whose body is a single fenced code block - * (the shape `buildArtifactFiles` writes). Returns the code with the fence and - * frontmatter stripped. If the body isn't a single clean code block, returns - * the trimmed body unchanged so callers always get something usable. + * (the shape `buildArtifactFiles` writes). Returns the code with the fence + * stripped, but ONLY when the body is exactly one fenced block — so we never + * silently discard prose around it or a second code block. Anything else + * returns the trimmed body unchanged (the documented fallback), so callers + * always get the complete content rather than a truncated slice. */ export function extractCode(entry: ContextEntry): string { const body = entry.body.trim(); - // Match a leading ```lang ... ``` block, optionally preceded by a heading. - const fence = body.match(/```[^\n]*\n([\s\S]*?)\n?```/); - if (fence) { - return fence[1].replace(/\n+$/, '') + '\n'; + // The whole body must be a single fence: opens with ```lang on line 1 and + // closes with ``` on the last line, with no other ``` fence in between. + const m = body.match(/^```[^\n]*\n([\s\S]*?)\n?```$/); + if (m) { + const inner = m[1]; + // Reject if the captured inner content itself contains a fence boundary + // (i.e. there were actually multiple fences) — fall back to the full body. + if (!/^```/m.test(inner)) { + return inner.replace(/\n+$/, '') + '\n'; + } } return body + '\n'; } diff --git a/mcp-server/test/store.test.ts b/mcp-server/test/store.test.ts index 08637d2..e0d4687 100644 --- a/mcp-server/test/store.test.ts +++ b/mcp-server/test/store.test.ts @@ -8,6 +8,7 @@ import { parseTags, describeStatus, extractCode, + isSafeSlugComponent, ContextStore, } from '../src/store.js'; import { searchContexts } from '../src/search.js'; @@ -142,6 +143,72 @@ describe('ContextStore.status + describeStatus', () => { }); }); +describe('isSafeSlugComponent (path-traversal guard)', () => { + it('accepts normal slugs', () => { + expect(isSafeSlugComponent('ccc-1a2b3c-auth')).toBe(true); + expect(isSafeSlugComponent('2026-05-31-react-hooks-abc123')).toBe(true); + }); + + it('rejects traversal, separators, absolute paths, and specials', () => { + expect(isSafeSlugComponent('../etc/passwd')).toBe(false); + expect(isSafeSlugComponent('..')).toBe(false); + expect(isSafeSlugComponent('.')).toBe(false); + expect(isSafeSlugComponent('a/b')).toBe(false); + expect(isSafeSlugComponent('a\\b')).toBe(false); + expect(isSafeSlugComponent('/etc/passwd')).toBe(false); + expect(isSafeSlugComponent('foo/../bar')).toBe(false); + expect(isSafeSlugComponent('a\0b')).toBe(false); + expect(isSafeSlugComponent('')).toBe(false); + }); +}); + +describe('ContextStore path containment', () => { + let tmp: string; + let secretPath: string; + + beforeAll(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ccc-sec-')); + await fs.mkdir(path.join(tmp, 'contexts')); + secretPath = path.join(tmp, 'secret.md'); + await fs.writeFile(secretPath, '# TOP SECRET'); + await fs.writeFile(path.join(tmp, 'contexts', 'real.md'), '# real'); + }); + + afterAll(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it('does not read files outside the contexts dir via traversal', async () => { + const store = new ContextStore(path.join(tmp, 'contexts')); + const entry = await store.readBySlug('../secret'); + expect(entry).toBeUndefined(); + }); + + it('does not delete files outside the contexts dir via traversal', async () => { + const store = new ContextStore(path.join(tmp, 'contexts')); + const deleted = await store.deleteBySlug('../secret'); + expect(deleted).toBe(false); + // The secret file must still exist. + await expect(fs.stat(secretPath)).resolves.toBeDefined(); + }); + + it('still reads a legitimate file inside the dir', async () => { + const store = new ContextStore(path.join(tmp, 'contexts')); + const entry = await store.readBySlug('real'); + expect(entry?.slug).toBe('real'); + }); +}); + +describe('splitFrontmatter CRLF handling', () => { + it('parses frontmatter with Windows line endings', () => { + const crlf = '---\r\ntitle: Hello\r\nparser: github\r\n---\r\n\r\n# Body\r\n'; + const { frontmatter, body } = splitFrontmatter(crlf); + expect(frontmatter.title).toBe('Hello'); + expect(frontmatter.parser).toBe('github'); + expect(body).toContain('# Body'); + }); +}); + describe('extractCode', () => { const ARTIFACT = `--- title: Auth middleware @@ -171,6 +238,23 @@ export const auth = () => { const code = extractCode(entry); expect(code).toContain('just prose, no fence'); }); + + it('does NOT truncate when there is prose after the fence', () => { + const mixed = '```ts\nconst x = 1;\n```\n\nTrailing prose must not be lost.'; + const entry = parseContextFile('z', '/z/z.md', mixed, mixed.length); + const code = extractCode(entry); + // Full body preserved (no silent loss), not just the first fence. + expect(code).toContain('Trailing prose must not be lost.'); + expect(code).toContain('const x = 1;'); + }); + + it('does NOT truncate when there are multiple code fences', () => { + const two = '```ts\na();\n```\n\n```ts\nb();\n```'; + const entry = parseContextFile('w', '/w/w.md', two, two.length); + const code = extractCode(entry); + expect(code).toContain('a();'); + expect(code).toContain('b();'); + }); }); describe('searchContexts', () => { diff --git a/src/content/parsers/claude-ai.ts b/src/content/parsers/claude-ai.ts index a86f95d..73820ab 100644 --- a/src/content/parsers/claude-ai.ts +++ b/src/content/parsers/claude-ai.ts @@ -370,8 +370,11 @@ export function artifactFromInput(input: unknown): Artifact | undefined { * version wins — so we capture the final state, not every intermediate edit. */ export function collectArtifacts(messages: ConversationMessage[]): Artifact[] { - const byId = new Map<string, Artifact>(); - const anon: Artifact[] = []; + // Preserve authoring order. Each artifact occupies one slot at its FIRST + // appearance; a later update/rewrite of the same id replaces the content in + // that same slot (so the final version wins without jumping position). + const order: Array<{ id?: string; art: Artifact }> = []; + const indexById = new Map<string, number>(); for (const msg of messages) { const blocks = Array.isArray(msg.content) ? msg.content : []; @@ -380,15 +383,16 @@ export function collectArtifacts(messages: ConversationMessage[]): Artifact[] { if ((block as { name?: string }).name !== 'artifacts') continue; const art = artifactFromInput((block as { input?: unknown }).input); if (!art) continue; - if (art.id) { - byId.set(art.id, art); // later create/rewrite replaces earlier + if (art.id !== undefined && indexById.has(art.id)) { + order[indexById.get(art.id)!].art = art; // update in place } else { - anon.push(art); + if (art.id !== undefined) indexById.set(art.id, order.length); + order.push({ id: art.id, art }); } } } - return [...byId.values(), ...anon]; + return order.map((o) => o.art); } /** Render a single artifact as a titled, fenced code block. */ diff --git a/src/shared/artifact-file.ts b/src/shared/artifact-file.ts index 557a755..a9dbd34 100644 --- a/src/shared/artifact-file.ts +++ b/src/shared/artifact-file.ts @@ -1,5 +1,5 @@ import type { CapturedArtifact, CapturedContext } from './types'; -import { buildContextSlug, shortHash } from './slug'; +import { buildContextSlug } from './slug'; /** One artifact rendered as a standalone store file (name + contents). */ export interface ArtifactFile { @@ -37,12 +37,18 @@ function uniqueArtifactFileName( ): string { const titlePart = slugifyTitle(art.title) || `artifact-${index + 1}`; // Keep artifact files grouped next to (but distinct from) the parent slug. - let base = `${parentSlug}--${titlePart}`; - if (used.has(base)) { - base = `${base}-${shortHash(art.id ?? art.content).slice(0, 4)}`; + const base = `${parentSlug}--${titlePart}`; + // Guarantee uniqueness: if the base (or a numbered variant) is taken, keep + // incrementing. A plain counter is collision-proof, unlike a hash suffix + // that could itself collide and silently overwrite another artifact's file. + let candidate = base; + let n = 2; + while (used.has(candidate)) { + candidate = `${base}-${n}`; + n++; } - used.add(base); - return `${base}.md`; + used.add(candidate); + return `${candidate}.md`; } function renderArtifactFile( diff --git a/src/shared/slug.ts b/src/shared/slug.ts index cf525e7..d521225 100644 --- a/src/shared/slug.ts +++ b/src/shared/slug.ts @@ -55,12 +55,21 @@ function truncate(s: string, max: number): string { return s.length <= max ? s : s.slice(0, max).replace(/-+$/g, ''); } -/** Small deterministic hash (djb2) rendered as base36, 6 chars. */ +/** + * Small deterministic hash (djb2) rendered as base36. + * + * Returns the FULL 32-bit value in base36 (6–7 chars), padded to a minimum of + * 6. We deliberately do NOT truncate: a `.slice(0, 6)` would discard a digit + * for ~half the 32-bit range, both shrinking the space and skewing it — and + * since this hash gates which files a re-capture deletes, a collision means + * deleting an unrelated capture's files. Keeping the full value preserves the + * entire 2^32 space. + */ export function shortHash(input: string): string { let h = 5381; for (let i = 0; i < input.length; i++) { h = (h * 33) ^ input.charCodeAt(i); } // >>> 0 coerces to unsigned 32-bit before base36. - return (h >>> 0).toString(36).padStart(6, '0').slice(0, 6); + return (h >>> 0).toString(36).padStart(6, '0'); } diff --git a/tests/parsers/claude-ai.test.ts b/tests/parsers/claude-ai.test.ts index 0c7bb42..86d9bf1 100644 --- a/tests/parsers/claude-ai.test.ts +++ b/tests/parsers/claude-ai.test.ts @@ -432,4 +432,35 @@ describe('collectArtifacts', () => { ]; expect(collectArtifacts(messages)).toHaveLength(0); }); + + it('preserves authoring order; an updated artifact keeps its original slot', () => { + // create A(id=a), then anonymous X, then UPDATE A. A must stay first + // (its first-appearance slot), updated to its latest content; X stays + // second. (Regression test for the earlier "all id'd before all anon".) + const messages = [ + { + uuid: 'm1', + sender: 'assistant' as const, + content: [ + { type: 'tool_use', name: 'artifacts', input: { id: 'a', content: 'A-v1' } }, + ], + }, + { + uuid: 'm2', + sender: 'assistant' as const, + content: [ + { type: 'tool_use', name: 'artifacts', input: { content: 'X-anon' } }, + ], + }, + { + uuid: 'm3', + sender: 'assistant' as const, + content: [ + { type: 'tool_use', name: 'artifacts', input: { id: 'a', content: 'A-v2' } }, + ], + }, + ]; + const arts = collectArtifacts(messages); + expect(arts.map((a) => a.content)).toEqual(['A-v2', 'X-anon']); + }); });