From 7dd73bdff0b4bed9ea38dbac268686b94e6d32ed Mon Sep 17 00:00:00 2001 From: yohnark <213253858+oy-zenprax@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:51:17 +0900 Subject: [PATCH 1/3] feat: import foundation layer (compression primitives, envelope, state store basics) Import from private predecessor, first of a series of dependency-ordered PRs. Includes deterministic compression primitives (ANSI/JSON/lines/code skeleton/tool-description/budget), the result envelope contract, request logging, telemetry sink, TTL-bounded artifact retention, read-governor classification/evidence groundwork, OAuth broker helper, and the cross-platform state directory resolver. Renames the project-scoped state directory and log prefixes from the predecessor's mottainai-nosy-mcp to mottainai. Proxy relay, upstream connections, tool catalog, adaptive routing, and the CLI entry point land in follow-up PRs as their dependencies are satisfied. Co-Authored-By: Claude Sonnet 5 --- .editorconfig | 18 + .gitignore | 27 + LICENSE | 21 + README.md | 56 +- mottainai.config.json.example | 19 + package.json | 56 ++ pnpm-lock.yaml | 1163 +++++++++++++++++++++++ pnpm-workspace.yaml | 5 + src/adaptive/stats.test.ts | 120 +++ src/adaptive/stats.ts | 388 ++++++++ src/adaptive/taxonomy.test.ts | 46 + src/adaptive/taxonomy.ts | 179 ++++ src/adaptive/trace.test.ts | 173 ++++ src/adaptive/trace.ts | 473 +++++++++ src/auth.test.ts | 33 + src/auth.ts | 59 ++ src/compress/ansi.test.ts | 35 + src/compress/ansi.ts | 11 + src/compress/budget.test.ts | 19 + src/compress/budget.ts | 38 + src/compress/code.test.ts | 43 + src/compress/code.ts | 117 +++ src/compress/json.test.ts | 69 ++ src/compress/json.ts | 98 ++ src/compress/lines.test.ts | 75 ++ src/compress/lines.ts | 107 +++ src/compress/static-information.test.ts | 33 + src/compress/static-information.ts | 57 ++ src/compress/tool-description.test.ts | 76 ++ src/compress/tool-description.ts | 80 ++ src/envelope.ts | 31 + src/logging.test.ts | 177 ++++ src/logging.ts | 198 ++++ src/read-governor/classify.test.ts | 47 + src/read-governor/classify.ts | 56 ++ src/read-governor/evidence.test.ts | 58 ++ src/read-governor/evidence.ts | 104 ++ src/retrieve.test.ts | 60 ++ src/retrieve.ts | 187 ++++ src/state/migrations.ts | 83 ++ src/state/paths.test.ts | 44 + src/state/paths.ts | 41 + src/state/store.ts | 101 ++ src/telemetry.test.ts | 89 ++ src/telemetry.ts | 180 ++++ tsconfig.build.json | 4 + tsconfig.json | 17 + 47 files changed, 5170 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 mottainai.config.json.example create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/adaptive/stats.test.ts create mode 100644 src/adaptive/stats.ts create mode 100644 src/adaptive/taxonomy.test.ts create mode 100644 src/adaptive/taxonomy.ts create mode 100644 src/adaptive/trace.test.ts create mode 100644 src/adaptive/trace.ts create mode 100644 src/auth.test.ts create mode 100644 src/auth.ts create mode 100644 src/compress/ansi.test.ts create mode 100644 src/compress/ansi.ts create mode 100644 src/compress/budget.test.ts create mode 100644 src/compress/budget.ts create mode 100644 src/compress/code.test.ts create mode 100644 src/compress/code.ts create mode 100644 src/compress/json.test.ts create mode 100644 src/compress/json.ts create mode 100644 src/compress/lines.test.ts create mode 100644 src/compress/lines.ts create mode 100644 src/compress/static-information.test.ts create mode 100644 src/compress/static-information.ts create mode 100644 src/compress/tool-description.test.ts create mode 100644 src/compress/tool-description.ts create mode 100644 src/envelope.ts create mode 100644 src/logging.test.ts create mode 100644 src/logging.ts create mode 100644 src/read-governor/classify.test.ts create mode 100644 src/read-governor/classify.ts create mode 100644 src/read-governor/evidence.test.ts create mode 100644 src/read-governor/evidence.ts create mode 100644 src/retrieve.test.ts create mode 100644 src/retrieve.ts create mode 100644 src/state/migrations.ts create mode 100644 src/state/paths.test.ts create mode 100644 src/state/paths.ts create mode 100644 src/state/store.ts create mode 100644 src/telemetry.test.ts create mode 100644 src/telemetry.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e5f596dd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9e67edf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules/ +dist/ +*.tsbuildinfo + +mottainai.*.json +.mottainai/ + +.DS_Store +*.local +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log + +.env +.env.* +!.env.example + +.vscode/* +!.vscode/extensions.json +.idea/ + +coverage/ + +.codegraph +.claude/worktrees/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..07e326cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 yohn.jp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e2f55bc0..f4aa598e 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# mottainai \ No newline at end of file +# Mottainai + +**Mottainai** ("wasteful" / "what a waste" in Japanese) is a proxy gateway +that sits between an LLM client and one or more upstream +[MCP](https://modelcontextprotocol.io/) servers, and **compresses tool +definitions and tool call results before they reach the model context**. + +> **Status: pre-1.0 (`0.x`).** This repository is being imported from a +> private predecessor in a series of small, dependency-ordered PRs so each +> one stays reviewable and keeps the build green. This first PR is the +> foundation layer only (compression primitives, envelope/logging/telemetry, +> state-store basics) — the proxy, upstream connections, and CLI entry point +> land in follow-up PRs. Full architecture docs land once the pieces they +> describe exist in this repo. + +## How it fits together + +``` + ┌───────────────────────────┐ + LLM client ⇄ │ mottainai │ ⇄ upstream MCP servers + (Claude Code, │ (this project, one stdio │ (codegraph, fff-mcp, + Codex, etc.) │ MCP endpoint) │ GitHub MCP, ...) + └───────────────────────────┘ +``` + +Every upstream tool will be exposed under a prefixed name +(`__`) to avoid collisions. Tool call results pass through a +compression pipeline before being returned to the client; the pre-compression +original is kept for a short time and can be retrieved on demand instead of +being lost. + +## Installation + +Requires Node.js >= 22.13, [pnpm](https://pnpm.io/) 11.18.0, and +[ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) on `PATH`. + +```bash +git clone https://github.com/yohn-jp/mottainai.git +cd mottainai +pnpm install +pnpm run build +``` + +## Development + +```bash +pnpm install +pnpm run build # tsc -> dist/ +pnpm test # node --import tsx --test "src/**/*.test.ts" +pnpm run typecheck # tsc --noEmit +``` + +## License + +[MIT](LICENSE) diff --git a/mottainai.config.json.example b/mottainai.config.json.example new file mode 100644 index 00000000..00f5041a --- /dev/null +++ b/mottainai.config.json.example @@ -0,0 +1,19 @@ +{ + "version": 2, + "mcpServers": { + "codegraph": { + "command": "codegraph", + "args": ["serve", "--mcp", "--path", "."] + }, + "fff": { + "command": "fff-mcp", + "args": ["."] + }, + "github": { + "transport": "streamableHttp", + "url": "https://api.githubcopilot.com/mcp/", + "capabilities": ["github"], + "enabled": false + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..fb38d983 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@yohn-jp/mottainai", + "version": "0.1.0", + "description": "MCP proxy gateway: aggregates upstream MCP servers behind one endpoint and compresses their tool definitions and results before they reach the LLM.", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "node --import tsx --test \"src/**/*.test.ts\"", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.11.0", + "tree-sitter": "^0.22.4", + "tree-sitter-javascript": "^0.23.1", + "tree-sitter-typescript": "^0.23.2", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=22.13" + }, + "packageManager": "pnpm@11.18.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/yohn-jp/mottainai.git" + }, + "homepage": "https://github.com/yohn-jp/mottainai#readme", + "bugs": { + "url": "https://github.com/yohn-jp/mottainai/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "mcp-server", + "llm", + "agent", + "gateway", + "proxy", + "tool-compression" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..3b2764a5 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1163 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.11.0 + version: 1.29.0(zod@3.25.76) + tree-sitter: + specifier: ^0.22.4 + version: 0.22.4 + tree-sitter-javascript: + specifier: ^0.23.1 + version: 0.23.1(tree-sitter@0.22.4) + tree-sitter-typescript: + specifier: ^0.23.2 + version: 0.23.2(tree-sitter@0.22.4) + zod: + specifier: ^3.24.4 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.15.3 + version: 22.20.1 + tsx: + specifier: ^4.19.4 + version: 4.23.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.6.1: + resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.32: + resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@8.9.0: + resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-sitter-javascript@0.23.1: + resolution: {integrity: sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-typescript@0.23.2: + resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==} + peerDependencies: + tree-sitter: ^0.21.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter@0.22.4: + resolution: {integrity: sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@hono/node-server@1.19.17(hono@4.12.32)': + dependencies: + hono: 4.12.32 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.12.32) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.1(express@5.2.1) + hono: 4.12.32 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + express-rate-limit@8.6.1(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.3.1 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.4: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.32: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.3.1: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.4: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + node-addon-api@8.9.0: {} + + node-gyp-build@4.8.4: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + 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.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + tree-sitter-javascript@0.23.1(tree-sitter@0.22.4): + dependencies: + node-addon-api: 8.9.0 + node-gyp-build: 4.8.4 + optionalDependencies: + tree-sitter: 0.22.4 + + tree-sitter-typescript@0.23.2(tree-sitter@0.22.4): + dependencies: + node-addon-api: 8.9.0 + node-gyp-build: 4.8.4 + tree-sitter-javascript: 0.23.1(tree-sitter@0.22.4) + optionalDependencies: + tree-sitter: 0.22.4 + + tree-sitter@0.22.4: + dependencies: + node-addon-api: 8.9.0 + node-gyp-build: 4.8.4 + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..2a4b3479 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + tree-sitter: true + tree-sitter-javascript: true + tree-sitter-typescript: true diff --git a/src/adaptive/stats.test.ts b/src/adaptive/stats.test.ts new file mode 100644 index 00000000..8d0f58f0 --- /dev/null +++ b/src/adaptive/stats.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { aggregateTraces } from "./stats.js"; +import type { Trace, TraceExecutionRecord, TraceExecutionReviewRecord, TraceReviewRecord } from "./trace.js"; + +let executionSequence = 0; + +function execution(overrides: Partial = {}): TraceExecutionRecord { + return { + type: "execution", schema_version: 1, execution_id: `ex_${executionSequence++}`, request_id: "rq_1", timestamp: "2026-07-30T00:00:01.000Z", + provider: "codegraph", tool: "codegraph__explore", capability: "callers", + duration_ms: 10, result_count: 2, output_size: 100, status: "success", ...overrides, + }; +} + +function review(overrides: Partial = {}): TraceReviewRecord { + return { + type: "review", schema_version: 1, request_id: "rq_1", timestamp: "2026-07-30T00:00:02.000Z", + expected_found: true, sufficient: true, usefulness: 4, + missing_capabilities: [], unexpected_noise: [], follow_up_requested: false, next_capabilities: [], ...overrides, + }; +} + +function executionReview(executionId: string, overrides: Partial = {}): TraceExecutionReviewRecord { + return { + type: "execution_review", schema_version: 1, request_id: "rq_1", execution_id: executionId, + timestamp: "2026-07-30T00:00:03.000Z", useful: true, sufficient_for_capability: false, + missing_capabilities: [], unexpected_noise: [], ...overrides, + }; +} + +function trace(id: string, category: string, requested: string[], executions: TraceExecutionRecord[], reviewRecord?: TraceReviewRecord, executionReviews: TraceExecutionReviewRecord[] = []): Trace { + return { + request: { + type: "request", schema_version: 1, request_id: id, timestamp: "2026-07-30T00:00:00.000Z", task_category: category, + caller_requested_capabilities: requested, planned_capabilities: requested, added_by_policy: [], suppressed_by_policy: [], + policy_version: "builtin-1", + }, + executions: executions.map((entry) => ({ ...entry, request_id: id })), + review: reviewRecord === undefined ? undefined : { ...reviewRecord, request_id: id }, + execution_reviews: executionReviews.map((entry) => ({ ...entry, request_id: id })), + }; +} + +const traces: Trace[] = [ + trace("rq_1", "bug_investigation", ["callers", "tests"], [execution(), execution({ provider: "local", tool: "mottainai_exec", capability: "tests", status: "empty", duration_ms: 30, output_size: 20 })], + review({ sufficient: false, missing_capabilities: ["ownership"], unexpected_noise: ["generated_files"], follow_up_requested: true, next_capabilities: ["ownership"] }), + [executionReview("ex_0")]), + trace("rq_2", "bug_investigation", ["callers"], [execution({ status: "tool_error", duration_ms: 50, output_size: 0 })], + review({ expected_found: false, sufficient: false, usefulness: 2, missing_capabilities: ["ownership", "recent_changes"] })), + trace("rq_3", "symbol_lookup", ["definitions"], [execution({ capability: "definitions", duration_ms: 6, output_size: 300 })]), +]; + +test("totals count reviewed traces separately from requests", () => { + const stats = aggregateTraces(traces); + assert.deepEqual(stats.totals, { + requests: 3, reviewed: 2, review_rate: 0.667, executions: 4, + technical_success_rate: 0.75, execution_reviews: 1, execution_useful_rate: 1, execution_sufficient_rate: 0, + expected_found_rate: 0.5, sufficient_rate: 0, mean_usefulness: 3, + }); +}); + +test("category stats rank missing capabilities and noise by frequency", () => { + const stats = aggregateTraces(traces); + const bug = stats.by_task_category.find((entry) => entry.task_category === "bug_investigation")!; + assert.equal(bug.requests, 2); + assert.equal(bug.expected_found_rate, 0.5); + assert.equal(bug.follow_up_rate, 0.5); + assert.deepEqual(bug.missing_capabilities, [{ label: "ownership", count: 2 }, { label: "recent_changes", count: 1 }]); + assert.deepEqual(bug.unexpected_noise, [{ label: "generated_files", count: 1 }]); +}); + +test("capability stats separate empty results from errors and successes", () => { + const stats = aggregateTraces(traces); + const tests = stats.by_capability.find((entry) => entry.capability === "tests")!; + assert.equal(tests.executions, 1); + assert.equal(tests.status_counts.empty, 1); + assert.equal(tests.status_counts.success, 0); + + const callers = stats.by_capability.find((entry) => entry.capability === "callers")!; + assert.equal(callers.requested, 2); + assert.equal(callers.status_counts.tool_error, 1); + assert.equal(callers.mean_duration_ms, 30); + assert.equal(callers.expected_found_rate, 0.5); + assert.equal(callers.useful_rate, 1); + assert.equal(callers.technical_success_rate, 0.5); + + // review でしか現れない capability も、欠落として数える + const ownership = stats.by_capability.find((entry) => entry.capability === "ownership")!; + assert.equal(ownership.missing_reports, 2); + assert.equal(ownership.executions, 0); +}); + +test("provider stats break down success rate per capability", () => { + const stats = aggregateTraces(traces); + const codegraph = stats.by_provider.find((entry) => entry.provider === "codegraph")!; + assert.equal(codegraph.executions, 3); + assert.equal(codegraph.status_counts.success, 2); + assert.equal(codegraph.status_counts.tool_error, 1); + assert.equal(codegraph.useful_rate, 1); + assert.deepEqual(codegraph.capabilities, [ + { capability: "callers", executions: 2, success_rate: 0.5 }, + { capability: "definitions", executions: 1, success_rate: 1 }, + ]); +}); + +test("follow-up capabilities become transitions per task category", () => { + const stats = aggregateTraces(traces); + assert.deepEqual(stats.transitions, [{ task_category: "bug_investigation", next_capability: "ownership", count: 1 }]); +}); + +test("aggregating no traces returns nulls instead of dividing by zero", () => { + const stats = aggregateTraces([]); + assert.deepEqual(stats.totals, { + requests: 0, reviewed: 0, review_rate: null, executions: 0, + technical_success_rate: null, execution_reviews: 0, execution_useful_rate: null, execution_sufficient_rate: null, + expected_found_rate: null, sufficient_rate: null, mean_usefulness: null, + }); + assert.deepEqual(stats.by_task_category, []); +}); diff --git a/src/adaptive/stats.ts b/src/adaptive/stats.ts new file mode 100644 index 00000000..5aff5a6a --- /dev/null +++ b/src/adaptive/stats.ts @@ -0,0 +1,388 @@ +import type { ExecutionStatus, Trace } from "./trace.js"; + +function zeroStatusCounts(): Record { + return { success: 0, empty: 0, tool_error: 0, provider_error: 0, unavailable: 0, policy_suppressed: 0, not_executed: 0 }; +} + +/** + * trace の決定論的集約。LLM を使わない。 + * + * 「provider が成功したか」ではなく「期待した証拠が揃ったか」を中心に数える。 + * 欠けた capability と noise の頻度が、policy 提案の主な入力になる。 + */ + +export interface LabelCount { + label: string; + count: number; +} + +export interface CategoryStats { + task_category: string; + requests: number; + reviewed: number; + expected_found_rate: number | null; + sufficient_rate: number | null; + mean_usefulness: number | null; + follow_up_rate: number | null; + /** policy 適用後、実際に使われた capability(呼び出し側の指定 + policy が足した分)。 */ + planned_capabilities: LabelCount[]; + missing_capabilities: LabelCount[]; + unexpected_noise: LabelCount[]; + next_capabilities: LabelCount[]; +} + +export interface CapabilityStats { + capability: string; + requested: number; + executions: number; + status_counts: Record; + missing_reports: number; + mean_duration_ms: number | null; + mean_output_size: number | null; + /** この capability を要求した review 済み trace のうち expected_found だった比率。 */ + expected_found_rate: number | null; + execution_reviews: number; + useful_rate: number | null; + sufficient_for_capability_rate: number | null; + technical_success_rate: number | null; +} + +export interface ProviderCapabilityStats { + capability: string; + executions: number; + success_rate: number | null; +} + +export interface ProviderStats { + provider: string; + executions: number; + status_counts: Record; + mean_duration_ms: number | null; + mean_output_size: number | null; + capabilities: ProviderCapabilityStats[]; + useful_rate: number | null; + technical_success_rate: number | null; +} + +export interface TransitionStats { + task_category: string; + next_capability: string; + count: number; +} + +export interface RoutingStats { + totals: { + requests: number; + reviewed: number; + review_rate: number | null; + executions: number; + technical_success_rate: number | null; + execution_reviews: number; + execution_useful_rate: number | null; + execution_sufficient_rate: number | null; + expected_found_rate: number | null; + sufficient_rate: number | null; + mean_usefulness: number | null; + }; + by_task_category: CategoryStats[]; + by_capability: CapabilityStats[]; + by_provider: ProviderStats[]; + provider_gaps: LabelCount[]; + transitions: TransitionStats[]; + unknown_labels: LabelCount[]; + policy_versions: LabelCount[]; +} + +interface CategoryAccumulator { + requests: number; + reviewed: number; + expectedFound: number; + sufficient: number; + usefulness: number[]; + followUp: number; + requested: Map; + missing: Map; + noise: Map; + next: Map; +} + +interface CapabilityAccumulator { + requested: number; + executions: number; + statusCounts: Record; + missing: number; + durations: number[]; + outputSizes: number[]; + reviewedRequests: number; + expectedFound: number; + technicalAttempts: number; + technicalSuccesses: number; + executionReviews: number; + usefulReviews: number; + useful: number; + sufficientReviews: number; + sufficient: number; +} + +interface ProviderAccumulator { + executions: number; + statusCounts: Record; + durations: number[]; + outputSizes: number[]; + capabilities: Map; + executionReviews: number; + usefulReviews: number; + useful: number; + technicalAttempts: number; + technicalSuccesses: number; +} + +const TECHNICAL_ATTEMPT_STATUSES = new Set(["success", "empty", "tool_error", "provider_error"]); + +function rate(numerator: number, denominator: number): number | null { + return denominator === 0 ? null : round(numerator / denominator); +} + +function mean(values: number[]): number | null { + return values.length === 0 ? null : round(values.reduce((sum, value) => sum + value, 0) / values.length); +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function increment(counter: Map, key: string, by = 1): void { + counter.set(key, (counter.get(key) ?? 0) + by); +} + +function toLabelCounts(counter: Map): LabelCount[] { + return [...counter.entries()] + .map(([label, count]) => ({ label, count })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); +} + +export function aggregateTraces(traces: Trace[]): RoutingStats { + const categories = new Map(); + const capabilities = new Map(); + const providers = new Map(); + const transitions = new Map(); + const unknownLabels = new Map(); + const policyVersions = new Map(); + const providerGaps = new Map(); + + let reviewed = 0; + let executions = 0; + let expectedFound = 0; + let sufficient = 0; + let technicalAttempts = 0; + let technicalSuccesses = 0; + let executionReviews = 0; + let executionUsefulReviews = 0; + let executionUseful = 0; + let executionSufficientReviews = 0; + let executionSufficient = 0; + const usefulness: number[] = []; + + for (const trace of traces) { + const category: CategoryAccumulator = categories.get(trace.request.task_category) ?? { + requests: 0, reviewed: 0, expectedFound: 0, sufficient: 0, usefulness: [], followUp: 0, + requested: new Map(), missing: new Map(), noise: new Map(), next: new Map(), + }; + category.requests += 1; + categories.set(trace.request.task_category, category); + increment(policyVersions, trace.request.policy_version); + for (const label of trace.request.unknown_labels ?? []) increment(unknownLabels, label); + + for (const capability of trace.request.planned_capabilities) { + increment(category.requested, capability); + const entry = capabilityEntry(capabilities, capability); + entry.requested += 1; + if (trace.review !== undefined) { + entry.reviewedRequests += 1; + if (trace.review.expected_found) entry.expectedFound += 1; + } + } + + for (const execution of trace.executions) { + executions += 1; + const entry = capabilityEntry(capabilities, execution.capability); + entry.executions += 1; + entry.statusCounts[execution.status] += 1; + entry.durations.push(execution.duration_ms); + entry.outputSizes.push(execution.output_size); + if (TECHNICAL_ATTEMPT_STATUSES.has(execution.status)) { + entry.technicalAttempts += 1; + technicalAttempts += 1; + if (execution.status === "success" || execution.status === "empty") { + entry.technicalSuccesses += 1; + technicalSuccesses += 1; + } + } + if (execution.status === "unavailable") increment(providerGaps, execution.capability); + + const provider: ProviderAccumulator = providers.get(execution.provider) ?? { + executions: 0, statusCounts: zeroStatusCounts(), durations: [], outputSizes: [], capabilities: new Map(), + executionReviews: 0, usefulReviews: 0, useful: 0, technicalAttempts: 0, technicalSuccesses: 0, + }; + provider.executions += 1; + provider.statusCounts[execution.status] += 1; + provider.durations.push(execution.duration_ms); + provider.outputSizes.push(execution.output_size); + if (TECHNICAL_ATTEMPT_STATUSES.has(execution.status)) { + provider.technicalAttempts += 1; + if (execution.status === "success" || execution.status === "empty") provider.technicalSuccesses += 1; + } + const perCapability = provider.capabilities.get(execution.capability) ?? { executions: 0, success: 0 }; + perCapability.executions += 1; + if (execution.status === "success") perCapability.success += 1; + provider.capabilities.set(execution.capability, perCapability); + providers.set(execution.provider, provider); + } + + for (const executionReview of trace.execution_reviews ?? []) { + executionReviews += 1; + if (executionReview.useful !== undefined) { + executionUsefulReviews += 1; + if (executionReview.useful) executionUseful += 1; + } + if (executionReview.sufficient_for_capability !== undefined) { + executionSufficientReviews += 1; + if (executionReview.sufficient_for_capability) executionSufficient += 1; + } + const execution = trace.executions.find((candidate) => candidate.execution_id === executionReview.execution_id); + if (execution === undefined) continue; + const capability = capabilityEntry(capabilities, execution.capability); + capability.executionReviews += 1; + if (executionReview.useful !== undefined) { + capability.usefulReviews += 1; + if (executionReview.useful) capability.useful += 1; + } + if (executionReview.sufficient_for_capability !== undefined) { + capability.sufficientReviews += 1; + if (executionReview.sufficient_for_capability) capability.sufficient += 1; + } + const provider = providers.get(execution.provider); + if (provider !== undefined) { + provider.executionReviews += 1; + if (executionReview.useful !== undefined) { + provider.usefulReviews += 1; + if (executionReview.useful) provider.useful += 1; + } + } + } + + if (trace.review === undefined) continue; + reviewed += 1; + category.reviewed += 1; + if (trace.review.expected_found) { + expectedFound += 1; + category.expectedFound += 1; + } + if (trace.review.sufficient) { + sufficient += 1; + category.sufficient += 1; + } + if (typeof trace.review.usefulness === "number") { + usefulness.push(trace.review.usefulness); + category.usefulness.push(trace.review.usefulness); + } + if (trace.review.follow_up_requested) category.followUp += 1; + for (const capability of trace.review.missing_capabilities) { + increment(category.missing, capability); + capabilityEntry(capabilities, capability).missing += 1; + } + for (const label of trace.review.unexpected_noise) increment(category.noise, label); + for (const capability of trace.review.next_capabilities) { + increment(category.next, capability); + increment(transitions, `${trace.request.task_category}${capability}`); + } + } + + return { + totals: { + requests: traces.length, + reviewed, + review_rate: rate(reviewed, traces.length), + executions, + technical_success_rate: rate(technicalSuccesses, technicalAttempts), + execution_reviews: executionReviews, + execution_useful_rate: rate(executionUseful, executionUsefulReviews), + execution_sufficient_rate: rate(executionSufficient, executionSufficientReviews), + expected_found_rate: rate(expectedFound, reviewed), + sufficient_rate: rate(sufficient, reviewed), + mean_usefulness: mean(usefulness), + }, + by_task_category: [...categories.entries()] + .map(([taskCategory, entry]) => ({ + task_category: taskCategory, + requests: entry.requests, + reviewed: entry.reviewed, + expected_found_rate: rate(entry.expectedFound, entry.reviewed), + sufficient_rate: rate(entry.sufficient, entry.reviewed), + mean_usefulness: mean(entry.usefulness), + follow_up_rate: rate(entry.followUp, entry.reviewed), + planned_capabilities: toLabelCounts(entry.requested), + missing_capabilities: toLabelCounts(entry.missing), + unexpected_noise: toLabelCounts(entry.noise), + next_capabilities: toLabelCounts(entry.next), + })) + .sort((left, right) => right.requests - left.requests || left.task_category.localeCompare(right.task_category)), + by_capability: [...capabilities.entries()] + .map(([capability, entry]) => ({ + capability, + requested: entry.requested, + executions: entry.executions, + status_counts: entry.statusCounts, + missing_reports: entry.missing, + mean_duration_ms: mean(entry.durations), + mean_output_size: mean(entry.outputSizes), + expected_found_rate: rate(entry.expectedFound, entry.reviewedRequests), + execution_reviews: entry.executionReviews, + useful_rate: rate(entry.useful, entry.usefulReviews), + sufficient_for_capability_rate: rate(entry.sufficient, entry.sufficientReviews), + technical_success_rate: rate(entry.technicalSuccesses, entry.technicalAttempts), + })) + .sort((left, right) => + right.requested + right.executions - (left.requested + left.executions) || + left.capability.localeCompare(right.capability), + ), + by_provider: [...providers.entries()] + .map(([provider, entry]) => ({ + provider, + executions: entry.executions, + status_counts: entry.statusCounts, + mean_duration_ms: mean(entry.durations), + mean_output_size: mean(entry.outputSizes), + capabilities: [...entry.capabilities.entries()] + .map(([capability, perCapability]) => ({ + capability, + executions: perCapability.executions, + success_rate: rate(perCapability.success, perCapability.executions), + })) + .sort((left, right) => right.executions - left.executions || left.capability.localeCompare(right.capability)), + useful_rate: rate(entry.useful, entry.usefulReviews), + technical_success_rate: rate(entry.technicalSuccesses, entry.technicalAttempts), + })) + .sort((left, right) => right.executions - left.executions || left.provider.localeCompare(right.provider)), + transitions: [...transitions.entries()] + .map(([key, count]) => { + const [taskCategory, nextCapability] = key.split(""); + return { task_category: taskCategory, next_capability: nextCapability, count }; + }) + .sort((left, right) => right.count - left.count || left.task_category.localeCompare(right.task_category)), + provider_gaps: toLabelCounts(providerGaps), + unknown_labels: toLabelCounts(unknownLabels), + policy_versions: toLabelCounts(policyVersions), + }; +} + +function capabilityEntry(capabilities: Map, capability: string): CapabilityAccumulator { + const entry: CapabilityAccumulator = capabilities.get(capability) ?? { + requested: 0, executions: 0, statusCounts: zeroStatusCounts(), + missing: 0, durations: [], outputSizes: [], reviewedRequests: 0, expectedFound: 0, + technicalAttempts: 0, technicalSuccesses: 0, executionReviews: 0, usefulReviews: 0, useful: 0, + sufficientReviews: 0, sufficient: 0, + }; + capabilities.set(capability, entry); + return entry; +} diff --git a/src/adaptive/taxonomy.test.ts b/src/adaptive/taxonomy.test.ts new file mode 100644 index 00000000..369e4db0 --- /dev/null +++ b/src/adaptive/taxonomy.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isKnownCapability, + normalizeCapability, + normalizeCapabilityList, + normalizeIntent, + normalizeNoiseList, + normalizeTaskCategory, +} from "./taxonomy.js"; + +test("capability aliases collapse to a canonical id", () => { + assert.deepEqual(normalizeCapability("code.search"), { id: "text_matches", known: true }); + assert.deepEqual(normalizeCapability("Git History"), { id: "recent_changes", known: true }); + assert.deepEqual(normalizeCapability("CALLERS"), { id: "callers", known: true }); +}); + +test("unknown capabilities pass through normalized instead of being rejected", () => { + assert.deepEqual(normalizeCapability("Terraform Plan!"), { id: "terraform_plan", known: false }); + assert.equal(isKnownCapability("terraform_plan"), false); +}); + +test("capability lists drop duplicates and keep caller order", () => { + const capabilities = normalizeCapabilityList(["callers", "code.search", "text_matches", "callers"]); + assert.deepEqual(capabilities.map((entry) => entry.id), ["callers", "text_matches"]); +}); + +test("task categories keep unknown values but flag them", () => { + assert.deepEqual(normalizeTaskCategory("bug_investigation"), { id: "bug_investigation", known: true }); + assert.deepEqual(normalizeTaskCategory("migration audit"), { id: "migration_audit", known: false }); +}); + +test("noise labels normalize without a closed vocabulary", () => { + const labels = normalizeNoiseList(["Generated Files", "vendor blobs"]); + assert.deepEqual(labels, [{ label: "generated_files", known: true }, { label: "vendor_blobs", known: false }].map((entry) => ({ id: entry.label, known: entry.known }))); +}); + +test("intent normalizes without a known set", () => { + assert.equal(normalizeIntent("Locate Root Cause"), "locate_root_cause"); +}); + +test("empty and non-string labels are rejected", () => { + assert.throws(() => normalizeCapability(" "), /must be a non-empty label/); + assert.throws(() => normalizeCapability(42), /must be a string/); + assert.throws(() => normalizeCapabilityList("callers"), /must be an array/); +}); diff --git a/src/adaptive/taxonomy.ts b/src/adaptive/taxonomy.ts new file mode 100644 index 00000000..61a05ec9 --- /dev/null +++ b/src/adaptive/taxonomy.ts @@ -0,0 +1,179 @@ +/** + * 呼び出し側が付けるタスク分類と、証拠 capability の語彙。 + * + * 既知の語彙は「統計を集約できる正準形」を与えるためだけに存在する。未知のラベルも + * 正規化して受け入れる(`known: false` で記録する)。閉じた enum にすると provider や + * 分野が増えるたびに呼び出し側が弾かれ、フィードバックそのものが失われるため。 + */ + +/** 証拠 capability の正準 ID。provider 名ではなく「どの証拠が欲しいか」を表す。 */ +export const KNOWN_CAPABILITIES = [ + "definitions", + "references", + "callers", + "symbols", + "text_matches", + "file_content", + "directory_structure", + "recent_changes", + "ownership", + "tests", + "runtime_state", + "diagnostics", + "dependencies", + "docs", + "issues_and_prs", + "dom", + "styles", + "screenshots", +] as const; + +/** 呼び出し側が付けるタスク分類の正準 ID。 */ +export const KNOWN_TASK_CATEGORIES = [ + "symbol_lookup", + "bug_investigation", + "ui_investigation", + "ownership_history", + "feature_implementation", + "refactor", + "test_failure", + "performance_investigation", + "dependency_audit", + "config_investigation", + "security_review", + "documentation", +] as const; + +/** review の `unexpected_noise` でよく使うラベル。未知の値も受け入れる。 */ +export const KNOWN_NOISE_LABELS = [ + "generated_files", + "vendored_code", + "build_artifacts", + "lockfiles", + "test_fixtures", + "unrelated_matches", + "binary_files", + "stale_results", +] as const; + +/** + * 別名から正準 capability への写像。 + * + * config の `capabilities`(例 `code.search`)や provider 固有の呼び名を、統計が + * 集約できる形へ寄せる。ここに無い値は正規化だけして通す。 + */ +const CAPABILITY_ALIASES: Record = { + "code.search": "text_matches", + code_search: "text_matches", + grep: "text_matches", + search: "text_matches", + xrefs: "references", + refs: "references", + usages: "references", + callgraph: "callers", + call_sites: "callers", + definition: "definitions", + declarations: "definitions", + outline: "symbols", + symbol: "symbols", + history: "recent_changes", + git_history: "recent_changes", + commits: "recent_changes", + blame: "ownership", + authors: "ownership", + test_results: "tests", + runtime: "runtime_state", + lint: "diagnostics", + typecheck: "diagnostics", + deps: "dependencies", + dependency_graph: "dependencies", + documentation: "docs", + issues: "issues_and_prs", + prs: "issues_and_prs", + pull_requests: "issues_and_prs", + computed_styles: "styles", + screenshot: "screenshots", + file: "file_content", + files: "file_content", + directory: "directory_structure", + tree: "directory_structure", +}; + +const MAX_LABEL_LENGTH = 64; + +export interface NormalizedLabel { + id: string; + known: boolean; +} + +const capabilitySet = new Set(KNOWN_CAPABILITIES); +const taskCategorySet = new Set(KNOWN_TASK_CATEGORIES); +const noiseSet = new Set(KNOWN_NOISE_LABELS); + +/** 大小・区切り文字の揺れを吸収する。`Code.Search` と `code_search` を同じ統計へ寄せるため。 */ +function normalizeIdentifier(value: unknown, field: string): string { + if (typeof value !== "string") throw new Error(`${field} must be a string`); + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, MAX_LABEL_LENGTH); + if (normalized.length === 0) throw new Error(`${field} must be a non-empty label`); + return normalized; +} + +export function normalizeCapability(value: unknown, field = "capability"): NormalizedLabel { + const normalized = normalizeIdentifier(value, field); + const canonical = CAPABILITY_ALIASES[normalized] ?? normalized; + return { id: canonical, known: capabilitySet.has(canonical) }; +} + +export function normalizeTaskCategory(value: unknown, field = "task.category"): NormalizedLabel { + const normalized = normalizeIdentifier(value, field); + return { id: normalized, known: taskCategorySet.has(normalized) }; +} + +/** intent は自由語彙。既知集合を持たず、集約できる形へ正規化するだけ。 */ +export function normalizeIntent(value: unknown, field = "task.intent"): string { + return normalizeIdentifier(value, field); +} + +export function normalizeNoiseLabel(value: unknown, field = "unexpected_noise"): NormalizedLabel { + const normalized = normalizeIdentifier(value, field); + return { id: normalized, known: noiseSet.has(normalized) }; +} + +/** 重複を除いた capability 配列。順序は呼び出し側の指定順を保つ(優先度の手掛かりになる)。 */ +export function normalizeCapabilityList(value: unknown, field = "requested_capabilities"): NormalizedLabel[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${field} must be an array of strings`); + const seen = new Set(); + const out: NormalizedLabel[] = []; + for (const entry of value) { + const normalized = normalizeCapability(entry, field); + if (seen.has(normalized.id)) continue; + seen.add(normalized.id); + out.push(normalized); + } + return out; +} + +export function normalizeNoiseList(value: unknown, field = "unexpected_noise"): NormalizedLabel[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${field} must be an array of strings`); + const seen = new Set(); + const out: NormalizedLabel[] = []; + for (const entry of value) { + const normalized = normalizeNoiseLabel(entry, field); + if (seen.has(normalized.id)) continue; + seen.add(normalized.id); + out.push(normalized); + } + return out; +} + +export function isKnownCapability(id: string): boolean { + return capabilitySet.has(id); +} diff --git a/src/adaptive/trace.test.ts b/src/adaptive/trace.test.ts new file mode 100644 index 00000000..666543d6 --- /dev/null +++ b/src/adaptive/trace.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { TRACE_SCHEMA_VERSION, createTraceStore } from "./trace.js"; + +function temporaryDir(): string { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-trace-")), "trace"); +} + +async function seed(directory: string, extraEnv: NodeJS.ProcessEnv = {}) { + const store = createTraceStore({ MOTTAINAI_TRACE_DIR: directory, ...extraEnv }); + const request = await store.beginRequest({ + task_category: "bug_investigation", + task_intent: "locate_root_cause", + task_confidence: 0.8, + caller_requested_capabilities: ["definitions"], + planned_capabilities: ["definitions", "callers"], + added_by_policy: ["callers"], + suppressed_by_policy: [], + policy_version: "builtin-1", + context: "compression drops diagnostics lines", + }); + await store.recordExecution({ + request_id: request.request_id, provider: "codegraph", tool: "codegraph__explore", capability: "callers", + duration_ms: 12, result_count: 3, output_size: 900, status: "success", + }); + return { store, request }; +} + +test("a request, its executions and its review fold into one trace", async () => { + const directory = temporaryDir(); + const { store, request } = await seed(directory); + + assert.equal(await store.recordReview({ + request_id: request.request_id, expected_found: true, sufficient: false, usefulness: 4, + missing_capabilities: ["ownership"], unexpected_noise: ["generated_files"], + follow_up_requested: true, next_capabilities: ["ownership"], + }), "recorded"); + + const traces = store.load(); + assert.equal(traces.length, 1); + assert.equal(traces[0].executions.length, 1); + assert.equal(traces[0].review?.usefulness, 4); + assert.deepEqual(traces[0].review?.missing_capabilities, ["ownership"]); + const executionId = traces[0].executions[0].execution_id; + assert.equal(await store.recordExecutionReview({ + request_id: request.request_id, execution_id: executionId, useful: true, + sufficient_for_capability: false, expected_found: true, missing_capabilities: [], unexpected_noise: [], + }), "recorded"); + assert.equal(store.load()[0].execution_reviews?.[0].execution_id, executionId); +}); + +test("review for an unknown request_id is rejected instead of creating a trace", async () => { + const directory = temporaryDir(); + const { store } = await seed(directory); + const result = await store.recordReview({ + request_id: "rq_missing", expected_found: true, sufficient: true, + missing_capabilities: [], unexpected_noise: [], follow_up_requested: false, next_capabilities: [], + }); + assert.equal(result, "unknown_request"); + assert.equal(store.load({ requestId: "rq_missing" }).length, 0); +}); + +test("caller context is stored as a digest unless raw retention is enabled", async () => { + const withoutRaw = temporaryDir(); + const { store: defaultStore, request } = await seed(withoutRaw); + const stored = defaultStore.load({ requestId: request.request_id })[0]; + assert.equal(stored.request.context, undefined); + assert.equal(typeof stored.request.context_digest, "string"); + assert.equal(stored.request.context_length, "compression drops diagnostics lines".length); + + const withRaw = temporaryDir(); + const { store: rawStore, request: rawRequest } = await seed(withRaw, { MOTTAINAI_TRACE_RAW: "1" }); + assert.equal(rawStore.load({ requestId: rawRequest.request_id })[0].request.context, "compression drops diagnostics lines"); +}); + +test("traces survive a new store over the same directory", async () => { + const directory = temporaryDir(); + const { request } = await seed(directory); + const reopened = createTraceStore({ MOTTAINAI_TRACE_DIR: directory }); + assert.equal(reopened.knowsRequest(request.request_id), true); + assert.equal(await reopened.recordReview({ + request_id: request.request_id, expected_found: false, sufficient: false, + missing_capabilities: ["tests"], unexpected_noise: [], follow_up_requested: false, next_capabilities: [], + }), "recorded"); + assert.equal(reopened.load({ reviewedOnly: true }).length, 1); +}); + +test("disabled tracing still issues request ids but writes nothing", async () => { + const directory = temporaryDir(); + const store = createTraceStore({ MOTTAINAI_TRACE_DIR: directory, MOTTAINAI_TRACE: "0" }); + const request = await store.beginRequest({ + task_category: "symbol_lookup", caller_requested_capabilities: [], planned_capabilities: [], policy_version: "builtin-1", + }); + assert.match(request.request_id, /^rq_[0-9a-f]{16}$/); + assert.equal(store.enabled, false); + assert.equal(fs.existsSync(directory), false); + assert.deepEqual(store.load(), []); +}); + +test("the trace directory is not created until something is recorded", () => { + const directory = temporaryDir(); + const store = createTraceStore({ MOTTAINAI_TRACE_DIR: directory }); + assert.equal(store.enabled, true); + assert.equal(fs.existsSync(directory), false); +}); + +test("filters select by category, review state and time", async () => { + const directory = temporaryDir(); + const { store, request } = await seed(directory); + await store.beginRequest({ + task_category: "symbol_lookup", caller_requested_capabilities: ["definitions"], planned_capabilities: ["definitions"], policy_version: "builtin-1", + }); + + assert.equal(store.load({ taskCategory: "symbol_lookup" }).length, 1); + assert.equal(store.load({ reviewedOnly: true }).length, 0); + assert.equal(store.load({ since: Date.now() + 60_000 }).length, 0); + assert.equal(store.load({ requestId: request.request_id })[0].request.task_intent, "locate_root_cause"); +}); + +test("corrupt lines do not discard the rest of the file", async () => { + const directory = temporaryDir(); + const { store } = await seed(directory); + const file = fs.readdirSync(directory).find((name) => name.endsWith(".jsonl"))!; + fs.appendFileSync(path.join(directory, file), "{ truncated json\n"); + assert.equal(store.load().length, 1); +}); + +test("new records carry the current schema version, caller intent stays separate from the plan, and executions get a stable id", async () => { + const directory = temporaryDir(); + const { store, request } = await seed(directory); + const trace = store.load({ requestId: request.request_id })[0]; + + assert.equal(trace.request.schema_version, TRACE_SCHEMA_VERSION); + assert.deepEqual(trace.request.caller_requested_capabilities, ["definitions"]); + assert.deepEqual(trace.request.planned_capabilities, ["definitions", "callers"]); + assert.deepEqual(trace.request.added_by_policy, ["callers"]); + assert.deepEqual(trace.request.suppressed_by_policy, []); + assert.equal(trace.executions[0].schema_version, TRACE_SCHEMA_VERSION); + assert.match(trace.executions[0].execution_id, /^ex_[0-9a-f]{16}$/); +}); + +test("legacy records without schema_version are migrated on read instead of dropped", async () => { + const directory = temporaryDir(); + fs.mkdirSync(directory, { recursive: true }); + const legacyRequest = { + type: "request", request_id: "rq_legacy00000000", timestamp: "2026-01-01T00:00:00.000Z", + task_category: "bug_investigation", requested_capabilities: ["definitions", "callers"], policy_version: "builtin-1", + }; + const legacyExecution = { + type: "execution", request_id: "rq_legacy00000000", timestamp: "2026-01-01T00:00:01.000Z", + provider: "codegraph", tool: "codegraph__explore", capability: "callers", + duration_ms: 5, result_count: 0, output_size: 0, status: "skipped", + }; + fs.writeFileSync(path.join(directory, "legacy.jsonl"), `${JSON.stringify(legacyRequest)}\n${JSON.stringify(legacyExecution)}\n`); + + const store = createTraceStore({ MOTTAINAI_TRACE_DIR: directory }); + const trace = store.load({ requestId: "rq_legacy00000000" })[0]; + + assert.equal(trace.request.schema_version, 0); + // 旧形式は呼び出し側の元意図を区別して保存していなかった。best-effort で解決済みプラン + // をそのまま両方の値として扱う(policy の寄与は不明のため 0 件とみなす)。 + assert.deepEqual(trace.request.caller_requested_capabilities, ["definitions", "callers"]); + assert.deepEqual(trace.request.planned_capabilities, ["definitions", "callers"]); + assert.deepEqual(trace.request.added_by_policy, []); + assert.equal(trace.executions[0].status, "unavailable"); + assert.match(trace.executions[0].execution_id, /^ex_legacy_[0-9a-f]{12}$/); + + const reloaded = store.load({ requestId: "rq_legacy00000000" })[0]; + assert.equal(reloaded.executions[0].execution_id, trace.executions[0].execution_id); +}); diff --git a/src/adaptive/trace.ts b/src/adaptive/trace.ts new file mode 100644 index 00000000..97c2bc43 --- /dev/null +++ b/src/adaptive/trace.ts @@ -0,0 +1,473 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomBytes } from "node:crypto"; + +/** + * caller-supervised routing の trace 永続化。 + * + * 追記専用の JSON Lines で `request` / `execution` / `review` / `execution_review` を別レコードとして書き、 + * 読み出し時に `request_id` で畳み込む。review は探索の後に届くため、既存レコードを + * 書き換えない構造にしている(同時書き込みでの破損と、部分書きの取りこぼしを避ける)。 + * + * 既定では metadata だけを保存する。呼び出し側の自由記述 `context` は sha256 の断片と + * 長さだけを残し、原文は `MOTTAINAI_TRACE_RAW=1` のときにだけ保存する。 + */ + +/** + * execution の結果分類。issue #47: 「証拠が無い」を単一の `skipped` に潰すと、 + * provider 未導入(`unavailable`)と呼び出し側が避けるべき capability(将来の avoid 由来)を + * 統計上区別できなくなる。技術的失敗も tool 自体の失敗(`tool_error`)と provider/接続の + * 失敗(`provider_error`)を分け、原因ごとに集計できるようにする。 + */ +export type ExecutionStatus = + | "success" + | "empty" + | "tool_error" + | "provider_error" + | "unavailable" + | "policy_suppressed" + | "not_executed"; + +/** + * trace レコード形式の版。旧形式(版無し = {@link LEGACY_SCHEMA_VERSION})は読み出し時に + * best-effort で現行形式へ写像する(`normalizeRequestRecord` / `normalizeExecutionRecord`)。 + * 破壊的にレコード形状を変えるときはここを上げ、移行方針をこのコメントに書き足す。 + */ +export const TRACE_SCHEMA_VERSION = 1; +const LEGACY_SCHEMA_VERSION = 0; + +export interface TraceRequestRecord { + type: "request"; + schema_version: number; + request_id: string; + timestamp: string; + task_category: string; + task_intent?: string; + task_confidence?: number; + /** 呼び出し側が明示した capability。policy の追加・抑制を経ない、そのままの値。 */ + caller_requested_capabilities: string[]; + /** 呼び出し側の指定と policy を統合した、実際に使われた capability 列。 */ + planned_capabilities: string[]; + /** policy が足した capability。 */ + added_by_policy: string[]; + /** policy の avoid 指定で外した capability。 */ + suppressed_by_policy: string[]; + policy_version: string; + /** 既知語彙に無かったラベル。taxonomy を広げる判断材料として残す。 */ + unknown_labels?: string[]; + context_digest?: string; + context_length?: number; + context?: string; +} + +export interface TraceExecutionRecord { + type: "execution"; + schema_version: number; + /** 同一 request 内でも execution 単位に安定した ID。将来の execution 単位 review が使う。 */ + execution_id: string; + request_id: string; + timestamp: string; + provider: string; + tool: string; + capability: string; + duration_ms: number; + result_count: number; + output_size: number; + status: ExecutionStatus; + /** fallback で試した provider/tool。通常呼び出しでは空または未設定。 */ + attempts?: TraceExecutionAttempt[]; +} + +export interface TraceExecutionAttempt { + provider: string; + tool: string; + backend?: string; + error: string; +} + +export interface TraceReviewRecord { + type: "review"; + schema_version: number; + request_id: string; + timestamp: string; + expected_found: boolean; + sufficient: boolean; + usefulness?: number; + missing_capabilities: string[]; + unexpected_noise: string[]; + follow_up_requested: boolean; + next_capabilities: string[]; +} + +export interface TraceExecutionReviewRecord { + type: "execution_review"; + schema_version: number; + request_id: string; + execution_id: string; + timestamp: string; + expected_found?: boolean; + useful?: boolean; + sufficient_for_capability?: boolean; + missing_capabilities: string[]; + unexpected_noise: string[]; +} + +export type TraceRecord = TraceRequestRecord | TraceExecutionRecord | TraceReviewRecord | TraceExecutionReviewRecord; + +export interface Trace { + request: TraceRequestRecord; + executions: TraceExecutionRecord[]; + review?: TraceReviewRecord; + execution_reviews?: TraceExecutionReviewRecord[]; +} + +export interface BeginRequestInput { + task_category: string; + task_intent?: string; + task_confidence?: number; + /** 呼び出し側が明示した capability。policy 適用前のそのままの値。 */ + caller_requested_capabilities: string[]; + /** 呼び出し側の指定と policy を統合した、実際に使う capability 列。 */ + planned_capabilities: string[]; + added_by_policy?: string[]; + suppressed_by_policy?: string[]; + policy_version: string; + unknown_labels?: string[]; + context?: string; +} + +export type ExecutionInput = Omit; +export type ReviewInput = Omit; +export type ExecutionReviewInput = Omit; + +export interface TraceFilter { + requestId?: string; + taskCategory?: string; + /** この時刻以降に開始した request だけを返す(epoch ミリ秒)。 */ + since?: number; + reviewedOnly?: boolean; +} + +export interface TraceStore { + /** 永続化が有効か。無効でも request_id の発行と session 内の追跡は続く。 */ + readonly enabled: boolean; + readonly directory: string; + readonly retainRawEvidence: boolean; + beginRequest(input: BeginRequestInput): Promise; + recordExecution(input: ExecutionInput): Promise; + recordReview(input: ReviewInput): Promise<"recorded" | "unknown_request">; + recordExecutionReview(input: ExecutionReviewInput): Promise<"recorded" | "unknown_request" | "unknown_execution">; + knowsRequest(requestId: string): boolean; + load(filter?: TraceFilter): Trace[]; +} + +const DEFAULT_RETENTION_DAYS = 30; +const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; +const MAX_CONTEXT_CHARS = 2_000; + +export function isTraceEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env.MOTTAINAI_TRACE; + if (value === undefined) return true; + return value !== "0" && value.toLowerCase() !== "false"; +} + +export function resolveTraceDir(env: NodeJS.ProcessEnv): string { + return env.MOTTAINAI_TRACE_DIR ?? path.join(process.cwd(), ".mottainai", "trace"); +} + +function retainRawEvidence(env: NodeJS.ProcessEnv): boolean { + const value = env.MOTTAINAI_TRACE_RAW; + return value !== undefined && value !== "0" && value.toLowerCase() !== "false"; +} + +function positiveNumber(value: string | undefined, fallback: number): number { + const parsed = value !== undefined ? Number(value) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function newRequestId(): string { + return `rq_${randomBytes(8).toString("hex")}`; +} + +export function newExecutionId(): string { + return `ex_${randomBytes(8).toString("hex")}`; +} + +/** 同一ミリ秒でロールオーバーしても名前が衝突しないよう連番を足す。 */ +let traceFileSequence = 0; + +function traceFileName(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `${timestamp}_pid${process.pid}_${traceFileSequence++}.jsonl`; +} + +function sweepExpiredTraces(directory: string, maxAgeMs: number): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + const cutoff = Date.now() - maxAgeMs; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const filePath = path.join(directory, entry.name); + try { + if (fs.statSync(filePath).mtimeMs < cutoff) fs.unlinkSync(filePath); + } catch { + // 掃除の失敗は trace 記録を止めない + } + } +} + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function isTraceRecord(value: unknown): value is TraceRecord { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.request_id === "string" && + (record.type === "request" || record.type === "execution" || record.type === "review" || record.type === "execution_review") + ); +} + +/** 旧 execution status → 新語彙への best-effort 写像。読み出し専用、書き込みは常に新語彙を使う。 */ +const LEGACY_EXECUTION_STATUS: Record = { + success: "success", + empty: "empty", + // 旧 "error" は tool 自体の失敗と provider/接続の失敗を区別していなかった。 + // 過去分はどちらか一方に倒す必要があり、より頻度の高かった tool 起因側へ寄せる。 + error: "tool_error", + // 旧 "skipped" は「この capability を満たす provider が無い」の意味だった。 + skipped: "unavailable", +}; + +/** + * 旧形式(schema_version 無し)の request レコードを現行形式へ写像する。 + * + * 旧 `requested_capabilities` は呼び出し側の意図と policy の寄与を区別せず、 + * 解決済みプランだけを保存していた。過去分から呼び出し側の原意図は復元できないため、 + * best-effort で `planned_capabilities` 側へ寄せ、`caller_requested_capabilities` も + * 同じ値にする(policy の寄与を 0 とみなす、既知の近似)。 + */ +function normalizeRequestRecord(record: Record): TraceRequestRecord { + const schemaVersion = typeof record.schema_version === "number" ? record.schema_version : LEGACY_SCHEMA_VERSION; + if (schemaVersion >= TRACE_SCHEMA_VERSION && Array.isArray(record.caller_requested_capabilities)) { + return record as unknown as TraceRequestRecord; + } + const planned = Array.isArray(record.requested_capabilities) ? (record.requested_capabilities as string[]) : []; + return { + ...(record as unknown as TraceRequestRecord), + schema_version: LEGACY_SCHEMA_VERSION, + caller_requested_capabilities: planned, + planned_capabilities: planned, + added_by_policy: [], + suppressed_by_policy: [], + }; +} + +function normalizeExecutionRecord(record: Record): TraceExecutionRecord { + const schemaVersion = typeof record.schema_version === "number" ? record.schema_version : LEGACY_SCHEMA_VERSION; + if (schemaVersion >= TRACE_SCHEMA_VERSION && typeof record.execution_id === "string") { + return record as unknown as TraceExecutionRecord; + } + const legacyStatus = typeof record.status === "string" ? record.status : "success"; + // 同じレコードを読み直しても同じ ID になるよう、内容から合成する(乱数は使わない)。 + const executionId = `ex_legacy_${digest(`${record.request_id}:${record.timestamp}:${record.provider}:${record.tool}:${record.capability}`)}`; + return { + ...(record as unknown as TraceExecutionRecord), + schema_version: LEGACY_SCHEMA_VERSION, + execution_id: executionId, + status: LEGACY_EXECUTION_STATUS[legacyStatus] ?? "not_executed", + }; +} + +function normalizeReviewRecord(record: Record): TraceReviewRecord { + if (typeof record.schema_version === "number") return record as unknown as TraceReviewRecord; + return { ...(record as unknown as TraceReviewRecord), schema_version: LEGACY_SCHEMA_VERSION }; +} + +function normalizeExecutionReviewRecord(record: Record): TraceExecutionReviewRecord { + if (typeof record.schema_version === "number") return record as unknown as TraceExecutionReviewRecord; + return { ...(record as unknown as TraceExecutionReviewRecord), schema_version: LEGACY_SCHEMA_VERSION }; +} + +function normalizeRecord(record: TraceRecord): TraceRecord { + const raw = record as unknown as Record; + if (record.type === "request") return normalizeRequestRecord(raw); + if (record.type === "execution") return normalizeExecutionRecord(raw); + if (record.type === "execution_review") return normalizeExecutionReviewRecord(raw); + return normalizeReviewRecord(raw); +} + +function readRecords(directory: string): TraceRecord[] { + let names: string[]; + try { + names = fs.readdirSync(directory).filter((name) => name.endsWith(".jsonl")).sort(); + } catch { + return []; + } + const records: TraceRecord[] = []; + for (const name of names) { + let content: string; + try { + content = fs.readFileSync(path.join(directory, name), "utf8"); + } catch { + continue; + } + for (const line of content.split("\n")) { + if (line.length === 0) continue; + try { + const parsed: unknown = JSON.parse(line); + // 途中で切れた行や別形式の行は捨てる。1 行の破損で trace 全体を失わないため。 + if (isTraceRecord(parsed)) records.push(normalizeRecord(parsed)); + } catch { + continue; + } + } + } + return records; +} + +/** レコード列を request_id ごとに畳み込む。request レコードの無い孤児は捨てる。 */ +export function foldRecords(records: TraceRecord[]): Trace[] { + const traces = new Map(); + for (const record of records) { + if (record.type !== "request") continue; + traces.set(record.request_id, { request: record, executions: [], execution_reviews: [] }); + } + for (const record of records) { + const trace = traces.get(record.request_id); + if (trace === undefined) continue; + if (record.type === "execution") trace.executions.push(record); + if (record.type === "execution_review") trace.execution_reviews?.push(record); + // 同じ request に複数 review が届いたら最後を採る。呼び出し側の訂正を有効にする。 + if (record.type === "review") trace.review = record; + } + return [...traces.values()]; +} + +function matchesFilter(trace: Trace, filter: TraceFilter | undefined): boolean { + if (filter === undefined) return true; + if (filter.requestId !== undefined && trace.request.request_id !== filter.requestId) return false; + if (filter.taskCategory !== undefined && trace.request.task_category !== filter.taskCategory) return false; + if (filter.reviewedOnly === true && trace.review === undefined) return false; + if (filter.since !== undefined && Date.parse(trace.request.timestamp) < filter.since) return false; + return true; +} + +/** + * 環境変数から trace store を構築する。 + * + * - `MOTTAINAI_TRACE=0` — 永続化を無効化(既定は有効) + * - `MOTTAINAI_TRACE_DIR` — 出力先(既定 `.mottainai/trace/`) + * - `MOTTAINAI_TRACE_RAW=1` — 呼び出し側 `context` の原文保存を有効化(既定は digest のみ) + * - `MOTTAINAI_TRACE_RETENTION_DAYS` — 保存日数(既定 30 日)。起動時に期限切れを削除 + * - `MOTTAINAI_TRACE_MAX_FILE_BYTES` — 1 ファイル上限(既定 5MiB)。超過で新規ファイルへ + */ +export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceStore { + const enabled = isTraceEnabled(env); + const directory = resolveTraceDir(env); + const raw = retainRawEvidence(env); + const maxFileBytes = positiveNumber(env.MOTTAINAI_TRACE_MAX_FILE_BYTES, DEFAULT_MAX_FILE_BYTES); + const sessionRequests = new Set(); + + let filePath = ""; + let currentFileBytes = 0; + let writeQueue: Promise = Promise.resolve(); + let prepared = false; + + // ディレクトリ作成と期限切れ掃除は最初の書き込みまで遅らせる。metadata を一度も + // 受け取らない起動で `.mottainai/trace/` を作らないため。 + function prepareDirectory(): void { + if (prepared) return; + prepared = true; + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + sweepExpiredTraces(directory, positiveNumber(env.MOTTAINAI_TRACE_RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * 24 * 60 * 60 * 1000); + filePath = path.join(directory, traceFileName()); + } + + async function append(record: TraceRecord): Promise { + if (!enabled) return; + const line = `${JSON.stringify(record)}\n`; + writeQueue = writeQueue + .then(async () => { + prepareDirectory(); + const lineBytes = Buffer.byteLength(line, "utf8"); + if (currentFileBytes > 0 && currentFileBytes + lineBytes > maxFileBytes) { + filePath = path.join(directory, traceFileName()); + currentFileBytes = 0; + } + currentFileBytes += lineBytes; + await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 }); + }) + .catch((err) => { + console.error("mottainai: failed to write trace record", err); + }); + await writeQueue; + } + + function loadTraces(filter?: TraceFilter): Trace[] { + if (!enabled) return []; + return foldRecords(readRecords(directory)).filter((trace) => matchesFilter(trace, filter)); + } + + return { + enabled, + directory, + retainRawEvidence: raw, + async beginRequest(input) { + const record: TraceRequestRecord = { + type: "request", + schema_version: TRACE_SCHEMA_VERSION, + request_id: newRequestId(), + timestamp: new Date().toISOString(), + task_category: input.task_category, + task_intent: input.task_intent, + task_confidence: input.task_confidence, + caller_requested_capabilities: input.caller_requested_capabilities, + planned_capabilities: input.planned_capabilities, + added_by_policy: input.added_by_policy ?? [], + suppressed_by_policy: input.suppressed_by_policy ?? [], + policy_version: input.policy_version, + unknown_labels: input.unknown_labels?.length ? input.unknown_labels : undefined, + context_digest: input.context === undefined ? undefined : digest(input.context), + context_length: input.context?.length, + context: raw ? input.context?.slice(0, MAX_CONTEXT_CHARS) : undefined, + }; + sessionRequests.add(record.request_id); + await append(record); + return record; + }, + async recordExecution(input) { + const record: TraceExecutionRecord = { + type: "execution", + schema_version: TRACE_SCHEMA_VERSION, + execution_id: newExecutionId(), + timestamp: new Date().toISOString(), + ...input, + }; + await append(record); + }, + async recordReview(input) { + if (!sessionRequests.has(input.request_id) && loadTraces({ requestId: input.request_id }).length === 0) { + return "unknown_request"; + } + await append({ type: "review", schema_version: TRACE_SCHEMA_VERSION, timestamp: new Date().toISOString(), ...input }); + return "recorded"; + }, + async recordExecutionReview(input) { + const trace = loadTraces({ requestId: input.request_id })[0]; + if (trace === undefined) return "unknown_request"; + if (!trace.executions.some((execution) => execution.execution_id === input.execution_id)) return "unknown_execution"; + await append({ type: "execution_review", schema_version: TRACE_SCHEMA_VERSION, timestamp: new Date().toISOString(), ...input }); + return "recorded"; + }, + knowsRequest(requestId) { + return sessionRequests.has(requestId) || loadTraces({ requestId }).length > 0; + }, + load: loadTraces, + }; +} diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 00000000..eac581f1 --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { loadOAuthCredentialProvider, resolveBrokerEndpoint } from "./auth.js"; + +test("loads a generic OAuth credential provider module and resolves a broker endpoint", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-oauth-")); + const modulePath = path.join(directory, "provider.mjs"); + fs.writeFileSync(modulePath, "export default { resolveEndpoint: async () => 'http://127.0.0.1:9393/mcp' };\n"); + try { + const provider = await loadOAuthCredentialProvider("./provider.mjs", directory); + assert.ok(provider !== undefined); + assert.equal( + (await resolveBrokerEndpoint(provider, new URL("https://mcp.example.test/mcp"), "example")).toString(), + "http://127.0.0.1:9393/mcp", + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("sanitizes OAuth provider failures and rejects non-HTTP broker endpoints", async () => { + await assert.rejects( + () => resolveBrokerEndpoint({ resolveEndpoint: async () => { throw new Error("secret-token"); } }, new URL("https://mcp.example.test/mcp"), "example"), + /oauth broker resolution failed: example/, + ); + await assert.rejects( + () => resolveBrokerEndpoint({ resolveEndpoint: async () => "file:///tmp/mcp" }, new URL("https://mcp.example.test/mcp"), "example"), + /oauth broker returned invalid endpoint: example/, + ); +}); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 00000000..bfd64149 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** + * OAuth tokenをMottainaiへ渡さず、認証済みbrokerのMCP endpointだけ解決する契約。 + * targetUrlはproviderがどのremote server向けか判定するためだけに使う。 + */ +export interface OAuthCredentialProvider { + resolveEndpoint(targetUrl: URL, profile: string): Promise; +} + +function isOAuthCredentialProvider(value: unknown): value is OAuthCredentialProvider { + return typeof value === "object" + && value !== null + && typeof (value as { resolveEndpoint?: unknown }).resolveEndpoint === "function"; +} + +function brokerUrl(value: URL | string, profile: string): URL { + let endpoint: URL; + try { + endpoint = value instanceof URL ? value : new URL(value); + } catch { + throw new Error(`oauth broker returned invalid endpoint: ${profile}`); + } + if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") { + throw new Error(`oauth broker returned invalid endpoint: ${profile}`); + } + return endpoint; +} + +export async function resolveBrokerEndpoint( + provider: OAuthCredentialProvider, + targetUrl: URL, + profile: string, +): Promise { + try { + return brokerUrl(await provider.resolveEndpoint(targetUrl, profile), profile); + } catch (error) { + if (error instanceof Error && error.message.startsWith("oauth broker returned invalid endpoint:")) { + throw error; + } + throw new Error(`oauth broker resolution failed: ${profile}`); + } +} + +/** gateway起動時にhost側のbroker provider moduleを読み込む。 */ +export async function loadOAuthCredentialProvider( + modulePath: string | undefined, + baseDirectory: string, +): Promise { + if (modulePath === undefined) return undefined; + const moduleUrl = pathToFileURL(path.resolve(baseDirectory, modulePath)).href; + const loaded = await import(moduleUrl) as { default?: unknown; oauthCredentialProvider?: unknown }; + const provider = loaded.default ?? loaded.oauthCredentialProvider; + if (!isOAuthCredentialProvider(provider)) { + throw new Error("invalid oauth credential provider module"); + } + return provider; +} diff --git a/src/compress/ansi.test.ts b/src/compress/ansi.test.ts new file mode 100644 index 00000000..0639a180 --- /dev/null +++ b/src/compress/ansi.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { stripAnsi } from "./ansi.js"; + +const ESC = "\x1b"; + +test("stripAnsi removes CSI color codes", () => { + const input = `${ESC}[31mred${ESC}[0m plain`; + assert.equal(stripAnsi(input), "red plain"); +}); + +test("stripAnsi removes CSI cursor-movement codes", () => { + const input = `${ESC}[2K${ESC}[1Gline`; + assert.equal(stripAnsi(input), "line"); +}); + +test("stripAnsi removes OSC sequences terminated by BEL", () => { + const input = `${ESC}]0;window title${String.fromCharCode(7)}visible`; + assert.equal(stripAnsi(input), "visible"); +}); + +test("stripAnsi removes OSC sequences terminated by ESC \\\\", () => { + const input = `${ESC}]8;;http://example.com${ESC}\\link${ESC}]8;;${ESC}\\`; + assert.equal(stripAnsi(input), "link"); +}); + +test("stripAnsi preserves newlines and tabs", () => { + const input = "a\nb\tc"; + assert.equal(stripAnsi(input), "a\nb\tc"); +}); + +test("stripAnsi is a no-op on plain text", () => { + const input = "no escapes here"; + assert.equal(stripAnsi(input), input); +}); diff --git a/src/compress/ansi.ts b/src/compress/ansi.ts new file mode 100644 index 00000000..ad2dd5b0 --- /dev/null +++ b/src/compress/ansi.ts @@ -0,0 +1,11 @@ +const ESC = "\x1b"; +// CSI (ESC [ ... letter) と OSC (ESC ] ... BEL または ESC \) の両方にマッチする。 +const ANSI_PATTERN = new RegExp( + `${ESC}(?:\\[[0-9;?]*[a-zA-Z]|\\][^\\x07${ESC}]*(?:\\x07|${ESC}\\\\))`, + "g", +); + +/** ANSIエスケープシーケンス(CSI/OSC)を除去する。`\n`/`\t`はそのまま保持する。 */ +export function stripAnsi(input: string): string { + return input.replace(ANSI_PATTERN, ""); +} diff --git a/src/compress/budget.test.ts b/src/compress/budget.test.ts new file mode 100644 index 00000000..9da60a67 --- /dev/null +++ b/src/compress/budget.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compactToBudget } from "./budget.js"; + +test("compactToBudget does not shorten text that already fits the budget", () => { + const text = "line one\nline two\nline three"; + assert.equal(compactToBudget(text, 1_000, Buffer.byteLength(text)), text); +}); + +test("compactToBudget shortens text that exceeds the budget, keeping head and tail lines", () => { + const lines = Array.from({ length: 500 }, (_, index) => `line ${index}`); + const text = lines.join("\n"); + const compacted = compactToBudget(text, 256, Buffer.byteLength(text)); + + assert.notEqual(compacted, text); + assert.match(compacted, /^line 0\n/); + assert.match(compacted, /line 499$/); + assert.match(compacted, /⋯ mottainai omitted=\d+ lines sha256=[0-9a-f]{16}; use mottainai_result_get ⋯/); +}); diff --git a/src/compress/budget.ts b/src/compress/budget.ts new file mode 100644 index 00000000..689c8f97 --- /dev/null +++ b/src/compress/budget.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; + +/** + * トークン予算の最終切り詰め。 + * + * 4 レイヤ圧縮(ANSI/CLI/JSON/行フィルタ/コード骨格)が終わったあとの、最後の安全弁。 + * §4 の無変形対象(コードフェンス・URL・日本語行・git diff 等)はここでは尊重しない — + * それらは既に圧縮パイプラインを無変形で通過済みで、ここは「それでも大きすぎる」場合の + * 最終手段として頭と末尾を残して中間を切る。原文は必ず artifact store 経由で拾える + * (呼び出し元が `result_id` を付ける)ことが、無変形の代わりに守るべき不変条件。 + */ +export function compactToBudget(text: string, targetTokens: number, rawBytes: number): string { + // 共通envelope分を約256 token確保。行境界を維持して先頭・末尾を残す。 + const targetBytes = (targetTokens - 256) * 4; + // 生出力より大きいMCP payloadを返さないよう、envelope用に約1 KiB確保。 + const budget = Math.max(256, Math.min(targetBytes, rawBytes - 1024)); + if (Buffer.byteLength(text) <= budget) return text; + const lines = text.split("\n"); + const head: string[] = []; + const tail: string[] = []; + let used = 0; + const headBudget = Math.floor(budget * 0.6); + for (const line of lines) { + const bytes = Buffer.byteLength(`${line}\n`); + if (used + bytes > headBudget) break; + head.push(line); used += bytes; + } + let tailUsed = 0; + for (let index = lines.length - 1; index >= head.length; index -= 1) { + const line = lines[index]; + const bytes = Buffer.byteLength(`${line}\n`); + if (used + tailUsed + bytes > budget) break; + tail.unshift(line); tailUsed += bytes; + } + const omitted = Math.max(0, lines.length - head.length - tail.length); + const hash = createHash("sha256").update(text).digest("hex").slice(0, 16); + return [...head, `⋯ mottainai omitted=${omitted} lines sha256=${hash}; use mottainai_result_get ⋯`, ...tail].join("\n"); +} diff --git a/src/compress/code.test.ts b/src/compress/code.test.ts new file mode 100644 index 00000000..f25292db --- /dev/null +++ b/src/compress/code.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compressCodeText, detectCodeLanguage, skeletonizeCode } from "./code.js"; + +test("skeletonizeCode retains TypeScript signatures and removes function bodies", () => { + const input = [ + "export class Service {", + " run(value: string): number {", + " const parsed = value.trim();", + " return parsed.length;", + " }", + "}", + "export function build(name: string): string { return `hello ${name}`; }", + ].join("\n"); + const output = skeletonizeCode(input, "typescript"); + assert.match(output, /run\(value: string\): number \{ \/\* mottainai: body omitted \*\/ \}/); + assert.match(output, /function build\(name: string\): string \{ \/\* mottainai: body omitted \*\/ \}/); + assert.doesNotMatch(output, /value\.trim|hello/); +}); + +test("skeletonizeCode leaves syntactically invalid code unchanged", () => { + const input = "function broken( { return 1;"; + assert.equal(skeletonizeCode(input, "javascript"), input); +}); + +test("skeletonizeCode handles nested functions without overlapping replacements", () => { + const input = "function outer() { function inner() { return 1; } return inner(); }"; + assert.equal(skeletonizeCode(input, "javascript"), "function outer() { /* mottainai: body omitted */ }"); +}); + +test("compressCodeText handles language-tagged fenced code only", () => { + const input = "before\n```ts\nfunction f(): void { console.log('x'); }\n```\nafter"; + const output = compressCodeText(input); + assert.match(output, /function f\(\): void \{ \/\* mottainai: body omitted \*\/ \}/); + assert.match(output, /before/); + assert.match(output, /after/); +}); + +test("detectCodeLanguage accepts explicit language and common file paths", () => { + assert.equal(detectCodeLanguage({ language: "TSX" }), "tsx"); + assert.equal(detectCodeLanguage({ filePath: "/repo/src/proxy.ts" }), "typescript"); + assert.equal(detectCodeLanguage({ path: "/repo/README.md" }), undefined); +}); diff --git a/src/compress/code.ts b/src/compress/code.ts new file mode 100644 index 00000000..b509a238 --- /dev/null +++ b/src/compress/code.ts @@ -0,0 +1,117 @@ +import Parser from "tree-sitter"; +import javascript from "tree-sitter-javascript"; +import typescript from "tree-sitter-typescript"; + +export type CodeLanguage = "javascript" | "typescript" | "tsx"; + +export interface CodeSkeletonOptions { + /** 入力全体がコードである場合の言語。未指定時は fenced code block だけを対象にする。 */ + language?: CodeLanguage; +} + +const LANGUAGE_ALIASES: Record = { + js: "javascript", + javascript: "javascript", + jsx: "javascript", + ts: "typescript", + typescript: "typescript", + tsx: "tsx", +}; + +const FUNCTION_NODE_TYPES = new Set([ + "function_declaration", + "function_expression", + "generator_function_declaration", + "generator_function", + "method_definition", + "arrow_function", +]); + +const parser = new Parser(); + +function languageFor(language: CodeLanguage): Parameters[0] { + switch (language) { + // grammar packages expose compatible native Language objects, but their bundled + // declarations type the native handle as unknown. + case "javascript": return javascript as unknown as Parameters[0]; + case "typescript": return typescript.typescript as unknown as Parameters[0]; + case "tsx": return typescript.tsx as unknown as Parameters[0]; + } +} + +function bodyNodes(root: Parser.SyntaxNode): Parser.SyntaxNode[] { + const bodies: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode): void => { + if (FUNCTION_NODE_TYPES.has(node.type)) { + const body = node.childForFieldName("body"); + if (body?.type === "statement_block") bodies.push(body); + } + for (const child of node.namedChildren) visit(child); + }; + visit(root); + return bodies; +} + +/** + * 関数・メソッド本体だけを AST 境界で省略する。 + * 構文エラーを含む入力は無変形で返す。完全な意味保存ではなく、探索用骨格化。 + */ +export function skeletonizeCode(input: string, language: CodeLanguage): string { + parser.setLanguage(languageFor(language)); + const tree = parser.parse(input); + if (tree.rootNode.hasError) return input; + + const candidates = bodyNodes(tree.rootNode); + // 入れ子関数の body は外側 body の省略で同時に消える。重複置換しない。 + const bodies = candidates + .filter((body) => !candidates.some((other) => + other.startIndex < body.startIndex && body.endIndex < other.endIndex, + )) + .sort((a, b) => b.startIndex - a.startIndex); + if (bodies.length === 0) return input; + + let output = input; + for (const body of bodies) { + output = `${output.slice(0, body.startIndex)}{ /* mottainai: body omitted */ }${output.slice(body.endIndex)}`; + } + return output; +} + +function normalizeLanguage(value: string): CodeLanguage | undefined { + return LANGUAGE_ALIASES[value.trim().toLowerCase()]; +} + +const FENCED_CODE = /(^|\n)(```([^\n`]*)\n)([\s\S]*?)(\n```(?=\n|$))/g; + +/** 明示言語の全体コード、または言語付き fenced code block を骨格化する。 */ +export function compressCodeText(input: string, options: CodeSkeletonOptions = {}): string { + if (options.language) return skeletonizeCode(input, options.language); + + return input.replace(FENCED_CODE, (whole, prefix: string, opening: string, info: string, code: string, closing: string) => { + const language = normalizeLanguage(info.split(/\s+/, 1)[0] ?? ""); + if (!language) return whole; + return `${prefix}${opening}${skeletonizeCode(code, language)}${closing}`; + }); +} + +/** ツール引数の言語名またはファイル名から対応言語を安全に推定する。 */ +export function detectCodeLanguage(arguments_: unknown): CodeLanguage | undefined { + if (typeof arguments_ !== "object" || arguments_ === null) return undefined; + const values = arguments_ as Record; + for (const key of ["language", "languageId"]) { + if (typeof values[key] === "string") { + const language = normalizeLanguage(values[key]); + if (language) return language; + } + } + for (const key of ["path", "filePath", "filepath", "filename", "uri"]) { + const value = values[key]; + if (typeof value !== "string") continue; + const extension = value.split(/[?#]/, 1)[0]?.split(".").pop()?.toLowerCase(); + if (extension) { + const language = normalizeLanguage(extension); + if (language) return language; + } + } + return undefined; +} diff --git a/src/compress/json.test.ts b/src/compress/json.test.ts new file mode 100644 index 00000000..e5015280 --- /dev/null +++ b/src/compress/json.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compressJsonText, compressJsonValue, tryParseJson } from "./json.js"; + +test("tryParseJson returns the parsed value for valid JSON", () => { + assert.deepEqual(tryParseJson('{"a":1}'), { a: 1 }); +}); + +test("tryParseJson returns undefined for invalid JSON", () => { + assert.equal(tryParseJson("not json"), undefined); +}); + +test("tryParseJson distinguishes JSON null from parse failure", () => { + assert.equal(tryParseJson("null"), null); +}); + +test("compressJsonValue preserves array head and tail, and marks the omitted middle", () => { + const value = [1, 2, 3, 4, 5]; + const out = compressJsonValue(value, { maxArrayItems: 3, tailArrayItems: 1 }); + assert.deepEqual(out, [ + 1, + 2, + { + __truncated__: true, + omittedCount: 2, + totalCount: 5, + omittedSha256: "8be6d66e9099c68d8feb52ce42478d2153cac2763b784174ae6ae96cd636b596", + }, + 5, + ]); +}); + +test("compressJsonValue leaves short arrays untouched", () => { + const value = [1, 2]; + assert.deepEqual(compressJsonValue(value, { maxArrayItems: 5 }), [1, 2]); +}); + +test("compressJsonValue truncates long strings", () => { + const value = "x".repeat(10); + assert.equal(compressJsonValue(value, { maxStringLength: 5 }), "xxxxx…(+5 chars)"); +}); + +test("compressJsonValue preserves keys, booleans, null, and short strings", () => { + const value = { keep: "ok", flag: true, empty: null, n: 42 }; + assert.deepEqual(compressJsonValue(value), value); +}); + +test("compressJsonValue truncates beyond maxDepth", () => { + const value = { a: { b: { c: "deep" } } }; + const out = compressJsonValue(value, { maxDepth: 1 }) as { a: { b: unknown } }; + assert.equal(out.a.b, "[truncated: max depth exceeded]"); +}); + +test("compressJsonText re-serializes a compressed JSON structure", () => { + const input = JSON.stringify({ items: [1, 2, 3] }); + const out = compressJsonText(input, { maxArrayItems: 2, tailArrayItems: 1, indent: 0 }); + assert.deepEqual(JSON.parse(out), { items: [1, { __truncated__: true, omittedCount: 1, totalCount: 3, omittedSha256: "038966de9f6b9a901b20b4c6ca8b2a46009feebe031babc842d43690c0bc222b" }, 3] }); +}); + +test("compressJsonText is a no-op on non-JSON input", () => { + const input = "plain text, not json"; + assert.equal(compressJsonText(input), input); +}); + +test("compressJsonText honors indent option", () => { + const input = JSON.stringify({ a: 1 }); + const out = compressJsonText(input, { indent: 2 }); + assert.equal(out, JSON.stringify({ a: 1 }, null, 2)); +}); diff --git a/src/compress/json.ts b/src/compress/json.ts new file mode 100644 index 00000000..9e4e650a --- /dev/null +++ b/src/compress/json.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; + +export interface JsonCompressOptions { + /** 配列から保持する先頭・末尾要素の合計上限。 */ + maxArrayItems?: number; + /** 配列末尾から保持する要素数。maxArrayItems未満に制限される。 */ + tailArrayItems?: number; + /** 文字列値をこの文字数で切り詰め。 */ + maxStringLength?: number; + /** オブジェクト/配列のネスト深さの上限。 */ + maxDepth?: number; + /** 出力インデント幅(0で改行なしのミニファイ)。 */ + indent?: number; +} + +export const DEFAULT_JSON_COMPRESS_OPTIONS: Required = { + maxArrayItems: 20, + tailArrayItems: 5, + maxStringLength: 300, + maxDepth: 6, + indent: 0, +}; + +const DEPTH_TRUNCATED_MARKER = "[truncated: max depth exceeded]"; + +function sha256Json(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +/** 入力がJSONとしてパース可能かどうかを判定する。パース不能なら undefined を返す。 */ +export function tryParseJson(input: string): unknown | undefined { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +function compressValue(value: unknown, options: Required, depth: number): unknown { + if (depth > options.maxDepth) return DEPTH_TRUNCATED_MARKER; + + if (Array.isArray(value)) { + if (value.length <= options.maxArrayItems) { + return value.map((item) => compressValue(item, options, depth + 1)); + } + + const tailCount = Math.min(options.tailArrayItems, Math.max(0, options.maxArrayItems - 1)); + const headCount = options.maxArrayItems - tailCount; + const head = value.slice(0, headCount).map((item) => compressValue(item, options, depth + 1)); + const omitted = value.slice(headCount, value.length - tailCount); + const tail = value.slice(value.length - tailCount).map((item) => compressValue(item, options, depth + 1)); + return [ + ...head, + { + __truncated__: true, + omittedCount: omitted.length, + totalCount: value.length, + omittedSha256: sha256Json(omitted), + }, + ...tail, + ]; + } + + if (typeof value === "string") { + if (value.length <= options.maxStringLength) return value; + const omitted = value.length - options.maxStringLength; + return `${value.slice(0, options.maxStringLength)}…(+${omitted} chars)`; + } + + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = compressValue(v, options, depth + 1); + } + return out; + } + + // number / boolean / null はそのまま保持 + return value; +} + +/** パース済みのJSON値に対して再帰的にサンプリング・切り詰めを適用する。 */ +export function compressJsonValue(value: unknown, options?: JsonCompressOptions): unknown { + const opts = { ...DEFAULT_JSON_COMPRESS_OPTIONS, ...options }; + return compressValue(value, opts, 0); +} + +/** + * 入力文字列がJSONとしてパースできればサンプリング・整形して再シリアライズした文字列を返す。 + * パース不能なら入力をそのまま返す(no-op)。 + */ +export function compressJsonText(input: string, options?: JsonCompressOptions): string { + const parsed = tryParseJson(input); + if (parsed === undefined) return input; + const opts = { ...DEFAULT_JSON_COMPRESS_OPTIONS, ...options }; + const compressed = compressValue(parsed, opts, 0); + return opts.indent > 0 ? JSON.stringify(compressed, null, opts.indent) : JSON.stringify(compressed); +} diff --git a/src/compress/lines.test.ts b/src/compress/lines.test.ts new file mode 100644 index 00000000..f3b1d666 --- /dev/null +++ b/src/compress/lines.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + collapseBlankLines, + collapseDuplicateLines, + filterLines, + truncateExcessLines, + truncateLongLines, +} from "./lines.js"; + +test("collapseDuplicateLines keeps up to maxConsecutive and adds a marker", () => { + const input = ["a", "a", "a", "a", "b"].join("\n"); + const out = collapseDuplicateLines(input, 1); + assert.equal(out, ["a", "⋯ 3 duplicate lines omitted ⋯", "b"].join("\n")); +}); + +test("collapseDuplicateLines is a no-op when no run exceeds the limit", () => { + const input = ["a", "b", "c"].join("\n"); + assert.equal(collapseDuplicateLines(input, 1), input); +}); + +test("collapseDuplicateLines exact-boundary run does not emit a marker", () => { + const input = ["a", "a", "b"].join("\n"); + assert.equal(collapseDuplicateLines(input, 2), input); +}); + +test("collapseBlankLines keeps up to maxConsecutive blank lines", () => { + const input = ["a", "", "", "", "b"].join("\n"); + assert.equal(collapseBlankLines(input, 1), ["a", "", "b"].join("\n")); +}); + +test("truncateLongLines truncates lines longer than the limit", () => { + const input = "x".repeat(10); + assert.equal(truncateLongLines(input, 5), "xxxxx…(+5 chars)"); +}); + +test("truncateLongLines leaves lines at or under the limit untouched", () => { + const input = "x".repeat(5); + assert.equal(truncateLongLines(input, 5), input); +}); + +test("truncateExcessLines keeps head/tail and omits the middle", () => { + const lines = Array.from({ length: 10 }, (_, i) => `L${i}`); + const input = lines.join("\n"); + const out = truncateExcessLines(input, 3, 2, 5); + assert.equal(out, ["L0", "L1", "L2", "⋯ 5 lines omitted ⋯", "L8", "L9"].join("\n")); +}); + +test("truncateExcessLines is a no-op when under the limit", () => { + const input = ["a", "b"].join("\n"); + assert.equal(truncateExcessLines(input, 3, 2, 5), input); +}); + +test("filterLines applies rules in order: duplicates, blanks, length, total", () => { + const lines = ["dup", "dup", "dup", "", "", "x".repeat(20)]; + const input = lines.join("\n"); + const out = filterLines(input, { + maxConsecutiveDuplicates: 1, + maxConsecutiveBlankLines: 1, + maxLineLength: 10, + maxTotalLines: 100, + headLines: 70, + tailLines: 30, + }); + assert.equal( + out, + [ + "dup", + "⋯ 2 duplicate lines omitted ⋯", + "", + "⋯ 1 duplicate lines omitted ⋯", + "x".repeat(10) + "…(+10 chars)", + ].join("\n"), + ); +}); diff --git a/src/compress/lines.ts b/src/compress/lines.ts new file mode 100644 index 00000000..f874a800 --- /dev/null +++ b/src/compress/lines.ts @@ -0,0 +1,107 @@ +export interface LineFilterOptions { + /** 連続する完全一致重複行をこの件数まで残す。 */ + maxConsecutiveDuplicates?: number; + /** 連続する空行をこの件数まで残す。 */ + maxConsecutiveBlankLines?: number; + /** 1行あたりの最大文字数。超えた分は切り詰める。 */ + maxLineLength?: number; + /** 全体の最大行数。超えた場合は先頭/末尾を残し中間を省略する。 */ + maxTotalLines?: number; + /** maxTotalLines超過時、先頭に残す行数。 */ + headLines?: number; + /** maxTotalLines超過時、末尾に残す行数。 */ + tailLines?: number; +} + +const MAX_TOTAL_LINES = 2000; + +export const DEFAULT_LINE_FILTER_OPTIONS: Required = { + maxConsecutiveDuplicates: 1, + maxConsecutiveBlankLines: 1, + maxLineLength: 500, + maxTotalLines: MAX_TOTAL_LINES, + headLines: Math.round(MAX_TOTAL_LINES * 0.7), + tailLines: Math.round(MAX_TOTAL_LINES * 0.3), +}; + +/** 連続する完全一致重複行を畳む(例: 同一行が50回続く → 1行 + 省略マーカー)。 */ +export function collapseDuplicateLines(input: string, maxConsecutive: number): string { + const lines = input.split("\n"); + const out: string[] = []; + let i = 0; + while (i < lines.length) { + const current = lines[i]; + let runLength = 1; + while (i + runLength < lines.length && lines[i + runLength] === current) { + runLength++; + } + const kept = Math.min(runLength, Math.max(maxConsecutive, 0)); + for (let k = 0; k < kept; k++) out.push(current); + const omitted = runLength - kept; + if (omitted > 0) out.push(`⋯ ${omitted} duplicate lines omitted ⋯`); + i += runLength; + } + return out.join("\n"); +} + +/** 連続する空行を畳む。 */ +export function collapseBlankLines(input: string, maxConsecutive: number): string { + const lines = input.split("\n"); + const out: string[] = []; + let blankRun = 0; + for (const line of lines) { + if (line.trim() === "") { + blankRun++; + if (blankRun <= Math.max(maxConsecutive, 0)) out.push(line); + } else { + blankRun = 0; + out.push(line); + } + } + return out.join("\n"); +} + +const OMISSION_MARKER_PATTERN = /^⋯ .+ omitted ⋯$/; + +/** 1行あたりの長さを切り詰める。省略マーカー行自身は対象外とする。 */ +export function truncateLongLines(input: string, maxLineLength: number): string { + if (maxLineLength <= 0) return input; + return input + .split("\n") + .map((line) => { + if (line.length <= maxLineLength || OMISSION_MARKER_PATTERN.test(line)) return line; + const omitted = line.length - maxLineLength; + return `${line.slice(0, maxLineLength)}…(+${omitted} chars)`; + }) + .join("\n"); +} + +/** 総行数が多すぎる場合、先頭・末尾を残し中間を省略する。 */ +export function truncateExcessLines( + input: string, + headLines: number, + tailLines: number, + maxTotalLines: number, +): string { + const lines = input.split("\n"); + if (lines.length <= maxTotalLines) return input; + + const head = lines.slice(0, Math.max(headLines, 0)); + const tail = tailLines > 0 ? lines.slice(lines.length - tailLines) : []; + const omitted = lines.length - head.length - tail.length; + return [...head, `⋯ ${omitted} lines omitted ⋯`, ...tail].join("\n"); +} + +/** + * 重複行畳み込み → 空行畳み込み → 行長切詰め → 総行数切詰め、の順で適用する合成関数。 + * 有効な情報を優先的に残すため、先に冗長性を畳んでから長さの制約をかける。 + */ +export function filterLines(input: string, options?: LineFilterOptions): string { + const opts = { ...DEFAULT_LINE_FILTER_OPTIONS, ...options }; + let text = input; + text = collapseDuplicateLines(text, opts.maxConsecutiveDuplicates); + text = collapseBlankLines(text, opts.maxConsecutiveBlankLines); + text = truncateLongLines(text, opts.maxLineLength); + text = truncateExcessLines(text, opts.headLines, opts.tailLines, opts.maxTotalLines); + return text; +} diff --git a/src/compress/static-information.test.ts b/src/compress/static-information.test.ts new file mode 100644 index 00000000..58b2bf4d --- /dev/null +++ b/src/compress/static-information.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + analyzeStaticInformation, + containsProtectedInformation, + staticSelfInformation, + tokenizeEnglishPhrase, +} from "./static-information.js"; + +test("common development-log boilerplate receives lower information than an unknown identifier", () => { + assert.ok(staticSelfInformation("finished") < staticSelfInformation("BuildGraphResolver")); +}); + +test("analyzer marks only known low-information phrases as candidates", () => { + const result = analyzeStaticInformation("the tests passed"); + assert.equal(result.lowInformation, true); + assert.equal(result.protected, false); +}); + +test("analyzer protects diagnostics, identifiers, paths, URLs, and numbers", () => { + for (const input of [ + "permission denied", + "BuildGraphResolver failed", + "see /repo/src/proxy.ts", + "https://example.test/docs", + "14 tests passed", + ]) assert.equal(analyzeStaticInformation(input).lowInformation, false, input); + assert.equal(containsProtectedInformation("error"), true); +}); + +test("tokenizer retains English lexical tokens", () => { + assert.deepEqual(tokenizeEnglishPhrase("Finished: the tests passed."), ["Finished", "the", "tests", "passed"]); +}); diff --git a/src/compress/static-information.ts b/src/compress/static-information.ts new file mode 100644 index 00000000..921c8e4a --- /dev/null +++ b/src/compress/static-information.ts @@ -0,0 +1,57 @@ +const COMMON_TOKEN_PROBABILITIES: Readonly> = { + a: 0.08, all: 0.025, an: 0.02, and: 0.06, are: 0.025, as: 0.03, at: 0.025, + be: 0.03, been: 0.01, but: 0.02, by: 0.02, for: 0.035, from: 0.015, + has: 0.012, in: 0.06, is: 0.07, it: 0.035, no: 0.02, not: 0.02, + of: 0.07, on: 0.03, or: 0.025, please: 0.01, the: 0.09, this: 0.025, + to: 0.08, was: 0.02, were: 0.01, with: 0.025, + build: 0.005, building: 0.004, checking: 0.003, compiling: 0.003, completed: 0.004, + finished: 0.004, lint: 0.004, passed: 0.005, running: 0.004, successful: 0.003, successfully: 0.003, + success: 0.003, tests: 0.004, +}; + +const PROTECTED_WORDS = new Set([ + "abort", "assertion", "denied", "error", "exception", "fail", "failed", + "failure", "fatal", "panic", "permission", "refused", "timeout", "traceback", +]); + +const TOKEN_PATTERN = /[A-Za-z]+(?:'[A-Za-z]+)?/g; +const PROTECTED_SYNTAX = /(?:https?:\/\/|\b(?:[A-Za-z]:)?[/\\]|::|`|"|'|\b\d|\b[A-Za-z]+[A-Z][A-Za-z]*\b|\b[A-Za-z]+_[A-Za-z0-9_]*\b)/; + +export interface StaticInformation { + tokens: string[]; + averageBits: number; + protected: boolean; + lowInformation: boolean; +} + +/** 未知語は高情報量として扱う。頻度値は開発ログ定型語向けの初期値。 */ +export function staticSelfInformation(token: string): number { + const probability = COMMON_TOKEN_PROBABILITIES[token.toLowerCase()]; + return probability === undefined ? 12 : -Math.log2(probability); +} + +export function tokenizeEnglishPhrase(input: string): string[] { + return input.match(TOKEN_PATTERN) ?? []; +} + +export function containsProtectedInformation(input: string, tokens = tokenizeEnglishPhrase(input)): boolean { + return PROTECTED_SYNTAX.test(input) || tokens.some((token) => PROTECTED_WORDS.has(token.toLowerCase())); +} + +/** + * 低情報候補だけを判定する。削除は呼出側の構文境界・コマンド別規則で決める。 + */ +export function analyzeStaticInformation(input: string): StaticInformation { + const tokens = tokenizeEnglishPhrase(input); + const protectedInformation = containsProtectedInformation(input, tokens); + const averageBits = tokens.length === 0 + ? Number.POSITIVE_INFINITY + : tokens.reduce((total, token) => total + staticSelfInformation(token), 0) / tokens.length; + const allKnown = tokens.every((token) => COMMON_TOKEN_PROBABILITIES[token.toLowerCase()] !== undefined); + return { + tokens, + averageBits, + protected: protectedInformation, + lowInformation: !protectedInformation && tokens.length >= 2 && allKnown && averageBits <= 8, + }; +} diff --git a/src/compress/tool-description.test.ts b/src/compress/tool-description.test.ts new file mode 100644 index 00000000..32bdb973 --- /dev/null +++ b/src/compress/tool-description.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compressToolDefinition, compressToolDescription } from "./tool-description.js"; + +test("compressToolDescription removes English articles and filler phrases", () => { + assert.equal( + compressToolDescription("Please use the tool in order to find a file. Note that it is very fast."), + "use tool to find file. fast.", + ); +}); + +test("compressToolDescription shortens common MCP instruction phrases", () => { + assert.equal( + compressToolDescription("IMPORTANT: Use it when you need to search for a file. See server instructions for syntax."), + "use to search file. server instructions: syntax.", + ); +}); + +test("compressToolDescription preserves code fences, literals, URLs, and Japanese", () => { + const input = [ + "Use `the value` from https://example.com/the/path.", + "Use constraint 'name **/src/* !test/'.", + "```text", + "Please use the exact example.", + "```", + "日本語の説明はそのまま。", + ].join("\n"); + assert.equal( + compressToolDescription(input), + [ + "Use `the value` from https://example.com/the/path.", + "Use constraint 'name **/src/* !test/'.", + "```text", + "Please use the exact example.", + "```", + "日本語の説明はそのまま。", + ].join("\n"), + ); +}); + +test("compressToolDefinition changes only description fields and does not mutate its input", () => { + const tool = { + name: "grep", + description: "Search the file contents. Please use a term.", + inputSchema: { + type: "object", + title: "GrepParams", + properties: { + query: { + type: "string", + description: "The search query. You can use a literal.", + examples: ["the exact text"], + default: "the default", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }; + const original = structuredClone(tool); + + const out = compressToolDefinition(tool); + + assert.equal(out.description, "Search file contents. use term."); + assert.equal( + (out.inputSchema as { properties: { query: { description: string } } }).properties.query.description, + "search query. use literal.", + ); + assert.deepEqual((out.inputSchema as { properties: { query: { examples: string[]; default: string } } }).properties.query, { + type: "string", + description: "search query. use literal.", + examples: ["the exact text"], + default: "the default", + }); + assert.deepEqual(tool, original); +}); diff --git a/src/compress/tool-description.ts b/src/compress/tool-description.ts new file mode 100644 index 00000000..7a0bf12d --- /dev/null +++ b/src/compress/tool-description.ts @@ -0,0 +1,80 @@ +/** + * MCPツール説明のうち、英語散文にだけ適用する機械的圧縮。 + * コードフェンス、インラインコード、URL、日本語を含む行は変更しない。 + */ +const PROTECTED_LITERAL = /https?:\/\/[^\s`]+|`[^`]*`|'[^']*'|"[^"]*"/g; +const ENGLISH_FILLERS: Array<[RegExp, string]> = [ + [/\buse it when you need to\s+/gi, "use to "], + [/\buse (.+?) instead for\s+/gi, "use $1 for "], + [/\bsee server instructions for\s+/gi, "server instructions: "], + [/\bonly use if\s+/gi, "use if "], + [/\bthis returns\s+/gi, "returns "], + [/\bimportant:\s*/gi, ""], + [/\bplease\s+/gi, ""], + [/\bnote that\s+/gi, ""], + [/\bit is important to\s+/gi, ""], + [/\bit is\s+/gi, ""], + [/\bin order to\b/gi, "to"], + [/\b(?:basically|simply|just|actually|really|very)\s+/gi, ""], + [/\b(?:you can|you should)\s+/gi, ""], + [/\bsearch for\s+/gi, "search "], + [/\b(?:a|an|the)\s+/gi, ""], +]; + +function compressProseLine(line: string): string { + // 日本語の助詞・空白は英語向け規則で扱わない。 + if (/[\u3040-\u30ff\u3400-\u9fff]/.test(line)) return line; + + const protectedParts: string[] = []; + let text = line.replace(PROTECTED_LITERAL, (part) => { + const index = protectedParts.push(part) - 1; + return `\u0000${index}\u0000`; + }); + + for (const [pattern, replacement] of ENGLISH_FILLERS) { + text = text.replace(pattern, replacement); + } + + text = text.replace(/ {2,}/g, " ").replace(/\s+([,.;:!?])/g, "$1"); + return text.replace(/\u0000(\d+)\u0000/g, (_, index: string) => protectedParts[Number(index)]); +} + +/** 説明文を圧縮する。Markdownコードフェンス内は完全に保持する。 */ +export function compressToolDescription(input: string): string { + let inCodeFence = false; + return input + .split("\n") + .map((line) => { + if (line.trimStart().startsWith("```")) { + inCodeFence = !inCodeFence; + return line; + } + return inCodeFence ? line : compressProseLine(line); + }) + .join("\n"); +} + +function compressSchemaDescriptions(value: unknown): unknown { + if (Array.isArray(value)) return value.map(compressSchemaDescriptions); + if (value === null || typeof value !== "object") return value; + + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = key === "description" && typeof child === "string" + ? compressToolDescription(child) + : compressSchemaDescriptions(child); + } + return out; +} + +/** + * name以外のToolフィールドを保ったまま、descriptionとinputSchema内descriptionを圧縮する。 + * JSON Schema構造・制約値・examples・defaultは変更しない。 + */ +export function compressToolDefinition(tool: T): T { + return { + ...tool, + ...(tool.description === undefined ? {} : { description: compressToolDescription(tool.description) }), + inputSchema: compressSchemaDescriptions(tool.inputSchema), + }; +} diff --git a/src/envelope.ts b/src/envelope.ts new file mode 100644 index 00000000..aa3c20f6 --- /dev/null +++ b/src/envelope.ts @@ -0,0 +1,31 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * gateway 自前ツールの共通 structured output。クライアント横断で安定した契約として + * 固定する(docs/result-envelope.md)。フィールドを削らない。 + */ +export const OUTPUT_SCHEMA = { + type: "object" as const, + properties: { + operation: { type: "string" }, status: { type: "string" }, summary: { type: "string" }, + facts: { type: "array" }, diagnostics: { type: "array" }, metrics: { type: "object" }, + result_id: { type: "string" }, truncated: { type: "boolean" }, test_results: { type: "object" }, + }, + required: ["operation", "status", "summary", "facts", "diagnostics", "metrics", "result_id", "truncated"], +}; + +export type EnvelopeStatus = "success" | "failed" | "partial"; + +export function output( + operation: string, + status: EnvelopeStatus, + summary: string, + resultId: string, + details: Record, + isError = false, +): CallToolResult { + const structuredContent = { + operation, status, summary, facts: [], diagnostics: [], metrics: {}, result_id: resultId, truncated: false, ...details, + }; + return { content: [{ type: "text", text: summary }], structuredContent, ...(isError ? { isError: true } : {}) }; +} diff --git a/src/logging.test.ts b/src/logging.test.ts new file mode 100644 index 00000000..0eff866b --- /dev/null +++ b/src/logging.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { createLogger } from "./logging.js"; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "mottainai-log-test-")); +} + +test("createLogger returns a no-op logger when MOTTAINAI_LOG=0", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG: "0", MOTTAINAI_LOG_DIR: dir }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: {}, rawResult: {} }); + assert.deepEqual(fs.readdirSync(dir), []); +}); + +test("createLogger writes JSON Lines records with id and timestamp", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir }); + await logger.log({ upstreamName: "fff", toolName: "grep", arguments: { q: "x" }, rawResult: { content: [] } }); + await logger.log({ upstreamName: "fff", toolName: "grep", arguments: { q: "y" }, rawResult: { content: [] } }); + + const files = fs.readdirSync(dir); + assert.equal(files.length, 1); + + const contents = fs.readFileSync(path.join(dir, files[0]), "utf8"); + const lines = contents.trim().split("\n"); + assert.equal(lines.length, 2); + + const record1 = JSON.parse(lines[0]); + assert.equal(typeof record1.id, "string"); + assert.equal(typeof record1.timestamp, "string"); + assert.equal(record1.upstreamName, "fff"); + assert.equal(record1.toolName, "grep"); + assert.deepEqual(record1.arguments, { q: "x" }); + + const record2 = JSON.parse(lines[1]); + assert.notEqual(record1.id, record2.id); +}); + +test("createLogger creates the log directory if missing", async () => { + const dir = path.join(tmpDir(), "nested", "log", "dir"); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: {}, rawResult: {} }); + assert.ok(fs.existsSync(dir)); + assert.equal(fs.readdirSync(dir).length, 1); +}); + +test("createLogger redacts fields whose key matches secret/token/cookie patterns", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir }); + await logger.log({ + upstreamName: "u", + toolName: "t", + arguments: { + apiKey: "sk-live-abc123", + headers: { Authorization: "Bearer xyz", Cookie: "session=abc" }, + password: "hunter2", + }, + rawResult: { access_token: "tok_1", nested: { secret: "s3cr3t" } }, + }); + + const files = fs.readdirSync(dir); + const record = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8").trim()); + + assert.equal(record.arguments.apiKey, "[REDACTED]"); + assert.equal(record.arguments.headers.Authorization, "[REDACTED]"); + assert.equal(record.arguments.headers.Cookie, "[REDACTED]"); + assert.equal(record.arguments.password, "[REDACTED]"); + assert.equal(record.rawResult.access_token, "[REDACTED]"); + assert.equal(record.rawResult.nested.secret, "[REDACTED]"); +}); + +test("createLogger keeps non-sensitive fields untouched", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir }); + await logger.log({ + upstreamName: "fff", + toolName: "grep", + arguments: { query: "TODO", path: "src/index.ts", limit: 10 }, + rawResult: { content: [{ type: "text", text: "line 1\nline 2" }] }, + }); + + const files = fs.readdirSync(dir); + const record = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8").trim()); + + assert.deepEqual(record.arguments, { query: "TODO", path: "src/index.ts", limit: 10 }); + assert.deepEqual(record.rawResult, { content: [{ type: "text", text: "line 1\nline 2" }] }); +}); + +test("createLogger skips redaction when MOTTAINAI_LOG_REDACT=0", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_REDACT: "0" }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: { token: "raw-value" }, rawResult: {} }); + + const files = fs.readdirSync(dir); + const record = JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8").trim()); + assert.equal(record.arguments.token, "raw-value"); +}); + +test("createLogger writes files and directory with restrictive permissions", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: {}, rawResult: {} }); + + const files = fs.readdirSync(dir); + const fileMode = fs.statSync(path.join(dir, files[0])).mode & 0o777; + assert.equal(fileMode, 0o600); +}); + +test("createLogger excludes records for tools listed in MOTTAINAI_LOG_EXCLUDE_TOOLS", async () => { + const dir = tmpDir(); + const logger = createLogger({ + MOTTAINAI_LOG_DIR: dir, + MOTTAINAI_LOG_EXCLUDE_TOOLS: "secret-tool,fff__grep", + }); + await logger.log({ upstreamName: "u", toolName: "secret-tool", arguments: {}, rawResult: {} }); + await logger.log({ upstreamName: "fff", toolName: "grep", arguments: {}, rawResult: {} }); + await logger.log({ upstreamName: "fff", toolName: "list", arguments: { ok: true }, rawResult: {} }); + + const files = fs.readdirSync(dir); + assert.equal(files.length, 1); + const lines = fs.readFileSync(path.join(dir, files[0]), "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + assert.equal(JSON.parse(lines[0]).toolName, "list"); +}); + +test("createLogger rolls over to a new file once MOTTAINAI_LOG_MAX_FILE_BYTES is exceeded", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_MAX_FILE_BYTES: "10" }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: { a: 1 }, rawResult: {} }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: { a: 2 }, rawResult: {} }); + + const files = fs.readdirSync(dir); + assert.equal(files.length, 2); +}); + +test("createLogger bounds an oversized record while retaining its digest", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_MAX_FILE_BYTES: "300" }); + await logger.log({ upstreamName: "u", toolName: "t", arguments: {}, rawResult: { text: "x".repeat(10_000) } }); + + const line = fs.readFileSync(path.join(dir, fs.readdirSync(dir)[0]), "utf8"); + assert.ok(Buffer.byteLength(line, "utf8") <= 300); + const record = JSON.parse(line) as { rawResult: { truncated?: boolean; sha256?: string } }; + assert.equal(record.rawResult.truncated, true); + assert.match(record.rawResult.sha256 ?? "", /^[0-9a-f]{64}$/); +}); + +test("createLogger rolls over to distinct files even within a single timestamp tick", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_MAX_FILE_BYTES: "10" }); + for (let index = 0; index < 5; index += 1) { + await logger.log({ upstreamName: "u", toolName: "t", arguments: { a: index }, rawResult: {} }); + } + + const files = fs.readdirSync(dir); + assert.equal(files.length, 5); + assert.equal(new Set(files).size, files.length); + for (const file of files) { + assert.equal(fs.readFileSync(path.join(dir, file), "utf8").trim().split("\n").length, 1); + } +}); + +test("createLogger removes jsonl files older than MOTTAINAI_LOG_RETENTION_DAYS on startup", async () => { + const dir = tmpDir(); + const stalePath = path.join(dir, "stale.jsonl"); + fs.writeFileSync(stalePath, "{}\n"); + const oldTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(stalePath, oldTime, oldTime); + + createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_RETENTION_DAYS: "1" }); + + assert.equal(fs.existsSync(stalePath), false); +}); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 00000000..24437a63 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,198 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +export interface LogRecord { + /** ログ全体で一意なID。将来「圧縮前オリジナルを取得する」機能の参照キーとして使える。 */ + id: string; + timestamp: string; + upstreamName: string; + toolName: string; + arguments: unknown; + /** upstreamから返った圧縮前の生のCallToolResult。 */ + rawResult: unknown; +} + +export interface Logger { + log(record: Omit): Promise; +} + +const NOOP_LOGGER: Logger = { + async log() { + // no-op + }, +}; + +const DEFAULT_RETENTION_DAYS = 14; +const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; + +// key名ベースのredaction。値の中身までは見ない(誤検知よりも見逃しを避ける方向はredact()側の再帰で担保)。 +const REDACT_KEY_PATTERN = + /(password|passwd|secret|token|api[-_]?key|authoriz|cookie|credential|access[-_]?key|private[-_]?key|session)/i; +const REDACTED = "[REDACTED]"; + +function isLoggingEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env.MOTTAINAI_LOG; + if (value === undefined) return true; + return value !== "0" && value.toLowerCase() !== "false"; +} + +function isRedactionEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env.MOTTAINAI_LOG_REDACT; + if (value === undefined) return true; + return value !== "0" && value.toLowerCase() !== "false"; +} + +function resolveLogDir(env: NodeJS.ProcessEnv): string { + return env.MOTTAINAI_LOG_DIR ?? path.join(process.cwd(), ".mottainai", "log"); +} + +function positiveNumber(value: string | undefined, fallback: number): number { + const parsed = value !== undefined ? Number(value) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function resolveRetentionMs(env: NodeJS.ProcessEnv): number { + return positiveNumber(env.MOTTAINAI_LOG_RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * 24 * 60 * 60 * 1000; +} + +function resolveMaxFileBytes(env: NodeJS.ProcessEnv): number { + return positiveNumber(env.MOTTAINAI_LOG_MAX_FILE_BYTES, DEFAULT_MAX_FILE_BYTES); +} + +function boundedLogLine(record: LogRecord, maxBytes: number): string { + const line = `${JSON.stringify(record)}\n`; + if (Buffer.byteLength(line, "utf8") <= maxBytes) return line; + const rawResultText = JSON.stringify(record.rawResult); + const compact: LogRecord = { + ...record, + arguments: "[mottainai log record truncated]", + rawResult: { + truncated: true, + original_bytes: Buffer.byteLength(rawResultText, "utf8"), + sha256: createHash("sha256").update(rawResultText).digest("hex"), + }, + }; + return `${JSON.stringify(compact)}\n`; +} + +function resolveExcludedTools(env: NodeJS.ProcessEnv): Set { + const raw = env.MOTTAINAI_LOG_EXCLUDE_TOOLS; + if (!raw) return new Set(); + return new Set( + raw + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ); +} + +// 同一ミリ秒内にロールオーバーするとtimestampだけでは名前が衝突し、 +// 上限超過後も同じファイルへ追記され続けるため連番で区別する。 +let logFileSequence = 0; + +function logFileName(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `${timestamp}_pid${process.pid}_${logFileSequence++}.jsonl`; +} + +/** key名が機微情報パターンに一致する値を再帰的に[REDACTED]へ置換する。 */ +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = REDACT_KEY_PATTERN.test(key) ? REDACTED : redact(v); + } + return out; + } + return value; +} + +/** 起動時に保存期間を超えたjsonlを削除する。掃除の失敗はロギング続行を妨げない。 */ +function sweepExpiredLogs(logDir: string, maxAgeMs: number): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(logDir, { withFileTypes: true }); + } catch { + return; + } + const cutoff = Date.now() - maxAgeMs; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const filePath = path.join(logDir, entry.name); + try { + if (fs.statSync(filePath).mtimeMs < cutoff) fs.unlinkSync(filePath); + } catch { + // 掃除中の消失・権限エラーはロギング続行を妨げない + } + } +} + +/** + * 環境変数から設定を解決し、Loggerを構築する。MOTTAINAI_LOG=0 なら no-op logger を返す。 + * + * 環境変数: + * - MOTTAINAI_LOG=0 — ロギング無効化(既定は有効) + * - MOTTAINAI_LOG_DIR — 出力先ディレクトリ(既定 `.mottainai/log/`) + * - MOTTAINAI_LOG_REDACT=0 — secret/token/cookie等のredactionを無効化(既定は有効。デバッグ用の逃げ道) + * - MOTTAINAI_LOG_EXCLUDE_TOOLS — カンマ区切りのtool名。`toolName`単体または`__`でマッチしたら記録しない + * - MOTTAINAI_LOG_RETENTION_DAYS — 保存日数(既定14日)。起動時に期限切れjsonlを削除 + * - MOTTAINAI_LOG_MAX_FILE_BYTES — 1ファイルの上限バイト数(既定10MiB)。超過したら新規ファイルへロールオーバー + */ +export function createLogger(env: NodeJS.ProcessEnv = process.env): Logger { + if (!isLoggingEnabled(env)) return NOOP_LOGGER; + + const logDir = resolveLogDir(env); + fs.mkdirSync(logDir, { recursive: true, mode: 0o700 }); + sweepExpiredLogs(logDir, resolveRetentionMs(env)); + + const redactEnabled = isRedactionEnabled(env); + const excludedTools = resolveExcludedTools(env); + const maxFileBytes = resolveMaxFileBytes(env); + // 最小 envelope 未満の設定でも JSONL を壊さない。通常値では設定値をそのまま使う。 + const maxRecordBytes = Math.max(maxFileBytes, 256); + + let filePath = path.join(logDir, logFileName()); + let currentFileBytes = 0; + + // 単一プロセス内での書き込み順序を保証するための直列化チェーン。 + // ロールオーバー判定もこのチェーン内で行い、並行呼び出し時のサイズ計算競合を避ける。 + let writeQueue: Promise = Promise.resolve(); + + return { + async log(record) { + if ( + excludedTools.has(record.toolName) || + excludedTools.has(`${record.upstreamName}__${record.toolName}`) + ) { + return; + } + + const full: LogRecord = { + id: randomUUID(), + timestamp: new Date().toISOString(), + upstreamName: record.upstreamName, + toolName: record.toolName, + arguments: redactEnabled ? redact(record.arguments) : record.arguments, + rawResult: redactEnabled ? redact(record.rawResult) : record.rawResult, + }; + const line = boundedLogLine(full, maxRecordBytes); + + writeQueue = writeQueue + .then(async () => { + const lineBytes = Buffer.byteLength(line, "utf8"); + if (currentFileBytes > 0 && currentFileBytes + lineBytes > maxFileBytes) { + filePath = path.join(logDir, logFileName()); + currentFileBytes = 0; + } + currentFileBytes += lineBytes; + await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 }); + }) + .catch((err) => { + console.error("mottainai: failed to write log record", err); + }); + await writeQueue; + }, + }; +} diff --git a/src/read-governor/classify.test.ts b/src/read-governor/classify.test.ts new file mode 100644 index 00000000..32fd0e2e --- /dev/null +++ b/src/read-governor/classify.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { classifyFile } from "./classify.js"; + +test("classifies source extensions", () => { + assert.equal(classifyFile("src/index.ts"), "source"); + assert.equal(classifyFile("apps/gateway/main.py"), "source"); + assert.equal(classifyFile("scripts/deploy.sh"), "source"); +}); + +test("classifies document extensions", () => { + assert.equal(classifyFile("docs/architecture.md"), "document"); + assert.equal(classifyFile("README.mdx"), "document"); +}); + +test("classifies structured config extensions", () => { + assert.equal(classifyFile("package.json"), "structured-config"); + assert.equal(classifyFile("config/values.yaml"), "structured-config"); + assert.equal(classifyFile("Cargo.toml"), "structured-config"); +}); + +test("classifies log files", () => { + assert.equal(classifyFile("var/log/app.log"), "log"); +}); + +test("classifies known lockfiles regardless of extension", () => { + assert.equal(classifyFile("pnpm-lock.yaml"), "lockfile"); + assert.equal(classifyFile("package-lock.json"), "lockfile"); + assert.equal(classifyFile("Cargo.lock"), "lockfile"); +}); + +test("classifies generated/vendor paths by directory segment", () => { + assert.equal(classifyFile("node_modules/foo/index.js"), "generated"); + assert.equal(classifyFile("apps/gateway/dist/index.js"), "generated"); + assert.equal(classifyFile("packages/domain/coverage/lcov.info"), "generated"); +}); + +test("classifies generated files by suffix", () => { + assert.equal(classifyFile("bundle.min.js"), "generated"); + assert.equal(classifyFile("app.js.map"), "generated"); + assert.equal(classifyFile("src/index.tsbuildinfo"), "generated"); +}); + +test("classifies unrecognized extensions as unknown", () => { + assert.equal(classifyFile("assets/logo.png"), "unknown"); + assert.equal(classifyFile("Makefile"), "unknown"); +}); diff --git a/src/read-governor/classify.ts b/src/read-governor/classify.ts new file mode 100644 index 00000000..b0c0a2ea --- /dev/null +++ b/src/read-governor/classify.ts @@ -0,0 +1,56 @@ +import path from "node:path"; + +/** + * Read Governor のファイル分類。issue #62 の "File-type routing" に対応する。 + * source/document 系は構造探索を要求し、structured-config/log/lockfile/generated は + * 別経路を要求する。分類自体は decision を出さない — policy.ts が phase を見て決める。 + */ +export type FileClass = + | "source" + | "document" + | "structured-config" + | "log" + | "lockfile" + | "generated" + | "unknown"; + +const SOURCE_EXTENSIONS = new Set([ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", + ".py", ".rs", ".go", ".java", ".kt", ".rb", ".c", ".cc", ".cpp", ".h", ".hpp", + ".sh", ".bash", ".zsh", +]); + +const DOCUMENT_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]); + +const STRUCTURED_CONFIG_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".toml"]); + +const LOG_EXTENSIONS = new Set([".log"]); + +const LOCKFILE_BASENAMES = new Set([ + "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock", "Gemfile.lock", + "poetry.lock", "composer.lock", "flake.lock", "bun.lockb", +]); + +const GENERATED_PATH_SEGMENTS = new Set([ + "node_modules", "dist", "build", "target", ".turbo", ".next", "coverage", + "vendor", ".git", ".codegraph", +]); + +const GENERATED_SUFFIXES = [".min.js", ".min.css", ".map", ".tsbuildinfo"]; + +export function classifyFile(filePath: string): FileClass { + const normalized = filePath.replaceAll("\\", "/"); + const segments = normalized.split("/").filter(Boolean); + const basename = segments.at(-1) ?? normalized; + + if (segments.some((segment) => GENERATED_PATH_SEGMENTS.has(segment))) return "generated"; + if (GENERATED_SUFFIXES.some((suffix) => basename.endsWith(suffix))) return "generated"; + if (LOCKFILE_BASENAMES.has(basename)) return "lockfile"; + + const ext = path.extname(basename).toLowerCase(); + if (LOG_EXTENSIONS.has(ext)) return "log"; + if (STRUCTURED_CONFIG_EXTENSIONS.has(ext)) return "structured-config"; + if (DOCUMENT_EXTENSIONS.has(ext)) return "document"; + if (SOURCE_EXTENSIONS.has(ext)) return "source"; + return "unknown"; +} diff --git a/src/read-governor/evidence.test.ts b/src/read-governor/evidence.test.ts new file mode 100644 index 00000000..cb642f04 --- /dev/null +++ b/src/read-governor/evidence.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { InMemoryEvidenceStore } from "./evidence.js"; + +function baseInput() { + return { + repositoryId: "repo-1", + worktreeId: "worktree-1", + sessionId: "session-1", + provider: "codegraph", + path: "src/foo.ts", + startLine: 10, + endLine: 40, + reason: "codegraph_explore located definition", + }; +} + +test("issue() assigns an id, createdAt, and expiresAt", () => { + const store = new InMemoryEvidenceStore({ now: () => 1000 }); + const evidence = store.issue(baseInput()); + assert.match(evidence.evidenceId, /^rev_/); + assert.equal(evidence.createdAt, 1000); + assert.ok(evidence.expiresAt > evidence.createdAt); +}); + +test("get() returns a previously issued evidence record", () => { + const store = new InMemoryEvidenceStore(); + const evidence = store.issue(baseInput()); + assert.deepEqual(store.get(evidence.evidenceId), evidence); +}); + +test("get() returns undefined for an unknown id", () => { + const store = new InMemoryEvidenceStore(); + assert.equal(store.get("rev_does-not-exist"), undefined); +}); + +test("get() still returns an expired record so callers can distinguish expired from not-found", () => { + const store = new InMemoryEvidenceStore({ now: () => 1000, ttlMs: 10 }); + const evidence = store.issue(baseInput()); + assert.equal(store.get(evidence.evidenceId)?.evidenceId, evidence.evidenceId); + assert.ok(evidence.expiresAt <= 1000 + 10); +}); + +test("issue() rejects a startLine below 1", () => { + assert.throws(() => new InMemoryEvidenceStore().issue({ ...baseInput(), startLine: 0 })); +}); + +test("issue() rejects an endLine before startLine", () => { + assert.throws(() => new InMemoryEvidenceStore().issue({ ...baseInput(), startLine: 40, endLine: 10 })); +}); + +test("issue() evicts the oldest entry once maxEntries is reached", () => { + const store = new InMemoryEvidenceStore({ maxEntries: 1 }); + const first = store.issue(baseInput()); + const second = store.issue({ ...baseInput(), path: "src/bar.ts" }); + assert.equal(store.get(first.evidenceId), undefined); + assert.equal(store.get(second.evidenceId)?.path, "src/bar.ts"); +}); diff --git a/src/read-governor/evidence.ts b/src/read-governor/evidence.ts new file mode 100644 index 00000000..b328dc5d --- /dev/null +++ b/src/read-governor/evidence.ts @@ -0,0 +1,104 @@ +import { randomUUID } from "node:crypto"; + +/** + * Evidence-based Read Authorization の証拠モデル。structural exploration + * (codegraph 等)が特定 repository/worktree/session 内の path・line-range read を + * 正当化した記録。署名なし・DB永続化なし — session-local な軽量ストアが前提(次段階拡張用)。 + */ +export interface ReadEvidence { + evidenceId: string; + repositoryId: string; + worktreeId: string; + sessionId: string; + provider: string; + path: string; + startLine: number; + endLine: number; + reason: string; + createdAt: number; + expiresAt: number; +} + +export interface NewReadEvidence { + repositoryId: string; + worktreeId: string; + sessionId: string; + provider: string; + path: string; + startLine: number; + endLine: number; + reason: string; + /** 省略時はストアの既定TTLを使う。 */ + ttlMs?: number; +} + +export interface EvidenceStore { + issue(input: NewReadEvidence): ReadEvidence; + get(evidenceId: string): ReadEvidence | undefined; +} + +export interface InMemoryEvidenceStoreOptions { + ttlMs?: number; + maxEntries?: number; + now?: () => number; + createId?: () => string; +} + +const DEFAULT_TTL_MS = 15 * 60 * 1000; +const DEFAULT_MAX_ENTRIES = 500; + +/** + * session-local な ReadEvidence ストア(InMemoryArtifactStore と同型: TTL + 上限件数の + * インメモリ Map)。プロセス再起動・セッション終了で消える。期限切れ判定は authorize 側の + * 責務(get() は生の記録をそのまま返す。expired/not-found を区別できるようにするため)。 + */ +export class InMemoryEvidenceStore implements EvidenceStore { + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + private readonly createId: () => string; + + constructor(options: InMemoryEvidenceStoreOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.now = options.now ?? Date.now; + this.createId = options.createId ?? randomUUID; + } + + issue(input: NewReadEvidence): ReadEvidence { + if (!Number.isInteger(input.startLine) || input.startLine < 1) { + throw new Error("startLine must be a positive integer"); + } + if (!Number.isInteger(input.endLine) || input.endLine < input.startLine) { + throw new Error("endLine must be an integer >= startLine"); + } + + while (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + + const now = this.now(); + const evidence: ReadEvidence = { + evidenceId: `rev_${this.createId()}`, + repositoryId: input.repositoryId, + worktreeId: input.worktreeId, + sessionId: input.sessionId, + provider: input.provider, + path: input.path, + startLine: input.startLine, + endLine: input.endLine, + reason: input.reason, + createdAt: now, + expiresAt: now + (input.ttlMs ?? this.ttlMs), + }; + this.entries.set(evidence.evidenceId, evidence); + return evidence; + } + + get(evidenceId: string): ReadEvidence | undefined { + return this.entries.get(evidenceId); + } +} diff --git a/src/retrieve.test.ts b/src/retrieve.test.ts new file mode 100644 index 00000000..497009e9 --- /dev/null +++ b/src/retrieve.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { InMemoryArtifactStore } from "./retrieve.js"; + +test("artifact store retrieves original text by ID with a bounded line window", () => { + const store = new InMemoryArtifactStore({ createId: () => "test", maxEntries: 2 }); + const id = store.put({ content: [{ type: "text", text: "one\ntwo\nthree" }] }); + + assert.equal(id, "mx_test"); + assert.deepEqual(store.retrieve(id, { startLine: 1, maxLines: 1 }), { + id, + text: "two", + totalLines: 3, + returnedStartLine: 2, + returnedEndLine: 2, + omittedLines: 2, + }); +}); + +test("artifact store returns a matching line with requested context", () => { + const store = new InMemoryArtifactStore({ createId: () => "query" }); + const id = store.put({ content: [{ type: "text", text: "before\nok\nError: broken\nnext" }] }); + + const result = store.retrieve(id, { query: "Error", contextLines: 1, maxLines: 2 }); + assert.equal(result?.text, "ok\nError: broken"); + assert.equal(result?.matchLine, 3); +}); + +test("artifact store expires entries at the configured TTL", () => { + let now = 0; + const store = new InMemoryArtifactStore({ ttlMs: 10, now: () => now, createId: () => "ttl" }); + const id = store.put({ content: [{ type: "text", text: "raw" }] }); + now = 10; + + assert.equal(store.retrieve(id), undefined); +}); + +test("artifact store evicts the least recently used entry at the configured maximum", () => { + let sequence = 0; + const store = new InMemoryArtifactStore({ + maxEntries: 2, + createId: () => `${++sequence}`, + }); + const first = store.put({ content: [{ type: "text", text: "first" }] }); + const second = store.put({ content: [{ type: "text", text: "second" }] }); + assert.equal(store.retrieve(first)?.text, "first"); + const third = store.put({ content: [{ type: "text", text: "third" }] }); + + assert.equal(store.retrieve(first)?.text, "first"); + assert.equal(store.retrieve(second), undefined); + assert.equal(store.retrieve(third)?.text, "third"); +}); + +test("artifact store bounds oversized text instead of retaining unbounded output", () => { + const store = new InMemoryArtifactStore({ createId: () => "bounded", maxBytes: 16 }); + const id = store.putArtifact({ text: "x".repeat(100), metadata: { operation: "test" } }); + const result = store.retrieve(id); + assert.ok(result); + assert.match(result.text, /artifact truncated bytes=100 max=16/); +}); diff --git a/src/retrieve.ts b/src/retrieve.ts new file mode 100644 index 00000000..7d849b3e --- /dev/null +++ b/src/retrieve.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export interface RetrievedArtifact { + id: string; + stream?: "combined" | "stdout" | "stderr"; + text: string; + totalLines: number; + returnedStartLine: number; + returnedEndLine: number; + omittedLines: number; + matchLine?: number; +} + +export interface ArtifactStore { + put(result: CallToolResult): string; + putArtifact(artifact: StoredArtifactInput): string; + retrieve(id: string, options?: RetrieveOptions): RetrievedArtifact | undefined; + search(query: string, maxResults?: number): ArtifactSearchResult[]; +} + +export interface RetrieveOptions { + query?: string; + startLine?: number; + maxLines?: number; + contextLines?: number; + stream?: "combined" | "stdout" | "stderr"; +} + +export interface StoredArtifactInput { + text: string; + stdout?: string; + stderr?: string; + metadata?: ArtifactMetadata; +} + +export interface ArtifactMetadata { + operation: string; + command?: string; + cwd?: string; + summary?: string; + diagnostics?: Array<{ severity: string; message: string; path?: string; line?: number }>; +} + +export interface ArtifactSearchResult { + id: string; + operation: string; + summary?: string; + command?: string; + cwd?: string; +} + +export interface InMemoryArtifactStoreOptions { + ttlMs?: number; + maxEntries?: number; + maxBytes?: number; + now?: () => number; + createId?: () => string; +} + +interface StoredArtifact { + text: string; + stdout?: string; + stderr?: string; + metadata?: ArtifactMetadata; + expiresAt: number; +} + +const DEFAULT_TTL_MS = 15 * 60 * 1000; +const DEFAULT_MAX_ENTRIES = 200; +const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; +const DEFAULT_MAX_LINES = 80; + +function textFromResult(result: CallToolResult): string { + if (!Array.isArray(result.content)) return ""; + return result.content + .filter((block): block is Extract => block.type === "text") + .map((block) => block.text) + .join("\n\n"); +} + +/** 圧縮前textを短時間だけ保持する、プロセス内CCRストア。 */ +export class InMemoryArtifactStore implements ArtifactStore { + private readonly entries = new Map(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly maxBytes: number; + private readonly now: () => number; + private readonly createId: () => string; + + constructor(options: InMemoryArtifactStoreOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.now = options.now ?? Date.now; + this.createId = options.createId ?? randomUUID; + } + + put(result: CallToolResult): string { + return this.putArtifact({ text: textFromResult(result), metadata: { operation: "upstream" } }); + } + + putArtifact(artifact: StoredArtifactInput): string { + this.deleteExpired(); + while (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + + const id = `mx_${this.createId()}`; + const rawBytes = Buffer.byteLength(artifact.text, "utf8"); + const text = rawBytes <= this.maxBytes + ? artifact.text + : `${Buffer.from(artifact.text, "utf8").subarray(0, this.maxBytes).toString("utf8")}\n⋯ artifact truncated bytes=${rawBytes} max=${this.maxBytes} ⋯`; + this.entries.set(id, { ...artifact, text, expiresAt: this.now() + this.ttlMs }); + return id; + } + + retrieve( + id: string, + options: RetrieveOptions = {}, + ): RetrievedArtifact | undefined { + const entry = this.entries.get(id); + if (!entry) return undefined; + if (entry.expiresAt <= this.now()) { + this.entries.delete(id); + return undefined; + } + this.entries.delete(id); + this.entries.set(id, entry); + + const stream = options.stream ?? "combined"; + const source = stream === "stdout" ? entry.stdout ?? "" : stream === "stderr" ? entry.stderr ?? "" : entry.text; + const lines = source.split("\n"); + const matchIndex = options.query ? lines.findIndex((line) => line.includes(options.query!)) : -1; + const contextLines = Math.max(0, Math.min(options.contextLines ?? 0, 20)); + const startLine = matchIndex === -1 + ? Math.max(0, options.startLine ?? 0) + : Math.max(0, matchIndex - contextLines); + const maxLines = Math.max(1, Math.min(options.maxLines ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES)); + const selected = lines.slice(startLine, startLine + maxLines); + const endLine = startLine + selected.length; + + return { + id, + ...(stream === "combined" ? {} : { stream }), + text: selected.join("\n"), + totalLines: lines.length, + returnedStartLine: startLine + 1, + returnedEndLine: endLine, + omittedLines: lines.length - selected.length, + ...(matchIndex === -1 ? {} : { matchLine: matchIndex + 1 }), + }; + } + + search(query: string, maxResults = 20): ArtifactSearchResult[] { + this.deleteExpired(); + const needle = query.toLowerCase(); + const limit = Math.max(1, Math.min(maxResults, 100)); + const matches: ArtifactSearchResult[] = []; + for (const [id, entry] of [...this.entries.entries()].reverse()) { + const metadataText = [ + entry.metadata?.command, + entry.metadata?.summary, + ...(entry.metadata?.diagnostics?.map((item) => item.message) ?? []), + ].join("\n"); + if (!`${metadataText}\n${entry.text}`.toLowerCase().includes(needle)) continue; + matches.push({ + id, + operation: entry.metadata?.operation ?? "unknown", + ...(entry.metadata?.summary ? { summary: entry.metadata.summary } : {}), + ...(entry.metadata?.command ? { command: entry.metadata.command } : {}), + ...(entry.metadata?.cwd ? { cwd: entry.metadata.cwd } : {}), + }); + if (matches.length >= limit) break; + } + return matches; + } + + private deleteExpired(): void { + const now = this.now(); + for (const [id, entry] of this.entries) { + if (entry.expiresAt <= now) this.entries.delete(id); + } + } +} diff --git a/src/state/migrations.ts b/src/state/migrations.ts new file mode 100644 index 00000000..2c083a5c --- /dev/null +++ b/src/state/migrations.ts @@ -0,0 +1,83 @@ +import type { DatabaseSync } from "node:sqlite"; + +/** + * 1 migration = 1 version。`up` は単一 transaction 内で実行され、失敗時は + * ロールバックする。将来のスキーマ変更はこの配列に追記するだけでよく、 + * 既存 migration の内容は変更しない(適用済み環境との整合性のため)。 + */ +export interface Migration { + version: number; + description: string; + up: (db: DatabaseSync) => void; +} + +export const MIGRATIONS: Migration[] = [ + { + version: 1, + description: "initial schema: sessions, read_evidence, read_decisions", + up: (db) => { + db.exec(` + CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + worktree_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + ); + + CREATE TABLE read_evidence ( + evidence_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + worktree_id TEXT NOT NULL, + provider TEXT NOT NULL, + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX idx_read_evidence_session ON read_evidence (session_id); + + CREATE TABLE read_decisions ( + decision_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + path TEXT NOT NULL, + action TEXT NOT NULL, + file_class TEXT NOT NULL, + capability TEXT NOT NULL, + policy_code TEXT NOT NULL, + reason TEXT NOT NULL, + stage TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX idx_read_decisions_session ON read_decisions (session_id); + `); + }, + }, +]; + +function currentVersion(db: DatabaseSync): number { + db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)"); + const row = db.prepare("SELECT MAX(version) as version FROM schema_migrations").get() as { version: number | null } | undefined; + return row?.version ?? 0; +} + +/** 未適用の migration を version 昇順に適用する。冪等(適用済みなら何もしない)。 */ +export function applyMigrations(db: DatabaseSync, migrations: Migration[] = MIGRATIONS): void { + const applied = currentVersion(db); + const pending = migrations.filter((migration) => migration.version > applied).sort((left, right) => left.version - right.version); + const recordApplied = db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)"); + for (const migration of pending) { + db.exec("BEGIN"); + try { + migration.up(db); + recordApplied.run(migration.version, Date.now()); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw new Error(`migration ${migration.version} (${migration.description}) failed: ${(err as Error).message}`); + } + } +} diff --git a/src/state/paths.test.ts b/src/state/paths.test.ts new file mode 100644 index 00000000..51c2f827 --- /dev/null +++ b/src/state/paths.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { resolveStateDbPath, resolveStateDir, STATE_DB_FILE_NAME } from "./paths.js"; + +test("resolveStateDir: MOTTAINAI_STATE_DIR override wins on every platform", () => { + const env = { MOTTAINAI_STATE_DIR: "/custom/state/dir" }; + assert.equal(resolveStateDir(env, "linux"), path.resolve("/custom/state/dir")); + assert.equal(resolveStateDir(env, "darwin"), path.resolve("/custom/state/dir")); + assert.equal(resolveStateDir(env, "win32"), path.resolve("/custom/state/dir")); +}); + +test("resolveStateDir: linux uses XDG_STATE_HOME when set", () => { + const env = { HOME: "/home/user", XDG_STATE_HOME: "/home/user/.state" }; + assert.equal(resolveStateDir(env, "linux"), path.join("/home/user/.state", "mottainai")); +}); + +test("resolveStateDir: linux falls back to ~/.local/state without XDG_STATE_HOME", () => { + const env = { HOME: "/home/user" }; + assert.equal(resolveStateDir(env, "linux"), path.join("/home/user", ".local", "state", "mottainai")); +}); + +test("resolveStateDir: macOS uses Application Support", () => { + const env = { HOME: "/Users/user" }; + assert.equal(resolveStateDir(env, "darwin"), path.join("/Users/user", "Library", "Application Support", "mottainai")); +}); + +test("resolveStateDir: windows uses LOCALAPPDATA when set", () => { + const env = { HOME: "C:\\Users\\user", LOCALAPPDATA: "C:\\Users\\user\\AppData\\Local" }; + assert.equal(resolveStateDir(env, "win32"), path.join("C:\\Users\\user\\AppData\\Local", "mottainai")); +}); + +test("resolveStateDir: never resolves inside cwd/node_modules/tmp by default", () => { + const env = { HOME: "/home/user" }; + const resolved = resolveStateDir(env, "linux"); + assert.ok(!resolved.includes("node_modules")); + assert.ok(!resolved.startsWith("/tmp")); + assert.ok(!resolved.startsWith(process.cwd())); +}); + +test("resolveStateDbPath: appends state db file name", () => { + const env = { MOTTAINAI_STATE_DIR: "/custom/state/dir" }; + assert.equal(resolveStateDbPath(env, "linux"), path.join(path.resolve("/custom/state/dir"), STATE_DB_FILE_NAME)); +}); diff --git a/src/state/paths.ts b/src/state/paths.ts new file mode 100644 index 00000000..b2d1206f --- /dev/null +++ b/src/state/paths.ts @@ -0,0 +1,41 @@ +import os from "node:os"; +import path from "node:path"; + +const APP_DIR_NAME = "mottainai"; +export const STATE_DB_FILE_NAME = "state.sqlite3"; + +/** + * OS ごとの user state directory を返す。`MOTTAINAI_STATE_DIR` が設定されていれば + * それを最優先する(テスト・コンテナ・CI での上書き用)。 + * + * - Linux: `$XDG_STATE_HOME/mottainai`(既定 `~/.local/state/mottainai`) + * - macOS: `~/Library/Application Support/mottainai` + * - Windows: `%LOCALAPPDATA%\mottainai`(既定 `~/AppData/Local/mottainai`) + * + * リポジトリ内・node_modules・OS 一時ディレクトリには置かない。 + */ +export function resolveStateDir(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string { + const override = env.MOTTAINAI_STATE_DIR; + if (override !== undefined && override.length > 0) { + return path.resolve(override); + } + + const home = env.HOME ?? os.homedir(); + + if (platform === "darwin") { + return path.join(home, "Library", "Application Support", APP_DIR_NAME); + } + + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"); + return path.join(localAppData, APP_DIR_NAME); + } + + const xdgStateHome = env.XDG_STATE_HOME; + const base = xdgStateHome !== undefined && xdgStateHome.length > 0 ? xdgStateHome : path.join(home, ".local", "state"); + return path.join(base, APP_DIR_NAME); +} + +export function resolveStateDbPath(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string { + return path.join(resolveStateDir(env, platform), STATE_DB_FILE_NAME); +} diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 00000000..9cecd778 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,101 @@ +/** + * 永続 state の抽象。実装(SQLite 等)を差し替え可能にするため、呼び出し側は + * この interface だけに依存する。責務は初期段階として session / read evidence / + * read decision / schema migration に限定する(telemetry・review は次段階)。 + */ + +export interface SessionRecord { + sessionId: string; + repositoryId: string; + worktreeId: string; + createdAt: number; + lastSeenAt: number; +} + +export interface NewSession { + sessionId: string; + repositoryId: string; + worktreeId: string; + createdAt?: number; +} + +export interface ReadEvidenceRecord { + evidenceId: string; + sessionId: string; + repositoryId: string; + worktreeId: string; + provider: string; + path: string; + startLine: number; + endLine: number; + reason: string; + createdAt: number; + expiresAt: number; +} + +export interface NewReadEvidenceRecord { + evidenceId: string; + sessionId: string; + repositoryId: string; + worktreeId: string; + provider: string; + path: string; + startLine: number; + endLine: number; + reason: string; + createdAt: number; + expiresAt: number; +} + +export interface ReadDecisionRecord { + decisionId: string; + sessionId: string; + path: string; + action: string; + fileClass: string; + capability: string; + policyCode: string; + reason: string; + stage: string; + createdAt: number; +} + +export interface NewReadDecisionRecord { + decisionId: string; + sessionId: string; + path: string; + action: string; + fileClass: string; + capability: string; + policyCode: string; + reason: string; + stage: string; + createdAt?: number; +} + +export interface ListReadDecisionsFilter { + sessionId?: string; + limit?: number; +} + +/** + * 全 backend 共通の契約。将来の telemetry / review 追加時もこの interface に + * メソッドを足す形で拡張し、既存メソッドの意味は変えない。 + */ +export interface StateStore { + /** backend 固有の初期化(DB オープン・migration 適用)。呼び出し前は他メソッドを使わない。 */ + init(): void; + + createSession(input: NewSession): SessionRecord; + getSession(sessionId: string): SessionRecord | undefined; + touchSession(sessionId: string, lastSeenAt?: number): void; + + recordReadEvidence(input: NewReadEvidenceRecord): ReadEvidenceRecord; + getReadEvidence(evidenceId: string): ReadEvidenceRecord | undefined; + + recordReadDecision(input: NewReadDecisionRecord): ReadDecisionRecord; + listReadDecisions(filter?: ListReadDecisionsFilter): ReadDecisionRecord[]; + + /** backend 固有のリソース解放(DB クローズ等)。プロセス終了時 best-effort で呼ぶ。 */ + close(): void; +} diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts new file mode 100644 index 00000000..e14c535d --- /dev/null +++ b/src/telemetry.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + compressionRatio, + createTelemetrySink, + isTelemetryEnabled, + resolveTelemetryPath, + retrievalRate, +} from "./telemetry.js"; + +test("telemetry is disabled by default and enabled only by MOTTAINAI_TELEMETRY=1/true", () => { + assert.equal(isTelemetryEnabled({}), false); + assert.equal(isTelemetryEnabled({ MOTTAINAI_TELEMETRY: "0" }), false); + assert.equal(isTelemetryEnabled({ MOTTAINAI_TELEMETRY: "1" }), true); + assert.equal(isTelemetryEnabled({ MOTTAINAI_TELEMETRY: "true" }), true); +}); + +test("resolveTelemetryPath defaults under .mottainai/telemetry and honors MOTTAINAI_TELEMETRY_FILE", () => { + assert.match(resolveTelemetryPath({}), /\.mottainai[\\/]telemetry[\\/]summary\.json$/); + assert.equal(resolveTelemetryPath({ MOTTAINAI_TELEMETRY_FILE: "/tmp/x.json" }), "/tmp/x.json"); +}); + +test("a disabled sink is a no-op and never touches the filesystem", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mottainai-telemetry-off-")); + const filePath = path.join(dir, "telemetry", "summary.json"); + const sink = createTelemetrySink({ MOTTAINAI_TELEMETRY_FILE: filePath }); + assert.equal(sink.enabled, false); + sink.recordToolCall({ provider: "fff", originalBytes: 1000, compressedBytes: 100, isError: false }); + sink.recordRetrieval(); + assert.equal(sink.snapshot().enabled, false); + await assert.rejects(() => fs.access(filePath)); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("an enabled sink aggregates calls, errors, bytes and retrievals by provider and capability", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mottainai-telemetry-on-")); + const filePath = path.join(dir, "telemetry", "summary.json"); + const sink = createTelemetrySink({ MOTTAINAI_TELEMETRY: "1", MOTTAINAI_TELEMETRY_FILE: filePath }); + assert.equal(sink.enabled, true); + + sink.recordToolCall({ provider: "fff", capability: "text_matches", originalBytes: 1000, compressedBytes: 200, isError: false }); + sink.recordToolCall({ provider: "fff", capability: "text_matches", originalBytes: 500, compressedBytes: 500, isError: true }); + sink.recordToolCall({ provider: "codegraph", capability: "definitions", originalBytes: 300, compressedBytes: 300, isError: false }); + sink.recordRetrieval(); + + const snapshot = sink.snapshot(); + assert.equal(snapshot.totals.calls, 3); + assert.equal(snapshot.totals.errors, 1); + assert.equal(snapshot.totals.original_bytes, 1800); + assert.equal(snapshot.totals.compressed_bytes, 1000); + assert.equal(snapshot.totals.retrievals, 1); + assert.equal(snapshot.by_provider.fff.calls, 2); + assert.equal(snapshot.by_provider.codegraph.calls, 1); + assert.equal(snapshot.by_capability.text_matches.calls, 2); + assert.equal(snapshot.by_capability.definitions.calls, 1); + + assert.equal(compressionRatio(snapshot.totals), 1000 / 1800); + assert.equal(retrievalRate(snapshot.totals), 1 / 3); + + // 非同期の書き込みキューが flush されるまで待つ。 + await new Promise((resolve) => setTimeout(resolve, 50)); + const persisted = JSON.parse(await fs.readFile(filePath, "utf8")) as { totals: { calls: number } }; + assert.equal(persisted.totals.calls, 3); + + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("a new sink resumes accumulating from a previously persisted summary", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mottainai-telemetry-resume-")); + const filePath = path.join(dir, "telemetry", "summary.json"); + const first = createTelemetrySink({ MOTTAINAI_TELEMETRY: "1", MOTTAINAI_TELEMETRY_FILE: filePath }); + first.recordToolCall({ provider: "fff", originalBytes: 100, compressedBytes: 10, isError: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const second = createTelemetrySink({ MOTTAINAI_TELEMETRY: "1", MOTTAINAI_TELEMETRY_FILE: filePath }); + assert.equal(second.snapshot().totals.calls, 1); + second.recordToolCall({ provider: "fff", originalBytes: 100, compressedBytes: 10, isError: false }); + assert.equal(second.snapshot().totals.calls, 2); + + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("compressionRatio and retrievalRate are undefined when there is nothing to divide by", () => { + assert.equal(compressionRatio({ original_bytes: 0, compressed_bytes: 0 }), undefined); + assert.equal(retrievalRate({ calls: 0, retrievals: 0 }), undefined); +}); diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 00000000..2c5aa858 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * ローカル専用の利用状況・トークン節約テレメトリ(#27)。 + * + * 既定は無効。個々の呼び出し・引数・出力本文は保持しない — provider / capability 単位の + * 呼び出し回数・エラー回数・圧縮前後バイト数・retrieval 回数という**集計値だけ**を単一の + * JSON ファイルへ書き戻す。secret redaction が必要な生データを一切持たないため、 + * `src/logging.ts` のような redaction 機構はここには無い。 + */ + +export interface TelemetryCounts { + calls: number; + errors: number; + original_bytes: number; + compressed_bytes: number; +} + +export interface TelemetrySnapshot { + enabled: boolean; + generated_at: string; + totals: TelemetryCounts & { retrievals: number }; + by_provider: Record; + by_capability: Record; +} + +export interface RecordToolCallInput { + provider: string; + capability?: string; + originalBytes: number; + compressedBytes: number; + isError: boolean; +} + +export interface TelemetrySink { + readonly enabled: boolean; + readonly filePath?: string; + recordToolCall(input: RecordToolCallInput): void; + recordRetrieval(): void; + snapshot(): TelemetrySnapshot; +} + +interface TelemetryState { + totals: TelemetryCounts & { retrievals: number }; + by_provider: Record; + by_capability: Record; +} + +function emptyCounts(): TelemetryCounts { + return { calls: 0, errors: 0, original_bytes: 0, compressed_bytes: 0 }; +} + +function emptyState(): TelemetryState { + return { totals: { ...emptyCounts(), retrievals: 0 }, by_provider: {}, by_capability: {} }; +} + +export function isTelemetryEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.MOTTAINAI_TELEMETRY; + return value === "1" || (value?.toLowerCase() === "true"); +} + +export function resolveTelemetryPath(env: NodeJS.ProcessEnv = process.env): string { + return env.MOTTAINAI_TELEMETRY_FILE ?? path.join(process.cwd(), ".mottainai", "telemetry", "summary.json"); +} + +function isCounts(value: unknown): value is TelemetryCounts { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return typeof record.calls === "number" && typeof record.errors === "number" + && typeof record.original_bytes === "number" && typeof record.compressed_bytes === "number"; +} + +/** 破損・旧形式のファイルは読み捨てて 0 から再開する。集計値の再構築は失うが致命的ではない。 */ +function loadState(filePath: string): TelemetryState | undefined { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Record; + const totals = parsed.totals; + const retrievals = typeof totals === "object" && totals !== null ? (totals as Record).retrievals : undefined; + if (!isCounts(totals) || typeof retrievals !== "number") return undefined; + const byProvider = parsed.by_provider; + const byCapability = parsed.by_capability; + if (typeof byProvider !== "object" || byProvider === null) return undefined; + if (typeof byCapability !== "object" || byCapability === null) return undefined; + for (const value of Object.values(byProvider as Record)) if (!isCounts(value)) return undefined; + for (const value of Object.values(byCapability as Record)) if (!isCounts(value)) return undefined; + return { + totals: totals as TelemetryState["totals"], + by_provider: byProvider as Record, + by_capability: byCapability as Record, + }; + } catch { + return undefined; + } +} + +function bump(counts: TelemetryCounts, input: RecordToolCallInput): void { + counts.calls += 1; + if (input.isError) counts.errors += 1; + counts.original_bytes += input.originalBytes; + counts.compressed_bytes += input.compressedBytes; +} + +const NOOP_SINK: TelemetrySink = { + enabled: false, + recordToolCall() { /* telemetry disabled */ }, + recordRetrieval() { /* telemetry disabled */ }, + snapshot() { + return { enabled: false, generated_at: new Date().toISOString(), totals: { ...emptyCounts(), retrievals: 0 }, by_provider: {}, by_capability: {} }; + }, +}; + +/** + * 環境変数から telemetry sink を構築する。 + * + * - `MOTTAINAI_TELEMETRY=1` — 有効化(既定は無効) + * - `MOTTAINAI_TELEMETRY_FILE` — 集計値の保存先(既定 `.mottainai/telemetry/summary.json`) + * + * 無効時は fs へ一切触れない no-op sink を返す。有効時は既存ファイルがあれば読み込み、 + * プロセス再起動をまたいで集計を継続する。 + */ +export function createTelemetrySink(env: NodeJS.ProcessEnv = process.env): TelemetrySink { + if (!isTelemetryEnabled(env)) return NOOP_SINK; + + const filePath = resolveTelemetryPath(env); + const state = loadState(filePath) ?? emptyState(); + let writeQueue: Promise = Promise.resolve(); + + function persist(): void { + writeQueue = writeQueue + .then(async () => { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const snapshot: TelemetrySnapshot = { enabled: true, generated_at: new Date().toISOString(), ...state }; + await fs.promises.writeFile(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }); + }) + .catch((err) => { + console.error("mottainai: failed to write telemetry summary", err); + }); + } + + return { + enabled: true, + filePath, + recordToolCall(input) { + bump(state.totals, input); + const provider = state.by_provider[input.provider] ?? emptyCounts(); + bump(provider, input); + state.by_provider[input.provider] = provider; + if (input.capability !== undefined) { + const capability = state.by_capability[input.capability] ?? emptyCounts(); + bump(capability, input); + state.by_capability[input.capability] = capability; + } + persist(); + }, + recordRetrieval() { + state.totals.retrievals += 1; + persist(); + }, + snapshot() { + return { enabled: true, generated_at: new Date().toISOString(), ...state }; + }, + }; +} + +/** 圧縮率(0〜1、1 は無変化)。呼び出しが無ければ `undefined`。 */ +export function compressionRatio(counts: Pick): number | undefined { + return counts.original_bytes > 0 ? counts.compressed_bytes / counts.original_bytes : undefined; +} + +/** artifact 再取得率(retrieval 回数 / 呼び出し回数)。呼び出しが無ければ `undefined`。 */ +export function retrievalRate(totals: Pick): number | undefined { + return totals.calls > 0 ? totals.retrievals / totals.calls : undefined; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..0a11719f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..c18704ee --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "incremental": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} From 6c7a2f802cd8be3aa0bc1cfcc071c5fd3f9b262f Mon Sep 17 00:00:00 2001 From: yohnark <213253858+oy-zenprax@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:51:01 +0900 Subject: [PATCH 2/3] fix: address PR review feedback --- README.md | 2 +- src/adaptive/trace.test.ts | 27 ++++ src/adaptive/trace.ts | 17 ++- src/auth.test.ts | 4 + src/auth.ts | 13 +- src/compress/budget.test.ts | 5 + src/compress/budget.ts | 12 +- src/compress/json.test.ts | 16 +++ src/compress/json.ts | 21 ++- src/compress/lines.test.ts | 8 ++ src/compress/lines.ts | 9 +- src/compress/tool-description.test.ts | 7 + src/compress/tool-description.ts | 2 +- src/envelope.test.ts | 53 ++++++++ src/envelope.ts | 18 ++- src/logging.test.ts | 11 ++ src/logging.ts | 24 ++-- src/read-governor/evidence.test.ts | 22 +++ src/read-governor/evidence.ts | 26 +++- src/retrieve.test.ts | 51 ++++++- src/retrieve.ts | 188 ++++++++++++++++++++++++-- src/state/migrations.test.ts | 26 ++++ src/state/migrations.ts | 26 +++- src/state/paths.test.ts | 5 + src/state/paths.ts | 5 +- src/telemetry.test.ts | 21 +++ src/telemetry.ts | 49 ++++++- 27 files changed, 612 insertions(+), 56 deletions(-) create mode 100644 src/envelope.test.ts create mode 100644 src/state/migrations.test.ts diff --git a/README.md b/README.md index f4aa598e..54902a94 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ definitions and tool call results before they reach the model context**. ## How it fits together -``` +```text ┌───────────────────────────┐ LLM client ⇄ │ mottainai │ ⇄ upstream MCP servers (Claude Code, │ (this project, one stdio │ (codegraph, fff-mcp, diff --git a/src/adaptive/trace.test.ts b/src/adaptive/trace.test.ts index 666543d6..0fd83d30 100644 --- a/src/adaptive/trace.test.ts +++ b/src/adaptive/trace.test.ts @@ -88,6 +88,33 @@ test("traces survive a new store over the same directory", async () => { assert.equal(reopened.load({ reviewedOnly: true }).length, 1); }); +test("trace store rotates to multiple files at the configured size", async () => { + const directory = temporaryDir(); + await seed(directory, { MOTTAINAI_TRACE_MAX_FILE_BYTES: "1" }); + + const files = fs.readdirSync(directory).filter((name) => name.endsWith(".jsonl")); + assert.ok(files.length >= 2); +}); + +test("trace store removes aged files during the next store preparation", async () => { + const directory = temporaryDir(); + await seed(directory); + const stalePath = path.join(directory, "stale.jsonl"); + fs.writeFileSync(stalePath, "{}\n"); + const oldTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + fs.utimesSync(stalePath, oldTime, oldTime); + + const reopened = createTraceStore({ MOTTAINAI_TRACE_DIR: directory, MOTTAINAI_TRACE_RETENTION_DAYS: "1" }); + await reopened.beginRequest({ + task_category: "symbol_lookup", + caller_requested_capabilities: [], + planned_capabilities: [], + policy_version: "builtin-1", + }); + + assert.equal(fs.existsSync(stalePath), false); +}); + test("disabled tracing still issues request ids but writes nothing", async () => { const directory = temporaryDir(); const store = createTraceStore({ MOTTAINAI_TRACE_DIR: directory, MOTTAINAI_TRACE: "0" }); diff --git a/src/adaptive/trace.ts b/src/adaptive/trace.ts index 97c2bc43..5e0ffa06 100644 --- a/src/adaptive/trace.ts +++ b/src/adaptive/trace.ts @@ -372,7 +372,9 @@ export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceSto const directory = resolveTraceDir(env); const raw = retainRawEvidence(env); const maxFileBytes = positiveNumber(env.MOTTAINAI_TRACE_MAX_FILE_BYTES, DEFAULT_MAX_FILE_BYTES); + const retentionMs = positiveNumber(env.MOTTAINAI_TRACE_RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * 24 * 60 * 60 * 1000; const sessionRequests = new Set(); + const sessionExecutionIds = new Map>(); let filePath = ""; let currentFileBytes = 0; @@ -385,7 +387,7 @@ export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceSto if (prepared) return; prepared = true; fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - sweepExpiredTraces(directory, positiveNumber(env.MOTTAINAI_TRACE_RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * 24 * 60 * 60 * 1000); + sweepExpiredTraces(directory, retentionMs); filePath = path.join(directory, traceFileName()); } @@ -397,6 +399,7 @@ export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceSto prepareDirectory(); const lineBytes = Buffer.byteLength(line, "utf8"); if (currentFileBytes > 0 && currentFileBytes + lineBytes > maxFileBytes) { + sweepExpiredTraces(directory, retentionMs); filePath = path.join(directory, traceFileName()); currentFileBytes = 0; } @@ -450,6 +453,9 @@ export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceSto ...input, }; await append(record); + const executionIds = sessionExecutionIds.get(record.request_id) ?? new Set(); + executionIds.add(record.execution_id); + sessionExecutionIds.set(record.request_id, executionIds); }, async recordReview(input) { if (!sessionRequests.has(input.request_id) && loadTraces({ requestId: input.request_id }).length === 0) { @@ -459,9 +465,12 @@ export function createTraceStore(env: NodeJS.ProcessEnv = process.env): TraceSto return "recorded"; }, async recordExecutionReview(input) { - const trace = loadTraces({ requestId: input.request_id })[0]; - if (trace === undefined) return "unknown_request"; - if (!trace.executions.some((execution) => execution.execution_id === input.execution_id)) return "unknown_execution"; + const knownExecutionIds = sessionExecutionIds.get(input.request_id); + if (!sessionRequests.has(input.request_id) || knownExecutionIds === undefined || !knownExecutionIds.has(input.execution_id)) { + const trace = loadTraces({ requestId: input.request_id })[0]; + if (trace === undefined) return "unknown_request"; + if (!trace.executions.some((execution) => execution.execution_id === input.execution_id)) return "unknown_execution"; + } await append({ type: "execution_review", schema_version: TRACE_SCHEMA_VERSION, timestamp: new Date().toISOString(), ...input }); return "recorded"; }, diff --git a/src/auth.test.ts b/src/auth.test.ts index eac581f1..466a49cb 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -30,4 +30,8 @@ test("sanitizes OAuth provider failures and rejects non-HTTP broker endpoints", () => resolveBrokerEndpoint({ resolveEndpoint: async () => "file:///tmp/mcp" }, new URL("https://mcp.example.test/mcp"), "example"), /oauth broker returned invalid endpoint: example/, ); + await assert.rejects( + () => resolveBrokerEndpoint({ resolveEndpoint: async () => { throw new Error("oauth broker returned invalid endpoint: leaked"); } }, new URL("https://mcp.example.test/mcp"), "example"), + /oauth broker resolution failed: example/, + ); }); diff --git a/src/auth.ts b/src/auth.ts index bfd64149..3b6a0250 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -15,15 +15,22 @@ function isOAuthCredentialProvider(value: unknown): value is OAuthCredentialProv && typeof (value as { resolveEndpoint?: unknown }).resolveEndpoint === "function"; } +class BrokerEndpointValidationError extends Error { + constructor(profile: string) { + super(`oauth broker returned invalid endpoint: ${profile}`); + this.name = "BrokerEndpointValidationError"; + } +} + function brokerUrl(value: URL | string, profile: string): URL { let endpoint: URL; try { endpoint = value instanceof URL ? value : new URL(value); } catch { - throw new Error(`oauth broker returned invalid endpoint: ${profile}`); + throw new BrokerEndpointValidationError(profile); } if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") { - throw new Error(`oauth broker returned invalid endpoint: ${profile}`); + throw new BrokerEndpointValidationError(profile); } return endpoint; } @@ -36,7 +43,7 @@ export async function resolveBrokerEndpoint( try { return brokerUrl(await provider.resolveEndpoint(targetUrl, profile), profile); } catch (error) { - if (error instanceof Error && error.message.startsWith("oauth broker returned invalid endpoint:")) { + if (error instanceof BrokerEndpointValidationError) { throw error; } throw new Error(`oauth broker resolution failed: ${profile}`); diff --git a/src/compress/budget.test.ts b/src/compress/budget.test.ts index 9da60a67..07e0f658 100644 --- a/src/compress/budget.test.ts +++ b/src/compress/budget.test.ts @@ -17,3 +17,8 @@ test("compactToBudget shortens text that exceeds the budget, keeping head and ta assert.match(compacted, /line 499$/); assert.match(compacted, /⋯ mottainai omitted=\d+ lines sha256=[0-9a-f]{16}; use mottainai_result_get ⋯/); }); + +test("compactToBudget does not floor the target when rawBytes is below the envelope reservation", () => { + const text = "x".repeat(1_000); + assert.equal(compactToBudget(text, 1_000, 800), text); +}); diff --git a/src/compress/budget.ts b/src/compress/budget.ts index 689c8f97..e433ea17 100644 --- a/src/compress/budget.ts +++ b/src/compress/budget.ts @@ -13,13 +13,19 @@ export function compactToBudget(text: string, targetTokens: number, rawBytes: nu // 共通envelope分を約256 token確保。行境界を維持して先頭・末尾を残す。 const targetBytes = (targetTokens - 256) * 4; // 生出力より大きいMCP payloadを返さないよう、envelope用に約1 KiB確保。 - const budget = Math.max(256, Math.min(targetBytes, rawBytes - 1024)); + const rawCap = rawBytes > 1024 ? rawBytes - 1024 : Number.POSITIVE_INFINITY; + const budget = Math.max(256, Math.min(targetBytes, rawCap)); if (Buffer.byteLength(text) <= budget) return text; const lines = text.split("\n"); const head: string[] = []; const tail: string[] = []; let used = 0; - const headBudget = Math.floor(budget * 0.6); + const markerOverhead = Buffer.byteLength( + `⋯ mottainai omitted=${"9".repeat(String(lines.length).length)} lines sha256=${"0".repeat(16)}; use mottainai_result_get ⋯`, + "utf8", + ) + 2; + const contentBudget = Math.max(0, budget - markerOverhead); + const headBudget = Math.floor(contentBudget * 0.6); for (const line of lines) { const bytes = Buffer.byteLength(`${line}\n`); if (used + bytes > headBudget) break; @@ -29,7 +35,7 @@ export function compactToBudget(text: string, targetTokens: number, rawBytes: nu for (let index = lines.length - 1; index >= head.length; index -= 1) { const line = lines[index]; const bytes = Buffer.byteLength(`${line}\n`); - if (used + tailUsed + bytes > budget) break; + if (used + tailUsed + bytes > contentBudget) break; tail.unshift(line); tailUsed += bytes; } const omitted = Math.max(0, lines.length - head.length - tail.length); diff --git a/src/compress/json.test.ts b/src/compress/json.test.ts index e5015280..2dc1a8b3 100644 --- a/src/compress/json.test.ts +++ b/src/compress/json.test.ts @@ -45,6 +45,22 @@ test("compressJsonValue preserves keys, booleans, null, and short strings", () = assert.deepEqual(compressJsonValue(value), value); }); +test("compressJsonValue preserves an own __proto__ data property", () => { + const value = JSON.parse('{"__proto__":{"polluted":true},"safe":1}'); + const out = compressJsonValue(value) as Record; + + assert.equal(Object.getPrototypeOf(out), Object.prototype); + assert.equal(Object.prototype.hasOwnProperty.call(out, "__proto__"), true); + assert.deepEqual(out["__proto__"], { polluted: true }); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); +}); + +test("compressJsonValue rejects negative numeric limits", () => { + assert.throws(() => compressJsonValue("value", { maxStringLength: -1 }), /maxStringLength/); + assert.throws(() => compressJsonValue([], { maxArrayItems: -1 }), /maxArrayItems/); + assert.throws(() => compressJsonValue([], { tailArrayItems: -1 }), /tailArrayItems/); +}); + test("compressJsonValue truncates beyond maxDepth", () => { const value = { a: { b: { c: "deep" } } }; const out = compressJsonValue(value, { maxDepth: 1 }) as { a: { b: unknown } }; diff --git a/src/compress/json.ts b/src/compress/json.ts index 9e4e650a..5b87ec34 100644 --- a/src/compress/json.ts +++ b/src/compress/json.ts @@ -27,6 +27,16 @@ function sha256Json(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function resolveJsonCompressOptions(options?: JsonCompressOptions): Required { + const merged = { ...DEFAULT_JSON_COMPRESS_OPTIONS, ...options }; + for (const [name, value] of Object.entries(merged)) { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new RangeError(`${name} must be a finite non-negative integer`); + } + } + return merged; +} + /** 入力がJSONとしてパース可能かどうかを判定する。パース不能なら undefined を返す。 */ export function tryParseJson(input: string): unknown | undefined { try { @@ -70,7 +80,12 @@ function compressValue(value: unknown, options: Required, d if (value !== null && typeof value === "object") { const out: Record = {}; for (const [key, v] of Object.entries(value as Record)) { - out[key] = compressValue(v, options, depth + 1); + Object.defineProperty(out, key, { + value: compressValue(v, options, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }); } return out; } @@ -81,7 +96,7 @@ function compressValue(value: unknown, options: Required, d /** パース済みのJSON値に対して再帰的にサンプリング・切り詰めを適用する。 */ export function compressJsonValue(value: unknown, options?: JsonCompressOptions): unknown { - const opts = { ...DEFAULT_JSON_COMPRESS_OPTIONS, ...options }; + const opts = resolveJsonCompressOptions(options); return compressValue(value, opts, 0); } @@ -92,7 +107,7 @@ export function compressJsonValue(value: unknown, options?: JsonCompressOptions) export function compressJsonText(input: string, options?: JsonCompressOptions): string { const parsed = tryParseJson(input); if (parsed === undefined) return input; - const opts = { ...DEFAULT_JSON_COMPRESS_OPTIONS, ...options }; + const opts = resolveJsonCompressOptions(options); const compressed = compressValue(parsed, opts, 0); return opts.indent > 0 ? JSON.stringify(compressed, null, opts.indent) : JSON.stringify(compressed); } diff --git a/src/compress/lines.test.ts b/src/compress/lines.test.ts index f3b1d666..45d3300a 100644 --- a/src/compress/lines.test.ts +++ b/src/compress/lines.test.ts @@ -51,6 +51,14 @@ test("truncateExcessLines is a no-op when under the limit", () => { assert.equal(truncateExcessLines(input, 3, 2, 5), input); }); +test("truncateExcessLines caps head and tail without overlap when their sum exceeds the limit", () => { + const lines = Array.from({ length: 10 }, (_, i) => `L${i}`); + const out = truncateExcessLines(lines.join("\n"), 4, 4, 5); + + assert.equal(out, ["L0", "L1", "L2", "L3", "⋯ 5 lines omitted ⋯", "L9"].join("\n")); + assert.doesNotMatch(out, /L[4-8].*L[0-3]/s); +}); + test("filterLines applies rules in order: duplicates, blanks, length, total", () => { const lines = ["dup", "dup", "dup", "", "", "x".repeat(20)]; const input = lines.join("\n"); diff --git a/src/compress/lines.ts b/src/compress/lines.ts index f874a800..05a3f77e 100644 --- a/src/compress/lines.ts +++ b/src/compress/lines.ts @@ -86,9 +86,12 @@ export function truncateExcessLines( const lines = input.split("\n"); if (lines.length <= maxTotalLines) return input; - const head = lines.slice(0, Math.max(headLines, 0)); - const tail = tailLines > 0 ? lines.slice(lines.length - tailLines) : []; - const omitted = lines.length - head.length - tail.length; + const retainedLineBudget = Math.max(maxTotalLines, 0); + const headCount = Math.min(Math.max(headLines, 0), retainedLineBudget); + const tailCount = Math.min(Math.max(tailLines, 0), Math.max(retainedLineBudget - headCount, 0)); + const head = lines.slice(0, headCount); + const tail = tailCount > 0 ? lines.slice(lines.length - tailCount) : []; + const omitted = Math.max(0, lines.length - head.length - tail.length); return [...head, `⋯ ${omitted} lines omitted ⋯`, ...tail].join("\n"); } diff --git a/src/compress/tool-description.test.ts b/src/compress/tool-description.test.ts index 32bdb973..03276e29 100644 --- a/src/compress/tool-description.test.ts +++ b/src/compress/tool-description.test.ts @@ -38,6 +38,13 @@ test("compressToolDescription preserves code fences, literals, URLs, and Japanes ); }); +test("compressToolDescription does not treat apostrophes in contractions as literals", () => { + assert.equal( + compressToolDescription("Don't say it's important: use the tool."), + "Don't say it's use tool.", + ); +}); + test("compressToolDefinition changes only description fields and does not mutate its input", () => { const tool = { name: "grep", diff --git a/src/compress/tool-description.ts b/src/compress/tool-description.ts index 7a0bf12d..00d3f610 100644 --- a/src/compress/tool-description.ts +++ b/src/compress/tool-description.ts @@ -2,7 +2,7 @@ * MCPツール説明のうち、英語散文にだけ適用する機械的圧縮。 * コードフェンス、インラインコード、URL、日本語を含む行は変更しない。 */ -const PROTECTED_LITERAL = /https?:\/\/[^\s`]+|`[^`]*`|'[^']*'|"[^"]*"/g; +const PROTECTED_LITERAL = /https?:\/\/[^\s`]+|`[^`]*`|(? = [ [/\buse it when you need to\s+/gi, "use to "], [/\buse (.+?) instead for\s+/gi, "use $1 for "], diff --git a/src/envelope.test.ts b/src/envelope.test.ts new file mode 100644 index 00000000..7dcaaa20 --- /dev/null +++ b/src/envelope.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { output } from "./envelope.js"; + +test("output keeps envelope fields authoritative and accepts typed optional fields", () => { + const result = output("read", "failed", "operation failed", "mx_result", { + operation: "spoofed", + status: "success", + summary: "spoofed", + result_id: "spoofed", + facts: ["fact"], + diagnostics: [{ message: "detail" }], + metrics: { attempts: 2 }, + truncated: true, + test_results: { passed: 1 }, + extension: "kept", + }); + const structured = result.structuredContent as Record; + + assert.equal(structured.operation, "read"); + assert.equal(structured.status, "failed"); + assert.equal(structured.summary, "operation failed"); + assert.equal(structured.result_id, "mx_result"); + assert.deepEqual(structured.facts, ["fact"]); + assert.deepEqual(structured.diagnostics, [{ message: "detail" }]); + assert.deepEqual(structured.metrics, { attempts: 2 }); + assert.equal(structured.truncated, true); + assert.deepEqual(structured.test_results, { passed: 1 }); + assert.equal(structured.extension, "kept"); +}); + +test("output falls back to typed defaults for invalid reserved details", () => { + const result = output("read", "success", "ok", "mx_result", { + facts: "invalid", + diagnostics: null, + metrics: [], + truncated: "true", + test_results: [], + }); + const structured = result.structuredContent as Record; + + assert.deepEqual(structured.facts, []); + assert.deepEqual(structured.diagnostics, []); + assert.deepEqual(structured.metrics, {}); + assert.equal(structured.truncated, false); + assert.equal("test_results" in structured, false); +}); + +test("output preserves the error flag independently of details", () => { + const result = output("read", "failed", "failed", "mx_result", {}, true); + assert.equal(result.isError, true); + assert.equal((result.structuredContent as Record).isError, undefined); +}); diff --git a/src/envelope.ts b/src/envelope.ts index aa3c20f6..bbde6f55 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -14,6 +14,12 @@ export const OUTPUT_SCHEMA = { required: ["operation", "status", "summary", "facts", "diagnostics", "metrics", "result_id", "truncated"], }; +const RESERVED_OUTPUT_FIELDS = new Set(Object.keys(OUTPUT_SCHEMA.properties)); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + export type EnvelopeStatus = "success" | "failed" | "partial"; export function output( @@ -24,8 +30,18 @@ export function output( details: Record, isError = false, ): CallToolResult { + const facts = Array.isArray(details.facts) ? details.facts : []; + const diagnostics = Array.isArray(details.diagnostics) ? details.diagnostics : []; + const metrics = isRecord(details.metrics) ? details.metrics : {}; + const truncated = typeof details.truncated === "boolean" ? details.truncated : false; + const testResults = isRecord(details.test_results) ? details.test_results : undefined; + const extensions = Object.fromEntries( + Object.entries(details).filter(([key]) => !RESERVED_OUTPUT_FIELDS.has(key)), + ); const structuredContent = { - operation, status, summary, facts: [], diagnostics: [], metrics: {}, result_id: resultId, truncated: false, ...details, + operation, status, summary, facts, diagnostics, metrics, result_id: resultId, truncated, + ...(testResults === undefined ? {} : { test_results: testResults }), + ...extensions, }; return { content: [{ type: "text", text: summary }], structuredContent, ...(isError ? { isError: true } : {}) }; } diff --git a/src/logging.test.ts b/src/logging.test.ts index 0eff866b..6b69731b 100644 --- a/src/logging.test.ts +++ b/src/logging.test.ts @@ -149,6 +149,17 @@ test("createLogger bounds an oversized record while retaining its digest", async assert.match(record.rawResult.sha256 ?? "", /^[0-9a-f]{64}$/); }); +test("createLogger does not reject when a raw result cannot be serialized", async () => { + const dir = tmpDir(); + const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_REDACT: "0" }); + const circular: { self?: unknown } = {}; + circular.self = circular; + + await assert.doesNotReject(async () => { + await logger.log({ upstreamName: "u", toolName: "t", arguments: {}, rawResult: circular }); + }); +}); + test("createLogger rolls over to distinct files even within a single timestamp tick", async () => { const dir = tmpDir(); const logger = createLogger({ MOTTAINAI_LOG_DIR: dir, MOTTAINAI_LOG_MAX_FILE_BYTES: "10" }); diff --git a/src/logging.ts b/src/logging.ts index 24437a63..bde18066 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -169,18 +169,24 @@ export function createLogger(env: NodeJS.ProcessEnv = process.env): Logger { return; } - const full: LogRecord = { - id: randomUUID(), - timestamp: new Date().toISOString(), - upstreamName: record.upstreamName, - toolName: record.toolName, - arguments: redactEnabled ? redact(record.arguments) : record.arguments, - rawResult: redactEnabled ? redact(record.rawResult) : record.rawResult, - }; - const line = boundedLogLine(full, maxRecordBytes); + let full: LogRecord; + try { + full = { + id: randomUUID(), + timestamp: new Date().toISOString(), + upstreamName: record.upstreamName, + toolName: record.toolName, + arguments: redactEnabled ? redact(record.arguments) : record.arguments, + rawResult: redactEnabled ? redact(record.rawResult) : record.rawResult, + }; + } catch (err) { + console.error("mottainai: failed to serialize log record", err); + return; + } writeQueue = writeQueue .then(async () => { + const line = boundedLogLine(full, maxRecordBytes); const lineBytes = Buffer.byteLength(line, "utf8"); if (currentFileBytes > 0 && currentFileBytes + lineBytes > maxFileBytes) { filePath = path.join(logDir, logFileName()); diff --git a/src/read-governor/evidence.test.ts b/src/read-governor/evidence.test.ts index cb642f04..52e6ff65 100644 --- a/src/read-governor/evidence.test.ts +++ b/src/read-governor/evidence.test.ts @@ -56,3 +56,25 @@ test("issue() evicts the oldest entry once maxEntries is reached", () => { assert.equal(store.get(first.evidenceId), undefined); assert.equal(store.get(second.evidenceId)?.path, "src/bar.ts"); }); + +test("constructor rejects invalid retention limits", () => { + assert.throws(() => new InMemoryEvidenceStore({ ttlMs: Number.POSITIVE_INFINITY }), /ttlMs/); + assert.throws(() => new InMemoryEvidenceStore({ ttlMs: -1 }), /ttlMs/); + assert.throws(() => new InMemoryEvidenceStore({ maxEntries: Number.NaN }), /maxEntries/); + assert.throws(() => new InMemoryEvidenceStore({ maxEntries: 1.5 }), /maxEntries/); + assert.throws(() => new InMemoryEvidenceStore({ maxEntries: 0 }), /maxEntries/); +}); + +test("issue() and get() return copies instead of exposing stored evidence", () => { + const store = new InMemoryEvidenceStore(); + const issued = store.issue(baseInput()); + issued.path = "tampered.ts"; + issued.expiresAt = 0; + + const stored = store.get(issued.evidenceId); + assert.equal(stored?.path, "src/foo.ts"); + assert.notEqual(stored?.expiresAt, 0); + + if (stored !== undefined) stored.path = "mutated-again.ts"; + assert.equal(store.get(issued.evidenceId)?.path, "src/foo.ts"); +}); diff --git a/src/read-governor/evidence.ts b/src/read-governor/evidence.ts index b328dc5d..32252e49 100644 --- a/src/read-governor/evidence.ts +++ b/src/read-governor/evidence.ts @@ -47,6 +47,20 @@ export interface InMemoryEvidenceStoreOptions { const DEFAULT_TTL_MS = 15 * 60 * 1000; const DEFAULT_MAX_ENTRIES = 500; +function validateTtlMs(value: number, name: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`${name} must be a finite non-negative number`); + } + return value; +} + +function validateMaxEntries(value: number): number { + if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + throw new RangeError("maxEntries must be a finite positive integer"); + } + return value; +} + /** * session-local な ReadEvidence ストア(InMemoryArtifactStore と同型: TTL + 上限件数の * インメモリ Map)。プロセス再起動・セッション終了で消える。期限切れ判定は authorize 側の @@ -60,8 +74,8 @@ export class InMemoryEvidenceStore implements EvidenceStore { private readonly createId: () => string; constructor(options: InMemoryEvidenceStoreOptions = {}) { - this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; - this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.ttlMs = validateTtlMs(options.ttlMs ?? DEFAULT_TTL_MS, "ttlMs"); + this.maxEntries = validateMaxEntries(options.maxEntries ?? DEFAULT_MAX_ENTRIES); this.now = options.now ?? Date.now; this.createId = options.createId ?? randomUUID; } @@ -73,6 +87,7 @@ export class InMemoryEvidenceStore implements EvidenceStore { if (!Number.isInteger(input.endLine) || input.endLine < input.startLine) { throw new Error("endLine must be an integer >= startLine"); } + const ttlMs = validateTtlMs(input.ttlMs ?? this.ttlMs, "ttlMs"); while (this.entries.size >= this.maxEntries) { const oldest = this.entries.keys().next().value; @@ -92,13 +107,14 @@ export class InMemoryEvidenceStore implements EvidenceStore { endLine: input.endLine, reason: input.reason, createdAt: now, - expiresAt: now + (input.ttlMs ?? this.ttlMs), + expiresAt: now + ttlMs, }; this.entries.set(evidence.evidenceId, evidence); - return evidence; + return { ...evidence }; } get(evidenceId: string): ReadEvidence | undefined { - return this.entries.get(evidenceId); + const evidence = this.entries.get(evidenceId); + return evidence === undefined ? undefined : { ...evidence }; } } diff --git a/src/retrieve.test.ts b/src/retrieve.test.ts index 497009e9..3a773129 100644 --- a/src/retrieve.test.ts +++ b/src/retrieve.test.ts @@ -26,6 +26,16 @@ test("artifact store returns a matching line with requested context", () => { assert.equal(result?.matchLine, 3); }); +test("artifact store keeps the matching line when context exceeds maxLines", () => { + const store = new InMemoryArtifactStore({ createId: () => "match-window" }); + const id = store.put({ content: [{ type: "text", text: "one\ntwo\nError: broken\nfour" }] }); + + const result = store.retrieve(id, { query: "Error", contextLines: 20, maxLines: 1 }); + assert.equal(result?.text, "Error: broken"); + assert.equal(result?.matchLine, 3); + assert.equal(result?.returnedStartLine, 3); +}); + test("artifact store expires entries at the configured TTL", () => { let now = 0; const store = new InMemoryArtifactStore({ ttlMs: 10, now: () => now, createId: () => "ttl" }); @@ -52,9 +62,46 @@ test("artifact store evicts the least recently used entry at the configured maxi }); test("artifact store bounds oversized text instead of retaining unbounded output", () => { - const store = new InMemoryArtifactStore({ createId: () => "bounded", maxBytes: 16 }); + const store = new InMemoryArtifactStore({ createId: () => "bounded", maxBytes: 100 }); const id = store.putArtifact({ text: "x".repeat(100), metadata: { operation: "test" } }); const result = store.retrieve(id); assert.ok(result); - assert.match(result.text, /artifact truncated bytes=100 max=16/); + assert.match(result.text, /artifact truncated bytes=100 max=100/); +}); + +test("artifact store truncates oversized UTF-8 text on character boundaries", () => { + const store = new InMemoryArtifactStore({ createId: () => "utf8", maxBytes: 160 }); + const id = store.putArtifact({ text: "あ".repeat(100), metadata: { operation: "test" } }); + const result = store.retrieve(id); + assert.ok(result); + assert.match(result.text, /artifact truncated bytes=300 max=160/); + assert.equal(result.text.includes("\uFFFD"), false); + assert.ok(Buffer.byteLength(result.text, "utf8") <= 160); +}); + +test("artifact store bounds oversized stdout and stderr fields", () => { + const store = new InMemoryArtifactStore({ createId: () => "streams", maxBytes: 160 }); + const id = store.putArtifact({ + text: "small", + stdout: "あ".repeat(100), + stderr: "b".repeat(1_000), + metadata: { operation: "test" }, + }); + + const stdout = store.retrieve(id, { stream: "stdout" }); + const stderr = store.retrieve(id, { stream: "stderr" }); + assert.ok(stdout); + assert.ok(stderr); + assert.ok(Buffer.byteLength(stdout.text, "utf8") <= 160); + assert.ok(Buffer.byteLength(stderr.text, "utf8") <= 160); + assert.ok(stdout.text.length < 100 || stderr.text.length < 1_000); +}); + +test("artifact store rejects invalid retention and byte limits", () => { + assert.throws(() => new InMemoryArtifactStore({ ttlMs: Number.POSITIVE_INFINITY }), /ttlMs/); + assert.throws(() => new InMemoryArtifactStore({ ttlMs: -1 }), /ttlMs/); + assert.throws(() => new InMemoryArtifactStore({ maxEntries: Number.NaN }), /maxEntries/); + assert.throws(() => new InMemoryArtifactStore({ maxEntries: 1.5 }), /maxEntries/); + assert.throws(() => new InMemoryArtifactStore({ maxBytes: Number.POSITIVE_INFINITY }), /maxBytes/); + assert.throws(() => new InMemoryArtifactStore({ maxBytes: 0 }), /maxBytes/); }); diff --git a/src/retrieve.ts b/src/retrieve.ts index 7d849b3e..30a0dfad 100644 --- a/src/retrieve.ts +++ b/src/retrieve.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { TextDecoder } from "node:util"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; export interface RetrievedArtifact { @@ -71,6 +72,8 @@ const DEFAULT_MAX_ENTRIES = 200; const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; const DEFAULT_MAX_LINES = 80; +type ArtifactPayload = Pick; + function textFromResult(result: CallToolResult): string { if (!Array.isArray(result.content)) return ""; return result.content @@ -79,6 +82,166 @@ function textFromResult(result: CallToolResult): string { .join("\n\n"); } +function utf8Prefix(value: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + const bytes = Buffer.from(value, "utf8"); + if (bytes.byteLength <= maxBytes) return value; + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (let end = Math.min(maxBytes, bytes.byteLength); end >= 0; end -= 1) { + try { + return decoder.decode(bytes.subarray(0, end)); + } catch { + // 切断位置がマルチバイト文字の途中なら、さらに1 byte戻す。 + } + } + return ""; +} + +function payloadBytes(payload: ArtifactPayload): number { + return Buffer.byteLength(JSON.stringify(payload), "utf8"); +} + +function fitStringField( + payload: ArtifactPayload, + key: "stdout" | "stderr", + value: string, + maxBytes: number, +): string | undefined { + let low = 0; + let high = Buffer.byteLength(value, "utf8"); + let best: string | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = utf8Prefix(value, middle); + if (payloadBytes({ ...payload, [key]: candidate }) <= maxBytes) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + return best; +} + +type MetadataStringKey = "operation" | "command" | "cwd" | "summary"; + +function fitMetadataString( + payload: ArtifactPayload, + metadata: ArtifactMetadata, + key: MetadataStringKey, + value: string, + maxBytes: number, +): string | undefined { + let low = 0; + let high = Buffer.byteLength(value, "utf8"); + let best: string | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = { ...metadata, [key]: utf8Prefix(value, middle) }; + if (payloadBytes({ ...payload, metadata: candidate }) <= maxBytes) { + best = candidate[key]; + low = middle + 1; + } else { + high = middle - 1; + } + } + return best; +} + +function boundMetadata( + payload: ArtifactPayload, + metadata: ArtifactMetadata, + maxBytes: number, +): ArtifactMetadata | undefined { + const operation = fitMetadataString(payload, { operation: "" }, "operation", metadata.operation, maxBytes); + if (operation === undefined) return undefined; + let bounded: ArtifactMetadata = { operation }; + for (const key of ["command", "cwd", "summary"] as const) { + const value = metadata[key]; + if (value === undefined) continue; + const fitted = fitMetadataString(payload, bounded, key, value, maxBytes); + if (fitted !== undefined) bounded = { ...bounded, [key]: fitted }; + } + if (metadata.diagnostics !== undefined) { + const withDiagnostics = { ...bounded, diagnostics: metadata.diagnostics }; + if (payloadBytes({ ...payload, metadata: withDiagnostics }) <= maxBytes) bounded = withDiagnostics; + } + return bounded; +} + +function truncationFooter(rawBytes: number, maxBytes: number): string { + return `\n⋯ artifact truncated bytes=${rawBytes} max=${maxBytes} ⋯`; +} + +function fitText(payload: ArtifactPayload, originalText: string, maxBytes: number): string { + const rawBytes = Buffer.byteLength(originalText, "utf8"); + const footer = truncationFooter(rawBytes, maxBytes); + let low = 0; + let high = rawBytes; + let best: string | undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = `${utf8Prefix(originalText, middle)}${footer}`; + if (payloadBytes({ ...payload, text: candidate }) <= maxBytes) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + if (best !== undefined) return best; + + low = 0; + high = rawBytes; + best = undefined; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = utf8Prefix(originalText, middle); + if (payloadBytes({ ...payload, text: candidate }) <= maxBytes) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + return best ?? ""; +} + +function boundArtifact(artifact: StoredArtifactInput, maxBytes: number): ArtifactPayload { + const payload: ArtifactPayload = { + text: artifact.text, + ...(artifact.stdout === undefined ? {} : { stdout: artifact.stdout }), + ...(artifact.stderr === undefined ? {} : { stderr: artifact.stderr }), + ...(artifact.metadata === undefined ? {} : { metadata: artifact.metadata }), + }; + if (payloadBytes(payload) <= maxBytes) return payload; + + for (const key of ["stdout", "stderr"] as const) { + const value = payload[key]; + if (value === undefined) continue; + const bounded = fitStringField(payload, key, value, maxBytes); + if (bounded === undefined) delete payload[key]; + else payload[key] = bounded; + } + + if (payload.metadata !== undefined) { + const bounded = boundMetadata(payload, payload.metadata, maxBytes); + if (bounded === undefined) delete payload.metadata; + else payload.metadata = bounded; + } + + for (const key of ["metadata", "stderr", "stdout"] as const) { + if (payloadBytes(payload) <= maxBytes) break; + delete payload[key]; + } + + if (payloadBytes(payload) > maxBytes) { + const originalText = payload.text; + payload.text = fitText({ ...payload, text: "" }, originalText, maxBytes); + } + return payload; +} + /** 圧縮前textを短時間だけ保持する、プロセス内CCRストア。 */ export class InMemoryArtifactStore implements ArtifactStore { private readonly entries = new Map(); @@ -89,9 +252,17 @@ export class InMemoryArtifactStore implements ArtifactStore { private readonly createId: () => string; constructor(options: InMemoryArtifactStoreOptions = {}) { - this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; - this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; - this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new RangeError("ttlMs must be a finite non-negative number"); + if (!Number.isFinite(maxEntries) || !Number.isInteger(maxEntries) || maxEntries <= 0) { + throw new RangeError("maxEntries must be a finite positive integer"); + } + if (!Number.isFinite(maxBytes) || maxBytes <= 0) throw new RangeError("maxBytes must be a finite positive number"); + this.ttlMs = ttlMs; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; this.now = options.now ?? Date.now; this.createId = options.createId ?? randomUUID; } @@ -109,11 +280,8 @@ export class InMemoryArtifactStore implements ArtifactStore { } const id = `mx_${this.createId()}`; - const rawBytes = Buffer.byteLength(artifact.text, "utf8"); - const text = rawBytes <= this.maxBytes - ? artifact.text - : `${Buffer.from(artifact.text, "utf8").subarray(0, this.maxBytes).toString("utf8")}\n⋯ artifact truncated bytes=${rawBytes} max=${this.maxBytes} ⋯`; - this.entries.set(id, { ...artifact, text, expiresAt: this.now() + this.ttlMs }); + const bounded = boundArtifact(artifact, this.maxBytes); + this.entries.set(id, { ...bounded, expiresAt: this.now() + this.ttlMs }); return id; } @@ -134,11 +302,11 @@ export class InMemoryArtifactStore implements ArtifactStore { const source = stream === "stdout" ? entry.stdout ?? "" : stream === "stderr" ? entry.stderr ?? "" : entry.text; const lines = source.split("\n"); const matchIndex = options.query ? lines.findIndex((line) => line.includes(options.query!)) : -1; + const maxLines = Math.max(1, Math.min(options.maxLines ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES)); const contextLines = Math.max(0, Math.min(options.contextLines ?? 0, 20)); const startLine = matchIndex === -1 ? Math.max(0, options.startLine ?? 0) - : Math.max(0, matchIndex - contextLines); - const maxLines = Math.max(1, Math.min(options.maxLines ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES)); + : Math.max(0, matchIndex - Math.min(contextLines, maxLines - 1)); const selected = lines.slice(startLine, startLine + maxLines); const endLine = startLine + selected.length; diff --git a/src/state/migrations.test.ts b/src/state/migrations.test.ts new file mode 100644 index 00000000..e1980ac9 --- /dev/null +++ b/src/state/migrations.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "node:test"; +import { applyMigrations } from "./migrations.js"; + +test("applyMigrations discovers and applies one ordered migration per transaction", () => { + const db = new DatabaseSync(":memory:"); + const applied: number[] = []; + try { + applyMigrations(db, [ + { version: 2, description: "second", up: () => { applied.push(2); } }, + { version: 1, description: "first", up: () => { applied.push(1); } }, + ]); + + assert.deepEqual(applied, [1, 2]); + assert.equal(db.prepare("SELECT MAX(version) AS version FROM schema_migrations").get()?.version, 2); + + applyMigrations(db, [ + { version: 1, description: "first", up: () => { applied.push(1); } }, + { version: 2, description: "second", up: () => { applied.push(2); } }, + ]); + assert.deepEqual(applied, [1, 2]); + } finally { + db.close(); + } +}); diff --git a/src/state/migrations.ts b/src/state/migrations.ts index 2c083a5c..314a22fa 100644 --- a/src/state/migrations.ts +++ b/src/state/migrations.ts @@ -66,17 +66,29 @@ function currentVersion(db: DatabaseSync): number { /** 未適用の migration を version 昇順に適用する。冪等(適用済みなら何もしない)。 */ export function applyMigrations(db: DatabaseSync, migrations: Migration[] = MIGRATIONS): void { - const applied = currentVersion(db); - const pending = migrations.filter((migration) => migration.version > applied).sort((left, right) => left.version - right.version); - const recordApplied = db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)"); - for (const migration of pending) { - db.exec("BEGIN"); + const ordered = [...migrations].sort((left, right) => left.version - right.version); + for (;;) { + db.exec("BEGIN IMMEDIATE"); + let migration: Migration | undefined; try { + const applied = currentVersion(db); + migration = ordered.find((candidate) => candidate.version > applied); + if (migration === undefined) { + db.exec("COMMIT"); + return; + } + migration.up(db); - recordApplied.run(migration.version, Date.now()); + db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)") + .run(migration.version, Date.now()); db.exec("COMMIT"); } catch (err) { - db.exec("ROLLBACK"); + try { + db.exec("ROLLBACK"); + } catch { + // 元の migration エラーを保持する + } + if (migration === undefined) throw err; throw new Error(`migration ${migration.version} (${migration.description}) failed: ${(err as Error).message}`); } } diff --git a/src/state/paths.test.ts b/src/state/paths.test.ts index 51c2f827..c1a17a32 100644 --- a/src/state/paths.test.ts +++ b/src/state/paths.test.ts @@ -20,6 +20,11 @@ test("resolveStateDir: linux falls back to ~/.local/state without XDG_STATE_HOME assert.equal(resolveStateDir(env, "linux"), path.join("/home/user", ".local", "state", "mottainai")); }); +test("resolveStateDir: linux ignores a relative XDG_STATE_HOME", () => { + const env = { HOME: "/home/user", XDG_STATE_HOME: "relative/state" }; + assert.equal(resolveStateDir(env, "linux"), path.join("/home/user", ".local", "state", "mottainai")); +}); + test("resolveStateDir: macOS uses Application Support", () => { const env = { HOME: "/Users/user" }; assert.equal(resolveStateDir(env, "darwin"), path.join("/Users/user", "Library", "Application Support", "mottainai")); diff --git a/src/state/paths.ts b/src/state/paths.ts index b2d1206f..9dbfc167 100644 --- a/src/state/paths.ts +++ b/src/state/paths.ts @@ -32,7 +32,10 @@ export function resolveStateDir(env: NodeJS.ProcessEnv = process.env, platform: } const xdgStateHome = env.XDG_STATE_HOME; - const base = xdgStateHome !== undefined && xdgStateHome.length > 0 ? xdgStateHome : path.join(home, ".local", "state"); + const base = + xdgStateHome !== undefined && xdgStateHome.length > 0 && path.isAbsolute(xdgStateHome) + ? xdgStateHome + : path.join(home, ".local", "state"); return path.join(base, APP_DIR_NAME); } diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts index e14c535d..62448bb9 100644 --- a/src/telemetry.test.ts +++ b/src/telemetry.test.ts @@ -80,6 +80,27 @@ test("a new sink resumes accumulating from a previously persisted summary", asyn second.recordToolCall({ provider: "fff", originalBytes: 100, compressedBytes: 10, isError: false }); assert.equal(second.snapshot().totals.calls, 2); + await new Promise((resolve) => setTimeout(resolve, 50)); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("snapshot returns deep copies of mutable counters", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mottainai-telemetry-copy-")); + const filePath = path.join(dir, "telemetry", "summary.json"); + const sink = createTelemetrySink({ MOTTAINAI_TELEMETRY: "1", MOTTAINAI_TELEMETRY_FILE: filePath }); + sink.recordToolCall({ provider: "fff", capability: "grep", originalBytes: 100, compressedBytes: 10, isError: false }); + + const snapshot = sink.snapshot(); + snapshot.totals.calls = 999; + snapshot.by_provider.fff.calls = 999; + snapshot.by_capability.grep.calls = 999; + + const next = sink.snapshot(); + assert.equal(next.totals.calls, 1); + assert.equal(next.by_provider.fff.calls, 1); + assert.equal(next.by_capability.grep.calls, 1); + + await new Promise((resolve) => setTimeout(resolve, 50)); await fs.rm(dir, { recursive: true, force: true }); }); diff --git a/src/telemetry.ts b/src/telemetry.ts index 2c5aa858..570e3d97 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -55,6 +55,22 @@ function emptyState(): TelemetryState { return { totals: { ...emptyCounts(), retrievals: 0 }, by_provider: {}, by_capability: {} }; } +function cloneCounts(counts: TelemetryCounts): TelemetryCounts { + return { ...counts }; +} + +function snapshotState(state: TelemetryState): Pick { + return { + totals: { ...state.totals }, + by_provider: Object.fromEntries( + Object.entries(state.by_provider).map(([key, counts]) => [key, cloneCounts(counts)]), + ), + by_capability: Object.fromEntries( + Object.entries(state.by_capability).map(([key, counts]) => [key, cloneCounts(counts)]), + ), + }; +} + export function isTelemetryEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const value = env.MOTTAINAI_TELEMETRY; return value === "1" || (value?.toLowerCase() === "true"); @@ -131,12 +147,20 @@ export function createTelemetrySink(env: NodeJS.ProcessEnv = process.env): Telem const filePath = resolveTelemetryPath(env); const state = loadState(filePath) ?? emptyState(); let writeQueue: Promise = Promise.resolve(); + let pendingUpdates = 0; + let persistTimer: ReturnType | undefined; + const PERSIST_BATCH_SIZE = 10; + const PERSIST_DEBOUNCE_MS = 10; - function persist(): void { + function persistNow(): void { writeQueue = writeQueue .then(async () => { await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - const snapshot: TelemetrySnapshot = { enabled: true, generated_at: new Date().toISOString(), ...state }; + const snapshot: TelemetrySnapshot = { + enabled: true, + generated_at: new Date().toISOString(), + ...snapshotState(state), + }; await fs.promises.writeFile(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }); }) .catch((err) => { @@ -144,6 +168,25 @@ export function createTelemetrySink(env: NodeJS.ProcessEnv = process.env): Telem }); } + function persist(): void { + pendingUpdates += 1; + if (pendingUpdates >= PERSIST_BATCH_SIZE) { + pendingUpdates = 0; + if (persistTimer !== undefined) { + clearTimeout(persistTimer); + persistTimer = undefined; + } + persistNow(); + return; + } + if (persistTimer !== undefined) return; + persistTimer = setTimeout(() => { + persistTimer = undefined; + pendingUpdates = 0; + persistNow(); + }, PERSIST_DEBOUNCE_MS); + } + return { enabled: true, filePath, @@ -164,7 +207,7 @@ export function createTelemetrySink(env: NodeJS.ProcessEnv = process.env): Telem persist(); }, snapshot() { - return { enabled: true, generated_at: new Date().toISOString(), ...state }; + return { enabled: true, generated_at: new Date().toISOString(), ...snapshotState(state) }; }, }; } From 7734cf88094416118e574b3acb43fb34b18ce9b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 22:48:59 +0000 Subject: [PATCH 3/3] fix: reserve isError and preserve metadata.operation under size limits - envelope.ts: reserve `isError` alongside OUTPUT_SCHEMA fields so details.isError can no longer leak into structuredContent while the actual MCP result reports a different error state. - retrieve.ts: keep a minimal { operation } metadata record alive through artifact size bounding, reserving its bytes before text truncation, so search() doesn't fall back to "unknown" once an oversized text/stdout/stderr forces the rest of metadata out. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Prz9oz69aU1eZoVvcTry89 --- src/envelope.test.ts | 6 ++++++ src/envelope.ts | 2 +- src/retrieve.test.ts | 13 +++++++++++++ src/retrieve.ts | 18 +++++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/envelope.test.ts b/src/envelope.test.ts index 7dcaaa20..917cc296 100644 --- a/src/envelope.test.ts +++ b/src/envelope.test.ts @@ -51,3 +51,9 @@ test("output preserves the error flag independently of details", () => { assert.equal(result.isError, true); assert.equal((result.structuredContent as Record).isError, undefined); }); + +test("output does not let details.isError leak into structuredContent", () => { + const result = output("read", "success", "ok", "mx_result", { isError: true }); + assert.equal(result.isError, undefined); + assert.equal((result.structuredContent as Record).isError, undefined); +}); diff --git a/src/envelope.ts b/src/envelope.ts index bbde6f55..27612eef 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -14,7 +14,7 @@ export const OUTPUT_SCHEMA = { required: ["operation", "status", "summary", "facts", "diagnostics", "metrics", "result_id", "truncated"], }; -const RESERVED_OUTPUT_FIELDS = new Set(Object.keys(OUTPUT_SCHEMA.properties)); +const RESERVED_OUTPUT_FIELDS = new Set([...Object.keys(OUTPUT_SCHEMA.properties), "isError"]); function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); diff --git a/src/retrieve.test.ts b/src/retrieve.test.ts index 3a773129..38153516 100644 --- a/src/retrieve.test.ts +++ b/src/retrieve.test.ts @@ -97,6 +97,19 @@ test("artifact store bounds oversized stdout and stderr fields", () => { assert.ok(stdout.text.length < 100 || stderr.text.length < 1_000); }); +test("artifact store keeps metadata.operation discoverable even when oversized text forces metadata out", () => { + const store = new InMemoryArtifactStore({ createId: () => "op-preserved", maxBytes: 200 }); + const id = store.putArtifact({ + text: `MARKER${"x".repeat(5_000)}`, + metadata: { operation: "build", command: "npm test" }, + }); + + const results = store.search("MARKER"); + assert.equal(results.length, 1); + assert.equal(results[0].id, id); + assert.equal(results[0].operation, "build"); +}); + test("artifact store rejects invalid retention and byte limits", () => { assert.throws(() => new InMemoryArtifactStore({ ttlMs: Number.POSITIVE_INFINITY }), /ttlMs/); assert.throws(() => new InMemoryArtifactStore({ ttlMs: -1 }), /ttlMs/); diff --git a/src/retrieve.ts b/src/retrieve.ts index 30a0dfad..11688f08 100644 --- a/src/retrieve.ts +++ b/src/retrieve.ts @@ -235,10 +235,26 @@ function boundArtifact(artifact: StoredArtifactInput, maxBytes: number): Artifac delete payload[key]; } + // `operation` drives artifact discovery in search(); keep a minimal { operation } + // record alive even when the rest of metadata, stdout, stderr, and text had to be + // dropped or truncated to fit maxBytes. Reserve its bytes before truncating text so + // fitText doesn't spend the entire remaining budget on text alone. + const operation = artifact.metadata?.operation; + const minimalMetadata: ArtifactMetadata | undefined = operation === undefined ? undefined : { operation }; + if (payloadBytes(payload) > maxBytes) { const originalText = payload.text; - payload.text = fitText({ ...payload, text: "" }, originalText, maxBytes); + const budgetPayload: ArtifactPayload = { ...payload, text: "" }; + if (minimalMetadata === undefined) delete budgetPayload.metadata; + else budgetPayload.metadata = minimalMetadata; + payload.text = fitText(budgetPayload, originalText, maxBytes); + delete payload.metadata; + } + + if (minimalMetadata !== undefined && payload.metadata === undefined) { + if (payloadBytes({ ...payload, metadata: minimalMetadata }) <= maxBytes) payload.metadata = minimalMetadata; } + return payload; }