diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3d2cf25 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +{ + "name": "Reporecall", + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "remoteUser": "node", + "updateRemoteUserUID": true, + "init": true, + "postCreateCommand": "npm ci", + "postStartCommand": "bash -lc 'git rev-parse --git-dir >/dev/null 2>&1 && git config --global --add safe.directory \"${containerWorkspaceFolder}\" || true'", + "forwardPorts": [37222], + "portsAttributes": { + "37222": { + "label": "Reporecall daemon", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "GitHub.vscode-github-actions", + "vitest.explorer", + "esbenp.prettier-vscode" + ], + "settings": { + "files.eol": "\n", + "typescript.tsdk": "node_modules/typescript/lib" + } + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fe2b12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test -- --run + - run: npm run build + - run: npm run smoke + - run: npm audit --omit=dev --audit-level=high + - run: npm pack --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 084c8e2..e048f80 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,8 @@ name: Publish on: - push: - tags: ['v*'] + release: + types: [published] + workflow_dispatch: permissions: contents: read jobs: @@ -17,6 +18,9 @@ jobs: - run: npm run lint - run: npm test -- --run - run: npm run build + - run: npm run smoke + - run: npm audit --omit=dev --audit-level=high + - run: npm pack --dry-run - run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..122c867 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,23 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore index 35cf153..e486788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .memory/ +.engram/ reports .claude/ .demo/ @@ -15,3 +16,17 @@ claude-code-source-main .tmp/ test/.test-data-server-*/ test/.test-data-server-*/graphify-3/ + +.env +.env.* +*.log +.idea/ +.vscode/ +coverage/ +*.tgz + +# Lockfiles: npm is canonical (package-lock.json); ignore pnpm +pnpm-lock.yaml + +# Local MCP config (non-portable absolute paths) — copy from .mcp.json.example +.mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 6f6ed88..0000000 --- a/.mcp.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mcpServers": { - "reporecall": { - "command": "/Users/danillofelanso/.nvm/versions/node/v22.5.1/bin/node", - "args": [ - "/Users/danillofelanso/projects/proofofworks/idea/dist/memory.js", - "mcp", - "--project", - "/Users/danillofelanso/projects/proofofworks/idea" - ] - } - } -} \ No newline at end of file diff --git a/.mcp.json.example b/.mcp.json.example new file mode 100644 index 0000000..7d74077 --- /dev/null +++ b/.mcp.json.example @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "reporecall": { + "command": "node", + "args": [ + "./dist/memory.js", + "mcp", + "--project", + "${PROJECT_ROOT}" + ] + } + } +} diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 0000000..1caaa26 --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-title-pattern": "chore: release ${version}", + "packages": { + ".": { + "package-name": "@proofofwork-agency/reporecall", + "release-type": "node" + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true } + ] +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..13708fa --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.7.1" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..50f4dbd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing to Reporecall + +Reporecall is a local-first codebase context engine for coding agents. Keep changes focused, source-grounded, and easy to verify. + +## Local Setup + +```bash +npm ci +npm run build +npm test -- --run +``` + +Use `make ci-precheck` before pushing. It mirrors the CI gates that run on pull requests. + +## Development + +Reporecall requires Node.js `>=20` (declared in `package.json` `engines`). Canonical workflow: + +```bash +npm ci # install dependencies +npm run lint # type-check (tsc --noEmit) +npm test -- --run # run the test suite (Vitest, single run) +npm run build # build with tsup +``` + +## Pull Requests + +- Use one logical change per PR. +- Include tests for behavior changes. +- Run `make ci-precheck` and include anything you could not verify. +- Use conventional commit-style titles such as `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `ci:`, or `chore:`. +- Update `CHANGELOG.md` or relevant docs for user-facing changes. + +## Dependencies + +Dependency changes need a short justification in the PR: + +- why the package or version is needed; +- whether it adds native code, install-time downloads, or runtime network access; +- why existing dependencies are not enough. + +Security fixes are welcome. Production dependency advisories at `high` or `critical` severity must be fixed or explicitly justified before release. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0501cdb --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +SHELL := /bin/bash + +.PHONY: ci-precheck lint test build smoke audit pack-check + +ci-precheck: lint test build smoke audit pack-check + @echo "ci-precheck passed" + +lint: + npm run lint + +test: + npm test -- --run + +build: + npm run build + +smoke: + npm run smoke + +audit: + npm audit --omit=dev --audit-level=high + +pack-check: + npm pack --dry-run diff --git a/README.md b/README.md index 24a1981..b0a49c8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ reporecall serve # Start daemon + file watcher reporecall lens --serve # Open the architecture dashboard ``` +The package also exposes a `memory` CLI alias, which may collide with other global installs; `reporecall` is the canonical command name. + That's it. Context is injected automatically into every Claude Code prompt via hooks, wiki pages regenerate as the code changes, and the lens dashboard is always one command away. --- @@ -300,7 +302,7 @@ Main tool groups: | Group | Tools | | --- | --- | -| Code search | `search_code`, `get_symbol`, `resolve_seed` | +| Code search | `search_context`, `search_code`, `read_code_chunk`, `get_symbol`, `resolve_seed` | | Flow/navigation | `find_callers`, `find_callees`, `explain_flow`, `build_stack_tree`, `get_imports` | | Business context | `list_product_areas`, `business_context_query` | | Topology | `get_communities`, `get_hub_nodes`, `get_surprises`, `suggest_investigations` | @@ -314,16 +316,22 @@ Configuration lives in `.memory/config.json`. | Key | Default | Description | | --- | --- | --- | -| `embeddingProvider` | `"keyword"` | Retrieval backend. `keyword` is local FTS-only. | +| `embeddingProvider` | `"local"` | Retrieval backend. `local` uses Xenova/all-MiniLM-L6-v2 local vector embeddings; `keyword` is FTS-only with no vectors (also: `ollama`, `openai`). | | `wikiBudget` | `400` | Max tokens for wiki injection per prompt. | | `wikiMaxPages` | `3` | Max wiki pages injected per prompt. | | `memoryBudget` | `500` | Max tokens for memory injection per prompt. | | `capabilityEvidence` | `true` | Use code/wiki/graph evidence to select related files for trace, architecture, and change prompts. | | `genericCapabilityHydration` | `true` | Hydrate broad inventory evidence into prompt context for questions like "which files implement...". | +| `contextCompressionMode` | `"auto"` | Compress secondary code evidence in assembled context. Use `"off"` to disable or `"always"` for diagnostics. | +| `contextCompressionPreserveTopChunks` | `1` | Number of top chunks kept as full source before secondary evidence can be compacted. | +| `contextCompressionMinChunkTokens` | `100` | Minimum chunk size before compression is attempted. | +| `contextCompressionTargetRatio` | `0.75` | Maximum compressed/full token ratio accepted for compacted evidence. | | `topologyEnabled` | `true` | Run topology/community analysis after indexing. | | `topologyMaxChunks` | `50000` | Skip full topology graph construction above this indexed chunk count. | | `shutdownTimeoutMs` | `10000` | Graceful shutdown timeout in milliseconds. | +This table lists common keys only; see `src/core/config.ts` for the full, authoritative list of configuration options and defaults. + Assistant/client instruction files such as `AGENTS.md`, `CLAUDE.md`, `.claude/**`, `.codex/**`, and `.mcp.json` are ignored by default as code evidence. ## CLI Reference diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md new file mode 100644 index 0000000..050af39 --- /dev/null +++ b/docs/architecture-overview.md @@ -0,0 +1,16 @@ +# Architecture Overview + +Reporecall indexes a project locally and exposes source-grounded context through CLI commands, Claude Code hooks, and MCP tools. + +The main layers are: + +| Layer | Role | +| --- | --- | +| Indexer | Scans files, chunks source with Tree-sitter, records imports and call edges. | +| Storage | Persists chunks, full-text search, metadata, graph edges, wiki, memory, and lens data in local stores. | +| Search | Routes user prompts by intent, retrieves candidate chunks, expands graph evidence, and assembles prompt context. | +| Hooks | Injects selected context into agent prompts and records hook diagnostics. | +| MCP/CLI | Exposes search, flow explanation, wiki, memory, business context, lens export, and indexing operations. | +| Lens/Wiki | Generates deterministic project views over code topology and business-facing capability evidence. | + +Reporecall should stay a repo-intelligence engine. It should not absorb provider proxying, model hosting, or editor UI responsibilities. diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 0000000..2c03351 --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,28 @@ +# Release And Verification + +Reporecall releases are managed through release-please. + +Day to day: + +- Merge `feat:` and `fix:` changes to `main`. +- The release-please workflow maintains a release PR. +- Merging the release PR creates a GitHub release. +- The publish workflow publishes the npm package from the release event. + +Before pushing or publishing, run: + +```bash +make ci-precheck +``` + +The CI and publish gates run: + +- `npm ci` +- `npm run lint` +- `npm test -- --run` +- `npm run build` +- `npm run smoke` +- `npm audit --omit=dev --audit-level=high` +- `npm pack --dry-run` + +Do not soft-fail security, test, build, or packaging gates. diff --git a/docs/retrieval-context-pipeline.md b/docs/retrieval-context-pipeline.md new file mode 100644 index 0000000..b8af89c --- /dev/null +++ b/docs/retrieval-context-pipeline.md @@ -0,0 +1,29 @@ +# Retrieval And Context Pipeline + +Reporecall builds context in this order: + +1. Sanitize and classify the prompt as `lookup`, `trace`, `bug`, `architecture`, `change`, or `skip`. +2. Resolve explicit seeds when the prompt names a file, route, symbol, or subsystem. +3. Retrieve candidates through keyword/vector indexes and graph expansion. +4. Apply route-specific selection for bug localization, trace flow, or broad architecture inventory. +5. Assemble context under the configured token budget. +6. Add wiki, memory, and product-area evidence when relevant and within budget. +7. Return text plus diagnostics through hooks, CLI, or MCP. + +Context quality is more important than raw chunk volume. For trace and architecture prompts, Reporecall should cover the implementation path across entry points, services, storage, handlers, and shared helpers when those layers exist. + +## Evidence Compression + +When context compression is enabled, Reporecall keeps primary evidence intact and compacts secondary evidence into language-aware bullets with retrievable original chunk references. Compressed entries preserve imports, signatures, decorators, route/config/error literals, query matches, line numbers, and a `chunkId`. + +The behavior is controlled in `.memory/config.json`: + +- `contextCompressionEnabled`: set `false` to disable evidence compression. +- `contextCompressionMode`: `auto`, `always`, or `off`. +- `contextCompressionPreserveTopChunks`: number of top chunks kept as full source. +- `contextCompressionMinChunkTokens`: minimum chunk size before compression is attempted. +- `contextCompressionTargetRatio`: maximum compressed/full token ratio accepted. + +Compressed context is reversible through the MCP `read_code_chunk` tool. Use the `chunkId` from compressed evidence, or provide `filePath` with `startLine`/`endLine`, to retrieve the full original chunk. + +MCP clients should prefer `search_context` for multi-file questions because it returns the same assembled, compression-aware context used by hooks and CLI explain. Use `search_code` when you explicitly need raw matching chunks instead of a token-budgeted context bundle. diff --git a/package-lock.json b/package-lock.json index 324d1df..3b743f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proofofwork-agency/reporecall", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proofofwork-agency/reporecall", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT", "dependencies": { "@huggingface/transformers": "^3.8.1", @@ -1255,19 +1255,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -1277,9 +1276,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -3169,9 +3168,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3998,24 +3997,24 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", + "integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" diff --git a/package.json b/package.json index 82e5493..d9a4aed 100644 --- a/package.json +++ b/package.json @@ -57,14 +57,18 @@ "benchmark:memory": "vitest run test/benchmark/metrics.test.ts test/benchmark/memory-benchmark.test.ts test/daemon/memory-runtime.test.ts test/benchmark/live-memory-benchmark.test.ts", "benchmark:live-memory": "tsx scripts/benchmarks/memory-v1-live.ts", "smoke": "node scripts/smoke-test.mjs", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run lint && npm test -- --run && npm run build" }, "engines": { - "node": ">=18" + "node": ">=20" }, "publishConfig": { "access": "public" }, + "overrides": { + "hono": "4.12.26", + "protobufjs": "7.6.3" + }, "license": "MIT", "dependencies": { "@huggingface/transformers": "^3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 3868255..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,3699 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@huggingface/transformers': - specifier: ^3.8.1 - version: 3.8.1 - '@lancedb/lancedb': - specifier: ^0.27.0 - version: 0.27.2(apache-arrow@18.1.0) - '@modelcontextprotocol/sdk': - specifier: ^1.27.1 - version: 1.29.0(zod@4.3.6) - better-sqlite3: - specifier: ^12.8.0 - version: 12.8.0 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 - commander: - specifier: ^14.0.3 - version: 14.0.3 - glob: - specifier: ^13.0.6 - version: 13.0.6 - ignore: - specifier: ^7.0.5 - version: 7.0.5 - pino: - specifier: ^10.3.1 - version: 10.3.1 - safe-regex2: - specifier: ^5.1.0 - version: 5.1.0 - tiktoken: - specifier: ^1.0.22 - version: 1.0.22 - tree-sitter-wasms: - specifier: ^0.1.13 - version: 0.1.13 - web-tree-sitter: - specifier: ^0.22.6 - version: 0.22.6 - xxhash-wasm: - specifier: ^1.1.0 - version: 1.1.0 - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/node': - specifier: ^25.5.0 - version: 25.5.0 - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.1.0 - version: 4.1.2(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) - -packages: - - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} - - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} - - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@hono/node-server@1.19.12': - resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@huggingface/jinja@0.5.6': - resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} - engines: {node: '>=18'} - - '@huggingface/transformers@3.8.1': - resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@lancedb/lancedb-darwin-arm64@0.27.2': - resolution: {integrity: sha512-+XM68V/Rou8kKWDnUeKvg9ChKS0zGeQC2sKAop+06Ty4LwIjEGkeYBYrK0vMhZkBN5EFaOjTOp8E8hGQxdFwXA==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [darwin] - - '@lancedb/lancedb-linux-arm64-gnu@0.27.2': - resolution: {integrity: sha512-laiTTDeMUTzm7t+t6ME5nNQMDoERjmkeuWAFWekbXiFdmp62Dqu34Lvf2BvpWnKwxLMZ5JcBJFIw32WS8/8Jnw==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [linux] - - '@lancedb/lancedb-linux-arm64-musl@0.27.2': - resolution: {integrity: sha512-bK5Mc50EvwGZaaiym5CoPu8Y4GNSyEEvTQ0dTC2AUIm83qdQu1rGw6kkYtc/rTH/hbvAvPQot4agHDZfMVxfYw==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [linux] - - '@lancedb/lancedb-linux-x64-gnu@0.27.2': - resolution: {integrity: sha512-qe+ML0YmPru0o84f33RBHqoNk6zsHBjiXTLKsEBDiiFYKks/XMsrkKy9NQYcTxShBrg/nx/MLzCzd7dihqgNYw==} - engines: {node: '>= 18'} - cpu: [x64] - os: [linux] - - '@lancedb/lancedb-linux-x64-musl@0.27.2': - resolution: {integrity: sha512-ZpX6Oxn06qvzAdm+D/gNb3SRp/A9lgRAPvPg6nnMmSQk5XamC/hbGO07uK1wwop7nlqXUH/thk4is2y2ieWdTw==} - engines: {node: '>= 18'} - cpu: [x64] - os: [linux] - - '@lancedb/lancedb-win32-arm64-msvc@0.27.2': - resolution: {integrity: sha512-4ffpFvh49MiUtkdFJOmBytXEbgUPXORphTOuExnJAgT1VAKwQcu4ZzdsgNoK6mumKBaU+pYQU/MedNkgTzx/Lw==} - engines: {node: '>= 18'} - cpu: [arm64] - os: [win32] - - '@lancedb/lancedb-win32-x64-msvc@0.27.2': - resolution: {integrity: sha512-XlwiI6CK2Gkqq+FFVAStHojao/XjIJpDPTm7Tb9SpLL64IlwGw3yaT2hnWKTm90W4KlSrpfSldPly+s+y4U7JQ==} - engines: {node: '>= 18'} - cpu: [x64] - os: [win32] - - '@lancedb/lancedb@0.27.2': - resolution: {integrity: sha512-JQpZHV5KzUzDI3flYCjtZcfHlEbL8lM54E0NT+jrRYe29aKYegfavvPsAsuZp0VdcMwFMZcpMkaBhjQMo/fwvg==} - engines: {node: '>= 18'} - cpu: [x64, arm64] - os: [darwin, linux, win32] - peerDependencies: - apache-arrow: '>=15.0.0 <=18.1.0' - - '@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 - - '@napi-rs/wasm-runtime@1.1.2': - resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - - '@rolldown/binding-android-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} - cpu: [x64] - os: [win32] - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@swc/helpers@0.5.20': - resolution: {integrity: sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==} - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/command-line-args@5.2.3': - resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} - - '@types/command-line-usage@5.0.4': - resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} - - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - - '@vitest/expect@4.1.2': - resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - - '@vitest/mocker@4.1.2': - resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - - '@vitest/runner@4.1.2': - resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - - '@vitest/snapshot@4.1.2': - resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - - '@vitest/spy@4.1.2': - resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - apache-arrow@18.1.0: - resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==} - hasBin: true - - array-back@3.1.0: - resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} - engines: {node: '>=6'} - - array-back@6.2.3: - resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} - engines: {node: '>=12.17'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@12.8.0: - resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=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'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chalk-template@0.4.0: - resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} - engines: {node: '>=12'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - command-line-args@5.2.1: - resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} - engines: {node: '>=4.0.0'} - - command-line-usage@7.0.4: - resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} - engines: {node: '>=12.20.0'} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - 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 - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - - 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'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - 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-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - express-rate-limit@8.3.2: - resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} - 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.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - find-replace@3.0.0: - resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} - engines: {node: '>=4.0.0'} - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - flatbuffers@24.12.23: - resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} - - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - - 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'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - 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'} - - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - global-agent@3.0.0: - resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} - engines: {node: '>=10.0'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - guid-typescript@1.0.9: - resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hono@4.12.9: - resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} - 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.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - 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.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - json-bignum@0.0.3: - resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} - engines: {node: '>=0.8'} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} - engines: {node: 20 || >=22} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - matcher@3.0.0: - resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - 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'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} - engines: {node: '>=10'} - - 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'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - 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==} - - onnxruntime-common@1.21.0: - resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} - - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: - resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==} - - onnxruntime-node@1.21.0: - resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} - os: [win32, darwin, linux] - - onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: - resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} - - 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-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - - path-to-regexp@8.4.1: - resolution: {integrity: sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} - engines: {node: '>=12.0.0'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - roarr@2.15.4: - resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} - engines: {node: '>=8.0'} - - rolldown@1.0.0-rc.12: - resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-regex2@5.1.0: - resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} - hasBin: true - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver-compare@1.0.0: - resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serialize-error@7.0.1: - resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} - engines: {node: '>=10'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - 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.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - 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.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - table-layout@4.1.1: - resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} - engines: {node: '>=12.17'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} - engines: {node: '>=18'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} - engines: {node: '>=20'} - - tiktoken@1.0.22: - resolution: {integrity: sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - tree-sitter-wasms@0.1.13: - resolution: {integrity: sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==} - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-fest@0.13.1: - resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} - engines: {node: '>=10'} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - typical@4.0.0: - resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} - engines: {node: '>=8'} - - typical@7.3.0: - resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} - engines: {node: '>=12.17'} - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite@8.0.3: - resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.2: - resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.2 - '@vitest/browser-preview': 4.1.2 - '@vitest/browser-webdriverio': 4.1.2 - '@vitest/ui': 4.1.2 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - web-tree-sitter@0.22.6: - resolution: {integrity: sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wordwrapjs@5.1.1: - resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} - engines: {node: '>=12.17'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xxhash-wasm@1.1.0: - resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@emnapi/core@1.9.1': - dependencies: - '@emnapi/wasi-threads': 1.2.0 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.4': - optional: true - - '@esbuild/android-arm64@0.27.4': - optional: true - - '@esbuild/android-arm@0.27.4': - optional: true - - '@esbuild/android-x64@0.27.4': - optional: true - - '@esbuild/darwin-arm64@0.27.4': - optional: true - - '@esbuild/darwin-x64@0.27.4': - optional: true - - '@esbuild/freebsd-arm64@0.27.4': - optional: true - - '@esbuild/freebsd-x64@0.27.4': - optional: true - - '@esbuild/linux-arm64@0.27.4': - optional: true - - '@esbuild/linux-arm@0.27.4': - optional: true - - '@esbuild/linux-ia32@0.27.4': - optional: true - - '@esbuild/linux-loong64@0.27.4': - optional: true - - '@esbuild/linux-mips64el@0.27.4': - optional: true - - '@esbuild/linux-ppc64@0.27.4': - optional: true - - '@esbuild/linux-riscv64@0.27.4': - optional: true - - '@esbuild/linux-s390x@0.27.4': - optional: true - - '@esbuild/linux-x64@0.27.4': - optional: true - - '@esbuild/netbsd-arm64@0.27.4': - optional: true - - '@esbuild/netbsd-x64@0.27.4': - optional: true - - '@esbuild/openbsd-arm64@0.27.4': - optional: true - - '@esbuild/openbsd-x64@0.27.4': - optional: true - - '@esbuild/openharmony-arm64@0.27.4': - optional: true - - '@esbuild/sunos-x64@0.27.4': - optional: true - - '@esbuild/win32-arm64@0.27.4': - optional: true - - '@esbuild/win32-ia32@0.27.4': - optional: true - - '@esbuild/win32-x64@0.27.4': - optional: true - - '@hono/node-server@1.19.12(hono@4.12.9)': - dependencies: - hono: 4.12.9 - - '@huggingface/jinja@0.5.6': {} - - '@huggingface/transformers@3.8.1': - dependencies: - '@huggingface/jinja': 0.5.6 - onnxruntime-node: 1.21.0 - onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 - sharp: 0.34.5 - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.9.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@lancedb/lancedb-darwin-arm64@0.27.2': - optional: true - - '@lancedb/lancedb-linux-arm64-gnu@0.27.2': - optional: true - - '@lancedb/lancedb-linux-arm64-musl@0.27.2': - optional: true - - '@lancedb/lancedb-linux-x64-gnu@0.27.2': - optional: true - - '@lancedb/lancedb-linux-x64-musl@0.27.2': - optional: true - - '@lancedb/lancedb-win32-arm64-msvc@0.27.2': - optional: true - - '@lancedb/lancedb-win32-x64-msvc@0.27.2': - optional: true - - '@lancedb/lancedb@0.27.2(apache-arrow@18.1.0)': - dependencies: - apache-arrow: 18.1.0 - reflect-metadata: 0.2.2 - optionalDependencies: - '@lancedb/lancedb-darwin-arm64': 0.27.2 - '@lancedb/lancedb-linux-arm64-gnu': 0.27.2 - '@lancedb/lancedb-linux-arm64-musl': 0.27.2 - '@lancedb/lancedb-linux-x64-gnu': 0.27.2 - '@lancedb/lancedb-linux-x64-musl': 0.27.2 - '@lancedb/lancedb-win32-arm64-msvc': 0.27.2 - '@lancedb/lancedb-win32-x64-msvc': 0.27.2 - - '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': - dependencies: - '@hono/node-server': 1.19.12(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.9 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@oxc-project/types@0.122.0': {} - - '@pinojs/redact@0.4.0': {} - - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.4': {} - - '@protobufjs/eventemitter@1.1.0': {} - - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/inquire@1.1.0': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.0': {} - - '@rolldown/binding-android-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.12': {} - - '@rollup/rollup-android-arm-eabi@4.60.1': - optional: true - - '@rollup/rollup-android-arm64@4.60.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.1': - optional: true - - '@rollup/rollup-darwin-x64@4.60.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.1': - optional: true - - '@standard-schema/spec@1.1.0': {} - - '@swc/helpers@0.5.20': - dependencies: - tslib: 2.8.1 - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 25.5.0 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/command-line-args@5.2.3': {} - - '@types/command-line-usage@5.0.4': {} - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/node@20.19.37': - dependencies: - undici-types: 6.21.0 - - '@types/node@25.5.0': - dependencies: - undici-types: 7.18.2 - - '@vitest/expect@4.1.2': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))': - dependencies: - '@vitest/spy': 4.1.2 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) - - '@vitest/pretty-format@4.1.2': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.2': - dependencies: - '@vitest/utils': 4.1.2 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.2': - dependencies: - '@vitest/pretty-format': 4.1.2 - '@vitest/utils': 4.1.2 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.2': {} - - '@vitest/utils@4.1.2': - dependencies: - '@vitest/pretty-format': 4.1.2 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn@8.16.0: {} - - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - any-promise@1.3.0: {} - - apache-arrow@18.1.0: - dependencies: - '@swc/helpers': 0.5.20 - '@types/command-line-args': 5.2.3 - '@types/command-line-usage': 5.0.4 - '@types/node': 20.19.37 - command-line-args: 5.2.1 - command-line-usage: 7.0.4 - flatbuffers: 24.12.23 - json-bignum: 0.0.3 - tslib: 2.8.1 - - array-back@3.1.0: {} - - array-back@6.2.3: {} - - assertion-error@2.0.1: {} - - atomic-sleep@1.0.0: {} - - balanced-match@4.0.4: {} - - base64-js@1.5.1: {} - - better-sqlite3@12.8.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.0 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - boolean@3.2.0: {} - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bundle-require@5.1.0(esbuild@0.27.4): - dependencies: - esbuild: 0.27.4 - load-tsconfig: 0.2.5 - - bytes@3.1.2: {} - - cac@6.7.14: {} - - 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 - - chai@6.2.2: {} - - chalk-template@0.4.0: - dependencies: - chalk: 4.1.2 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - - chownr@1.1.4: {} - - chownr@3.0.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - command-line-args@5.2.1: - dependencies: - array-back: 3.1.0 - find-replace: 3.0.0 - lodash.camelcase: 4.3.0 - typical: 4.0.0 - - command-line-usage@7.0.4: - dependencies: - array-back: 6.2.3 - chalk-template: 0.4.0 - table-layout: 4.1.1 - typical: 7.3.0 - - commander@14.0.3: {} - - commander@4.1.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - - convert-source-map@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 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-extend@0.6.0: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - depd@2.0.0: {} - - detect-libc@2.1.2: {} - - detect-node@2.1.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: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.0.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es6-error@4.1.1: {} - - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - - escape-html@1.0.3: {} - - escape-string-regexp@4.0.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - etag@1.8.1: {} - - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - - expand-template@2.0.3: {} - - expect-type@1.3.0: {} - - express-rate-limit@8.3.2(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.1.0 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - 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.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-uri-to-path@1.0.0: {} - - 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 - - find-replace@3.0.0: - dependencies: - array-back: 3.1.0 - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.2 - rollup: 4.60.1 - - flatbuffers@24.12.23: {} - - flatbuffers@25.9.23: {} - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-constants@1.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.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-tsconfig@4.13.7: - dependencies: - resolve-pkg-maps: 1.0.0 - - github-from-package@0.0.0: {} - - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - global-agent@3.0.0: - dependencies: - boolean: 3.2.0 - es6-error: 4.1.1 - matcher: 3.0.0 - roarr: 2.15.4 - semver: 7.7.4 - serialize-error: 7.0.1 - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - - guid-typescript@1.0.9: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-symbols@1.1.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hono@4.12.9: {} - - 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.2: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - ignore@7.0.5: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - ip-address@10.1.0: {} - - ipaddr.js@1.9.1: {} - - is-promise@4.0.0: {} - - isexe@2.0.0: {} - - jose@6.2.2: {} - - joycon@3.1.1: {} - - json-bignum@0.0.3: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - json-stringify-safe@5.0.1: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - lodash.camelcase@4.3.0: {} - - long@5.3.2: {} - - lru-cache@11.2.7: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - matcher@3.0.0: - dependencies: - escape-string-regexp: 4.0.0 - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-response@3.1.0: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimist@1.2.8: {} - - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - - mkdirp-classic@0.5.3: {} - - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.11: {} - - napi-build-utils@2.0.0: {} - - negotiator@1.0.0: {} - - node-abi@3.89.0: - dependencies: - semver: 7.7.4 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - obug@2.1.1: {} - - on-exit-leak-free@2.1.2: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onnxruntime-common@1.21.0: {} - - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} - - onnxruntime-node@1.21.0: - dependencies: - global-agent: 3.0.0 - onnxruntime-common: 1.21.0 - tar: 7.5.13 - - onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: - dependencies: - flatbuffers: 25.9.23 - guid-typescript: 1.0.9 - long: 5.3.2 - onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4 - platform: 1.3.6 - protobufjs: 7.5.4 - - parseurl@1.3.3: {} - - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.7 - minipass: 7.1.3 - - path-to-regexp@8.4.1: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.0.0 - - pirates@4.0.7: {} - - pkce-challenge@5.0.1: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - platform@1.3.6: {} - - postcss-load-config@6.0.1(postcss@8.5.8)(tsx@4.21.0): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - postcss: 8.5.8 - tsx: 4.21.0 - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.89.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - - process-warning@5.0.0: {} - - protobufjs@7.5.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 25.5.0 - long: 5.3.2 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - quick-format-unescaped@4.0.4: {} - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@4.1.2: {} - - readdirp@5.0.0: {} - - real-require@0.2.0: {} - - reflect-metadata@0.2.2: {} - - require-from-string@2.0.2: {} - - resolve-from@5.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - ret@0.5.0: {} - - roarr@2.15.4: - dependencies: - boolean: 3.2.0 - detect-node: 2.1.0 - globalthis: 1.0.4 - json-stringify-safe: 5.0.1 - semver-compare: 1.0.0 - sprintf-js: 1.1.3 - - rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - rollup@4.60.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 - fsevents: 2.3.3 - - 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.1 - transitivePeerDependencies: - - supports-color - - safe-buffer@5.2.1: {} - - safe-regex2@5.1.0: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - - safer-buffer@2.1.2: {} - - semver-compare@1.0.0: {} - - semver@7.7.4: {} - - 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.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serialize-error@7.0.1: - dependencies: - type-fest: 0.13.1 - - 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: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - 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.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - - source-map-js@1.2.1: {} - - source-map@0.7.6: {} - - split2@4.2.0: {} - - sprintf-js@1.1.3: {} - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@4.0.0: {} - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-json-comments@2.0.1: {} - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - table-layout@4.1.1: - dependencies: - array-back: 6.2.3 - wordwrapjs: 5.1.1 - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar@7.5.13: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - thread-stream@4.0.0: - dependencies: - real-require: 0.2.0 - - tiktoken@1.0.22: {} - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyexec@1.0.4: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinyrainbow@3.1.0: {} - - toidentifier@1.0.1: {} - - tree-kill@1.2.2: {} - - tree-sitter-wasms@0.1.13: {} - - ts-interface-checker@0.1.13: {} - - tslib@2.8.1: {} - - tsup@8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.4) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.4 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.8)(tsx@4.21.0) - resolve-from: 5.0.0 - rollup: 4.60.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.8 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - tsx@4.21.0: - dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.7 - optionalDependencies: - fsevents: 2.3.3 - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - type-fest@0.13.1: {} - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - typical@4.0.0: {} - - typical@7.3.0: {} - - ufo@1.6.3: {} - - undici-types@6.21.0: {} - - undici-types@7.18.2: {} - - unpipe@1.0.0: {} - - util-deprecate@1.0.2: {} - - vary@1.1.2: {} - - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.5.0 - esbuild: 0.27.4 - fsevents: 2.3.3 - tsx: 4.21.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - vitest@4.1.2(@types/node@25.5.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)): - dependencies: - '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) - '@vitest/pretty-format': 4.1.2 - '@vitest/runner': 4.1.2 - '@vitest/snapshot': 4.1.2 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.5.0 - transitivePeerDependencies: - - msw - - web-tree-sitter@0.22.6: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wordwrapjs@5.1.1: {} - - wrappy@1.0.2: {} - - xxhash-wasm@1.1.0: {} - - yallist@5.0.0: {} - - zod-to-json-schema@3.25.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} diff --git a/src/analysis/call-graph.ts b/src/analysis/call-graph.ts index 7d94a82..f142da7 100644 --- a/src/analysis/call-graph.ts +++ b/src/analysis/call-graph.ts @@ -161,14 +161,22 @@ function walkForCallNodes( callNodeTypes: string[], results: SyntaxNode[] ): void { - if (callNodeTypes.includes(node.type)) { - results.push(node); - // Don't recurse into call nodes to avoid double-counting nested calls - // at this level — but we do want nested calls as separate edges - } - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child) walkForCallNodes(child, callNodeTypes, results); + // Iterative traversal (explicit stack). A recursive walker overflows the + // call stack on deep/minified/generated ASTs (e.g. 10k-deep call chains). + // Pre-order DFS is preserved by pushing children in reverse so they pop in + // original order — identical visitation set/order to the prior recursion. + const stack: SyntaxNode[] = [node]; + while (stack.length > 0) { + const current = stack.pop()!; + if (callNodeTypes.includes(current.type)) { + results.push(current); + // Don't recurse into call nodes to avoid double-counting nested calls + // at this level — but we do want nested calls as separate edges + } + for (let i = current.childCount - 1; i >= 0; i--) { + const child = current.child(i); + if (child) stack.push(child); + } } } diff --git a/src/analysis/community-detection.ts b/src/analysis/community-detection.ts index f320991..21e878b 100644 --- a/src/analysis/community-detection.ts +++ b/src/analysis/community-detection.ts @@ -98,13 +98,31 @@ export function detectCommunities( raw.set(nextCid++, [iso]); } + // Single membership-bucketed pass over all edges: precompute each raw + // community's internal edges so splitCommunity can read them instead of + // scanning the whole graph once per oversized community. + const rawMembership = new Map(); + for (const [cid, members] of raw) { + for (const m of members) rawMembership.set(m, cid); + } + const rawEdges = new Map>(); + g.forEachEdge((edge, _attrs, source, target) => { + const cs = rawMembership.get(source); + const ct = rawMembership.get(target); + if (cs !== undefined && cs === ct) { + let bucket = rawEdges.get(cs); + if (bucket === undefined) { bucket = []; rawEdges.set(cs, bucket); } + bucket.push([source, target, edge]); + } + }); + // Split oversized communities const maxSize = Math.max(minSplit, Math.floor(adjGraph.nodeCount * maxFrac)); const splitCommunities: string[][] = []; - for (const [, members] of raw) { + for (const [cid, members] of raw) { if (members.length > maxSize) { - const subs = splitCommunity(g, members); + const subs = splitCommunity(g, members, rawEdges.get(cid) ?? []); for (const sub of subs) splitCommunities.push(sub); } else { splitCommunities.push(members); @@ -123,27 +141,46 @@ export function detectCommunities( const members = splitCommunities[i]!.sort(); communities.set(i, members); for (const m of members) membership.set(m, i); - cohesionMap.set(i, computeCohesion(g, members)); + } + + // Single pass over all edges: count internal edges per final community + // (replaces one full forEachEdge per community in computeCohesion). + const internalEdges = new Map(); + for (let i = 0; i < splitCommunities.length; i++) internalEdges.set(i, 0); + g.forEachEdge((_edge, _attrs, source, target) => { + const cs = membership.get(source); + const ct = membership.get(target); + if (cs !== undefined && cs === ct) { + internalEdges.set(cs, internalEdges.get(cs)! + 1); + } + }); + + for (let i = 0; i < splitCommunities.length; i++) { + const members = communities.get(i)!; + cohesionMap.set(i, computeCohesion(members.length, internalEdges.get(i) ?? 0)); labels.set(i, generateLabel(adjGraph, members)); } return { communities, membership, cohesion: cohesionMap, labels }; } -function splitCommunity(g: Graph, nodes: string[]): string[][] { - // Build subgraph and re-run Louvain +function splitCommunity( + g: Graph, + nodes: string[], + edges: Array<[string, string, string]> +): string[][] { + // Build subgraph from pre-bucketed edges (order preserved from the single pass) const sub = new Graph({ type: "undirected", multi: false }); - const nodeSet = new Set(nodes); for (const n of nodes) { if (g.hasNode(n)) sub.addNode(n, g.getNodeAttributes(n)); } - g.forEachEdge((_edge, _attrs, source, target) => { - if (nodeSet.has(source) && nodeSet.has(target) && sub.hasNode(source) && sub.hasNode(target)) { - try { sub.addEdge(source, target, _attrs); } catch { /* dup */ } + for (const [source, target, edge] of edges) { + if (sub.hasNode(source) && sub.hasNode(target)) { + try { sub.addEdge(source, target, g.getEdgeAttributes(edge)); } catch { /* dup */ } } - }); + } if (sub.size === 0) { // No internal edges — each node is its own community @@ -168,22 +205,15 @@ function splitCommunity(g: Graph, nodes: string[]): string[][] { /** * Cohesion = actual_internal_edges / possible_internal_edges. - * Mirrors graphify-3's cohesion_score(). + * Mirrors graphify-3's cohesion_score(). `internalEdges` is precomputed in a + * single membership-bucketed pass over all edges by the caller. */ -function computeCohesion(g: Graph, members: string[]): number { - const n = members.length; +function computeCohesion(n: number, internalEdges: number): number { if (n <= 1) return 1.0; - const memberSet = new Set(members); - let actualEdges = 0; - - g.forEachEdge((_edge, _attrs, source, target) => { - if (memberSet.has(source) && memberSet.has(target)) actualEdges++; - }); - const possible = (n * (n - 1)) / 2; if (possible === 0) return 0; - return Math.round((actualEdges / possible) * 100) / 100; + return Math.round((internalEdges / possible) * 100) / 100; } /** diff --git a/src/analysis/imports.ts b/src/analysis/imports.ts index 5a3192e..51e956c 100644 --- a/src/analysis/imports.ts +++ b/src/analysis/imports.ts @@ -13,13 +13,40 @@ export interface RawImport { /** * Extract static imports from a tree-sitter AST root node. - * Handles named, default, namespace, aliased, type imports, and re-exports. + * Dispatches on the parsed language. Each handler walks the top-level + * statements of the root node (matching the original TS/JS behaviour) and + * emits {@link RawImport} records describing the imported module path. * * @param rootNode - The root node of the parsed tree - * @param _language - The language name (reserved for future use) + * @param language - The language name (e.g. "typescript", "python") * @returns Array of raw import records */ -export function extractImports(rootNode: SyntaxNode, _language: string): RawImport[] { +export function extractImports(rootNode: SyntaxNode, language: string): RawImport[] { + const normalized = language.toLowerCase(); + if (normalized === "typescript" || normalized === "tsx" || normalized === "javascript") { + return extractTypeScriptImports(rootNode); + } + if (normalized === "python") { + return extractPythonImports(rootNode); + } + if (normalized === "rust") { + return extractRustImports(rootNode); + } + if (normalized === "go") { + return extractGoImports(rootNode); + } + if (normalized === "java") { + return extractJavaImports(rootNode); + } + return []; +} + +/** + * TS/JS/TSX import extraction. Handles named, default, namespace, aliased, + * type imports, and re-exports (`export { x } from "m"`, + * `export * from "m"`, `export * as ns from "m"`). + */ +function extractTypeScriptImports(rootNode: SyntaxNode): RawImport[] { const imports: RawImport[] = []; for (let i = 0; i < rootNode.childCount; i++) { @@ -42,17 +69,20 @@ export function extractImports(rootNode: SyntaxNode, _language: string): RawImpo } } - // Handle re-exports: export { foo } from "./module" + // Handle re-exports: `export { foo } from "./m"`, + // `export * from "./m"`, and `export * as ns from "./m"`. if (node.type === "export_statement") { const sourceNode = node.childForFieldName("source"); if (!sourceNode) continue; const sourceModule = stripQuotes(sourceNode.text); + let sawExportClause = false; for (let j = 0; j < node.childCount; j++) { const child = node.child(j); if (!child) continue; if (child.type === "export_clause") { + sawExportClause = true; for (let k = 0; k < child.namedChildCount; k++) { const specifier = child.namedChild(k); if (!specifier) continue; @@ -70,12 +100,208 @@ export function extractImports(rootNode: SyntaxNode, _language: string): RawImpo } } } + + // Namespace/barrel re-exports carry a `source` but no `export_clause`. + if (!sawExportClause) { + const nsMatch = node.text.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/); + imports.push({ + importedName: nsMatch ? nsMatch[1]! : "*", + sourceModule, + isDefault: false, + isNamespace: true, + }); + } + } + } + + return imports; +} + +/** + * Python import extraction. + * Handles `import a.b.c`, `import a.b as c`, `from m import x`, + * `from m import x as y`, `from m import *`, and relative `from . import x`. + * Only top-level statements are considered, matching the TS/JS handler. + */ +function extractPythonImports(rootNode: SyntaxNode): RawImport[] { + const imports: RawImport[] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node) continue; + + if (node.type === "import_statement") { + for (let j = 0; j < node.namedChildCount; j++) { + const child = node.namedChild(j); + if (!child) continue; + + if (child.type === "dotted_name") { + imports.push({ + importedName: child.text, + sourceModule: child.text, + isDefault: false, + isNamespace: true, + }); + } else if (child.type === "aliased_import") { + const nameNode = child.childForFieldName("name"); + const aliasNode = child.childForFieldName("alias"); + const sourceModule = nameNode ? nameNode.text : ""; + if (!sourceModule) continue; + imports.push({ + importedName: aliasNode ? aliasNode.text : sourceModule, + sourceModule, + isDefault: false, + isNamespace: true, + }); + } + } + } else if (node.type === "import_from_statement") { + const moduleNode = node.childForFieldName("module"); + let sourceModule = moduleNode ? moduleNode.text : ""; + if (!sourceModule) { + // Relative imports (`from . import x`) may not expose a named module + // node across grammar versions; fall back to the raw text. + const match = node.text.match(/(?:^|\n)\s*from\s+([.\w]+)\s+import\b/); + sourceModule = match ? match[1]! : ""; + } + if (!sourceModule) continue; + + for (let j = 0; j < node.namedChildCount; j++) { + const child = node.namedChild(j); + if (!child || child === moduleNode) continue; + + if (child.type === "dotted_name") { + imports.push({ + importedName: child.text, + sourceModule, + isDefault: false, + isNamespace: false, + }); + } else if (child.type === "aliased_import") { + const nameNode = child.childForFieldName("name"); + const aliasNode = child.childForFieldName("alias"); + const name = nameNode ? nameNode.text : ""; + if (!name) continue; + imports.push({ + importedName: aliasNode ? aliasNode.text : name, + sourceModule, + isDefault: false, + isNamespace: false, + }); + } else if (child.type === "wildcard_import") { + imports.push({ + importedName: "*", + sourceModule, + isDefault: false, + isNamespace: true, + }); + } + } + } + } + + return imports; +} + +/** + * Rust `use` extraction (best-effort). Records the full `use` path as the + * source module; grouped imports (`use foo::{a, b}`) are flattened to a single + * namespace record. The single-target `use a::b::C;` form is grammar-stable. + */ +function extractRustImports(rootNode: SyntaxNode): RawImport[] { + const imports: RawImport[] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node || node.type !== "use_declaration") continue; + + const arg = findChildOfType(node, "use_argument"); + if (!arg) continue; + const sourceModule = arg.text.replace(/;$/, "").trim(); + const isGrouped = sourceModule.includes("{"); + const lastSegment = sourceModule.split("::").pop() ?? sourceModule; + imports.push({ + importedName: isGrouped ? "*" : lastSegment, + sourceModule, + isDefault: false, + isNamespace: true, + }); + } + + return imports; +} + +/** + * Go import extraction. Handles single (`import "fmt"`) and grouped + * (`import ( "fmt"; f "os" )`) forms, reading the quoted import path. + */ +function extractGoImports(rootNode: SyntaxNode): RawImport[] { + const imports: RawImport[] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node || node.type !== "import_declaration") continue; + + for (let j = 0; j < node.namedChildCount; j++) { + const child = node.namedChild(j); + if (!child) continue; + + if (child.type === "import_spec") { + addGoImportSpec(child, imports); + } else if (child.type === "import_list") { + for (let k = 0; k < child.namedChildCount; k++) { + const spec = child.namedChild(k); + if (spec?.type === "import_spec") addGoImportSpec(spec, imports); + } + } else if (child.type === "interpreted_string_literal") { + imports.push({ + importedName: "*", + sourceModule: stripQuotes(child.text), + isDefault: false, + isNamespace: true, + }); + } } } return imports; } +function addGoImportSpec(spec: SyntaxNode, imports: RawImport[]): void { + const pathNode = spec.childForFieldName("path"); + if (!pathNode) return; + const sourceModule = stripQuotes(pathNode.text); + const nameNode = spec.childForFieldName("name"); + const importedName = nameNode ? nameNode.text : (sourceModule.split("/").pop() ?? sourceModule); + imports.push({ importedName, sourceModule, isDefault: false, isNamespace: true }); +} + +/** + * Java import extraction. Handles `import foo.bar.Baz;` and + * `import foo.bar.*;`, reading the scoped identifier path. + */ +function extractJavaImports(rootNode: SyntaxNode): RawImport[] { + const imports: RawImport[] = []; + + for (let i = 0; i < rootNode.childCount; i++) { + const node = rootNode.child(i); + if (!node || node.type !== "import_declaration") continue; + + const idNode = findChildOfType(node, "scoped_identifier"); + if (!idNode) continue; + const sourceModule = idNode.text; + const isWildcard = node.text.includes("*"); + imports.push({ + importedName: isWildcard ? "*" : (sourceModule.split(".").pop() ?? sourceModule), + sourceModule, + isDefault: false, + isNamespace: isWildcard, + }); + } + + return imports; +} + function extractFromImportClause( clause: SyntaxNode, sourceModule: string, @@ -147,6 +373,14 @@ function findIdentifierChild(node: SyntaxNode): SyntaxNode | null { return null; } +function findChildOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === type) return child; + } + return null; +} + function stripQuotes(text: string): string { if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { diff --git a/src/analysis/topology-analysis.ts b/src/analysis/topology-analysis.ts index d5357ae..9b066db 100644 --- a/src/analysis/topology-analysis.ts +++ b/src/analysis/topology-analysis.ts @@ -1,7 +1,7 @@ import { basename } from "path"; import type { AdjacencyGraph, GraphNode } from "./graph-builder.js"; import type { CommunityResult } from "./community-detection.js"; -import { detectExecutionSurfaces } from "../search/utils.js"; +import { detectExecutionSurfaces, isTestFile } from "../search/utils.js"; import type { GodNodeRecord, SurpriseRecord, @@ -9,7 +9,6 @@ import type { } from "../storage/community-store.js"; // --- Constants ported from graphify-3 analyze.py --- -const TEST_FILE_RE = /[/\\](test|__tests__|spec|__spec__|fixtures|__fixtures__)[/\\]|\.(test|spec)\.[jt]sx?$/i; // Resolution source → surprise weight (replaces AMBIGUOUS/INFERRED/EXTRACTED) // Lowered alias_path from 3→1 because many normal cross-module deps use alias_path resolution @@ -52,10 +51,6 @@ function isFileNode(node: GraphNode): boolean { return false; } -function isTestFile(filePath: string): boolean { - return TEST_FILE_RE.test(filePath); -} - export function findGodNodes( graph: AdjacencyGraph, communityResult?: CommunityResult, @@ -424,52 +419,3 @@ export function suggestQuestions( return questions.slice(0, topN); } -// --- Graph Diff --- - -export interface GraphDiffResult { - newNodes: Array<{ chunkId: string; name: string }>; - removedNodes: Array<{ chunkId: string; name: string }>; - communityChanges: Array<{ chunkId: string; name: string; oldCommunity: number; newCommunity: number }>; - summary: string; -} - -export function graphDiff( - oldMembership: Map, - newMembership: Map, - nodeNames: Map -): GraphDiffResult { - const newNodes: Array<{ chunkId: string; name: string }> = []; - const removedNodes: Array<{ chunkId: string; name: string }> = []; - const communityChanges: Array<{ chunkId: string; name: string; oldCommunity: number; newCommunity: number }> = []; - - for (const [id, comm] of newMembership) { - if (!oldMembership.has(id)) { - newNodes.push({ chunkId: id, name: nodeNames.get(id) ?? id }); - } else if (oldMembership.get(id) !== comm) { - communityChanges.push({ - chunkId: id, - name: nodeNames.get(id) ?? id, - oldCommunity: oldMembership.get(id)!, - newCommunity: comm, - }); - } - } - - for (const [id] of oldMembership) { - if (!newMembership.has(id)) { - removedNodes.push({ chunkId: id, name: nodeNames.get(id) ?? id }); - } - } - - const parts: string[] = []; - if (newNodes.length > 0) parts.push(`${newNodes.length} new node${newNodes.length !== 1 ? "s" : ""}`); - if (removedNodes.length > 0) parts.push(`${removedNodes.length} removed node${removedNodes.length !== 1 ? "s" : ""}`); - if (communityChanges.length > 0) parts.push(`${communityChanges.length} community change${communityChanges.length !== 1 ? "s" : ""}`); - - return { - newNodes, - removedNodes, - communityChanges, - summary: parts.length > 0 ? parts.join(", ") : "no structural changes", - }; -} diff --git a/src/business/product-areas.ts b/src/business/product-areas.ts index 38bf528..73f8972 100644 --- a/src/business/product-areas.ts +++ b/src/business/product-areas.ts @@ -1,4 +1,5 @@ import type { Memory } from "../memory/types.js"; +import { slugify, extractSectionText, extractBulletSection, humanizeSlug } from "../core/strings.js"; export type PresentationQuality = "high" | "medium" | "low" | "fallback"; @@ -583,7 +584,11 @@ function toProductArea( const supportingSymbols = unique(uniquePages.flatMap((page) => page.supportingSymbols)).slice(0, 12); const displaySummary = summarizeProductAreaDisplay(displayName, uniquePages); const avgConfidence = uniquePages.reduce((sum, page) => sum + page.confidence, 0) / Math.max(1, uniquePages.length); - const confidence = Math.max(0.55, Math.min(0.94, avgConfidence + Math.min(score * 0.01, 0.08))); + // Confidence reflects the actual evidence: a low average page confidence + // with little supporting score yields a low value, instead of being clamped + // up to a synthetic floor. The bonus is bounded so strong evidence can lift + // (but not inflate past) a sane ceiling. + const confidence = Math.max(0.2, Math.min(0.95, avgConfidence + Math.min(score * 0.01, 0.08))); const presentation = evaluateProductAreaPresentation({ id, name, @@ -762,7 +767,7 @@ function evaluateProductAreaPresentation(input: { }; } -function deriveBusinessDisplaySummary(input: { +export function deriveBusinessDisplaySummary(input: { displayName: string; summary: string; businessOutcome: string; @@ -781,10 +786,13 @@ function deriveBusinessDisplaySummary(input: { } function isTechnicalCapabilityLabel(value: string): boolean { + // Note: deliberately NOT matching bare camelCase identifiers. The word-boundary + // technical-suffix check below already catches genuine technical camelCase + // (e.g. "orderService" -> "order Service" -> "service"); a generic camelCase + // pattern would false-positive on legitimate business names like "productId". return /\b(?:backend|frontend):/i.test(value) || /`[^`]+`/.test(value) || /\b(?:service|controller|repository|handler|processor|provider|middleware|modal|store|query|string|parser|builder|factory|client)\b/i.test(splitTechnicalWords(value)) - || /[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]+/.test(value) || /\b[A-Za-z0-9_-]+\.(?:ts|tsx|js|jsx|mjs|cjs|sql|json|mdx?|ya?ml)\b/i.test(value); } @@ -878,8 +886,13 @@ function isGenericPresentationSummary(value: string): boolean { function countDomainSignals(values: string[]): number { const tokens = new Set(); for (const value of values) { + // tokenize() yields single lowercase words, so a per-token membership test + // against the multi-word GENERIC_PRESENTATION_LABELS could never match. + // Guard at the whole-value level instead: a value that is itself a generic + // presentation label contributes no domain signal. + if (isGenericPresentationLabel(value)) continue; for (const token of tokenize(value)) { - if (!PRODUCT_AREA_GENERIC_TERMS.has(token) && !GENERIC_PRESENTATION_LABELS.has(token)) tokens.add(token); + if (!PRODUCT_AREA_GENERIC_TERMS.has(token)) tokens.add(token); } } return tokens.size; @@ -934,41 +947,6 @@ function toTechnicalEvidence(files: string[], symbols: string[]): TechnicalEvide }; } -function extractSectionText(content: string, heading: string): string { - const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); - if (!match?.[1]) return ""; - return match[1] - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("- ")) - .join(" ") - .trim(); -} - -function extractBulletSection(content: string, heading: string): string[] { - const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); - if (!match?.[1]) return []; - return match[1] - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("- ")) - .map((line) => line.slice(2).trim()) - .filter((line) => line.length > 0); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function humanizeSlug(name: string): string { - return name - .replace(/^business-/, "") - .split("-") - .filter(Boolean) - .map((part) => part[0] ? `${part[0].toUpperCase()}${part.slice(1)}` : part) - .join(" "); -} - function firstNonEmpty(primary: string, fallback: string): string { return primary.trim() || fallback; } @@ -987,14 +965,6 @@ function titleCase(input: string): string { .join(" "); } -function slugify(label: string): string { - return label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); -} - function unique(values: string[]): string[] { return Array.from(new Set(values.filter(Boolean))); } diff --git a/src/cli/explain.ts b/src/cli/explain.ts index 10e47bc..9b46ed8 100644 --- a/src/cli/explain.ts +++ b/src/cli/explain.ts @@ -20,6 +20,7 @@ import { } from '../business/product-areas.js' import type { MemoryStore } from '../storage/memory-store.js' import { assertSqliteRuntimeHealthy } from '../storage/sqlite-utils.js' +import type { ContextCompressionMetadata } from '../search/types.js' function formatQueryMode(queryMode: QueryMode): string { if (queryMode === 'skip') return 'skip (meta/non-code prompt)' @@ -74,6 +75,7 @@ export interface ExplainResult { localizationSignals?: BugSelectionDiagnostics tokensInjected: number chunksInjected: number + compression?: ContextCompressionMetadata memoryTokensInjected?: number memoriesInjected?: number memoryNames?: string[] @@ -245,6 +247,7 @@ export async function resolveExplainResult( localizationSignals: queryMode === 'bug' ? bugSelection ?? undefined : undefined, tokensInjected: context?.tokenCount ?? 0, chunksInjected: context?.chunks.length ?? 0, + compression: context?.compression, memoryTokensInjected: promptContext.memoryTokenCount, memoriesInjected: promptContext.memoryCount, memoryNames: promptContext.memoryNames, @@ -349,6 +352,12 @@ export function explainCommand(): Command { } console.log(`Tokens: ${result.tokensInjected.toLocaleString()}`) console.log(`Chunks: ${result.chunksInjected}`) + if (result.compression?.compressedChunks) { + console.log( + `Compression: ${result.compression.compressedChunks} compacted, ` + + `${result.compression.tokensSaved.toLocaleString()} tokens saved` + ) + } if (result.contextStrength) { console.log(`Context: ${result.contextStrength}`) } diff --git a/src/cli/index-cmd.ts b/src/cli/index-cmd.ts index e3d8fe6..3cb3c74 100644 --- a/src/cli/index-cmd.ts +++ b/src/cli/index-cmd.ts @@ -101,7 +101,9 @@ export function indexCommand(): Command { console.error(`\nIndexing failed: ${err}`) process.exit(1) } finally { - pipeline.close() + // Use async teardown — the synchronous close() can race native + // vector-store destructors (libc++abi mutex errors on exit). + await pipeline.closeAsync() } }) } diff --git a/src/cli/index.ts b/src/cli/index.ts index acd7a0f..2e438b8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,7 +21,7 @@ function loadVersion(): string { try { return require('../package.json').version } catch { - return '0.3.0' + return '0.7.1' } } } diff --git a/src/cli/init.ts b/src/cli/init.ts index 092bbbc..4cf449b 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -255,7 +255,7 @@ ${MEMORY_MARKER} Codebase context is injected automatically via hooks on each message (marked "Relevant codebase context"). Follow this priority chain: 1. **Answer from injected context first.** It contains files, symbols, and call graphs for the query — do not re-fetch files listed in the injected context header. -2. **Fill gaps with any tool.** Reporecall MCP tools (search_code, explain_flow, find_callers, get_symbol) search a pre-built index. Grep/Read/Glob work for exact matches and raw lookups. Pick whichever fits the query. +2. **Fill gaps with any tool.** For multi-file questions, prefer Reporecall MCP search_context because it returns routed, token-budgeted context with compressed secondary evidence. Use search_code for raw hit lists, explain_flow/find_callers/get_symbol for graph navigation, and read_code_chunk to expand compressed chunkId refs. Grep/Read/Glob work for exact matches and raw lookups. 3. **Avoid redundant searches.** Do not re-search for symbols or files already present in the injected context. If the injected context is marked "low confidence", steps 2 and 3 are appropriate immediately. diff --git a/src/cli/lens.ts b/src/cli/lens.ts index 81067f9..d519fdc 100644 --- a/src/cli/lens.ts +++ b/src/cli/lens.ts @@ -10,7 +10,7 @@ import { generateVisualization } from "../visualize/index.js"; function openInBrowser(target: string): void { const onError = (err: Error | null) => { - if (err) console.log("Could not open browser. Open manually:", target); + if (err) console.error("Could not open browser. Open manually:", target); }; if (process.platform === "darwin") { execFile("open", [target], onError); @@ -156,8 +156,8 @@ export function lensCommand(): Command { if (options.serve) { const port = parseInt(options.port, 10); - if (!Number.isFinite(port) || port < 0 || port > 65535) { - console.log(`Invalid port: ${options.port}`); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + console.error(`Invalid port: ${options.port}`); process.exit(1); } await serveDashboard(outputPath, port, !!options.open); diff --git a/src/cli/mcp-proxy.ts b/src/cli/mcp-proxy.ts deleted file mode 100644 index 4c5c769..0000000 --- a/src/cli/mcp-proxy.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * MCP Proxy — an MCP server backed by HTTP calls to the running daemon. - * - * Instead of loading its own IndexingPipeline, HybridSearch, and MemoryStore - * (which duplicates the daemon and risks SQLite corruption due to no shared - * ReadWriteLock), this proxy forwards every MCP tool call to the daemon's - * HTTP API. - * - * Usage: - * import { createProxyMCPServer } from './mcp-proxy.js' - * const server = createProxyMCPServer({ port: 4111, dataDir: '...' }) - * await server.connect(new StdioServerTransport()) - * - * NOTE: The preferred approach is `serve --mcp` which runs the MCP server - * in-process with the daemon. This proxy exists as a lightweight alternative - * when the daemon is already running and you need a separate MCP stdio process - * (e.g., for clients that cannot use the `serve` command directly). - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { z } from 'zod' -import { readFileSync } from 'fs' -import { resolve } from 'path' -import { createRequire } from 'module' - -const requireFromHere = createRequire(import.meta.url) - -function loadPackageVersion(): string { - try { - return requireFromHere('../../package.json').version - } catch { - try { - return requireFromHere('../package.json').version - } catch { - return 'unknown' - } - } -} - -interface ProxyConfig { - port: number - dataDir: string -} - -function loadDaemonToken(dataDir: string): string | undefined { - try { - return readFileSync(resolve(dataDir, 'daemon.token'), 'utf-8').trim() - } catch { - return undefined - } -} - -async function callDaemon( - config: ProxyConfig, - toolName: string, - args: Record -): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { - const token = loadDaemonToken(config.dataDir) - const url = `http://127.0.0.1:${config.port}/mcp/tool-call` - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 30000) - - try { - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ tool: toolName, arguments: args }), - signal: controller.signal, - }) - - if (!res.ok) { - const body = await res.text() - return { - content: [{ type: 'text', text: `Daemon error (${res.status}): ${body}` }], - isError: true, - } - } - - const json = (await res.json()) as { - content?: Array<{ type: 'text'; text: string }> - isError?: boolean - } - return { - content: json.content ?? [{ type: 'text', text: JSON.stringify(json) }], - isError: json.isError, - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { - content: [{ type: 'text', text: `Failed to reach daemon at ${url}: ${message}` }], - isError: true, - } - } finally { - clearTimeout(timeout) - } -} - -export function createProxyMCPServer(config: ProxyConfig): McpServer { - const server = new McpServer({ - name: 'reporecall-proxy', - version: loadPackageVersion(), - }) - - // Helper to register a proxy tool that forwards to the daemon - function proxyTool( - name: string, - description: string, - inputSchema: Record, - annotations?: Record - ) { - server.registerTool( - name, - { description, inputSchema, annotations }, - async (args: Record) => callDaemon(config, name, args) - ) - } - - // --- Code search & indexing tools --- - - proxyTool( - 'search_code', - 'Search the codebase using hybrid vector + keyword search', - { - query: z.string().min(1).describe('Search query'), - limit: z.number().int().min(1).max(500).optional().describe('Max results (default 20)'), - activeFiles: z.array(z.string()).optional().describe('Currently open file paths for boosting'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'index_codebase', - 'Index or re-index the codebase', - { - paths: z.array(z.string().max(4096)).max(1000).optional().describe('Specific file paths to re-index (omit for full index)'), - }, - { destructiveHint: true } - ) - - proxyTool( - 'get_stats', - 'Get index statistics, conventions, and latency info', - {}, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'clear_index', - 'Clear all indexed data', - { confirm: z.boolean().describe('Must be true to proceed') }, - { destructiveHint: true } - ) - - // --- Call graph tools --- - - proxyTool( - 'find_callers', - 'Find functions that call a given function', - { - functionName: z.string().min(1).describe('Name of the function to find callers for'), - limit: z.number().int().min(1).max(500).optional().describe('Max results (default 20)'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'find_callees', - 'Find functions called by a given function', - { - functionName: z.string().min(1).describe('Name of the function to find callees for'), - limit: z.number().int().min(1).max(500).optional().describe('Max results (default 20)'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'resolve_seed', - 'Resolve a query to seed candidates for stack tree building', - { query: z.string().min(1).describe('Natural language query or code symbol name') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'build_stack_tree', - 'Build a bidirectional call tree from a seed function/method', - { - seed: z.string().min(1).describe('Function or method name to use as the tree seed'), - depth: z.number().int().min(1).max(10).optional().describe('Maximum tree depth (default: 2)'), - direction: z.enum(['up', 'down', 'both']).optional().describe('Tree direction (default: both)'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'get_imports', - 'Get import statements for a file', - { filePath: z.string().min(1).describe('Relative file path (e.g., src/auth/handler.ts)') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'get_symbol', - 'Look up code symbols (functions, classes, methods) by name', - { name: z.string().min(1).describe('Symbol name to look up') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'explain_flow', - 'Explain the call flow around a query or function name', - { - query: z.string().min(1).describe('Natural language query or function name'), - direction: z.enum(['up', 'down', 'both']).optional().describe('Tree direction (default: both)'), - maxDepth: z.number().int().min(1).max(10).optional().describe('Maximum tree depth (default: 2)'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - // --- Memory tools --- - - proxyTool( - 'recall_memories', - 'Search project and user memories using local keyword retrieval', - { - query: z.string().min(1).describe('Search query for memory recall'), - limit: z.number().int().min(1).max(50).optional().describe('Max results (default 10)'), - types: z.array(z.enum(['user', 'feedback', 'project', 'reference'])).optional().describe('Filter by memory type'), - classes: z.array(z.enum(['rule', 'fact', 'episode', 'working'])).optional().describe('Filter by memory class'), - scopes: z.array(z.enum(['global', 'project', 'branch'])).optional().describe('Filter by memory scope'), - statuses: z.array(z.enum(['active', 'archived', 'superseded'])).optional().describe('Filter by memory status'), - activeFiles: z.array(z.string()).optional().describe('Active file paths for contextual boosting'), - topCodeFiles: z.array(z.string()).optional().describe('Top code file paths for contextual boosting'), - topCodeSymbols: z.array(z.string()).optional().describe('Top code symbols for contextual boosting'), - minConfidence: z.number().min(0).max(1).optional().describe('Minimum confidence score'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'explain_memory', - 'Explain how memory recall would behave for a query', - { - query: z.string().min(1).describe('Search query for memory explanation'), - limit: z.number().int().min(1).max(50).optional().describe('Max results (default 8)'), - tokenBudget: z.number().min(0).optional().describe('Memory token budget (default 500)'), - types: z.array(z.enum(['user', 'feedback', 'project', 'reference'])).optional().describe('Filter by memory type'), - classes: z.array(z.enum(['rule', 'fact', 'episode', 'working'])).optional().describe('Filter by memory class'), - scopes: z.array(z.enum(['global', 'project', 'branch'])).optional().describe('Filter by memory scope'), - statuses: z.array(z.enum(['active', 'archived', 'superseded'])).optional().describe('Filter by memory status'), - activeFiles: z.array(z.string()).optional().describe('Active file paths for contextual boosting'), - topCodeFiles: z.array(z.string()).optional().describe('Top code file paths for contextual boosting'), - topCodeSymbols: z.array(z.string()).optional().describe('Top code symbols for contextual boosting'), - minConfidence: z.number().min(0).max(1).optional().describe('Minimum confidence score'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'compact_memories', - 'Refresh and compact memory indexes', - { confirm: z.boolean().describe('Must be true to proceed') }, - { destructiveHint: true } - ) - - proxyTool( - 'clear_working_memory', - 'Clear generated working memory entries from the local managed store', - { confirm: z.boolean().describe('Must be true to proceed') }, - { destructiveHint: true } - ) - - proxyTool( - 'store_memory', - 'Create or update a memory file', - { - name: z.string().min(1).max(200).describe('Memory name (used as filename)'), - description: z.string().min(1).max(500).describe('One-line description of the memory'), - memoryType: z.enum(['user', 'feedback', 'project', 'reference']).describe('Memory category'), - content: z.string().min(1).describe('Memory content (markdown)'), - class: z.enum(['rule', 'fact', 'episode', 'working']).optional().describe('Optional memory class'), - scope: z.enum(['global', 'project', 'branch']).optional().describe('Optional memory scope'), - status: z.enum(['active', 'archived', 'superseded']).optional().describe('Optional lifecycle status'), - summary: z.string().max(500).optional().describe('Optional compressed summary'), - sourceKind: z.enum(['claude_auto', 'reporecall_local', 'generated']).optional().describe('Optional source kind'), - pinned: z.boolean().optional().describe('Whether the memory should stay pinned'), - relatedFiles: z.array(z.string()).optional().describe('Related file paths'), - relatedSymbols: z.array(z.string()).optional().describe('Related symbols'), - supersedesId: z.string().optional().describe('Superseded memory ID'), - confidence: z.number().min(0).max(1).optional().describe('Confidence score'), - reason: z.string().max(500).optional().describe('Lifecycle or compaction reason'), - }, - { destructiveHint: true } - ) - - proxyTool( - 'forget_memory', - 'Delete a memory by name', - { name: z.string().min(1).describe('Name of the memory to forget') }, - { destructiveHint: true } - ) - - proxyTool( - 'list_memories', - 'List all stored memories with metadata', - { - memoryType: z.enum(['user', 'feedback', 'project', 'reference']).optional().describe('Filter by memory type'), - memoryClass: z.enum(['rule', 'fact', 'episode', 'working']).optional().describe('Filter by memory class'), - memoryScope: z.enum(['global', 'project', 'branch']).optional().describe('Filter by memory scope'), - memoryStatus: z.enum(['active', 'archived', 'superseded']).optional().describe('Filter by memory status'), - }, - { readOnlyHint: true, idempotentHint: true } - ) - - // --- Topology analysis tools --- - - proxyTool( - 'get_communities', - 'Get detected code communities (clusters of tightly-coupled modules)', - { limit: z.number().int().min(1).max(100).optional().describe('Max communities to return (default: 20)') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'get_hub_nodes', - 'Get the most connected nodes (architectural hubs) in the call graph', - { limit: z.number().int().min(1).max(50).optional().describe('Max hub nodes to return (default: 10)') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'get_surprises', - 'Get surprising cross-module connections in the codebase', - { limit: z.number().int().min(1).max(50).optional().describe('Max surprising connections to return (default: 10)') }, - { readOnlyHint: true, idempotentHint: true } - ) - - proxyTool( - 'suggest_investigations', - 'Get suggested investigation questions based on codebase topology', - { limit: z.number().int().min(1).max(20).optional().describe('Max questions to return (default: 7)') }, - { readOnlyHint: true, idempotentHint: true } - ) - - return server -} diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts index 6802d26..4e9f30e 100644 --- a/src/cli/mcp.ts +++ b/src/cli/mcp.ts @@ -182,17 +182,48 @@ export function mcpCommand(): Command { new (await import("@modelcontextprotocol/sdk/server/stdio.js")).StdioServerTransport() ); + // Graceful shutdown — mirrors the discipline in serve.ts: re-entrancy + // guard, try/catch around every step, and a force-exit safety timeout. + // Without this, any rejection from server.close()/stop()/closeAsync() + // became an unhandled rejection that could hang or crash the stdio MCP + // process and leave the client with a broken pipe. + let shuttingDown = false; const shutdown = async () => { - await server.close(); - await memoryRuntime?.stop(); - memoryStore?.close(); - await pipeline.closeAsync(); - process.exit(0); + if (shuttingDown) return; + shuttingDown = true; + + const forceExitTimer = setTimeout(() => { + console.error("MCP graceful shutdown timed out after 10s, forcing exit"); + process.exit(1); + }, 10_000); + forceExitTimer.unref(); + + try { + await server.close(); + await memoryRuntime?.stop(); + memoryStore?.close(); + await pipeline.closeAsync(); + } catch (err) { + console.error(`Error during MCP shutdown: ${err}`); + } finally { + clearTimeout(forceExitTimer); + process.exit(0); + } }; // Windows: SIGTERM is not sent by Task Manager/services. Only SIGINT (Ctrl+C) works. // Node.js emulates SIGINT on Windows, so graceful shutdown via Ctrl+C is supported. process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + + // Global safety nets so the long-running stdio process never hangs or + // exits ungracefully on an unhandled async failure. + process.on("unhandledRejection", (reason) => { + console.error(`Unhandled rejection in MCP server: ${reason}`); + }); + process.on("uncaughtException", (err) => { + console.error(`Uncaught exception in MCP server: ${err}`); + void shutdown(); + }); }); } diff --git a/src/cli/search.ts b/src/cli/search.ts index 67bf1d4..829a802 100644 --- a/src/cli/search.ts +++ b/src/cli/search.ts @@ -94,8 +94,12 @@ export function searchCommand(): Command { console.log('') } console.log(context.text) + const compressionSuffix = context.compression?.compressedChunks + ? `, ${context.compression.compressedChunks} compacted, ${context.compression.tokensSaved} tokens saved` + : '' console.log( `\n(${context.chunks.length} chunks, ${context.tokenCount} tokens` + + compressionSuffix + (memContext.memories.length > 0 ? `, ${memContext.memories.length} memories, ${memContext.tokenCount} mem tokens` : '') + ')' ) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index ff0c075..331f4ef 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -75,7 +75,13 @@ export function serveCommand(): Command { } if (options.maxChunks !== undefined) { const parsed = parseInt(options.maxChunks, 10) - if (!isNaN(parsed) && parsed >= 0) config.maxContextChunks = parsed + if (!isNaN(parsed) && parsed >= 0) { + config.maxContextChunks = parsed + } else { + console.error( + `Ignored invalid --max-chunks value "${options.maxChunks}"; using default ${config.maxContextChunks}.` + ) + } } // Health check for Ollama diff --git a/src/cli/stats.ts b/src/cli/stats.ts index 18141c3..3a4dafb 100644 --- a/src/cli/stats.ts +++ b/src/cli/stats.ts @@ -24,206 +24,215 @@ export function statsCommand(): Command { return } - const metadata = new MetadataStore(config.dataDir) - const stats = metadata.getStats() - const lastIndexed = metadata.getStat('lastIndexedAt') - const hooksCount = metadata.getStat('hooksFireCount') ?? '0' - const totalTokens = metadata.getStat('totalTokensInjected') ?? '0' - - // Calculate storage size - let storageBytes = 0 - const files = ['metadata.db', 'fts.db'] - for (const f of files) { - const p = resolve(config.dataDir, f) - if (existsSync(p)) storageBytes += statSync(p).size - } - const lanceDir = resolve(config.dataDir, 'lance') - if (existsSync(lanceDir)) { - storageBytes += dirSize(lanceDir) + let metadata: MetadataStore + try { + metadata = new MetadataStore(config.dataDir) + } catch { + console.error('No index found. Run "reporecall index" first.') + process.exit(1) } - const memoryDb = resolve(config.dataDir, 'memory-index', 'memories.db') + let memoryStore: MemoryStore | undefined - let memoryFreshness: string | undefined - let memoryTotals: { - total: number - active: number - archived: number - superseded: number - pinned: number - } | undefined - if (existsSync(memoryDb)) { - storageBytes += statSync(memoryDb).size - try { - memoryStore = new MemoryStore(resolve(config.dataDir, 'memory-index')) - const memories = memoryStore.getAll() - const newest = memories.reduce((acc, memory) => { - const mtime = new Date(memory.fileMtime).getTime() - return Number.isFinite(mtime) && mtime > acc ? mtime : acc - }, 0) - if (newest > 0) { - memoryFreshness = formatTimeSince(new Date(newest)) + try { + const stats = metadata.getStats() + const lastIndexed = metadata.getStat('lastIndexedAt') + const hooksCount = metadata.getStat('hooksFireCount') ?? '0' + const totalTokens = metadata.getStat('totalTokensInjected') ?? '0' + + // Calculate storage size + let storageBytes = 0 + const files = ['metadata.db', 'fts.db'] + for (const f of files) { + const p = resolve(config.dataDir, f) + if (existsSync(p)) storageBytes += statSync(p).size + } + const lanceDir = resolve(config.dataDir, 'lance') + if (existsSync(lanceDir)) { + storageBytes += dirSize(lanceDir) + } + const memoryDb = resolve(config.dataDir, 'memory-index', 'memories.db') + let memoryFreshness: string | undefined + let memoryTotals: { + total: number + active: number + archived: number + superseded: number + pinned: number + } | undefined + if (existsSync(memoryDb)) { + storageBytes += statSync(memoryDb).size + try { + memoryStore = new MemoryStore(resolve(config.dataDir, 'memory-index')) + const memories = memoryStore.getAll() + const newest = memories.reduce((acc, memory) => { + const mtime = new Date(memory.fileMtime).getTime() + return Number.isFinite(mtime) && mtime > acc ? mtime : acc + }, 0) + if (newest > 0) { + memoryFreshness = formatTimeSince(new Date(newest)) + } + memoryTotals = memories.reduce( + (acc, memory) => { + acc.total += 1 + const status = resolveMemoryStatus(memory) + if (status === 'archived') acc.archived += 1 + else if (status === 'superseded') acc.superseded += 1 + else acc.active += 1 + if (memory.pinned) acc.pinned += 1 + return acc + }, + { total: 0, active: 0, archived: 0, superseded: 0, pinned: 0 } + ) + } catch { + memoryStore = undefined } - memoryTotals = memories.reduce( - (acc, memory) => { - acc.total += 1 - const status = resolveMemoryStatus(memory) - if (status === 'archived') acc.archived += 1 - else if (status === 'superseded') acc.superseded += 1 - else acc.active += 1 - if (memory.pinned) acc.pinned += 1 - return acc - }, - { total: 0, active: 0, archived: 0, superseded: 0, pinned: 0 } - ) - } catch { - memoryStore = undefined } - } - - // Format languages - const totalChunks = stats.totalChunks || 1 - const langLines = Object.entries(stats.languages) - .map( - ([lang, count]) => - `${lang} (${((count / totalChunks) * 100).toFixed(0)}%)` - ) - .join(', ') - // Time since last indexed - const timeSince = lastIndexed - ? formatTimeSince(new Date(lastIndexed)) - : 'never' + // Format languages + const totalChunks = stats.totalChunks || 1 + const langLines = Object.entries(stats.languages) + .map( + ([lang, count]) => + `${lang} (${((count / totalChunks) * 100).toFixed(0)}%)` + ) + .join(', ') - const chunksServed = metadata.getStat('chunksServed') ?? '0' - const latency = metadata.getLatencyPercentiles() + // Time since last indexed + const timeSince = lastIndexed + ? formatTimeSince(new Date(lastIndexed)) + : 'never' - const tokensInjected = parseInt(totalTokens, 10) - const chunksServedNum = parseInt(chunksServed, 10) - const hooksNum = parseInt(hooksCount, 10) + const chunksServed = metadata.getStat('chunksServed') ?? '0' + const latency = metadata.getLatencyPercentiles() - console.log(`Reporecall`) - console.log(``) - console.log(`Index:`) - console.log( - ` Chunks: ${stats.totalChunks} across ${stats.totalFiles} files` - ) - console.log(` Languages: ${langLines || 'none'}`) - console.log(` Storage: ${formatBytes(storageBytes)}`) - console.log(` Last indexed: ${timeSince}`) - console.log(``) + const tokensInjected = parseInt(totalTokens, 10) + const chunksServedNum = parseInt(chunksServed, 10) + const hooksNum = parseInt(hooksCount, 10) - console.log(`Session Stats:`) - console.log(` Hooks fired: ${hooksCount}`) - console.log( - ` Chunks served: ${Number(chunksServed).toLocaleString()}` - ) - console.log( - ` Tokens injected: ${Number(totalTokens).toLocaleString()}` - ) - if (hooksNum > 0) { - const avgTokensPerQuery = Math.round(tokensInjected / hooksNum) + console.log(`Reporecall`) + console.log(``) + console.log(`Index:`) console.log( - ` Avg tokens/query: ${avgTokensPerQuery.toLocaleString()}` + ` Chunks: ${stats.totalChunks} across ${stats.totalFiles} files` ) - } - if (chunksServedNum > 0 && hooksNum > 0) { - const avgChunksPerQuery = (chunksServedNum / hooksNum).toFixed(1) - console.log(` Avg chunks/query: ${avgChunksPerQuery}`) - } - // Memory stats - const memoriesInjected = metadata.getStat('memoriesInjected') ?? '0' - const memoryTokensInjected = metadata.getStat('memoryTokensInjected') ?? '0' - const memoryHitCount = metadata.getStat('memoryHitCount') ?? '0' - const memoryHitNum = parseInt(memoryHitCount, 10) - const memoriesInjectedNum = parseInt(memoriesInjected, 10) - const memoryTokensNum = parseInt(memoryTokensInjected, 10) - - if (memoryHitNum > 0 || existsSync(resolve(config.dataDir, 'memory-index', 'memories.db'))) { + console.log(` Languages: ${langLines || 'none'}`) + console.log(` Storage: ${formatBytes(storageBytes)}`) + console.log(` Last indexed: ${timeSince}`) console.log(``) - console.log(`Memory:`) - console.log(` Queries with memory: ${memoryHitCount}${hooksNum > 0 ? ` (${((memoryHitNum / hooksNum) * 100).toFixed(0)}% hit rate)` : ''}`) - console.log(` Memories injected: ${Number(memoriesInjected).toLocaleString()}`) - console.log(` Memory tokens: ${Number(memoryTokensInjected).toLocaleString()}`) - if (memoryHitNum > 0) { - console.log(` Avg tokens/hit: ${Math.round(memoryTokensNum / memoryHitNum).toLocaleString()}`) - console.log(` Avg memories/hit: ${(memoriesInjectedNum / memoryHitNum).toFixed(1)}`) - } - if (memoryFreshness) { - console.log(` Freshness: newest update ${memoryFreshness} ago`) + + console.log(`Session Stats:`) + console.log(` Hooks fired: ${hooksCount}`) + console.log( + ` Chunks served: ${Number(chunksServed).toLocaleString()}` + ) + console.log( + ` Tokens injected: ${Number(totalTokens).toLocaleString()}` + ) + if (hooksNum > 0) { + const avgTokensPerQuery = Math.round(tokensInjected / hooksNum) + console.log( + ` Avg tokens/query: ${avgTokensPerQuery.toLocaleString()}` + ) } - if (memoryTotals) { - console.log(` Inventory: ${memoryTotals.total} total (${memoryTotals.active} active, ${memoryTotals.archived} archived, ${memoryTotals.superseded} superseded, ${memoryTotals.pinned} pinned)`) + if (chunksServedNum > 0 && hooksNum > 0) { + const avgChunksPerQuery = (chunksServedNum / hooksNum).toFixed(1) + console.log(` Avg chunks/query: ${avgChunksPerQuery}`) } - const classTokens = [ - ['rule', metadata.getStat('memoryTokens_rule'), metadata.getStat('memoryCount_rule')], - ['working', metadata.getStat('memoryTokens_working'), metadata.getStat('memoryCount_working')], - ['fact', metadata.getStat('memoryTokens_fact'), metadata.getStat('memoryCount_fact')], - ['episode', metadata.getStat('memoryTokens_episode'), metadata.getStat('memoryCount_episode')], - ] as const - if (classTokens.some(([, tokens, count]) => Number(tokens ?? '0') > 0 || Number(count ?? '0') > 0)) { - console.log(` Avg tokens/class:`) - for (const [label, tokens, count] of classTokens) { - const countNum = Number(count ?? '0') - const tokenNum = Number(tokens ?? '0') - if (countNum > 0) { - console.log(` ${label}: ${Math.round(tokenNum / countNum).toLocaleString()} tokens`) + // Memory stats + const memoriesInjected = metadata.getStat('memoriesInjected') ?? '0' + const memoryTokensInjected = metadata.getStat('memoryTokensInjected') ?? '0' + const memoryHitCount = metadata.getStat('memoryHitCount') ?? '0' + const memoryHitNum = parseInt(memoryHitCount, 10) + const memoriesInjectedNum = parseInt(memoriesInjected, 10) + const memoryTokensNum = parseInt(memoryTokensInjected, 10) + + if (memoryHitNum > 0 || existsSync(resolve(config.dataDir, 'memory-index', 'memories.db'))) { + console.log(``) + console.log(`Memory:`) + console.log(` Queries with memory: ${memoryHitCount}${hooksNum > 0 ? ` (${((memoryHitNum / hooksNum) * 100).toFixed(0)}% hit rate)` : ''}`) + console.log(` Memories injected: ${Number(memoriesInjected).toLocaleString()}`) + console.log(` Memory tokens: ${Number(memoryTokensInjected).toLocaleString()}`) + if (memoryHitNum > 0) { + console.log(` Avg tokens/hit: ${Math.round(memoryTokensNum / memoryHitNum).toLocaleString()}`) + console.log(` Avg memories/hit: ${(memoriesInjectedNum / memoryHitNum).toFixed(1)}`) + } + if (memoryFreshness) { + console.log(` Freshness: newest update ${memoryFreshness} ago`) + } + if (memoryTotals) { + console.log(` Inventory: ${memoryTotals.total} total (${memoryTotals.active} active, ${memoryTotals.archived} archived, ${memoryTotals.superseded} superseded, ${memoryTotals.pinned} pinned)`) + } + const classTokens = [ + ['rule', metadata.getStat('memoryTokens_rule'), metadata.getStat('memoryCount_rule')], + ['working', metadata.getStat('memoryTokens_working'), metadata.getStat('memoryCount_working')], + ['fact', metadata.getStat('memoryTokens_fact'), metadata.getStat('memoryCount_fact')], + ['episode', metadata.getStat('memoryTokens_episode'), metadata.getStat('memoryCount_episode')], + ] as const + if (classTokens.some(([, tokens, count]) => Number(tokens ?? '0') > 0 || Number(count ?? '0') > 0)) { + console.log(` Avg tokens/class:`) + for (const [label, tokens, count] of classTokens) { + const countNum = Number(count ?? '0') + const tokenNum = Number(tokens ?? '0') + if (countNum > 0) { + console.log(` ${label}: ${Math.round(tokenNum / countNum).toLocaleString()} tokens`) + } } } + const memoryCompactionCount = metadata.getStat('memoryCompactionCount') ?? '0' + const memoryArchivedCount = metadata.getStat('memoryArchivedCount') ?? '0' + const memorySupersededCount = metadata.getStat('memorySupersededCount') ?? '0' + if (Number(memoryCompactionCount) > 0 || Number(memoryArchivedCount) > 0 || Number(memorySupersededCount) > 0) { + console.log(` Compaction:`) + console.log(` Runs: ${memoryCompactionCount}`) + console.log(` Archived: ${memoryArchivedCount}`) + console.log(` Superseded: ${memorySupersededCount}`) + } } - const memoryCompactionCount = metadata.getStat('memoryCompactionCount') ?? '0' - const memoryArchivedCount = metadata.getStat('memoryArchivedCount') ?? '0' - const memorySupersededCount = metadata.getStat('memorySupersededCount') ?? '0' - if (Number(memoryCompactionCount) > 0 || Number(memoryArchivedCount) > 0 || Number(memorySupersededCount) > 0) { - console.log(` Compaction:`) - console.log(` Runs: ${memoryCompactionCount}`) - console.log(` Archived: ${memoryArchivedCount}`) - console.log(` Superseded: ${memorySupersededCount}`) - } - } - if (latency.count > 0) { - console.log(``) - console.log(`Search Latency (${latency.count} queries):`) - console.log(` Avg: ${latency.avg}ms`) - console.log(` p50: ${latency.p50}ms`) - console.log(` p95: ${latency.p95}ms`) - } + if (latency.count > 0) { + console.log(``) + console.log(`Search Latency (${latency.count} queries):`) + console.log(` Avg: ${latency.avg}ms`) + console.log(` p50: ${latency.p50}ms`) + console.log(` p95: ${latency.p95}ms`) + } - // Route breakdown (intent-based modes since v0.4.0) - const routeSkip = metadata.getStat('route_skip_count') ?? '0' - const routeLookup = metadata.getStat('route_lookup_count') ?? '0' - const routeTrace = metadata.getStat('route_trace_count') ?? '0' - const routeBug = metadata.getStat('route_bug_count') ?? '0' - const routeArch = metadata.getStat('route_architecture_count') ?? '0' - const routeChange = metadata.getStat('route_change_count') ?? '0' - const totalRoutes = [routeSkip, routeLookup, routeTrace, routeBug, routeArch, routeChange] - .reduce((s, v) => s + parseInt(v, 10), 0) - if (totalRoutes > 0) { - console.log(``) - console.log(`Query Mode Breakdown:`) - console.log(` skip: ${routeSkip}`) - console.log(` lookup: ${routeLookup}`) - console.log(` trace: ${routeTrace}`) - console.log(` bug: ${routeBug}`) - console.log(` architecture: ${routeArch}`) - console.log(` change: ${routeChange}`) - } + // Route breakdown (intent-based modes since v0.4.0) + const routeSkip = metadata.getStat('route_skip_count') ?? '0' + const routeLookup = metadata.getStat('route_lookup_count') ?? '0' + const routeTrace = metadata.getStat('route_trace_count') ?? '0' + const routeBug = metadata.getStat('route_bug_count') ?? '0' + const routeArch = metadata.getStat('route_architecture_count') ?? '0' + const routeChange = metadata.getStat('route_change_count') ?? '0' + const totalRoutes = [routeSkip, routeLookup, routeTrace, routeBug, routeArch, routeChange] + .reduce((s, v) => s + parseInt(v, 10), 0) + if (totalRoutes > 0) { + console.log(``) + console.log(`Query Mode Breakdown:`) + console.log(` skip: ${routeSkip}`) + console.log(` lookup: ${routeLookup}`) + console.log(` trace: ${routeTrace}`) + console.log(` bug: ${routeBug}`) + console.log(` architecture: ${routeArch}`) + console.log(` change: ${routeChange}`) + } - // Check daemon status - const pidPath = resolve(config.dataDir, 'daemon.pid') - if (existsSync(pidPath)) { - const pid = readFileSync(pidPath, 'utf-8').trim() - if (isProcessAlive(parseInt(pid, 10))) { - console.log(`\nDaemon: running (PID ${pid})`) + // Check daemon status + const pidPath = resolve(config.dataDir, 'daemon.pid') + if (existsSync(pidPath)) { + const pid = readFileSync(pidPath, 'utf-8').trim() + if (isProcessAlive(parseInt(pid, 10))) { + console.log(`\nDaemon: running (PID ${pid})`) + } else { + console.log(`\nDaemon: not running (stale PID file)`) + } } else { - console.log(`\nDaemon: not running (stale PID file)`) + console.log(`\nDaemon: not running`) } - } else { - console.log(`\nDaemon: not running`) + } finally { + metadata.close() + memoryStore?.close() } - - metadata.close() - memoryStore?.close() }) } diff --git a/src/core/config.ts b/src/core/config.ts index ddf49b1..082d09c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -87,6 +87,16 @@ export interface MemoryConfig { capabilityEvidence: boolean; /** Hydrate generic capability evidence into prompt context for broad inventory queries */ genericCapabilityHydration: boolean; + /** Enable language-aware code evidence compression in assembled context */ + contextCompressionEnabled: boolean; + /** Compression policy: off disables, auto compresses lower-ranked/budget chunks, always attempts all eligible chunks */ + contextCompressionMode: "off" | "auto" | "always"; + /** Number of top-ranked chunks to keep as full source before compressing later chunks */ + contextCompressionPreserveTopChunks: number; + /** Minimum chunk size, in tokens, before compression is attempted */ + contextCompressionMinChunkTokens: number; + /** Maximum compressed/full token ratio accepted for compressed evidence */ + contextCompressionTargetRatio: number; /** Enable topology analysis after indexing */ topologyEnabled?: boolean; /** Skip topology graph construction above this indexed chunk count */ @@ -167,6 +177,11 @@ const UserConfigSchema = z.object({ wikiMaxPages: z.number().int().min(1).max(10).optional(), capabilityEvidence: z.boolean().optional(), genericCapabilityHydration: z.boolean().optional(), + contextCompressionEnabled: z.boolean().optional(), + contextCompressionMode: z.enum(["off", "auto", "always"]).optional(), + contextCompressionPreserveTopChunks: z.number().int().min(0).max(20).optional(), + contextCompressionMinChunkTokens: z.number().int().min(1).optional(), + contextCompressionTargetRatio: z.number().min(0.1).max(0.95).optional(), topologyEnabled: z.boolean().optional(), topologyMaxChunks: z.number().int().min(100).optional(), }).strict(); @@ -255,6 +270,11 @@ const DEFAULTS: Omit = { wikiMaxPages: 3, capabilityEvidence: true, genericCapabilityHydration: true, + contextCompressionEnabled: true, + contextCompressionMode: "auto", + contextCompressionPreserveTopChunks: 1, + contextCompressionMinChunkTokens: 100, + contextCompressionTargetRatio: 0.75, topologyEnabled: true, topologyMaxChunks: 50_000, factExtractors: [], @@ -324,6 +344,12 @@ export function loadConfig(projectRoot: string): MemoryConfig { if (existsSync(configPath)) { try { const raw = JSON.parse(readFileSync(configPath, "utf-8")); + // Pre-scan the raw object: UserConfigSchema is .strict(), so an + // openaiApiKey would make safeParse reject with a generic message. + // Emit the specific security guidance before that happens. + if (raw && typeof raw === "object" && "openaiApiKey" in raw) { + getLogger().warn("openaiApiKey in config file is ignored for security. Use OPENAI_API_KEY env var."); + } const result = UserConfigSchema.safeParse(raw); if (result.success) { userConfig = result.data; @@ -409,10 +435,9 @@ export function loadConfig(projectRoot: string): MemoryConfig { }); } - // H-1: Never load API keys from config file — env var only - if ("openaiApiKey" in (userConfig as Record)) { - getLogger().warn("openaiApiKey in config file is ignored for security. Use OPENAI_API_KEY env var."); - } + // H-1: API keys are never loaded from config — handled above via raw pre-scan + // (openaiApiKey would be rejected by .strict() parsing, so the warning is + // emitted before safeParse). return merged; } diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..118a8e0 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,18 @@ +import type pino from "pino"; + +export function toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function logWarnWithStack( + logger: pino.Logger, + err: unknown, + context: Record, + msg: string +): void { + logger.warn({ err, ...context }, msg); + logger.debug( + { stack: err instanceof Error ? err.stack : String(err), ...context }, + msg + ); +} diff --git a/src/core/logger.ts b/src/core/logger.ts index e9adcf9..09709c9 100644 --- a/src/core/logger.ts +++ b/src/core/logger.ts @@ -6,7 +6,10 @@ export function getLogger(): pino.Logger { if (logger) return logger; const destination = process.env.MEMORY_LOG_DEST === "stderr" - ? pino.destination({ dest: 2, sync: true }) + ? // Async (buffered) stderr destination. Synchronous writes block the + // event loop and can hard-deadlock an MCP stdio server if the client + // stops draining stderr. + pino.destination({ dest: 2, sync: false }) : undefined; logger = pino( { level: process.env.MEMORY_LOG_LEVEL ?? "info" }, diff --git a/src/core/rwlock.ts b/src/core/rwlock.ts index e8131e3..60ded4a 100644 --- a/src/core/rwlock.ts +++ b/src/core/rwlock.ts @@ -41,6 +41,12 @@ export class ReadWriteLock { } private releaseRead(): void { + // Detect underflow from a double releaseRead()/unbalanced release: a + // negative reader count would silently break the readers===0 hand-off + // condition and stall the lock forever. + if (this.readers <= 0) { + throw new Error("ReadWriteLock.releaseRead: readers underflow (unbalanced release)"); + } this.readers--; if (this.readers === 0 && this.writerQueue.length > 0) { this.writing = true; @@ -62,6 +68,9 @@ export class ReadWriteLock { } private releaseWrite(): void { + if (!this.writing) { + throw new Error("ReadWriteLock.releaseWrite: not writing (unbalanced release)"); + } this.writing = false; // Writer-preferring: prioritize queued writers over readers if (this.writerQueue.length > 0) { diff --git a/src/core/strings.ts b/src/core/strings.ts new file mode 100644 index 0000000..82667b0 --- /dev/null +++ b/src/core/strings.ts @@ -0,0 +1,42 @@ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function humanizeSlug(name: string): string { + return name + .replace(/^business-/, "") + .split("-") + .filter(Boolean) + .map((part) => part[0] ? `${part[0].toUpperCase()}${part.slice(1)}` : part) + .join(" "); +} + +export function slugify(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +export function extractSectionText(content: string, heading: string): string { + const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); + if (!match?.[1]) return ""; + return match[1] + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("- ")) + .join(" ") + .trim(); +} + +export function extractBulletSection(content: string, heading: string): string[] { + const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); + if (!match?.[1]) return []; + return match[1] + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2).trim()) + .filter((line) => line.length > 0); +} diff --git a/src/daemon/mcp-server.ts b/src/daemon/mcp-server.ts index 1723ebd..c77cdd4 100644 --- a/src/daemon/mcp-server.ts +++ b/src/daemon/mcp-server.ts @@ -1,7 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import { resolve, sep } from 'path' -import { realpathSync, unlinkSync, mkdirSync, readFileSync } from 'fs' +import { realpathSync, unlinkSync, mkdirSync, rmSync } from 'fs' +import { readFile } from 'fs/promises' import type { HybridSearch } from '../search/hybrid.js' import type { IndexingPipeline } from '../indexer/pipeline.js' import type { MetadataStore } from '../storage/metadata-store.js' @@ -27,6 +28,7 @@ import { suggestWikiPages } from '../wiki/suggestions.js' import { buildBusinessContextFromMemoryStore, queryBusinessContext } from '../business/product-areas.js' import { extractDashboardData } from '../visualize/data-extractor.js' import type { DashboardData } from '../visualize/types.js' +import type { StoredChunk } from '../storage/types.js' import { getLogger } from '../core/logger.js' const require = createRequire(import.meta.url) @@ -70,6 +72,46 @@ function errorResult(err: unknown) { } } +function shapeCodeChunk(chunk: StoredChunk) { + return { + id: chunk.id, + name: chunk.name, + filePath: chunk.filePath, + kind: chunk.kind, + startLine: chunk.startLine, + endLine: chunk.endLine, + language: chunk.language, + content: chunk.content, + docstring: chunk.docstring, + parentName: chunk.parentName, + } +} + +function findBestChunkByLocation( + metadata: MetadataStore, + filePath: string | undefined, + startLine: number | undefined, + endLine: number | undefined +): StoredChunk | undefined { + if (!filePath) return undefined + const chunks = metadata.findChunksByFilePath(filePath) + if (chunks.length === 0) return undefined + if (startLine === undefined && endLine === undefined) return chunks[0] + + const start = startLine ?? endLine ?? 1 + const end = endLine ?? startLine ?? start + const overlapping = chunks + .filter((chunk) => chunk.startLine <= end && chunk.endLine >= start) + .sort((a, b) => { + const aContains = a.startLine <= start && a.endLine >= end ? 0 : 1 + const bContains = b.startLine <= start && b.endLine >= end ? 0 : 1 + const aDistance = Math.abs(a.startLine - start) + Math.abs(a.endLine - end) + const bDistance = Math.abs(b.startLine - start) + Math.abs(b.endLine - end) + return aContains - bContains || aDistance - bDistance + }) + return overlapping[0] +} + function shapeLensData( data: DashboardData, options: { @@ -159,7 +201,7 @@ export function createMCPServer( server.registerTool( 'search_code', { - description: 'Search the codebase using hybrid vector + keyword search', + description: 'Search the codebase using hybrid vector + keyword search and return raw matching chunks. For multi-file questions, prefer search_context.', inputSchema: { query: z.string().min(1).describe('Search query'), limit: z @@ -187,6 +229,7 @@ export function createMCPServer( text: JSON.stringify( results.map((r) => ({ name: r.name, + id: r.id, filePath: r.filePath, kind: r.kind, startLine: r.startLine, @@ -211,6 +254,119 @@ export function createMCPServer( } ) + server.registerTool( + 'search_context', + { + description: + 'Return assembled, token-budgeted code context for a multi-file question. Uses Reporecall routing and evidence compression; compressed entries can be expanded with read_code_chunk.', + inputSchema: { + query: z.string().min(1).describe('Natural language codebase question'), + tokenBudget: z + .number() + .int() + .min(1) + .optional() + .describe('Optional context token budget; defaults to configured auto budget'), + activeFiles: z + .array(z.string()) + .optional() + .describe('Currently open file paths for boosting') + }, + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ query, tokenBudget, activeFiles }) => { + try { + const doSearchContext = async () => { + const budget = tokenBudget ?? resolveContextBudget( + config.contextBudget, + metadata.getStats().totalChunks + ) + const context = await search.searchWithContext(query, budget, activeFiles) + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + text: context.text, + tokenCount: context.tokenCount, + chunksIncluded: context.chunks.length, + routeStyle: context.routeStyle, + deliveryMode: context.deliveryMode, + selectedFiles: [...new Set(context.chunks.map((chunk) => chunk.filePath))], + chunks: context.chunks.map((chunk) => ({ + id: chunk.id, + name: chunk.name, + filePath: chunk.filePath, + kind: chunk.kind, + startLine: chunk.startLine, + endLine: chunk.endLine, + score: chunk.score, + language: chunk.language, + })), + compression: context.compression, + }, + null, + 2 + ) + } + ] + } + } + return lock ? await lock.withRead(doSearchContext) : doSearchContext() + } catch (err) { + return errorResult(err) + } + } + ) + + server.registerTool( + 'read_code_chunk', + { + description: 'Read the full original source for a code chunk by chunkId, or by file path and line range.', + inputSchema: { + chunkId: z.string().min(1).optional().describe('Exact chunk id from search_code or compressed context'), + filePath: z.string().min(1).optional().describe('Indexed project-relative file path'), + startLine: z.number().int().min(1).optional().describe('Start line for file path lookup'), + endLine: z.number().int().min(1).optional().describe('End line for file path lookup') + }, + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ chunkId, filePath, startLine, endLine }) => { + try { + const readChunk = async () => { + const chunk = chunkId + ? metadata.getChunk(chunkId) + : findBestChunkByLocation(metadata, filePath, startLine, endLine) + + if (!chunk) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: 'chunk not found', chunkId, filePath, startLine, endLine }, null, 2) + } + ], + isError: true + } + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(shapeCodeChunk(chunk), null, 2) + } + ] + } + } + return lock ? await lock.withRead(readChunk) : readChunk() + } catch (err) { + return errorResult(err) + } + } + ) + server.registerTool( 'index_codebase', { @@ -393,8 +549,6 @@ export function createMCPServer( // Close stores and wipe merkle state before deleting files await pipeline.closeAndClearMerkle() - const { rmSync } = await import('fs') - const { resolve } = await import('path') const files = [ 'metadata.db', 'metadata.db-wal', @@ -420,8 +574,8 @@ export function createMCPServer( // Reinitialize pipeline with fresh stores so subsequent calls work await pipeline.reinit() - // Update search to use the new store instances - search.updateStores( + // Update search to use the new store instances (awaits the write lock). + await search.updateStores( pipeline.getVectorStore(), pipeline.getFTSStore(), pipeline.getMetadataStore() @@ -782,7 +936,14 @@ export function createMCPServer( tree, metadata, flowBudget, - query + query, + { + contextCompressionEnabled: config.contextCompressionEnabled, + contextCompressionMode: config.contextCompressionMode, + contextCompressionPreserveTopChunks: config.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: config.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: config.contextCompressionTargetRatio, + } ) const allNodes = [tree.seed, ...tree.upTree, ...tree.downTree] @@ -817,7 +978,8 @@ export function createMCPServer( }, flowContext: explained.flowContext.text, tokenCount: explained.flowContext.tokenCount, - chunksIncluded: explained.flowContext.chunks.length + chunksIncluded: explained.flowContext.chunks.length, + compression: explained.flowContext.compression }, null, 2 @@ -1163,10 +1325,13 @@ export function createMCPServer( annotations: { destructiveHint: true } }, async ({ name, description, memoryType, content, class: memoryClass, scope, status, summary, sourceKind, pinned, relatedFiles, relatedSymbols, supersedesId, confidence, reason }) => { - try { + // The consolidation check AND the write must run together under the + // write lock. Previously the check ran unlocked, so two concurrent + // stores of similar-named memories both passed the check and both wrote. + const doStore = async (): Promise<{ content: Array<{ type: 'text'; text: string }> } | { __filePath: string }> => { const writableDirs = memoryIndexer.getWritableDirs() if (writableDirs.length === 0) { - return errorResult(new Error('No memory directory configured')) + throw new Error('No memory directory configured') } const targetDir = writableDirs[0]! @@ -1175,22 +1340,13 @@ export function createMCPServer( // Consolidation check: warn if a memory with similar name exists if (memoryStore) { const existing = memoryStore.getByName(name) - if (existing) { - // Same name — will overwrite (existing behavior) - } else { - // Check FTS for genuinely similar content — only block if both - // name overlap AND strong FTS rank indicate a real duplicate. - // Previous logic blocked on ANY FTS match, causing false positives - // (e.g., a domain term blocked by an unrelated benchmark result). + if (!existing) { const similar = memoryStore.search(name, 5) const nameLower = name.toLowerCase() const blocked = similar.find((match) => { const existingMem = memoryStore.get(match.id) if (!existingMem || existingMem.name === name) return false - // Require strong FTS rank — BM25 inflates in small corpus (10-20 memories), - // so -25 is a genuinely strong match, not just a token overlap. if (match.rank > -25) return false - // Require substantial name character overlap (≥40% of the longer name) const existingLower = existingMem.name.toLowerCase() const overlapLen = Math.max(10, Math.floor(Math.max(existingLower.length, nameLower.length) * 0.40)) const nameOverlap = @@ -1222,43 +1378,40 @@ export function createMCPServer( .toLowerCase() .slice(0, 100) - // Write and index atomically under the write lock - const doIndex = async () => { - const filePath = writeManagedMemoryFile(targetDir, safeName, { - name, - description, - memoryType, - content, - class: memoryClass, - scope, - status, - summary, - sourceKind, - pinned, - relatedFiles, - relatedSymbols, - supersedesId, - confidence, - reason, - }) - await memoryIndexer.indexFile(filePath) - return filePath - } - let filePath: string - if (lock) { - filePath = await lock.withWrite(doIndex) - } else { - filePath = await doIndex() - } + const filePath = writeManagedMemoryFile(targetDir, safeName, { + name, + description, + memoryType, + content, + class: memoryClass, + scope, + status, + summary, + sourceKind, + pinned, + relatedFiles, + relatedSymbols, + supersedesId, + confidence, + reason, + }) + await memoryIndexer.indexFile(filePath) + return { __filePath: filePath } + } - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ stored: true, filePath, name }) - } - ] + try { + const outcome = lock ? await lock.withWrite(doStore) : await doStore() + if ('__filePath' in outcome) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ stored: true, filePath: outcome.__filePath, name }) + } + ] + } } + return outcome } catch (err) { return errorResult(err) } @@ -1647,7 +1800,7 @@ export function createMCPServer( annotations: { readOnlyHint: true, idempotentHint: true } }, async ({ query, limit }) => { - try { + const run = async () => { const results = await memorySearch.search(query, { types: ['wiki'], limit: limit ?? 5 @@ -1663,6 +1816,11 @@ export function createMCPServer( return { content: [{ type: 'text' as const, text: JSON.stringify({ pages, count: pages.length }, null, 2) }] } + } + try { + // Hold the read lock so concurrent wiki_write/store_memory cannot + // mutate the store mid-query. + return lock ? await lock.withRead(run) : await run() } catch (err) { return errorResult(err) } @@ -1680,7 +1838,7 @@ export function createMCPServer( annotations: { readOnlyHint: true, idempotentHint: true } }, async ({ name }) => { - try { + const run = async () => { if (name) { const page = memoryStore.getByName(name) if (!page || page.type !== 'wiki') { @@ -1726,6 +1884,9 @@ export function createMCPServer( return { content: [{ type: 'text' as const, text: JSON.stringify({ pages: index, count: index.length }, null, 2) }] } + } + try { + return lock ? await lock.withRead(run) : await run() } catch (err) { return errorResult(err) } @@ -1748,7 +1909,7 @@ export function createMCPServer( annotations: { readOnlyHint: false } }, async ({ name, description, content, relatedFiles, relatedSymbols, pageType }) => { - try { + const run = async () => { const writableDir = memoryIndexer.getWritableDirs()[0] if (!writableDir) { return errorResult(new Error('No writable memory directory configured')) @@ -1783,6 +1944,10 @@ export function createMCPServer( return { content: [{ type: 'text' as const, text: JSON.stringify({ slug, filePath, links: allLinks }) }] } + } + try { + // Serialize writes with other mutating memory tools. + return lock ? await lock.withWrite(run) : await run() } catch (err) { return errorResult(err) } @@ -1800,17 +1965,17 @@ export function createMCPServer( annotations: { readOnlyHint: true, idempotentHint: true } }, async ({ name }) => { - try { + const run = async () => { const pages = name ? [memoryStore.getByName(name)].filter((p): p is NonNullable => p != null && p.type === 'wiki') : memoryStore.getByType('wiki') - const results = pages.map(page => { + const results = await Promise.all(pages.map(async page => { // sourceCommit is in frontmatter, which is stripped from page.content. // Read the raw file from disk to extract it. let sourceCommit = '' try { - const raw = readFileSync(page.filePath, 'utf-8') + const raw = await readFile(page.filePath, 'utf-8') const match = raw.match(/sourceCommit:\s*"?([a-f0-9]+)"?/) sourceCommit = match?.[1] ?? '' } catch { /* file may not exist */ } @@ -1820,7 +1985,7 @@ export function createMCPServer( page.relatedFiles ?? [], config.projectRoot ) - }) + })) const staleCount = results.filter(r => r.stale).length return { @@ -1829,6 +1994,9 @@ export function createMCPServer( text: JSON.stringify({ results, total: results.length, stale: staleCount }, null, 2) }] } + } + try { + return lock ? await lock.withRead(run) : await run() } catch (err) { return errorResult(err) } diff --git a/src/daemon/memory/runtime.ts b/src/daemon/memory/runtime.ts index 4b5d4ea..785db77 100644 --- a/src/daemon/memory/runtime.ts +++ b/src/daemon/memory/runtime.ts @@ -1,6 +1,7 @@ -import { execFileSync } from "child_process"; +import { execFile } from "child_process"; import { existsSync, readdirSync, rmSync } from "fs"; -import { extname, basename, resolve } from "path"; +import { extname, basename, resolve, sep } from "path"; +import { promisify } from "util"; import { watch, type FSWatcher } from "chokidar"; import { getLogger } from "../../core/logger.js"; import { writeManagedMemoryFile } from "../../memory/files.js"; @@ -9,6 +10,8 @@ import type { MemorySearchResult, MemoryType } from "../../memory/types.js"; import { resolveMemoryClass } from "../../memory/types.js"; import type { MemoryStore } from "../../storage/memory-store.js"; +const execFileAsync = promisify(execFile); + type MemoryFileEventType = "add" | "change" | "unlink"; interface PendingMemoryChange { @@ -158,9 +161,13 @@ export class MemoryRuntime { async clearWorkingMemory(): Promise { if (!this.writableDir) return 0; let removed = 0; + // Use a separator-aware prefix so a sibling directory like "/a/.mem/proj-evil" + // cannot match writableDir "/a/.mem/proj" (the bare startsWith below would + // have allowed rmSync to delete files outside the intended directory). + const writablePrefix = this.writableDir.endsWith(sep) ? this.writableDir : this.writableDir + sep; for (const memory of this.store.getAll()) { if (resolveMemoryClass(memory) !== "working") continue; - if (!memory.filePath.startsWith(this.writableDir)) continue; + if (!memory.filePath.startsWith(writablePrefix)) continue; try { rmSync(memory.filePath, { force: true }); } catch { @@ -209,6 +216,13 @@ export class MemoryRuntime { }); this.watcher?.once("error", reject); }); + + // Persistent error handler: the `once("error")` above only covers the + // startup window. Without this, a later chokidar error (EMFILE, EACCES, + // watched-dir deletion) is unhandled and can crash the process. + this.watcher?.on("error", (err: unknown) => { + log.warn({ err }, "Memory runtime watcher error (non-fatal)"); + }); } private async flush(force = false): Promise { @@ -275,7 +289,7 @@ export class MemoryRuntime { private async upsertWorkingMemory(input: ObservePromptInput): Promise { if (!this.writableDir) return; - const branch = this.detectBranch(); + const branch = await this.detectBranch(); const scope = branch ? "branch" : "project"; const topFiles = uniq([...(input.activeFiles ?? []), ...(input.topFiles ?? [])]).slice(0, 5); const topSymbols = uniq(input.topSymbols ?? []).slice(0, 8); @@ -372,14 +386,14 @@ export class MemoryRuntime { } } - private detectBranch(): string | null { + private async detectBranch(): Promise { if (!this.projectRoot) return null; try { - const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: this.projectRoot, encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); + }); + const branch = stdout.trim(); return branch && branch !== "HEAD" ? branch : null; } catch { return null; diff --git a/src/daemon/rotating-log.ts b/src/daemon/rotating-log.ts index 0bdca5b..fedd841 100644 --- a/src/daemon/rotating-log.ts +++ b/src/daemon/rotating-log.ts @@ -45,7 +45,16 @@ export class RotatingLog { } this.currentSize! += bytes; }); - this.writeQueue = op.catch(() => {}); + // Surface write/rotate failures on stderr so they don't vanish silently. + // Use console.error (not the rotating log itself) to avoid recursion. + this.writeQueue = op.catch((err) => { + try { + // eslint-disable-next-line no-console + console.error("[RotatingLog] write/rotate failed:", err); + } catch { + // Last-resort: never throw out of the queue chain. + } + }); return op; } diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 07f6d0a..9016675 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -61,10 +61,6 @@ setInterval(() => { } }, RATE_LIMIT_CLEANUP_INTERVAL_MS).unref(); -export function resetHookSessionState(): void { - hookSessionState.clear(); -} - export function resetRateLimitMap(): void { rateLimitMap.clear(); } @@ -240,8 +236,10 @@ function withTimeout( endpoint?: string ): Promise { const abortController = new AbortController(); + let timedOut = false; return new Promise((resolve, reject) => { const timer = setTimeout(() => { + timedOut = true; abortController.abort(); if (!res.writableEnded) { json(res, { error: "Request timeout", code: "TIMEOUT", endpoint, timeoutMs }, 504); @@ -252,11 +250,17 @@ function withTimeout( handler(abortController.signal) .then(() => { clearTimeout(timer); - resolve(); + // If the handler resolved AFTER a timeout, the client already received + // a 504 and any writes it performed hit an ended response — treat as + // a no-op rather than resolving a second time. + if (!timedOut) resolve(); }) .catch((err) => { clearTimeout(timer); - reject(err); + // Swallow errors raised after a timeout (e.g. a handler attempting + // json() on an already-ended response throws ERR_HTTP_HEADERS_SENT). + // These are consequences of the timeout, not independent failures. + if (!timedOut) reject(err); }); }); } @@ -546,7 +550,8 @@ export function createDaemonServer( .filter((f: unknown) => typeof f === "string" && f.length < 1024) .slice(0, 100); } - } catch { + } catch (err) { + log.warn({ err }, "prompt-context body JSON parse failed; falling back to raw body as query"); query = body; } @@ -790,6 +795,7 @@ export function createDaemonServer( dominantFamily: promptContext.dominantFamily, familyConfidence: promptContext.familyConfidence, deferredReason: promptContext.deferredReason, + compression: context.compression, }; const memTok = promptContext.memoryTokenCount ?? 0; @@ -929,6 +935,7 @@ export function createDaemonServer( dominantFamily: promptContext.dominantFamily, familyConfidence: promptContext.familyConfidence, deferredReason: promptContext.deferredReason, + compression: context.compression, queryClassification: intent, latencyMs: elapsed, ...(seedCandidate ? { seedCandidate, seedConfidence } : {}), @@ -961,7 +968,8 @@ export function createDaemonServer( return; } parsed = validation.data as Record; - } catch { + } catch (err) { + log.warn({ err }, "pre-tool-use body JSON parse failed; continuing with empty context"); parsed = {}; } diff --git a/src/daemon/watcher.ts b/src/daemon/watcher.ts index abf7ee1..410ad3f 100644 --- a/src/daemon/watcher.ts +++ b/src/daemon/watcher.ts @@ -7,6 +7,10 @@ import { loadMemoryIgnore } from "../core/project.js"; import { getLogger } from "../core/logger.js"; const MAX_PENDING = 10_000; +// Hard upper bound on how long a burst of events can be coalesced before a +// flush is forced. Without this, sustained activity (builds, git checkouts) +// keeps resetting the trailing-edge debounce timer and the callback never fires. +const MAX_WAIT_MS = 5_000; export type WatcherCallback = ( changes: Array<{ path: string; type: "add" | "change" | "unlink" }> @@ -20,6 +24,7 @@ export class FileWatcher { type: "add" | "change" | "unlink"; }> = []; private debounceTimer: ReturnType | undefined; + private maxWaitTimer: ReturnType | undefined; private callback: WatcherCallback; constructor(config: MemoryConfig, callback: WatcherCallback) { @@ -76,22 +81,45 @@ export class FileWatcher { this.pendingChanges.push({ path: relPath, type: eventType }); - // Debounce + // Trailing-edge debounce: coalesce rapid bursts. The maxWaitTimer guards + // against indefinite postponement under sustained activity by forcing a + // flush after MAX_WAIT_MS regardless of ongoing events. if (this.debounceTimer) clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { const changes = [...this.pendingChanges]; this.pendingChanges = []; this.callback(changes); }, this.config.debounceMs); + if (!this.maxWaitTimer) { + this.maxWaitTimer = setTimeout(() => { + this.maxWaitTimer = undefined; + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + if (this.pendingChanges.length > 0) { + const changes = [...this.pendingChanges]; + this.pendingChanges = []; + this.callback(changes); + } + }, MAX_WAIT_MS); + } }; this.watcher.on("add", (p) => handleEvent("add", p)); this.watcher.on("change", (p) => handleEvent("change", p)); this.watcher.on("unlink", (p) => handleEvent("unlink", p)); + + // Persistent error handler so chokidar errors (EMFILE, EACCES) are logged + // instead of crashing the process as unhandled exceptions. + this.watcher.on("error", (err: unknown) => { + getLogger().warn({ err }, "FileWatcher error (non-fatal)"); + }); } async stop(): Promise { if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer); await this.watcher?.close(); } } diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 7adf6f2..e56b582 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -24,7 +24,7 @@ export interface PreToolUseHookInput { export interface PreToolUseHookOutput { hookSpecificOutput: { hookEventName: "PreToolUse"; - permissionDecision: "allow" | "deny"; + permissionDecision: "allow"; permissionDecisionReason: string; additionalContext?: string; }; diff --git a/src/hooks/prompt-context.ts b/src/hooks/prompt-context.ts index fba767d..3bb7465 100644 --- a/src/hooks/prompt-context.ts +++ b/src/hooks/prompt-context.ts @@ -146,6 +146,7 @@ async function buildDeepRouteContext( query: string, search: HybridSearch, budget: number, + config: MemoryConfig, activeFiles?: string[], signal?: AbortSignal, seedResult?: SeedResult @@ -154,7 +155,17 @@ async function buildDeepRouteContext( if (baseContext.routeStyle === "concept") { return baseContext; } - return assembleDeepRouteContext(baseContext.chunks, budget, query); + return assembleDeepRouteContext(baseContext.chunks, budget, query, compressionOptionsFromConfig(config)); +} + +function compressionOptionsFromConfig(config: MemoryConfig) { + return { + contextCompressionEnabled: config.contextCompressionEnabled, + contextCompressionMode: config.contextCompressionMode, + contextCompressionPreserveTopChunks: config.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: config.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: config.contextCompressionTargetRatio, + }; } function scoreTraceContextCoherence(query: string, context: AssembledContext): number { @@ -330,6 +341,7 @@ export async function handlePromptContextDetailed( query, search, codeBudget, + config, activeFiles, signal, queryMode, @@ -417,7 +429,12 @@ export async function handlePromptContextDetailed( scoreFloorRatio: 0.05, query, factExtractors: config.factExtractors, - compressionRank: 3, + compressionRank: config.contextCompressionPreserveTopChunks, + contextCompressionEnabled: config.contextCompressionEnabled, + contextCompressionMode: config.contextCompressionMode, + contextCompressionPreserveTopChunks: config.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: config.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: config.contextCompressionTargetRatio, }); context = { ...hydratedContext, @@ -614,6 +631,7 @@ async function resolveCodeContext( query: string, search: HybridSearch, codeBudget: number, + config: MemoryConfig, activeFiles?: string[], signal?: AbortSignal, queryMode?: QueryMode, @@ -681,7 +699,7 @@ async function resolveCodeContext( const augmentedTree = augmentFlowTreeWithRelatedSeeds(tree, resolvedSeeds, query); if (augmentedTree.nodeCount <= 1) { - const context = await buildDeepRouteContext(query, search, codeBudget, activeFiles, signal, resolvedSeeds); + const context = await buildDeepRouteContext(query, search, codeBudget, config, activeFiles, signal, resolvedSeeds); const diagnostics = getBroadDiagnostics(); return finalizePromptContextResult(query, { context, @@ -694,9 +712,9 @@ async function resolveCodeContext( }); } - const flowContext = assembleFlowContext(augmentedTree, metadata, codeBudget, query); + const flowContext = assembleFlowContext(augmentedTree, metadata, codeBudget, query, compressionOptionsFromConfig(config)); if (flowContext.chunks.length === 0 || !flowContext.text.trim()) { - const context = await buildDeepRouteContext(query, search, codeBudget, activeFiles, signal, resolvedSeeds); + const context = await buildDeepRouteContext(query, search, codeBudget, config, activeFiles, signal, resolvedSeeds); const diagnostics = getBroadDiagnostics(); return finalizePromptContextResult(query, { context, @@ -709,7 +727,7 @@ async function resolveCodeContext( }); } - const deepContext = await buildDeepRouteContext(query, search, codeBudget, activeFiles, signal, resolvedSeeds); + const deepContext = await buildDeepRouteContext(query, search, codeBudget, config, activeFiles, signal, resolvedSeeds); const flowScore = scoreTraceContextCoherence(query, flowContext); const deepScore = scoreTraceContextCoherence(query, deepContext); if (deepScore > flowScore * 1.1) { @@ -733,7 +751,7 @@ async function resolveCodeContext( }); } - const context = await buildDeepRouteContext(query, search, codeBudget, activeFiles, signal, resolvedSeeds); + const context = await buildDeepRouteContext(query, search, codeBudget, config, activeFiles, signal, resolvedSeeds); const diagnostics = getBroadDiagnostics(); return finalizePromptContextResult(query, { context, @@ -746,7 +764,7 @@ async function resolveCodeContext( }); } - const context = await buildDeepRouteContext(query, search, codeBudget, activeFiles, signal, seedResult); + const context = await buildDeepRouteContext(query, search, codeBudget, config, activeFiles, signal, seedResult); const diagnostics = getBroadDiagnostics(); return finalizePromptContextResult(query, { context, @@ -791,7 +809,7 @@ function finalizePromptContextResult( executionSurface, missingEvidence, recommendedNextReads, - advisoryText: buildReporecallAdvisory(result.resolvedQueryMode, contextStrength, selectedFiles, missingEvidence), + advisoryText: buildReporecallAdvisory(result.resolvedQueryMode, contextStrength, selectedFiles, missingEvidence, context?.compression), }; } @@ -924,7 +942,8 @@ function buildReporecallAdvisory( queryMode: QueryMode, contextStrength: "sufficient" | "partial" | "weak", selectedFiles: string[], - missingEvidence: string[] + missingEvidence: string[], + compression?: AssembledContext["compression"] ): string | undefined { if (selectedFiles.length === 0) return undefined; const lines = [ @@ -939,6 +958,11 @@ function buildReporecallAdvisory( } else { lines.push("The injected context is weak. If you expand, prefer the listed files first and keep exploration narrow."); } + if (compression?.compressedChunks) { + lines.push( + `Compressed ${compression.compressedChunks} secondary chunks, saving ${compression.tokensSaved} tokens. Use Reporecall MCP read_code_chunk with chunkId for full source.` + ); + } if (missingEvidence.length > 0) { lines.push(`Missing evidence: ${missingEvidence.join(" ")}`); } diff --git a/src/indexer/embedder.ts b/src/indexer/embedder.ts index b4d9247..32eca1e 100644 --- a/src/indexer/embedder.ts +++ b/src/indexer/embedder.ts @@ -1,6 +1,52 @@ import type { EmbeddingProvider } from "./types.js"; import { LocalEmbedder } from "./local-embedder.js"; import { NullEmbedder } from "./null-embedder.js"; +import { getLogger } from "../core/logger.js"; + +/** + * HTTP error thrown by embedding providers. Carries the status code and an + * optional parsed `Retry-After` (ms) so the retry policy can honor it. + */ +export class EmbeddingHttpError extends Error { + readonly status: number; + readonly retryAfterMs?: number; + constructor(status: number, message: string, retryAfterMs?: number) { + super(message); + this.name = "EmbeddingHttpError"; + this.status = status; + this.retryAfterMs = retryAfterMs; + } +} + +/** HTTP statuses that are worth retrying (transient failures). */ +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +function isRetryable(err: unknown): boolean { + if (err instanceof EmbeddingHttpError) { + return RETRYABLE_STATUS.has(err.status); + } + // fetch() rejects with a TypeError on network/DNS/connection failures — those + // are transient and worth retrying. Any other thrown Error (e.g. a 400/401 + // surfaced as EmbeddingHttpError) is not retryable. + if (err instanceof TypeError) return true; + return false; +} + +function parseRetryAfter(headerValue: string | null): number | undefined { + if (!headerValue) return undefined; + // Delta-seconds form + const seconds = Number(headerValue); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(seconds * 1000, 60_000); + } + // HTTP-date form + const dateMs = Date.parse(headerValue); + if (Number.isFinite(dateMs)) { + const delta = dateMs - Date.now(); + return delta > 0 ? Math.min(delta, 60_000) : 0; + } + return undefined; +} async function withRetry( fn: () => Promise, @@ -13,10 +59,16 @@ async function withRetry( return await fn(); } catch (err) { lastError = err; - if (attempt < maxRetries) { - const delay = baseDelayMs * Math.pow(2, attempt); - await new Promise((r) => setTimeout(r, delay)); + // Don't retry non-retryable failures (4xx other than 429, fatal errors). + if (attempt >= maxRetries || !isRetryable(err)) { + throw err; } + // Honor Retry-After when provided; otherwise exponential backoff + jitter. + const retryAfter = err instanceof EmbeddingHttpError ? err.retryAfterMs : undefined; + const base = retryAfter ?? baseDelayMs * Math.pow(2, attempt); + // Full jitter: randomize within [base/2, base] to avoid thundering herds. + const jittered = Math.max(50, base / 2 + Math.random() * (base / 2)); + await new Promise((r) => setTimeout(r, jittered)); } } throw lastError; @@ -109,8 +161,10 @@ export class OllamaEmbedder implements EmbeddingProvider { if (!response.ok) { const body = await response.text(); - throw new Error( - `Ollama embedding failed (${response.status}): ${body}` + throw new EmbeddingHttpError( + response.status, + `Ollama embedding failed (${response.status}): ${body}`, + parseRetryAfter(response.headers.get("retry-after")) ); } @@ -124,7 +178,8 @@ export class OllamaEmbedder implements EmbeddingProvider { try { const response = await fetch(`${this.url}/api/tags`); return response.ok; - } catch { + } catch (err) { + getLogger().debug({ err }, "Ollama healthCheck failed"); return false; } } @@ -176,8 +231,10 @@ export class OpenAIEmbedder implements EmbeddingProvider { if (!response.ok) { const body = await response.text(); - throw new Error( - `OpenAI embedding failed (${response.status}): ${body}` + throw new EmbeddingHttpError( + response.status, + `OpenAI embedding failed (${response.status}): ${body}`, + parseRetryAfter(response.headers.get("retry-after")) ); } diff --git a/src/indexer/merkle.ts b/src/indexer/merkle.ts index 7376c5b..974999c 100644 --- a/src/indexer/merkle.ts +++ b/src/indexer/merkle.ts @@ -8,10 +8,11 @@ import { getLogger } from "../core/logger.js"; interface MerkleFileEntry { hash: string; mtimeMs: number; + ctimeMs?: number; } interface MerkleState { - files: Record; // relativePath -> contentHash (legacy) or { hash, mtimeMs } + files: Record; // relativePath -> contentHash (legacy) or { hash, mtimeMs, ctimeMs? } } let hasherPromise: ReturnType | undefined; @@ -31,6 +32,11 @@ function entryMtime(entry: string | MerkleFileEntry): number { return typeof entry === "string" ? 0 : entry.mtimeMs; } +/** Extract ctimeMs from a state entry (returns undefined for legacy/older entries). */ +function entryCtime(entry: string | MerkleFileEntry): number | undefined { + return typeof entry === "string" ? undefined : entry.ctimeMs; +} + export class MerkleTree { private state: MerkleState = { files: {} }; private statePath: string; @@ -86,11 +92,21 @@ export class MerkleTree { const existing = this.state.files[file.relativePath]; const existingHash = existing ? entryHash(existing) : undefined; const existingMtime = existing ? entryMtime(existing) : 0; + const existingCtime = existing ? entryCtime(existing) : undefined; - // mtime pre-filter: if mtime hasn't changed, skip the expensive hash + // mtime+ctime pre-filter: skip the expensive hash only when BOTH the + // modification time and the inode-change time match. mtime alone is + // unreliable — `git checkout`, `touch -r`, and `cp`/rsync without + // `--times` can rewrite file contents while preserving mtime. ctime + // cannot be reset by userspace on POSIX, so it catches those cases. const stat = await fsPromises.stat(file.absolutePath); - if (existingHash && existingMtime > 0 && stat.mtimeMs === existingMtime) { - // mtime unchanged — file is assumed unmodified, skip hash computation + if ( + existingHash + && existingMtime > 0 + && stat.mtimeMs === existingMtime + && (existingCtime === undefined || stat.ctimeMs === existingCtime) + ) { + // mtime+ctime unchanged — file is assumed unmodified, skip hash computation continue; } @@ -99,13 +115,13 @@ export class MerkleTree { if (!existingHash) { changes.push({ path: file.relativePath, type: "added", hash }); - pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs }; + pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; } else if (existingHash !== hash) { changes.push({ path: file.relativePath, type: "modified", hash }); - pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs }; + pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; } else { - // Content unchanged but mtime changed — update mtime cache - pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs }; + // Content unchanged but mtime/ctime changed — update cache + pendingState[file.relativePath] = { hash, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; } } catch (err) { getLogger().warn({ err, path: file.relativePath }, "File disappeared during scan, skipping"); @@ -135,6 +151,7 @@ export class MerkleTree { this.state.files[relativePath] = { hash: h.h64ToString(content), mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, }; } diff --git a/src/indexer/pipeline.ts b/src/indexer/pipeline.ts index e89991b..1d8d100 100644 --- a/src/indexer/pipeline.ts +++ b/src/indexer/pipeline.ts @@ -301,15 +301,17 @@ export class IndexingPipeline { windowTextBytes: number, progressState: WindowProgressState, onProgress?: (progress: IndexProgress) => void - ): Promise> { + ): Promise<{ results: Array<{ chunk: CodeChunk & { fileMtime: string }; vector: EmbeddingVector }>; degraded: boolean }> { const log = getLogger(); const keywordMode = this.config.embeddingProvider === "keyword" || !this.embedder.isEnabled(); if (keywordMode) { progressState.embeddedChunks += chunks.length; - return chunks.map((chunk) => ({ chunk, vector: [] })); + // Keyword mode intentionally produces empty vectors — this is not degradation. + return { results: chunks.map((chunk) => ({ chunk, vector: [] })), degraded: false }; } const embeddedChunks: Array<{ chunk: CodeChunk & { fileMtime: string }; vector: EmbeddingVector }> = []; + let degraded = false; let batchSize = this.getAdaptiveEmbedBatchSize(chunks.length, windowTextBytes); let index = 0; @@ -352,7 +354,12 @@ export class IndexingPipeline { } } - log.warn({ err, batchSize, failedChunks: batch.length }, "Embedding batch failed — falling back to keyword vectors for batch"); + log.error( + { err, batchSize, failedChunks: batch.length }, + "Embedding batch failed — storing empty vectors as fallback. " + + "Vector retrieval will be degraded for these chunks until the next successful reindex." + ); + degraded = true; for (const chunk of batch) { embeddedChunks.push({ chunk, vector: [] }); } @@ -368,16 +375,16 @@ export class IndexingPipeline { }); } - return embeddedChunks; + return { results: embeddedChunks, degraded }; } private async persistWindow( records: ChunkedFileRecord[], progressState: WindowProgressState, onProgress?: (progress: IndexProgress) => void - ): Promise<{ filesProcessed: number; chunksCreated: number; filePaths: string[] }> { + ): Promise<{ filesProcessed: number; chunksCreated: number; filePaths: string[]; degraded: boolean }> { if (records.length === 0) { - return { filesProcessed: 0, chunksCreated: 0, filePaths: [] }; + return { filesProcessed: 0, chunksCreated: 0, filePaths: [], degraded: false }; } const log = getLogger(); @@ -395,7 +402,7 @@ export class IndexingPipeline { heapUsedMb: Math.round(this.getHeapUsedBytes() / 1024 / 1024), }, "Processing indexing window"); - const embeddedChunks = await this.embedWindowChunks(windowChunks, windowTextBytes, progressState, onProgress); + const { results: embeddedChunks, degraded } = await this.embedWindowChunks(windowChunks, windowTextBytes, progressState, onProgress); onProgress?.({ phase: "storing", @@ -435,16 +442,27 @@ export class IndexingPipeline { this.metadata.upsertImports(windowImports); } + // Dedup: resolveCallTarget is a pure read-only lookup, so identical + // (targetName, filePath, receiver, literalTargets) edges resolve identically. + // Cache per-window to skip redundant SQL for repeated call targets. + const edgeResolutionCache = new Map>(); for (const edge of windowCallEdges) { - const resolution = resolveCallTarget( - { - targetName: edge.targetName, - filePath: edge.filePath, - receiver: edge.receiver, - literalTargets: edge.literalTargets, - }, - this.metadata - ); + const cacheKey = `${edge.targetName}\0${edge.filePath}\0${edge.receiver ?? ""}\0${(edge.literalTargets ?? []).join("\u0001")}`; + let resolution: ReturnType; + if (edgeResolutionCache.has(cacheKey)) { + resolution = edgeResolutionCache.get(cacheKey) ?? null; + } else { + resolution = resolveCallTarget( + { + targetName: edge.targetName, + filePath: edge.filePath, + receiver: edge.receiver, + literalTargets: edge.literalTargets, + }, + this.metadata + ); + edgeResolutionCache.set(cacheKey, resolution); + } if (resolution) { edge.targetFilePath = resolution.filePath; edge.targetId = resolution.targetId; @@ -497,6 +515,7 @@ export class IndexingPipeline { filesProcessed: records.length, chunksCreated: metadataChunks.length, filePaths, + degraded, }; } @@ -505,13 +524,19 @@ export class IndexingPipeline { progressState: WindowProgressState, successfulFiles: Set, counters: { filesProcessed: number; chunksCreated: number }, - onProgress?: (progress: IndexProgress) => void + onProgress?: (progress: IndexProgress) => void, + degradedFiles?: Set ): Promise { if (window.length === 0) return; const result = await this.persistWindow(window, progressState, onProgress); counters.filesProcessed += result.filesProcessed; counters.chunksCreated += result.chunksCreated; for (const filePath of result.filePaths) successfulFiles.add(filePath); + // Track files whose embeddings fell back to empty vectors so the caller + // can skip committing their merkle state (forcing a retry next run). + if (result.degraded && degradedFiles) { + for (const filePath of result.filePaths) degradedFiles.add(filePath); + } window.length = 0; } @@ -596,6 +621,7 @@ export class IndexingPipeline { }); const successfulFiles = new Set(); + const degradedFiles = new Set(); const progressState: WindowProgressState = { discoveredChunks: 0, embeddedChunks: 0 }; const counters = { filesProcessed: 0, chunksCreated: 0 }; const pendingWindow: ChunkedFileRecord[] = []; @@ -618,7 +644,7 @@ export class IndexingPipeline { ); if (wouldOverflowWindow) { - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress, degradedFiles); pendingWindowBytes = 0; } @@ -629,7 +655,7 @@ export class IndexingPipeline { pendingWindow.length >= this.getFileBatchSize() || pendingWindowBytes >= this.getMaxChunkTextBytesPerWindow(); if (shouldFlushNow) { - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress, degradedFiles); pendingWindowBytes = 0; } } catch (err) { @@ -644,18 +670,29 @@ export class IndexingPipeline { }); } - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, onProgress, degradedFiles); const filteredPendingState: Record = {}; for (const [path, entry] of Object.entries(pendingState)) { const isChangedFile = toProcess.some((change) => change.path === path); - if (!isChangedFile || successfulFiles.has(path)) { + // Exclude degraded files: their embeddings fell back to empty vectors, + // so we leave their merkle state untouched to force a retry next run + // rather than marking them as fully indexed. + if ((!isChangedFile || successfulFiles.has(path)) && !degradedFiles.has(path)) { filteredPendingState[path] = entry; } } this.merkle.applyPendingState(filteredPendingState); this.merkle.save(); + if (degradedFiles.size > 0) { + log.error( + { degradedFileCount: degradedFiles.size }, + "Indexing completed but " + degradedFiles.size + " file(s) had embedding failures and were stored with empty vectors. " + + "Vector search will return no results for these files until re-indexed successfully. Their merkle state was not committed so they will retry on the next index run." + ); + } + this.rebuildTargetCatalog(); const now = new Date().toISOString(); this.metadata.setStat("lastIndexedAt", now); @@ -687,6 +724,7 @@ export class IndexingPipeline { await this.ensureIndexFormat(); const successfulFiles = new Set(); + const degradedFiles = new Set(); const progressState: WindowProgressState = { discoveredChunks: 0, embeddedChunks: 0 }; const counters = { filesProcessed: 0, chunksCreated: 0 }; const pendingWindow: ChunkedFileRecord[] = []; @@ -714,7 +752,7 @@ export class IndexingPipeline { ); if (wouldOverflowWindow) { - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, undefined, degradedFiles); pendingWindowBytes = 0; } @@ -725,7 +763,7 @@ export class IndexingPipeline { pendingWindow.length >= this.getFileBatchSize() || pendingWindowBytes >= this.getMaxChunkTextBytesPerWindow(); if (shouldFlushNow) { - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, undefined, degradedFiles); pendingWindowBytes = 0; } } catch (err) { @@ -733,22 +771,36 @@ export class IndexingPipeline { } } - await this.flushWindow(pendingWindow, progressState, successfulFiles, counters); + await this.flushWindow(pendingWindow, progressState, successfulFiles, counters, undefined, degradedFiles); this.rebuildTargetCatalog(); for (const pathValue of paths) { const relPath = isAbsolute(pathValue) ? relative(this.config.projectRoot, pathValue) : pathValue; const absPath = resolve(this.config.projectRoot, relPath); if (!successfulFiles.has(relPath)) continue; + // Don't commit merkle state for degraded files — they need to retry. + if (degradedFiles.has(relPath)) continue; try { await this.merkle.updateHash(relPath, absPath); - } catch { - // file may have been deleted + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") { + /* file deleted mid-index */ + } else { + getLogger().warn({ err, relPath }, "merkle.updateHash failed (non-ENOENT)"); + } } } this.merkle.save(); this.metadata.setStat("lastIndexedAt", new Date().toISOString()); + if (degradedFiles.size > 0) { + log.error( + { degradedFileCount: degradedFiles.size }, + "Incremental indexing completed but " + degradedFiles.size + " file(s) had embedding failures and were stored with empty vectors. " + + "Vector search will return no results for these files until re-indexed successfully. Their merkle state was not committed so they will retry on the next index run." + ); + } + try { const conventions = analyzeConventions(this.metadata); this.metadata.setConventions(conventions); diff --git a/src/memory/search.ts b/src/memory/search.ts index 7ce8197..3cf602b 100644 --- a/src/memory/search.ts +++ b/src/memory/search.ts @@ -131,12 +131,23 @@ export class MemorySearch { const topCodeSymbols = options?.topCodeSymbols ?? []; if (!allowedStatuses.includes(resolvedStatus)) { + // Zero the score before skipping, mirroring the minConfidence path below. + // Without this, archived/superseded memories retain their raw keyword + // score (set above) and leak into results because the secondary status + // filter at the bottom only runs when `options.statuses` is explicitly + // provided. + scores.set(id, 0); continue; } - // Recency boost - const age = now - new Date(memory.fileMtime).getTime(); - const recencyScore = Math.min(1.0, Math.max(0, 1 - age / ninetyDays)); + // Recency boost — guard against malformed timestamps: a single NaN mtime + // would corrupt the sort comparator below (NaN comparisons are unstable). + const mtimeMs = new Date(memory.fileMtime).getTime(); + let recencyScore = 0; + if (Number.isFinite(mtimeMs)) { + const age = now - mtimeMs; + recencyScore = Math.min(1.0, Math.max(0, 1 - age / ninetyDays)); + } adjusted += RECENCY_WEIGHT * recencyScore; // Importance: penalize over-accessed memories that fire on most queries. diff --git a/src/parser/chunker.ts b/src/parser/chunker.ts index 4b2eab1..c5b9524 100644 --- a/src/parser/chunker.ts +++ b/src/parser/chunker.ts @@ -2,7 +2,7 @@ import { readFile, stat } from "fs/promises"; import { extname, relative } from "path"; import type Parser from "web-tree-sitter"; import { getLanguage, createParser, initTreeSitter } from "./tree-sitter.js"; -import { getLanguageForExtension, type LanguageConfig } from "./languages.js"; +import { getLanguageForExtension, resolveLanguage, type LanguageConfig } from "./languages.js"; import type { CodeChunk } from "./types.js"; import { extractCallEdges, type CallEdge } from "../analysis/call-graph.js"; import { extractImports, type RawImport } from "../analysis/imports.js"; @@ -52,6 +52,14 @@ function buildWholeFileChunk( } function extractName(node: SyntaxNode): string { + // Python: a decorated_definition wraps the real function/class. Recurse + // into its "definition" field so the inner name is used instead of + // "". + if (node.type === "decorated_definition") { + const def = node.childForFieldName("definition"); + if (def) return extractName(def); + } + const nameNode = node.childForFieldName("name") ?? node.childForFieldName("declarator"); @@ -209,7 +217,7 @@ export async function chunkFileWithCalls( const MAX_CHUNK_FILE_SIZE = 1024 * 1024; // 1MB const ext = extname(filePath); - const langInfo = getLanguageForExtension(ext); + let langInfo = getLanguageForExtension(ext); const relPath = relative(projectRoot, filePath); const h = await getHasher(); @@ -236,6 +244,12 @@ export async function chunkFileWithCalls( const content = await readFile(filePath, "utf-8"); + // `.h` headers default to C but are commonly C++; sniff content and promote + // to the C++ grammar to avoid silent parse errors on real C++ headers. + if (ext === ".h" && langInfo?.language === "c") { + langInfo = resolveLanguage(ext, content); + } + if (!langInfo) { const id = h.h64ToString(`${relPath}:file:0`); return { @@ -276,6 +290,7 @@ export async function chunkFileWithCalls( try { tree = parser.parse(content); } catch { + try { parser.delete(); } catch { /* already freed */ } const id = h.h64ToString(`${relPath}:file:0`); return { chunks: [buildWholeFileChunk(id, relPath, content, langName)], @@ -285,6 +300,7 @@ export async function chunkFileWithCalls( }; } if (!tree) { + try { parser.delete(); } catch { /* already freed */ } const id = h.h64ToString(`${relPath}:file:0`); return { chunks: [buildWholeFileChunk(id, relPath, content, langName)], @@ -294,77 +310,85 @@ export async function chunkFileWithCalls( }; } - const nodes: Parser.SyntaxNode[] = []; try { - walkForExtractables(tree.rootNode, config, nodes); - } catch { - const id = h.h64ToString(`${relPath}:file:0`); - return { - chunks: [buildWholeFileChunk(id, relPath, content, langName)], - callEdges: [], - rawImports: [], - language: langName, - }; - } - - if (nodes.length === 0) { - const id = h.h64ToString(`${relPath}:file:0`); - // Still extract imports — the tree parsed fine, just no extractable nodes - const tsJsLanguages = new Set(["typescript", "tsx", "javascript"]); - const fallbackImports = tsJsLanguages.has(langName) - ? extractImports(tree.rootNode, langName) - : []; - return { - chunks: [buildWholeFileChunk(id, relPath, content, langName)], - callEdges: [], - rawImports: fallbackImports, - language: langName, - }; - } + const nodes: Parser.SyntaxNode[] = []; + try { + walkForExtractables(tree.rootNode, config, nodes); + } catch { + const id = h.h64ToString(`${relPath}:file:0`); + return { + chunks: [buildWholeFileChunk(id, relPath, content, langName)], + callEdges: [], + rawImports: [], + language: langName, + }; + } - const chunks: CodeChunk[] = []; - const allCallEdges: CallEdge[] = []; - - for (const node of nodes) { - const name = extractName(node); - const startLine = node.startPosition.row + 1; - const endLine = node.endPosition.row + 1; - const id = h.h64ToString(`${relPath}:${name}:${startLine}`); - const lineCount = endLine - startLine + 1; - - let chunkContent = node.text; - if (lineCount > MAX_CHUNK_LINES) { - const lines = chunkContent.split("\n"); - const kept = lines.slice(0, TRUNCATION_KEEP_LINES); - kept.push(`// ... truncated ${lineCount - TRUNCATION_KEEP_LINES} more lines (${lineCount} total) ...`); - chunkContent = kept.join("\n"); + if (nodes.length === 0) { + const id = h.h64ToString(`${relPath}:file:0`); + // Still extract imports — the tree parsed fine, just no extractable nodes + const tsJsLanguages = new Set(["typescript", "tsx", "javascript"]); + const fallbackImports = tsJsLanguages.has(langName) + ? extractImports(tree.rootNode, langName) + : []; + return { + chunks: [buildWholeFileChunk(id, relPath, content, langName)], + callEdges: [], + rawImports: fallbackImports, + language: langName, + }; } - chunks.push({ - id, - filePath: relPath, - name, - kind: node.type, - content: chunkContent, - startLine, - endLine, - parentName: extractParentName(node), - docstring: extractDocstring(node, config.docstringTypes), - language: langName, - isExported: isExported(node), - }); + const chunks: CodeChunk[] = []; + const allCallEdges: CallEdge[] = []; + + for (const node of nodes) { + const name = extractName(node); + const startLine = node.startPosition.row + 1; + const endLine = node.endPosition.row + 1; + const id = h.h64ToString(`${relPath}:${name}:${startLine}`); + const lineCount = endLine - startLine + 1; + + let chunkContent = node.text; + if (lineCount > MAX_CHUNK_LINES) { + const lines = chunkContent.split("\n"); + const kept = lines.slice(0, TRUNCATION_KEEP_LINES); + kept.push(`// ... truncated ${lineCount - TRUNCATION_KEEP_LINES} more lines (${lineCount} total) ...`); + chunkContent = kept.join("\n"); + } - if (config.callNodeTypes) { - const edges = extractCallEdges(node, id, relPath, config.callNodeTypes); - allCallEdges.push(...edges); + chunks.push({ + id, + filePath: relPath, + name, + kind: node.type, + content: chunkContent, + startLine, + endLine, + parentName: extractParentName(node), + docstring: extractDocstring(node, config.docstringTypes), + language: langName, + isExported: isExported(node), + }); + + if (config.callNodeTypes) { + const edges = extractCallEdges(node, id, relPath, config.callNodeTypes); + allCallEdges.push(...edges); + } } - } - // Extract imports from TS/JS/TSX files - const tsJsLanguages = new Set(["typescript", "tsx", "javascript"]); - const rawImports = tsJsLanguages.has(langName) - ? extractImports(tree.rootNode, langName) - : []; + // Extract imports from TS/JS/TSX files + const tsJsLanguages = new Set(["typescript", "tsx", "javascript"]); + const rawImports = tsJsLanguages.has(langName) + ? extractImports(tree.rootNode, langName) + : []; - return { chunks, callEdges: allCallEdges, rawImports, language: langName }; + return { chunks, callEdges: allCallEdges, rawImports, language: langName }; + } finally { + // web-tree-sitter allocates Parser/Tree objects on the WASM heap, which is + // NOT garbage-collected. Free both on every exit path to avoid unbounded + // heap growth when indexing large codebases (thousands of files). + try { tree.delete(); } catch { /* already freed */ } + try { parser.delete(); } catch { /* already freed */ } + } } diff --git a/src/parser/languages.ts b/src/parser/languages.ts index f174175..1f78ad5 100644 --- a/src/parser/languages.ts +++ b/src/parser/languages.ts @@ -127,7 +127,7 @@ export const LANGUAGE_CONFIGS: Record = { callNodeTypes: ["call_expression"], }, cpp: { - extensions: [".cpp", ".hpp", ".cc", ".cxx"], + extensions: [".cpp", ".hpp", ".cc", ".cxx", ".hh", ".hxx", ".hcc"], wasmName: "tree-sitter-cpp", extractableTypes: [ "function_definition", @@ -226,7 +226,10 @@ export const LANGUAGE_CONFIGS: Record = { html: { extensions: [".html", ".htm"], wasmName: "tree-sitter-html", - extractableTypes: ["element"], + // Only chunk semantic top-level blocks that carry real content. + // The generic "element" node matches EVERY tag, which produced hundreds + // of junk
/ chunks per page. + extractableTypes: ["script_element", "style_element"], docstringTypes: ["comment"], }, vue: { @@ -258,3 +261,38 @@ export function getLanguageForExtension( } return undefined; } + +/** + * Markers that are unambiguously C++ (the C grammar has no `namespace`, + * `template`, `class`, `std::`, scope resolution `::`, access specifiers, or + * C++ standard-library headers). C++ grammar is a near-superset of C, so a + * false positive (parsing real C as C++) is low-risk, while the reverse + * (parsing C++ with the C grammar) silently produces error nodes. + */ +const CPP_MARKER_RE = + /(?:\bnamespace\b|\btemplate\s*<|\bclass\s+[A-Za-z_]\w*|\bpublic:|\bprivate:|\bprotected:|\busing\s+namespace\b|\btypename\b|\bcout\b|\bcin\b|\bendl\b|\w+::\w|#include\s*<(?:vector|string|map|set|unordered_map|unordered_set|list|deque|queue|stack|tuple|array|bitset|memory|algorithm|functional|utility|regex|thread|mutex|atomic|chrono|iostream|fstream|sstream)>)/; + +/** + * Cheap content sniff: tests only the first ~4KB of a file for C++ markers. + */ +export function looksLikeCpp(content: string): boolean { + return CPP_MARKER_RE.test(content.slice(0, 4096)); +} + +/** + * Resolve a language from a file extension, optionally using content to + * disambiguate. `.h` headers map to C by default but are very commonly C++ in + * modern codebases; when content is supplied and looks like C++, the C++ + * grammar is used instead to avoid silent parse errors. + */ +export function resolveLanguage( + ext: string, + content?: string +): { language: string; config: LanguageConfig } | undefined { + const base = getLanguageForExtension(ext); + if (ext === ".h" && base?.language === "c" && content && looksLikeCpp(content)) { + const cpp = LANGUAGE_CONFIGS.cpp; + if (cpp) return { language: "cpp", config: cpp }; + } + return base; +} diff --git a/src/parser/tree-sitter.ts b/src/parser/tree-sitter.ts index 431d77b..686648c 100644 --- a/src/parser/tree-sitter.ts +++ b/src/parser/tree-sitter.ts @@ -12,6 +12,12 @@ const loadedLanguages = new Map>(); export async function initTreeSitter(): Promise { if (!initPromise) { initPromise = Parser.init(); + // If init fails, clear the cached promise so the next call retries + // instead of rethrowing the same rejection forever (mirrors how + // getLanguage deletes its entry on error). + initPromise.catch(() => { + initPromise = undefined; + }); } return initPromise; } diff --git a/src/search/architecture-strategy.ts b/src/search/architecture-strategy.ts index b68784d..18645b1 100644 --- a/src/search/architecture-strategy.ts +++ b/src/search/architecture-strategy.ts @@ -34,6 +34,18 @@ import { textMatchesQueryTerm, tokenizeQueryTerms, } from "./utils.js"; +import { + INVENTORY_GENERIC_TARGET_ALIAS_TERMS, + INVENTORY_STRUCTURAL_TERMS, + ADJACENT_WORKFLOW_FAMILIES, +} from "./shared/workflow-families.js"; +import { chunkToSearchResult, isImplementationPath } from "./shared/mappers.js"; + +export { + INVENTORY_GENERIC_TARGET_ALIAS_TERMS, + INVENTORY_STRUCTURAL_TERMS, + ADJACENT_WORKFLOW_FAMILIES, +} from "./shared/workflow-families.js"; // --------------------------------------------------------------------------- // Constants @@ -41,34 +53,9 @@ import { export const BROAD_PHRASE_GENERIC_TERMS = GENERIC_BROAD_TERMS; -export const INVENTORY_GENERIC_TARGET_ALIAS_TERMS = new Set([ - "route", "routes", "router", "routing", "navigation", -]); - export const BROAD_INVENTORY_RE = /\b(?:which|what|list|show)\s+files\b|\bfiles?\s+(?:implement|handle|power|control|cover)\b/i; -export const INVENTORY_STRUCTURAL_TERMS = new Set([ - "which", - "what", - "list", - "show", - "file", - "files", - "implement", - "implements", - "handle", - "handles", - "power", - "powers", - "control", - "controls", - "cover", - "covers", - "full", - "entire", -]); - export const SUBSYSTEM_INVENTORY_FAMILIES = new Set(["search"]); export const STRICT_WORKFLOW_FAMILY_COHESION = new Set([ @@ -80,16 +67,6 @@ export const STRICT_WORKFLOW_FAMILY_COHESION = new Set([ "workflow", ]); -export const ADJACENT_WORKFLOW_FAMILIES: Record = { - auth: ["routing", "permissions"], - routing: ["auth", "permissions"], - billing: ["auth", "generation"], - storage: ["auth", "generation"], - generation: ["storage", "queue", "billing", "workflow"], - queue: ["generation", "workflow"], - workflow: ["generation", "queue"], -}; - // --------------------------------------------------------------------------- // Types / interfaces // --------------------------------------------------------------------------- @@ -618,6 +595,7 @@ export class ArchitectureStrategy { && orderedSelectedFiles.length < Math.min(maxContextChunks, 4) ) { const orderedSeen = new Set(orderedSelectedFiles.map((candidate) => candidate.filePath)); + const orderedSelectedLayers = new Set(orderedSelectedFiles.flatMap((item) => item.layers)); const supplementalAuthRoutingFiles = this.mergeBroadFileCandidates( [...scopedFileCandidates], [...fileCandidates] @@ -635,8 +613,8 @@ export class ArchitectureStrategy { const bText = normalizeTargetText(`${b.filePath} ${b.primary.result.name}`); const aBackbone = /\b(callback|redirect|protected|guard|pending|destination)\b/.test(aText) ? 120 : 0; const bBackbone = /\b(callback|redirect|protected|guard|pending|destination)\b/.test(bText) ? 120 : 0; - const aLayerDiversity = a.layers.some((layer) => !orderedSelectedFiles.flatMap((item) => item.layers).includes(layer)) ? 30 : 0; - const bLayerDiversity = b.layers.some((layer) => !orderedSelectedFiles.flatMap((item) => item.layers).includes(layer)) ? 30 : 0; + const aLayerDiversity = a.layers.some((layer) => !orderedSelectedLayers.has(layer)) ? 30 : 0; + const bLayerDiversity = b.layers.some((layer) => !orderedSelectedLayers.has(layer)) ? 30 : 0; return (bBackbone + bLayerDiversity + b.score) - (aBackbone + aLayerDiversity + a.score); }) .slice(0, Math.min(maxContextChunks, 4) - orderedSelectedFiles.length); @@ -1870,7 +1848,9 @@ export class ArchitectureStrategy { : 1.0; const chunks = this.selectConceptChunks( bundle.symbols, - Math.min(bundle.symbols.length, Math.max(bundle.maxChunks ?? 4, 6)) + // Bug fix: Math.min (not Math.max) so a small configured maxChunks is + // honored rather than silently bumped to 6. + Math.min(bundle.symbols.length, Math.min(bundle.maxChunks ?? 4, 6)) ); for (let index = 0; index < chunks.length; index++) { @@ -1919,8 +1899,10 @@ export class ArchitectureStrategy { const byPath = new Map(); for (const bundle of bundles) { const chunks = this.selectConceptChunks( + // Bug fix: Math.min (not Math.max) so a small configured maxChunks is + // honored rather than silently bumped to 6. bundle.symbols, - Math.min(bundle.symbols.length, Math.max(bundle.maxChunks ?? 4, 6)) + Math.min(bundle.symbols.length, Math.min(bundle.maxChunks ?? 4, 6)) ); const hitsByPath = new Map(); for (const chunk of chunks) { @@ -2530,26 +2512,11 @@ export class ArchitectureStrategy { // ------------------------------------------------------------------------- private chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { - return { - id: chunk.id, - score, - filePath: chunk.filePath, - name: chunk.name, - kind: chunk.kind, - startLine: chunk.startLine, - endLine: chunk.endLine, - content: chunk.content, - docstring: chunk.docstring, - parentName: chunk.parentName, - language: chunk.language ?? "", - }; + return chunkToSearchResult(chunk, score); } private isImplementationPath(filePath: string): boolean { - const lowerPath = filePath.toLowerCase(); - const implPaths = this.config.implementationPaths ?? ["src/", "lib/", "bin/"]; - if (implPaths.some((prefix) => lowerPath.startsWith(prefix.toLowerCase()))) return true; - return /(?:^|\/)(src|lib|bin|app|server|api|functions|handlers|controllers|services|supabase)\//.test(lowerPath); + return isImplementationPath(filePath, this.config.implementationPaths ?? ["src/", "lib/", "bin/"]); } private getMatchedConceptBundles(query: string): CompiledConceptBundle[] { diff --git a/src/search/bug-strategy.ts b/src/search/bug-strategy.ts index 9a91fbc..4ecaeca 100644 --- a/src/search/bug-strategy.ts +++ b/src/search/bug-strategy.ts @@ -30,6 +30,12 @@ import { type ExecutionSurfaceBias, } from "./utils.js"; import { normalizeTargetText } from "./targets.js"; +import { + ADJACENT_WORKFLOW_FAMILIES, + INVENTORY_GENERIC_TARGET_ALIAS_TERMS, + TRACE_NOISE_TERMS, +} from "./shared/workflow-families.js"; +import { chunkToSearchResult, isImplementationPath } from "./shared/mappers.js"; // --------------------------------------------------------------------------- // Constants @@ -118,19 +124,6 @@ export const BUG_SUBJECT_TAG_RULES: Array<{ tag: string; pattern: RegExp; relate const MODE_EXPLICIT_LOGGING_RE = /\b(log|logger|logging|audit|instrument|instrumentation|telemetry|metrics?)\b/i; const MODE_EXPLICIT_WEBHOOK_RE = /\b(webhook|signature|payload|delivery|event)\b/i; -const ADJACENT_WORKFLOW_FAMILIES: Record = { - auth: ["routing", "permissions"], - routing: ["auth", "permissions"], - billing: ["auth"], - storage: ["auth"], - generation: ["storage"], -}; - -const INVENTORY_GENERIC_TARGET_ALIAS_TERMS = new Set(["route", "routes", "router", "routing", "navigation"]); - -// Imported via re-export so the trace noise set is only needed internally -const TRACE_NOISE_TERMS = new Set(["path", "page", "pages", "include", "includes", "including", "start", "first", "then", "full", "intent"]); - // --------------------------------------------------------------------------- // Interfaces // --------------------------------------------------------------------------- @@ -699,35 +692,65 @@ export class BugStrategy { const structuralSupportResults = this.buildBugStructuralSupportResults(subjectProfile); const seedResults = this.buildBugSeedResults(seedResult, subjectProfile); const keywordResults = this.buildBugKeywordResults(results, subjectProfile); - const strongKeywordAnchorResults = keywordResults.filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); + // Precompute tags, features, and signals per candidate to avoid repeated + // per-filter metadata lookups and signal recomputation. + const tagCache = new Map(); + const featureCache = new Map(); + const signalCache = new Map(); + { + const candidateIds = Array.from(new Set([ + ...keywordResults.map((r) => r.id), + ...semanticSeedResults.map((r) => r.id), + ...results.map((r) => r.id), + ])); + for (const tag of this.metadata.getChunkTagsByIds(candidateIds)) { + const list = tagCache.get(tag.chunkId); + if (list) list.push(tag.tag); else tagCache.set(tag.chunkId, [tag.tag]); + } + for (const feature of this.metadata.getChunkFeaturesByIds(candidateIds)) { + featureCache.set(feature.chunkId, feature); + } + } + const getTagsForResult = (result: SearchResult): string[] => { + let tags = tagCache.get(result.id); + if (tags === undefined) { + tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); + tagCache.set(result.id, tags); + } + return tags; + }; + const getFeatureForResult = (result: SearchResult): ChunkFeature | undefined => { + if (featureCache.has(result.id)) return featureCache.get(result.id); const feature = this.metadata.getChunkFeaturesByIds([result.id])[0]; - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + featureCache.set(result.id, feature); + return feature; + }; + const getSignalsForResult = (result: SearchResult): BugCandidateSignals => { + let signals = signalCache.get(result.id); + if (signals === undefined) { + signals = this.getBugCandidateSignals( + { filePath: result.filePath, name: result.name, content: result.content }, + subjectProfile, + getTagsForResult(result) + ); + signalCache.set(result.id, signals); + } + return signals; + }; + const strongKeywordAnchorResults = keywordResults.filter((result) => { + const signals = getSignalsForResult(result); + const feature = getFeatureForResult(result); return this.isStrongBugAnchorCandidate(result, signals, feature, subjectProfile); }); const strongSemanticAnchorResults = semanticSeedResults.filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const feature = this.metadata.getChunkFeaturesByIds([result.id])[0]; - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + const signals = getSignalsForResult(result); + const feature = getFeatureForResult(result); return this.isStrongBugAnchorCandidate(result, signals, feature, subjectProfile); }); const filteredSemanticSeedResults = strongKeywordAnchorResults.length > 0 ? semanticSeedResults.filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const feature = this.metadata.getChunkFeaturesByIds([result.id])[0]; - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + const signals = getSignalsForResult(result); + const feature = getFeatureForResult(result); return signals.pathNameTermMatches > 0 || signals.primaryTagMatches > 0 || this.isStrongBugAnchorCandidate(result, signals, feature, subjectProfile); @@ -757,36 +780,18 @@ export class BugStrategy { const neighborIds = new Set(neighborResults.map((result) => result.id)); const anchoredSemanticSeedIds = new Set( semanticSeedResults - .filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); - return this.hasBugAnchorSignals(signals); - }) + .filter((result) => this.hasBugAnchorSignals(getSignalsForResult(result))) .map((result) => result.id) ); const keywordFocused = keywordResults.filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + const signals = getSignalsForResult(result); return signals.pathNameTermMatches > 0 || signals.primaryTagMatches > 0 || signals.implementationMatches > 0 || signals.runtimeMatches > 0; }); const genericDomainResults = results.filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + const signals = getSignalsForResult(result); if (subjectProfile.primaryTags.size === 0) { return signals.literalMatches + signals.semanticMatches + signals.implementationMatches + signals.runtimeMatches > 0 || signals.runtimeGateOverlap; @@ -814,13 +819,8 @@ export class BugStrategy { const lowerContent = result.content.toLowerCase(); const combined = `${lowerPath} ${lowerName} ${lowerContent.slice(0, 1200)}`; const fileBase = lowerPath.split("/").pop() ?? lowerPath; - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); const feature = featureMap.get(result.id); - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); + const signals = getSignalsForResult(result); const candidateFamilies = this.getBugCandidateFamilies(result); const matchedPrimaryFamilyCount = Array.from(candidateFamilies).filter((family) => subjectProfile.primaryTags.has(family) @@ -1274,15 +1274,7 @@ export class BugStrategy { const selected: SearchResult[] = []; const seenFiles = new Set(); const primaryAnchorResults = keywordResults - .filter((result) => { - const tags = this.metadata.getChunkTagsByIds([result.id]).map((tag) => tag.tag); - const signals = this.getBugCandidateSignals( - { filePath: result.filePath, name: result.name, content: result.content }, - subjectProfile, - tags - ); - return this.hasBugAnchorSignals(signals); - }) + .filter((result) => this.hasBugAnchorSignals(getSignalsForResult(result))) .filter((result) => result.kind.includes("function") || result.kind.includes("method")) .slice(0, 2); const anchorGateNames = Array.from(new Set( @@ -1636,12 +1628,16 @@ export class BugStrategy { const cappedPromoted = this.isBugBackendRequestPrompt(subjectProfile) ? promoted.slice(0, 1) : promoted; - const final = cappedPromoted.map((result, index) => { - const normalizedScore = Math.max(1, 3 - index * 0.2); + const final = cappedPromoted.map((result) => { + // Bug fix: preserve the real multiplicative evidence score instead of + // overwriting it with a linear rank-based value (was Math.max(1, 3 - i*0.2)). + // Downstream (assembleContext) only uses the score as a ratio relative to + // the top result, so absolute values don't matter — the real signal must + // survive. hookScore is bumped to at least the real score for consumers + // that read it. return { ...result, - score: normalizedScore, - hookScore: Math.max(result.hookScore ?? 0, normalizedScore), + hookScore: Math.max(result.hookScore ?? 0, result.score), }; }); return final; @@ -2828,10 +2824,7 @@ export class BugStrategy { } private isImplementationPath(filePath: string): boolean { - const lowerPath = filePath.toLowerCase(); - const implPaths = this.config.implementationPaths ?? ["src/", "lib/", "bin/"]; - if (implPaths.some((prefix) => lowerPath.startsWith(prefix.toLowerCase()))) return true; - return /(?:^|\/)(src|lib|bin|app|server|api|functions|handlers|controllers|services|supabase)\//.test(lowerPath); + return isImplementationPath(filePath, this.config.implementationPaths ?? ["src/", "lib/", "bin/"]); } private detectWorkflowLayers(lowerPath: string, lowerName: string): string[] { @@ -2863,19 +2856,7 @@ export class BugStrategy { } private chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { - return { - id: chunk.id, - score, - filePath: chunk.filePath, - name: chunk.name, - kind: chunk.kind, - startLine: chunk.startLine, - endLine: chunk.endLine, - content: chunk.content, - docstring: chunk.docstring, - parentName: chunk.parentName, - language: chunk.language ?? "", - }; + return chunkToSearchResult(chunk, score); } private mergeBroadResults(targetResults: SearchResult[], results: SearchResult[]): SearchResult[] { diff --git a/src/search/capability-evidence.ts b/src/search/capability-evidence.ts index 105d674..70e7435 100644 --- a/src/search/capability-evidence.ts +++ b/src/search/capability-evidence.ts @@ -1,6 +1,5 @@ import type { MetadataStore } from "../storage/metadata-store.js"; import type { StoredChunk } from "../storage/types.js"; -import type { MemorySearchResult } from "../memory/types.js"; import type { QueryMode } from "./intent.js"; import type { SearchResult } from "./types.js"; import { isTestFile, textMatchesQueryTerm, tokenizeQueryTerms, STOP_WORDS, GENERIC_BROAD_TERMS, GENERIC_QUERY_ACTION_TERMS } from "./utils.js"; @@ -45,7 +44,7 @@ export interface CapabilityEvidenceResult { type CapabilityMetadata = Partial>; interface MutableEvidenceFile extends CapabilityEvidenceFile { @@ -198,6 +197,40 @@ export function resolveCapabilityEvidence(input: { const entries = new Map(); const seedFiles = new Set(); + const fileChunksCache = new Map(); + const capabilityScoreCache = new Map(); + const familySignalCache = new Map(); + + const cachedFileChunks = (filePath: string): StoredChunk[] => { + const cached = fileChunksCache.get(filePath); + if (cached) return cached; + const chunks = findFileChunks(metadata, filePath); + fileChunksCache.set(filePath, chunks); + return chunks; + }; + + const cachedCapabilityScore = (filePath: string): number => { + const cached = capabilityScoreCache.get(filePath); + if (cached !== undefined) return cached; + const value = scoreCapabilityFile(query, filePath, cachedFileChunks(filePath), families); + capabilityScoreCache.set(filePath, value); + return value; + }; + + const cachedFamilySignal = (filePath: string): boolean => { + const cached = familySignalCache.get(filePath); + if (cached !== undefined) return cached; + const value = hasPrimaryFamilySignal(query, filePath, cachedFileChunks(filePath), families); + familySignalCache.set(filePath, value); + return value; + }; + + const capabilityCache: CapabilityScoreCache = { + getFileChunks: cachedFileChunks, + getCapabilityScore: cachedCapabilityScore, + hasFamilySignal: cachedFamilySignal, + }; + const upsert = ( filePath: string | undefined | null, score: number, @@ -207,9 +240,9 @@ export function resolveCapabilityEvidence(input: { ) => { if (!filePath) return; if (!allowTests && isTestFile(filePath)) return; - const fileChunks = findFileChunks(metadata, filePath); + const fileChunks = cachedFileChunks(filePath); if (!fileChunks.some((chunk) => chunk.kind !== "file")) return; - const adjustedScore = score + scoreCapabilityFile(query, filePath, fileChunks, families); + const adjustedScore = score + cachedCapabilityScore(filePath); const existing = entries.get(filePath); if (!existing) { const next: MutableEvidenceFile = { @@ -237,7 +270,7 @@ export function resolveCapabilityEvidence(input: { for (const chunk of topCodeChunks.slice(0, 12)) { if ( (queryMode === "architecture" || queryMode === "change" || queryMode === "lookup") - && !hasPrimaryFamilySignal(query, chunk.filePath, findFileChunks(metadata, chunk.filePath), families) + && !cachedFamilySignal(chunk.filePath) ) { continue; } @@ -256,7 +289,7 @@ export function resolveCapabilityEvidence(input: { } } - for (const filePath of findMandatoryFlowFiles(query, queryMode, metadata, families)) { + for (const filePath of findMandatoryFlowFiles(query, queryMode, metadata, families, capabilityCache)) { seedFiles.add(filePath); upsert(filePath, 80, "mandatory_flow_step", "mandatory_flow_step"); } @@ -275,9 +308,9 @@ export function resolveCapabilityEvidence(input: { for (const callee of metadata.findCalleesForChunk?.(chunk.id, 8) ?? []) { upsert(callee.targetFilePath ?? callee.filePath, 22, "call_neighbor", "call_neighbor"); } - for (const callee of metadata.findCallees?.(chunk.name, 8) ?? []) { - upsert(callee.targetFilePath ?? callee.filePath, 18, "call_neighbor", "call_neighbor"); - } + // Bug fix: removed redundant unscoped `findCallees(chunk.name, 8)` — it + // pulled callees from ANY file with a matching symbol name (cross-file + // contamination), duplicating the chunk-scoped findCalleesForChunk above. } const files = Array.from(entries.values()) @@ -317,10 +350,6 @@ export function hydrateCapabilityEvidenceFiles( return hydrated; } -export function isBusinessWikiPage(page: Pick): boolean { - return page.name.startsWith("business-") || /^---[\s\S]*?\npageType:\s*"?business"?/m.test(page.content); -} - function inferCapabilityFamilies(query: string): CapabilityFamilies { const normalized = normalizeTargetText(query); return { @@ -477,14 +506,20 @@ function scoreGenericLayerFit(filePath: string, normalizedText: string): number return score; } +interface CapabilityScoreCache { + getFileChunks(filePath: string): StoredChunk[]; + getCapabilityScore(filePath: string): number; + hasFamilySignal(filePath: string): boolean; +} + function findMandatoryFlowFiles( query: string, queryMode: QueryMode, metadata: CapabilityMetadata, - families: CapabilityFamilies + families: CapabilityFamilies, + cache: CapabilityScoreCache ): string[] { - const files = new Set(); - for (const chunk of getAllChunks(metadata)) files.add(chunk.filePath); + const files = getDistinctFilePaths(metadata); const primary = primaryCapabilityFamily(families); const broad = queryMode === "architecture" || queryMode === "change" || /\b(full|from|through|which\s+files?|all\s+files?|flow|workflow|end[- ]?to[- ]?end)\b/i.test(query); if (!primary) { @@ -495,9 +530,9 @@ function findMandatoryFlowFiles( return Array.from(files) .map((filePath) => ({ filePath, - score: scoreCapabilityFile(query, filePath, findFileChunks(metadata, filePath), families), + score: cache.getCapabilityScore(filePath), })) - .filter((candidate) => hasPrimaryFamilySignal(query, candidate.filePath, findFileChunks(metadata, candidate.filePath), families)) + .filter((candidate) => cache.hasFamilySignal(candidate.filePath)) .filter((candidate) => candidate.score >= threshold) .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath)) .slice(0, limit) @@ -509,8 +544,7 @@ function findQueryAnchoredFlowFiles( metadata: CapabilityMetadata, limit: number ): string[] { - const files = new Set(); - for (const chunk of getAllChunks(metadata)) files.add(chunk.filePath); + const files = getDistinctFilePaths(metadata); const candidates = Array.from(files) .map((filePath) => { const chunks = findFileChunks(metadata, filePath); @@ -716,8 +750,17 @@ function findFileChunks(metadata: CapabilityMetadata, filePath: string): StoredC return metadata.findChunksByFilePath?.(filePath) ?? []; } -function getAllChunks(metadata: CapabilityMetadata): StoredChunk[] { - return metadata.getAllChunks?.() ?? []; +// Hot-path: enumerate unique file paths WITHOUT loading chunk content. The +// lightweight projection excludes `content`, avoiding the largest avoidable +// I/O on the per-query path. Falls back to getAllChunks() if unavailable. +function getDistinctFilePaths(metadata: CapabilityMetadata): Set { + const files = new Set(); + if (metadata.getChunksLightweight) { + for (const chunk of metadata.getChunksLightweight()) files.add(chunk.filePath); + } else { + for (const chunk of metadata.getAllChunks?.() ?? []) files.add(chunk.filePath); + } + return files; } function selectBestChunkForEvidence(chunks: StoredChunk[]): StoredChunk | null { @@ -729,7 +772,8 @@ function selectBestChunkForEvidence(chunks: StoredChunk[]): StoredChunk | null { if (aExport !== bExport) return bExport - aExport; const aSpan = a.endLine - a.startLine; const bSpan = b.endLine - b.startLine; - return aSpan - bSpan || a.startLine - b.startLine; + // Bug fix: pick the most substantial chunk (descending span), not the smallest. + return bSpan - aSpan || a.startLine - b.startLine; })[0] ?? null; } @@ -761,14 +805,14 @@ function bestSource(sources: Set): CapabilitySelectio for (const source of SOURCE_PRIORITY) { if (sources.has(source)) return source; } - return "direct_match"; + throw new Error("bestSource: empty source set (unreachable — every evidence file seeds sources with >= 1 element)"); } function bestReason(reasons: Set): CapabilitySelectionReason { for (const reason of REASON_PRIORITY) { if (reasons.has(reason)) return reason; } - return "direct_match"; + throw new Error("bestReason: empty reason set (unreachable — every evidence file seeds reasons with >= 1 element)"); } function buildCapabilityMissingEvidence( diff --git a/src/search/context-assembler.ts b/src/search/context-assembler.ts index 48e20bc..7a70970 100644 --- a/src/search/context-assembler.ts +++ b/src/search/context-assembler.ts @@ -1,8 +1,9 @@ import { encoding_for_model } from "tiktoken"; -import type { SearchResult, AssembledContext } from "./types.js"; +import type { ContextCompressionMetadata, SearchResult, AssembledContext } from "./types.js"; import type { StackTree } from "./tree-builder.js"; import type { StoredChunk } from "../storage/types.js"; import { getLogger } from "../core/logger.js"; +import { compressEvidenceChunk, type EvidenceCompressionMode, type EvidenceCompressionResult } from "./evidence-compressor.js"; let encoder: ReturnType | undefined; @@ -39,6 +40,21 @@ export interface AssembleOptions { query?: string; factExtractors?: Array<{ keyword: string; pattern: string; label: string }>; compressionRank?: number; // chunks after this rank use compressed format (default: undefined = no compression) + contextCompressionEnabled?: boolean; + contextCompressionMode?: EvidenceCompressionMode; + contextCompressionPreserveTopChunks?: number; + contextCompressionMinChunkTokens?: number; + contextCompressionTargetRatio?: number; +} + +interface RenderedChunk { + result: SearchResult; + text: string; + fullText: string; + tokenCount: number; + fullTokenCount: number; + compressed: boolean; + compression?: EvidenceCompressionResult; } export type ConceptContextKind = "ast" | "call_graph" | "search_pipeline" | "storage" | "daemon" | "embedding" | "cli" | "context_assembly" | (string & {}); @@ -57,14 +73,30 @@ export function assembleContext( const scoreFloorRatio = opts.scoreFloorRatio ?? 0.7; const maxChunks = opts.maxChunks ?? Infinity; const directiveHeader = opts.directiveHeader ?? true; + const compressionMode = opts.contextCompressionMode ?? "auto"; + const compressionEnabled = + opts.contextCompressionEnabled !== false && compressionMode !== "off"; + const preserveTopChunks = + opts.compressionRank ?? opts.contextCompressionPreserveTopChunks ?? 1; + const minChunkTokens = opts.contextCompressionMinChunkTokens ?? 100; + const targetRatio = opts.contextCompressionTargetRatio ?? 0.75; const included: SearchResult[] = []; + const rendered: RenderedChunk[] = []; let totalTokens = 0; // Header — file list is added after chunk assembly (placeholder budget for now) const baseHeader = "## Relevant codebase context\n\n"; const directiveLine = "> Answer from this context first. Only fetch files NOT listed above.\n\n"; - const headerBudget = countTokens(baseHeader) + (directiveHeader ? countTokens(directiveLine) + 40 /* file list estimate */ : 0); + const fileListFileCount = results.length > 0 + ? Math.min(new Set(results.map((r) => r.filePath)).size, 8) + : 0; + const fileListEstimate = fileListFileCount > 0 + ? countTokens("> Files included: ") + fileListFileCount * 12 + : 0; + const headerBudget = countTokens(baseHeader) + + fileListEstimate + + (directiveHeader ? countTokens(directiveLine) : 0); totalTokens += headerBudget; // Drop results scoring below scoreFloorRatio of the top result @@ -77,38 +109,64 @@ export function assembleContext( const SUMMARY_RESERVE = 80; for (const result of results) { + if (included.length >= maxChunks) break; if (result.score < scoreFloor) continue; - let useCompressed = opts.compressionRank !== undefined && included.length >= opts.compressionRank; - let fileHeader = `### ${result.filePath}\n`; - let fileHeaderTokens = useCompressed || emittedHeaders.has(result.filePath) ? 0 : countTokens(fileHeader); - let chunkText = useCompressed ? formatChunkCompressed(result) : formatChunk(result); - let chunkTokens = countTokens(chunkText); - - if (totalTokens + fileHeaderTokens + chunkTokens > tokenBudget - SUMMARY_RESERVE) { - if (!useCompressed && opts.compressionRank !== undefined) { - useCompressed = true; - fileHeaderTokens = 0; - chunkText = formatChunkCompressed(result); - chunkTokens = countTokens(chunkText); + const fullText = formatChunk(result); + const fullTokens = countTokens(fullText); + const shouldPreferCompressed = + compressionEnabled + && ( + compressionMode === "always" + || opts.compressionRank !== undefined && included.length >= opts.compressionRank + || included.length >= preserveTopChunks + ); + + let candidate = buildRenderedChunk( + result, + fullText, + fullTokens, + shouldPreferCompressed, + opts.query, + minChunkTokens, + targetRatio + ); + + const fileHeader = `### ${result.filePath}\n`; + let fileHeaderTokens = candidate.compressed || emittedHeaders.has(result.filePath) ? 0 : countTokens(fileHeader); + + if (totalTokens + fileHeaderTokens + candidate.tokenCount > tokenBudget - SUMMARY_RESERVE) { + if (compressionEnabled && !candidate.compressed) { + const compactCandidate = buildRenderedChunk( + result, + fullText, + fullTokens, + true, + opts.query, + minChunkTokens, + targetRatio + ); + if (compactCandidate.compressed) { + candidate = compactCandidate; + fileHeaderTokens = 0; + } } } - if (totalTokens + fileHeaderTokens + chunkTokens > tokenBudget - SUMMARY_RESERVE) { - if (opts.compressionRank !== undefined) { + if (totalTokens + fileHeaderTokens + candidate.tokenCount > tokenBudget - SUMMARY_RESERVE) { + if (compressionEnabled || opts.compressionRank !== undefined) { continue; } break; } - if (!useCompressed && !emittedHeaders.has(result.filePath)) { + if (!candidate.compressed && !emittedHeaders.has(result.filePath)) { emittedHeaders.add(result.filePath); totalTokens += fileHeaderTokens; } - totalTokens += chunkTokens; + totalTokens += candidate.tokenCount; included.push(result); - - if (included.length >= maxChunks) break; + rendered.push(candidate); } // Build direct facts (skip summary — chunk list is redundant with the chunks themselves) @@ -140,18 +198,28 @@ export function assembleContext( const seenFiles = new Set(); - for (let i = 0; i < included.length; i++) { - const chunk = included[i]!; - const useCompressed = opts.compressionRank !== undefined && i >= opts.compressionRank; - if (!useCompressed && !seenFiles.has(chunk.filePath)) { + for (const entry of rendered) { + const chunk = entry.result; + if (!entry.compressed && !seenFiles.has(chunk.filePath)) { if (seenFiles.size > 0) parts.push(""); // blank line between file groups parts.push(`### ${chunk.filePath}\n`); seenFiles.add(chunk.filePath); } - parts.push(useCompressed ? formatChunkCompressed(chunk) : formatChunk(chunk)); + parts.push(entry.text); } if (included.length > 0) parts.push(""); + const finalText = parts.join("\n"); + const finalTokenCount = countTokens(finalText); + const compression = buildCompressionMetadata( + compressionEnabled, + compressionMode, + header, + includeFacts ? factsSection : null, + rendered, + finalTokenCount + ); + const log = getLogger(); log.debug({ inputResults: results.length, @@ -159,16 +227,124 @@ export function assembleContext( includedChunks: included.length, droppedByScoreFloor: results.filter(r => r.score < scoreFloor).length, droppedByBudget: results.filter(r => r.score >= scoreFloor).length - included.length, - totalTokens, + totalTokens: finalTokenCount, tokenBudget, + compressedChunks: compression.compressedChunks, + compressionTokensSaved: compression.tokensSaved, }, "context assembly complete"); return { - text: parts.join("\n"), - tokenCount: totalTokens, + text: finalText, + tokenCount: finalTokenCount, chunks: included, routeStyle: "standard", deliveryMode: "code_context", + compression, + }; +} + +function buildRenderedChunk( + result: SearchResult, + fullText: string, + fullTokens: number, + preferCompressed: boolean, + query: string | undefined, + minChunkTokens: number, + targetRatio: number +): RenderedChunk { + if (!preferCompressed || fullTokens < minChunkTokens) { + return { + result, + text: fullText, + fullText, + tokenCount: fullTokens, + fullTokenCount: fullTokens, + compressed: false, + }; + } + + const compression = compressEvidenceChunk(result, { + query, + minChunkTokens, + targetRatio, + }); + const compressedTokens = countTokens(compression.text); + const ratio = fullTokens > 0 ? compressedTokens / fullTokens : 1; + const worthwhile = compression.text.trim().length > 0 + && compressedTokens < fullTokens + && ratio <= targetRatio; + + if (!worthwhile) { + return { + result, + text: fullText, + fullText, + tokenCount: fullTokens, + fullTokenCount: fullTokens, + compressed: false, + }; + } + + return { + result, + text: compression.text, + fullText, + tokenCount: compressedTokens, + fullTokenCount: fullTokens, + compressed: true, + compression, + }; +} + +function buildCompressionMetadata( + enabled: boolean, + mode: EvidenceCompressionMode, + header: string, + factsSection: string | null, + rendered: RenderedChunk[], + finalTokenCount: number +): ContextCompressionMetadata { + const fullParts: string[] = [header]; + if (factsSection) { + fullParts.push(factsSection); + fullParts.push(""); + } + + const seenFiles = new Set(); + for (const entry of rendered) { + const chunk = entry.result; + if (!seenFiles.has(chunk.filePath)) { + if (seenFiles.size > 0) fullParts.push(""); + fullParts.push(`### ${chunk.filePath}\n`); + seenFiles.add(chunk.filePath); + } + fullParts.push(entry.fullText); + } + if (rendered.length > 0) fullParts.push(""); + + const tokensBeforeCompression = countTokens(fullParts.join("\n")); + const compressedEntries = rendered.filter((entry) => entry.compressed && entry.compression); + const strategies: Record = {}; + for (const entry of compressedEntries) { + const strategy = entry.compression?.strategy; + if (!strategy) continue; + strategies[strategy] = (strategies[strategy] ?? 0) + 1; + } + + const tokensSaved = Math.max(0, tokensBeforeCompression - finalTokenCount); + return { + enabled, + mode, + tokensBeforeCompression, + tokensAfterCompression: finalTokenCount, + tokensSaved, + savingsRatio: tokensBeforeCompression > 0 ? tokensSaved / tokensBeforeCompression : 0, + fullChunks: rendered.length - compressedEntries.length, + compressedChunks: compressedEntries.length, + originalRefs: compressedEntries.flatMap((entry) => + entry.compression ? [entry.compression.originalRef] : [] + ), + strategies, }; } @@ -273,17 +449,25 @@ function extractUniqueMatches( return Array.from(values); } +function longestBacktickRun(content: string): number { + let longest = 0; + let current = 0; + for (const ch of content) { + if (ch === "`") { + current++; + if (current > longest) longest = current; + } else { + current = 0; + } + } + return longest; +} + function formatChunk(result: SearchResult): string { const lang = result.language || ""; const location = `Lines ${result.startLine}-${result.endLine}: ${result.kind} ${result.name}`; - return `\`\`\`${lang}\n// ${location}\n${result.content}\n\`\`\`\n`; -} - -function formatChunkCompressed(result: SearchResult): string { - const loc = `${result.filePath}:${result.startLine}-${result.endLine}`; - const sig = `${result.kind} ${result.name}`; - const doc = result.docstring ? ` — ${result.docstring.slice(0, 120)}` : ''; - return `- \`${sig}\` (${loc})${doc}\n`; + const fence = "`".repeat(Math.max(3, longestBacktickRun(result.content) + 1)); + return `${fence}${lang}\n// ${location}\n${result.content}\n${fence}\n`; } // --- Metadata-aware chunk type for hydration --- @@ -453,10 +637,15 @@ function selectFlowEntries( summaryReserve: number, implementationFirst: boolean, callerFocused: boolean, - direction: "caller" | "callee" -): { parts: string[]; results: SearchResult[]; totalTokens: number } { + direction: "caller" | "callee", + query: string | undefined, + compressionEnabled: boolean, + minChunkTokens: number, + targetRatio: number +): { parts: string[]; results: SearchResult[]; rendered: RenderedChunk[]; totalTokens: number } { const parts: string[] = []; const results: SearchResult[] = []; + const rendered: RenderedChunk[] = []; const seenFiles = new Set(); let currentTokens = totalTokens; let crossFileCount = 0; @@ -476,19 +665,29 @@ function selectFlowEntries( const chunkLines = entry.chunk.endLine - entry.chunk.startLine + 1; const useCompressed = parts.length >= 1 || chunkLines > 80 || (!entry.sameFile && results.length >= 1); - const text = useCompressed ? formatChunkCompressed(entry.result) : formatChunk(entry.result); - const tokens = countTokens(text); - if (currentTokens + tokens > tokenBudget - summaryReserve) continue; + const fullText = formatChunk(entry.result); + const fullTokens = countTokens(fullText); + const candidate = buildRenderedChunk( + entry.result, + fullText, + fullTokens, + compressionEnabled && useCompressed, + query, + minChunkTokens, + targetRatio + ); + if (currentTokens + candidate.tokenCount > tokenBudget - summaryReserve) continue; - currentTokens += tokens; - parts.push(text); + currentTokens += candidate.tokenCount; + parts.push(candidate.text); results.push(entry.result); + rendered.push(candidate); seenFiles.add(entry.filePath); if (!entry.sameFile) crossFileCount++; if (results.length >= maxEntries) break; } - return { parts, results, totalTokens: currentTokens }; + return { parts, results, rendered, totalTokens: currentTokens }; } function describeChunk(chunk: SearchResult | StoredChunk | undefined): string | null { @@ -617,7 +816,14 @@ export function assembleFlowContext( tree: StackTree, metadata: HydratableMetadata, tokenBudget: number, - query?: string + query?: string, + options: Pick = {} ): AssembledContext { const log = getLogger(); const SUMMARY_RESERVE = 80; @@ -627,6 +833,11 @@ export function assembleFlowContext( !!query && /\b(who|what)\s+calls\b|\bcalled\s+by\b|\bwhere\s+is\b.*\bused\b|\busage\b/i.test(query); const queryTerms = tokenizeFlowAssemblyQuery(query); const families = inferFlowFamilies(queryTerms); + const compressionMode = options.contextCompressionMode ?? "auto"; + const compressionEnabled = + options.contextCompressionEnabled !== false && compressionMode !== "off"; + const minChunkTokens = options.contextCompressionMinChunkTokens ?? 100; + const targetRatio = options.contextCompressionTargetRatio ?? 0.75; // Collect all node IDs for bulk hydration const allNodeIds = [ @@ -657,8 +868,21 @@ export function assembleFlowContext( // Build header const seedInfo = `${tree.seed.name} (${tree.seed.kind}, ${seedChunk.filePath}:${seedChunk.startLine}-${seedChunk.endLine})`; - // Header is built after chunk assembly to include file list; use budget estimate - const headerEstimate = 60; // conservative estimate for header tokens + // Header is built after chunk assembly to include file list; estimate its + // tokens from the title, file list (up to 8 paths), directive, and seed line. + const flowFileCount = Math.min( + new Set( + [seedChunk.filePath, ...tree.upTree.map((n) => n.filePath), ...tree.downTree.map((n) => n.filePath)] + .filter((p): p is string => Boolean(p)) + ).size, + 8 + ); + const flowTitle = "## Relevant codebase context (flow trace)\n\n"; + const flowDirective = "> Answer from this context first. The flow trace below shows the call graph from the seed.\n"; + const headerEstimate = countTokens(flowTitle) + + (flowFileCount > 0 ? countTokens("> Files included: ") + flowFileCount * 12 : 0) + + countTokens(flowDirective) + + countTokens(`> Seed: ${seedInfo}\n\n`); let totalTokens = headerEstimate; const included: SearchResult[] = []; @@ -666,6 +890,14 @@ export function assembleFlowContext( const seedResult = storedChunkToSearchResult(seedChunk); const seedSection = `> Flow seed\n\n` + formatChunk(seedResult); const seedTokens = countTokens(seedSection); + const seedRendered: RenderedChunk = { + result: seedResult, + text: seedSection, + fullText: seedSection, + tokenCount: seedTokens, + fullTokenCount: seedTokens, + compressed: false, + }; // Seed always gets included even if it fills the budget if (seedTokens > tokenBudget - SUMMARY_RESERVE) { @@ -698,7 +930,11 @@ export function assembleFlowContext( SUMMARY_RESERVE, implementationFirst, callerFocused, - "caller" + "caller", + query, + compressionEnabled, + minChunkTokens, + targetRatio ); totalTokens = selectedCallers.totalTokens; const callerParts = selectedCallers.parts; @@ -728,7 +964,11 @@ export function assembleFlowContext( SUMMARY_RESERVE, implementationFirst, callerFocused, - "callee" + "callee", + query, + compressionEnabled, + minChunkTokens, + targetRatio ); totalTokens = selectedCallees.totalTokens; const calleeParts = selectedCallees.parts; @@ -760,33 +1000,47 @@ export function assembleFlowContext( }; if (implementationFirst && !callerFocused) { - parts.push(seedSection); + parts.push(seedRendered.text); appendCallees(); appendCallers(); } else { appendCallers(); - parts.push(seedSection); + parts.push(seedRendered.text); appendCallees(); } parts.push(""); + const finalText = parts.join("\n"); + const finalTokenCount = countTokens(finalText); + const renderedForMetadata = [seedRendered, ...selectedCallers.rendered, ...selectedCallees.rendered]; + const compression = buildCompressionMetadata( + compressionEnabled, + compressionMode, + header, + null, + renderedForMetadata, + finalTokenCount + ); log.debug({ seedName: tree.seed.name, upTreeCount: tree.upTree.length, downTreeCount: tree.downTree.length, includedChunks: included.length, - totalTokens, + totalTokens: finalTokenCount, tokenBudget, coverage: tree.coverage, + compressedChunks: compression.compressedChunks, + compressionTokensSaved: compression.tokensSaved, }, "flow context assembly complete"); return { - text: parts.join("\n"), - tokenCount: totalTokens, + text: finalText, + tokenCount: finalTokenCount, chunks: included, routeStyle: "flow", deliveryMode: "code_context", + compression, }; } @@ -813,7 +1067,14 @@ function buildDeepRouteHeader(chunks: SearchResult[]): string { export function assembleDeepRouteContext( chunks: SearchResult[], tokenBudget: number, - query?: string + query?: string, + options: Pick = {} ): AssembledContext { // Reserve a generous estimate for the header (file list varies); adjust after assembly const headerEstimate = 60; @@ -826,26 +1087,35 @@ export function assembleDeepRouteContext( directiveHeader: false, query, maxChunks: 5, - compressionRank: 3, + compressionRank: options.contextCompressionPreserveTopChunks ?? 3, + contextCompressionEnabled: options.contextCompressionEnabled, + contextCompressionMode: options.contextCompressionMode, + contextCompressionPreserveTopChunks: options.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: options.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: options.contextCompressionTargetRatio, }); // Build final header with actual file list from assembled chunks const deepHeader = buildDeepRouteHeader(baseContext.chunks); - const deepHeaderTokens = countTokens(deepHeader); - // Replace the standard header in baseContext.text with our deep route header. - const baseHeader = "## Relevant codebase context\n\n"; - const baseHeaderTokens = countTokens(baseHeader); - const textWithoutHeader = baseContext.text.replace( - /^## Relevant codebase context\n\n\n?/, - "" - ); + // The base context was assembled with directiveHeader:false, so it still + // begins with "## Relevant codebase context\n\n> Files included: ...\n". + // Strip that entire prefix — buildDeepRouteHeader already rebuilds a complete + // (broad-search) header carrying the same file list, so leaving the base + // file-list line in place would emit it twice (and miscount tokens). + const strippedPrefixRe = /^## Relevant codebase context\n\n(?:> Files included:[^\n]*\n)?\n?/; + const prefixMatch = strippedPrefixRe.exec(baseContext.text); + const removedPrefix = prefixMatch ? prefixMatch[0] : "## Relevant codebase context\n\n"; + const removedTokens = countTokens(removedPrefix); + const textWithoutHeader = baseContext.text.slice(removedPrefix.length); + const deepHeaderTokens = countTokens(deepHeader); return { text: deepHeader + textWithoutHeader, - tokenCount: deepHeaderTokens + baseContext.tokenCount - baseHeaderTokens, + tokenCount: deepHeaderTokens + baseContext.tokenCount - removedTokens, chunks: baseContext.chunks, routeStyle: "deep", deliveryMode: baseContext.deliveryMode ?? "code_context", + compression: baseContext.compression, }; } diff --git a/src/search/context-prioritization.ts b/src/search/context-prioritization.ts index 15425a8..c807291 100644 --- a/src/search/context-prioritization.ts +++ b/src/search/context-prioritization.ts @@ -27,6 +27,10 @@ import type { SearchResult, AssembledContext } from "./types.js"; import { resolveSeeds } from "./seed.js"; import type { SeedResult } from "./seed.js"; import { normalizeTargetText } from "./targets.js"; +import { INVENTORY_GENERIC_TARGET_ALIAS_TERMS, INVENTORY_STRUCTURAL_TERMS } from "./shared/workflow-families.js"; +import { isImplementationPath } from "./shared/mappers.js"; + +export { isImplementationPath } from "./shared/mappers.js"; // ── Scoring constants from the retrieval pipeline ───────────────── import { @@ -55,15 +59,6 @@ export interface CompiledConceptBundle { * True when `filePath` sits under an implementation directory * (src/, lib/, bin/, etc.). */ -export function isImplementationPath( - filePath: string, - implementationPaths: string[] = ["src/", "lib/", "bin/"] -): boolean { - const lowerPath = filePath.toLowerCase(); - if (implementationPaths.some((prefix) => lowerPath.startsWith(prefix.toLowerCase()))) return true; - return /(?:^|\/)(src|lib|bin|app|server|api|functions|handlers|controllers|services|supabase)\//.test(lowerPath); -} - /** * True when the search result lives in an implementation path. */ @@ -293,13 +288,6 @@ export function prioritizeForHookContext( /** Inventory-suppression gate shared by several prepend strategies. */ const BROAD_INVENTORY_RE = /\b(?:which|what|list|show)\s+files\b|\bfiles?\s+(?:implement|handle|power|control|cover)\b/i; -const INVENTORY_GENERIC_TARGET_ALIAS_TERMS = new Set(["route", "routes", "router", "routing", "navigation"]); -const INVENTORY_STRUCTURAL_TERMS = new Set([ - "which", "what", "list", "show", "file", "files", - "implement", "implements", "handle", "handles", - "power", "powers", "control", "controls", "cover", "covers", - "full", "entire", -]); function shouldSuppressBroadResolvedTarget( query: string, diff --git a/src/search/evidence-compressor.ts b/src/search/evidence-compressor.ts new file mode 100644 index 0000000..f81ac85 --- /dev/null +++ b/src/search/evidence-compressor.ts @@ -0,0 +1,169 @@ +import type { ContextOriginalRef, SearchResult } from "./types.js"; + +export type EvidenceCompressionMode = "off" | "auto" | "always"; + +export interface EvidenceCompressionOptions { + query?: string; + minChunkTokens?: number; + targetRatio?: number; +} + +export interface EvidenceCompressionResult { + text: string; + strategy: "code" | "search_result" | "config_or_data" | "text"; + originalRef: ContextOriginalRef; +} + +const STOP_WORDS = new Set([ + "the", "and", "for", "with", "from", "that", "this", "what", "which", + "where", "when", "does", "how", "work", "flow", "files", "file", + "code", "show", "tell", "about", "into", "over", "under", "using", +]); + +const IMPORT_LIKE_RE = /^\s*(import|from|export|require\(|use\s+|using\s+|#include|include\s+|package\s+|module\s+|namespace\s+|mod\s+)/i; +const SIGNATURE_LIKE_RE = /^\s*(export\s+)?((async\s+)?function|def|class|interface|type|enum|struct|trait|impl|func|fn|pub\s+(fn|struct|enum|trait)|public|private|protected|static|const|let|var|protocol|extension|object|case\s+class)\b/i; +const DECORATOR_LIKE_RE = /^\s*(@[\w.:-]+|#\[|\[[A-Z]\w+|\*\s*@)/; +const CONFIG_LIKE_RE = /^\s*["']?[A-Z0-9_]*(URL|URI|HOST|PORT|TOKEN|SECRET|KEY|PASSWORD|AUTH|ROUTE|PATH|ENDPOINT|BUCKET|QUEUE|TOPIC|MODEL|ENV)[A-Z0-9_]*["']?\s*[:=]/i; +const ROUTE_LITERAL_RE = /["'`](\/[A-Za-z0-9_./:{}-]{2,}|[A-Z]{3,8}\s+\/[A-Za-z0-9_./:{}-]*)["'`]/; +const ERROR_LITERAL_RE = /\b(error|exception|fatal|panic|throw|throws|raise|warn|warning|failed|failure|invalid|unauthorized|forbidden|not found)\b/i; +const HIGH_ENTROPY_RE = /\b[A-Za-z0-9_-]{20,}\b/; +const SEARCH_RESULT_RE = /^[^\s:][^:\n]{0,240}:\d+:/m; +const DATA_LANGUAGE_SET = new Set(["json", "toml", "yaml", "yml", "html", "css"]); +const TEXT_LANGUAGE_SET = new Set(["markdown", "md", "text"]); + +export function compressEvidenceChunk( + chunk: SearchResult, + options: EvidenceCompressionOptions +): EvidenceCompressionResult { + const strategy = inferStrategy(chunk); + const selected = selectEvidenceLines(chunk, options.query, strategy, options.targetRatio); + const ref = buildOriginalRef(chunk); + const doc = chunk.docstring ? `\n - doc: ${oneLine(chunk.docstring, 180)}` : ""; + const evidence = selected.length > 0 + ? selected.map((line) => ` - L${line.line}: ${line.text}`).join("\n") + : ` - ${fallbackSummary(chunk)}`; + const omitted = Math.max(0, chunk.content.split("\n").length - selected.length); + const omittedLine = omitted > 0 ? `\n - omitted: ${omitted} lines; retrieve original with chunkId \`${chunk.id}\`` : ""; + + return { + strategy, + originalRef: ref, + text: + `- \`${chunk.kind} ${chunk.name}\` (${chunk.filePath}:${chunk.startLine}-${chunk.endLine}, chunkId \`${chunk.id}\`, ${chunk.language || "unknown"})` + + doc + + `\n - strategy: ${strategy}` + + `\n${evidence}` + + omittedLine + + "\n", + }; +} + +function inferStrategy(chunk: SearchResult): EvidenceCompressionResult["strategy"] { + const language = chunk.language.toLowerCase(); + const path = chunk.filePath.toLowerCase(); + if (SEARCH_RESULT_RE.test(chunk.content)) return "search_result"; + if (DATA_LANGUAGE_SET.has(language) || /\.(json|ya?ml|toml|env|ini|css|html?)$/.test(path)) { + return "config_or_data"; + } + if (TEXT_LANGUAGE_SET.has(language) || /\.(md|txt|rst)$/.test(path)) return "text"; + return "code"; +} + +function selectEvidenceLines( + chunk: SearchResult, + query: string | undefined, + strategy: EvidenceCompressionResult["strategy"], + targetRatio?: number +): Array<{ line: number; text: string; score: number }> { + const queryTerms = tokenizeQuery(query); + const lines = chunk.content.split("\n"); + const candidates: Array<{ index: number; line: number; text: string; score: number }> = []; + + for (let index = 0; index < lines.length; index++) { + const raw = lines[index] ?? ""; + const text = raw.trimEnd(); + if (!text.trim()) continue; + const commentOnly = /^\s*(\/\/|#(?!\[)|--|\*)/.test(text); + let score = 0; + if (IMPORT_LIKE_RE.test(text)) score += strategy === "code" ? 18 : 8; + if (SIGNATURE_LIKE_RE.test(text)) score += 24; + if (DECORATOR_LIKE_RE.test(text)) score += 12; + if (CONFIG_LIKE_RE.test(text)) score += 16; + if (ROUTE_LITERAL_RE.test(text)) score += 14; + if (ERROR_LITERAL_RE.test(text)) score += 12; + if (HIGH_ENTROPY_RE.test(text)) score += 8; + if (chunk.name && text.includes(chunk.name)) score += 18; + if (chunk.parentName && text.includes(chunk.parentName)) score += 8; + if (strategy === "search_result" && /^[^\s:][^:\n]{0,240}:\d+:/.test(text)) score += 18; + if (strategy === "text" && /^\s{0,3}(#{1,6}\s+|- |\* |\d+\. )/.test(text)) score += 10; + if (strategy === "config_or_data" && /["']?[A-Za-z0-9_.-]+["']?\s*[:=]/.test(text)) score += 10; + const queryScore = queryTerms.filter((term) => text.toLowerCase().includes(term)).length * 20; + score += commentOnly ? Math.min(queryScore, 6) : queryScore; + if (commentOnly && score < 25) score = Math.floor(score * 0.25); + + if (score > 0) { + candidates.push({ + index, + line: chunk.startLine + index, + text: oneLine(text.trim(), 220), + score, + }); + } + } + + if (candidates.length === 0) { + const first = lines.findIndex((line) => line.trim().length > 0); + if (first >= 0) { + candidates.push({ + index: first, + line: chunk.startLine + first, + text: oneLine((lines[first] ?? "").trim(), 220), + score: 1, + }); + } + } + + // Base cap by strategy; scale down conservatively when a targetRatio is + // requested so callers can request a tighter evidence summary. + const baseMaxLines = strategy === "code" ? 8 : 10; + const maxLines = targetRatio !== undefined && targetRatio > 0 + ? Math.max(2, Math.min(baseMaxLines, Math.floor(baseMaxLines * targetRatio))) + : baseMaxLines; + return candidates + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, maxLines) + .sort((a, b) => a.index - b.index) + .map(({ line, text, score }) => ({ line, text, score })); +} + +function tokenizeQuery(query: string | undefined): string[] { + if (!query) return []; + return query + .toLowerCase() + .split(/[^a-z0-9_/-]+/) + .map((term) => term.trim()) + .filter((term) => term.length >= 3 && !STOP_WORDS.has(term)) + .slice(0, 16); +} + +function buildOriginalRef(chunk: SearchResult): ContextOriginalRef { + return { + chunkId: chunk.id, + filePath: chunk.filePath, + startLine: chunk.startLine, + endLine: chunk.endLine, + name: chunk.name, + kind: chunk.kind, + language: chunk.language, + }; +} + +function fallbackSummary(chunk: SearchResult): string { + return `${chunk.language || "unknown"} ${chunk.kind} evidence in ${chunk.filePath}; retrieve original with chunkId \`${chunk.id}\``; +} + +function oneLine(text: string, maxLength: number): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (compact.length <= maxLength) return compact; + return `${compact.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} diff --git a/src/search/hybrid.ts b/src/search/hybrid.ts index 3b22a1e..3d9e5ae 100644 --- a/src/search/hybrid.ts +++ b/src/search/hybrid.ts @@ -136,13 +136,16 @@ export class HybridSearch { // ── Store hot-swap (after re-index) ───────────────────────────── - updateStores( + async updateStores( vectors: VectorStore, fts: FTSStore, metadata: MetadataStore - ): void { + ): Promise { this._fts = fts; - this.pipeline.updateStores(vectors, fts, metadata); + // Await the pipeline swap so it completes (under the write lock) before we + // propagate to the strategy stores. bugStrategy/archStrategy swaps are + // synchronous reference assignments. + await this.pipeline.updateStores(vectors, fts, metadata); this.bugStrategy.updateStores(metadata, fts); this.archStrategy.updateStores(metadata, fts); } @@ -273,6 +276,11 @@ export class HybridSearch { scoreFloorRatio: 0, query, factExtractors: this.config.factExtractors, + contextCompressionEnabled: this.config.contextCompressionEnabled, + contextCompressionMode: this.config.contextCompressionMode, + contextCompressionPreserveTopChunks: this.config.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: this.config.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: this.config.contextCompressionTargetRatio, } ); } @@ -373,7 +381,14 @@ export class HybridSearch { scoreFloorRatio: isBroadWorkflow ? 0.25 : queryMode === "bug" ? 0.05 : 0.7, query, factExtractors: this.config.factExtractors, - compressionRank: isBroadWorkflow ? 2 : queryMode === "bug" ? 2 : 3, + compressionRank: isBroadWorkflow || queryMode === "bug" + ? this.config.contextCompressionPreserveTopChunks + : 3, + contextCompressionEnabled: this.config.contextCompressionEnabled, + contextCompressionMode: this.config.contextCompressionMode, + contextCompressionPreserveTopChunks: this.config.contextCompressionPreserveTopChunks, + contextCompressionMinChunkTokens: this.config.contextCompressionMinChunkTokens, + contextCompressionTargetRatio: this.config.contextCompressionTargetRatio, } ); diff --git a/src/search/lookup-strategy.ts b/src/search/lookup-strategy.ts index f789dcd..de6d487 100644 --- a/src/search/lookup-strategy.ts +++ b/src/search/lookup-strategy.ts @@ -7,31 +7,15 @@ import type { SearchResult } from "./types.js"; import type { SeedResult, SeedCandidate } from "./seed.js"; -import type { StoredChunk } from "../storage/types.js"; import type { MetadataStore } from "../storage/metadata-store.js"; import { GENERIC_BROAD_TERMS, GENERIC_QUERY_ACTION_TERMS, isTestFile, STOP_WORDS } from "./utils.js"; import { resolveTargetsForQuery } from "./targets.js"; +import { chunkToSearchResult } from "./shared/mappers.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { - return { - id: chunk.id, - score, - filePath: chunk.filePath, - name: chunk.name, - kind: chunk.kind, - startLine: chunk.startLine, - endLine: chunk.endLine, - content: chunk.content, - docstring: chunk.docstring, - parentName: chunk.parentName, - language: chunk.language ?? "", - }; -} - // --------------------------------------------------------------------------- // Seed filtering helpers // --------------------------------------------------------------------------- diff --git a/src/search/pipeline-core.ts b/src/search/pipeline-core.ts index cefeba1..bc16f72 100644 --- a/src/search/pipeline-core.ts +++ b/src/search/pipeline-core.ts @@ -19,6 +19,7 @@ import type { SearchResult, SearchOptions } from "./types.js"; import type { ReadWriteLock } from "../core/rwlock.js"; import { classifyIntent } from "./intent.js"; import type { StoredChunk } from "../storage/types.js"; +import { chunkToSearchResult } from "./shared/mappers.js"; // ── Scoring constants ──────────────────────────────────────────────── export const IMPL_BOOST = 1.25; @@ -60,6 +61,7 @@ export class RetrievalPipeline { private fts: FTSStore; private metadata: MetadataStore; private config: MemoryConfig; + private lock?: ReadWriteLock; private queryEmbedCache = new Map(); constructor(opts: RetrievalPipelineConfig) { @@ -68,17 +70,37 @@ export class RetrievalPipeline { this.fts = opts.ftsStore; this.metadata = opts.metadata; this.config = opts.config; + // Previously this field was declared in the config interface but silently + // dropped here, giving callers a false guarantee of concurrency safety. + this.lock = opts.lock; + } + + /** Run fn under the read lock when one is configured; otherwise run it directly. */ + private async withReadLock(fn: () => Promise): Promise { + return this.lock ? this.lock.withRead(fn) : fn(); + } + + /** Run fn under the write lock when one is configured; otherwise run it directly. */ + private async withWriteLock(fn: () => Promise): Promise { + return this.lock ? this.lock.withWrite(fn) : fn(); } // ── Store hot-swap (used after re-index) ───────────────────────── - updateStores( + /** + * Swap the underlying stores. Runs under the write lock (when configured) so + * that in-flight `retrieve` calls cannot observe a half-swapped set of stores + * (old vectors/fts read alongside new metadata, causing chunk-id mismatches). + */ + async updateStores( vectors: VectorStore, fts: FTSStore, metadata: MetadataStore - ): void { - this.vectors = vectors; - this.fts = fts; - this.metadata = metadata; + ): Promise { + await this.withWriteLock(async () => { + this.vectors = vectors; + this.fts = fts; + this.metadata = metadata; + }); } // ── Accessors (for callers that need the underlying stores) ────── @@ -102,11 +124,16 @@ export class RetrievalPipeline { vectorResults: Array<{ id: string; score: number }>; keywordResults: Array<{ id: string; rank: number }>; }> { - const [vectorResults, keywordResults] = await Promise.all([ - isKeywordMode ? Promise.resolve([]) : this.vectorSearch(query, 50), - this.keywordSearch(query, 50), - ]); - return { vectorResults, keywordResults }; + // Hold the read lock for the duration of both searches so a concurrent + // updateStores() cannot swap the stores between the vector and keyword + // reads (which would yield mismatched chunk-id spaces). + return this.withReadLock(async () => { + const [vectorResults, keywordResults] = await Promise.all([ + isKeywordMode ? Promise.resolve([]) : this.vectorSearch(query, 50), + this.keywordSearch(query, 50), + ]); + return { vectorResults, keywordResults }; + }); } // ── Vector search (with LRU embedding cache) ──────────────────── @@ -251,8 +278,12 @@ export class RetrievalPipeline { const rankedIds = new Set(ranked.map((r) => r.id)); const topN = options?.graphTopN ?? 10; const top10 = ranked.slice(0, topN); - const discoveredNames = new Set(); - const nameScoreMap = new Map(); + // Bug fix: keep caller and callee names in separate sets. Previously both + // were merged into one set fed to findChunksByNames with a score map that + // only covered callee targets, so caller-named chunks got the wrong + // (top-score) fallback. Now only callee target names drive that lookup. + const calleeNames = new Set(); + const calleeScoreMap = new Map(); for (const item of top10) { const name = maps.chunkNames.get(item.id); @@ -263,7 +294,6 @@ export class RetrievalPipeline { for (const caller of callers) { if (!rankedIds.has(caller.chunkId)) { - discoveredNames.add(caller.callerName); ranked.push({ id: caller.chunkId, score: item.score * this.config.graphDiscountFactor, @@ -273,19 +303,19 @@ export class RetrievalPipeline { } for (const callee of callees) { - discoveredNames.add(callee.targetName); - const existing = nameScoreMap.get(callee.targetName) ?? 0; - nameScoreMap.set(callee.targetName, Math.max(existing, item.score)); + calleeNames.add(callee.targetName); + const existing = calleeScoreMap.get(callee.targetName) ?? 0; + calleeScoreMap.set(callee.targetName, Math.max(existing, item.score)); } } - if (discoveredNames.size > 0) { + if (calleeNames.size > 0) { const calleeChunks = this.metadata.findChunksByNames( - Array.from(discoveredNames) + Array.from(calleeNames) ); for (const chunk of calleeChunks) { if (!rankedIds.has(chunk.id)) { - const triggerScore = nameScoreMap.get(chunk.name) ?? top10[0]?.score ?? 0; + const triggerScore = calleeScoreMap.get(chunk.name) ?? top10[0]?.score ?? 0; ranked.push({ id: chunk.id, score: triggerScore * this.config.graphDiscountFactor, @@ -379,18 +409,6 @@ export class RetrievalPipeline { // ── Convert a StoredChunk to a SearchResult ───────────────────── chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { - return { - id: chunk.id, - score, - filePath: chunk.filePath, - name: chunk.name, - kind: chunk.kind, - startLine: chunk.startLine, - endLine: chunk.endLine, - content: chunk.content, - docstring: chunk.docstring, - parentName: chunk.parentName, - language: chunk.language ?? "", - }; + return chunkToSearchResult(chunk, score); } } diff --git a/src/search/ranker.ts b/src/search/ranker.ts index eef5e7f..bfa12c1 100644 --- a/src/search/ranker.ts +++ b/src/search/ranker.ts @@ -14,6 +14,30 @@ export interface RankedItem { score: number; } +const MATCH_BOOST_CEILING = 8; + +function splitBoundaryTokens(text: string): string[] { + return text + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/[^a-zA-Z0-9]+/g, " ") + .toLowerCase() + .trim() + .split(" ") + .filter(Boolean); +} + +function variantMatchesText( + variant: string, + boundaryTokens: Set, + lowerText: string +): boolean { + if (variant.length <= 4) { + return boundaryTokens.has(variant); + } + return lowerText.includes(variant); +} + export function reciprocalRankFusion( vectorResults: Array<{ id: string; score: number }>, keywordResults: Array<{ id: string; rank: number }>, @@ -37,8 +61,11 @@ export function reciprocalRankFusion( chunkLineRanges?: Map; } ): RankedItem[] { - const { vectorWeight, keywordWeight, recencyWeight, k, chunkDates, activeFiles, chunkFilePaths, chunkKinds, codeBoostFactor, chunkNames, testPenaltyFactor, testFileMode, anonymousPenaltyFactor, queryTerms, expandedQueryTerms, broadQuery, chunkLineRanges } = + const { vectorWeight, keywordWeight, recencyWeight, chunkDates, activeFiles, chunkFilePaths, chunkKinds, codeBoostFactor, chunkNames, testPenaltyFactor, testFileMode, anonymousPenaltyFactor, queryTerms, expandedQueryTerms, broadQuery, chunkLineRanges } = options; + // Guard k: a non-positive or non-finite value would yield Infinity/negative RRF + // scores. Fall back to the canonical RRF constant (60). + const k = options.k > 0 && Number.isFinite(options.k) ? options.k : 60; const scores = new Map(); // Vector scores — standard RRF: 1/(k + rank) with 1-indexed rank @@ -77,8 +104,13 @@ export function reciprocalRankFusion( for (const [id, item] of scores) { const dateStr = chunkDates.get(id); if (dateStr) { - const age = now - new Date(dateStr).getTime(); - const recencyScore = Math.max(0, 1 - age / ninetyDays); + const time = new Date(dateStr).getTime(); + // A single malformed date yields NaN, which propagates through the + // score and corrupts the final sort (NaN comparators are non-transitive, + // scrambling the whole result set). Skip such entries instead. + if (!Number.isFinite(time)) continue; + const age = now - time; + const recencyScore = Math.min(1, Math.max(0, 1 - age / ninetyDays)); item.score += recencyWeight * recencyScore; item.indexedAt = dateStr; } @@ -157,8 +189,12 @@ export function reciprocalRankFusion( if (terms.length > 0) { for (const [id, item] of scores) { - const filePath = (chunkFilePaths.get(id) ?? "").toLowerCase(); - const chunkName = (chunkNames?.get(id) ?? "").toLowerCase(); + const rawFilePath = chunkFilePaths.get(id) ?? ""; + const rawChunkName = chunkNames?.get(id) ?? ""; + const filePath = rawFilePath.toLowerCase(); + const chunkName = rawChunkName.toLowerCase(); + const pathTokens = new Set(splitBoundaryTokens(rawFilePath)); + const nameTokens = new Set(splitBoundaryTokens(rawChunkName)); let matchCount = 0; let matchedWeight = 0; @@ -169,7 +205,10 @@ export function reciprocalRankFusion( for (const term of expandedTerms) { const variants = getQueryTermVariants(term.term); - if (variants.some((variant) => filePath.includes(variant) || chunkName.includes(variant))) { + if (variants.some((variant) => + variantMatchesText(variant, pathTokens, filePath) + || variantMatchesText(variant, nameTokens, chunkName) + )) { matchCount++; matchedWeight += term.weight; matchBoost *= camelTerms.has(term.term) ? 1.7 : term.term.length >= 8 ? 1.45 : term.weight >= 0.7 ? 1.3 : 1.18; @@ -179,6 +218,8 @@ export function reciprocalRankFusion( } } + matchBoost = Math.min(matchBoost, MATCH_BOOST_CEILING); + if (matchCount > 0) { const coverageRatio = matchedWeight / totalExpandedWeight; if (terms.length >= 3 && coverageRatio < 0.5) { diff --git a/src/search/shared/mappers.ts b/src/search/shared/mappers.ts new file mode 100644 index 0000000..bb77491 --- /dev/null +++ b/src/search/shared/mappers.ts @@ -0,0 +1,27 @@ +import type { StoredChunk } from "../../storage/types.js"; +import type { SearchResult } from "../types.js"; + +export function chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { + return { + id: chunk.id, + score, + filePath: chunk.filePath, + name: chunk.name, + kind: chunk.kind, + startLine: chunk.startLine, + endLine: chunk.endLine, + content: chunk.content, + docstring: chunk.docstring, + parentName: chunk.parentName, + language: chunk.language ?? "", + }; +} + +export function isImplementationPath( + filePath: string, + implementationPaths: string[] = ["src/", "lib/", "bin/"], +): boolean { + const lowerPath = filePath.toLowerCase(); + if (implementationPaths.some((prefix) => lowerPath.startsWith(prefix.toLowerCase()))) return true; + return /(?:^|\/)(src|lib|bin|app|server|api|functions|handlers|controllers|services|supabase)\//.test(lowerPath); +} diff --git a/src/search/shared/workflow-families.ts b/src/search/shared/workflow-families.ts new file mode 100644 index 0000000..5cf1adf --- /dev/null +++ b/src/search/shared/workflow-families.ts @@ -0,0 +1,41 @@ +export const INVENTORY_GENERIC_TARGET_ALIAS_TERMS = new Set([ + "route", "routes", "router", "routing", "navigation", +]); + +export const INVENTORY_STRUCTURAL_TERMS = new Set([ + "which", + "what", + "list", + "show", + "file", + "files", + "implement", + "implements", + "handle", + "handles", + "power", + "powers", + "control", + "controls", + "cover", + "covers", + "full", + "entire", +]); + +export const TRACE_NOISE_TERMS = new Set([ + "path", "page", "pages", "include", "includes", "including", + "start", "first", "then", "full", "intent", +]); + +// Reconciled union of previously-divergent per-strategy copies. +export const ADJACENT_WORKFLOW_FAMILIES: Record = { + auth: ["routing", "permissions"], + routing: ["auth", "permissions"], + billing: ["auth", "generation"], + storage: ["auth", "generation"], + generation: ["storage", "queue", "billing", "workflow"], + queue: ["generation", "workflow"], + workflow: ["generation", "queue"], + bot: ["webhook", "daemon"], +}; diff --git a/src/search/targets.ts b/src/search/targets.ts index f78c972..03068cf 100644 --- a/src/search/targets.ts +++ b/src/search/targets.ts @@ -30,7 +30,7 @@ export interface ResolvedTargetCandidate { phrase: string; } -function splitIdentifierTokens(value: string): string[] { +export function splitIdentifierTokens(value: string): string[] { return value .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") @@ -471,14 +471,6 @@ export function buildLiteralAliasCandidates(values: string[]): string[] { return Array.from(aliases).filter(Boolean); } -export function sameNormalizedTarget(a: string, b: string): boolean { - return normalizeTargetText(a) === normalizeTargetText(b); -} - -export function isIndexFilePath(filePath: string): boolean { - return INDEX_FILE_RE.test(basename(filePath)); -} - export function fileStem(filePath: string): string { return basename(filePath, extname(filePath)); } diff --git a/src/search/trace-strategy.ts b/src/search/trace-strategy.ts index a4a9d5a..1756c6d 100644 --- a/src/search/trace-strategy.ts +++ b/src/search/trace-strategy.ts @@ -7,7 +7,6 @@ import type { SearchResult } from "./types.js"; import type { ExpandedQueryTerm } from "./utils.js"; -import type { StoredChunk } from "../storage/types.js"; import type { MetadataStore } from "../storage/metadata-store.js"; import { expandQueryTerms, @@ -17,27 +16,15 @@ import { tokenizeQueryTerms, } from "./utils.js"; import { normalizeTargetText } from "./targets.js"; +import { TRACE_NOISE_TERMS, ADJACENT_WORKFLOW_FAMILIES } from "./shared/workflow-families.js"; +import { chunkToSearchResult, isImplementationPath } from "./shared/mappers.js"; + +export { TRACE_NOISE_TERMS } from "./shared/workflow-families.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -export const TRACE_NOISE_TERMS = new Set([ - "path", "page", "pages", "include", "includes", "including", - "start", "first", "then", "full", "intent", -]); - -const ADJACENT_WORKFLOW_FAMILIES: Record = { - auth: ["routing", "permissions"], - routing: ["auth", "permissions"], - billing: ["auth", "generation"], - storage: ["auth", "generation"], - generation: ["storage", "queue", "billing", "workflow"], - queue: ["generation"], - workflow: ["generation", "queue"], - bot: ["webhook", "daemon"], -}; - const MODE_EXPLICIT_LOGGING_RE = /\b(log|logger|logging|audit|instrument|instrumentation|telemetry|metrics?)\b/i; const MODE_EXPLICIT_WEBHOOK_RE = @@ -47,32 +34,6 @@ const MODE_EXPLICIT_WEBHOOK_RE = // Helpers shared with the extraction pipeline // --------------------------------------------------------------------------- -function chunkToSearchResult(chunk: StoredChunk, score: number): SearchResult { - return { - id: chunk.id, - score, - filePath: chunk.filePath, - name: chunk.name, - kind: chunk.kind, - startLine: chunk.startLine, - endLine: chunk.endLine, - content: chunk.content, - docstring: chunk.docstring, - parentName: chunk.parentName, - language: chunk.language ?? "", - }; -} - -function isImplementationPath( - filePath: string, - implementationPaths?: string[], -): boolean { - const lowerPath = filePath.toLowerCase(); - const implPaths = implementationPaths ?? ["src/", "lib/", "bin/"]; - if (implPaths.some((prefix) => lowerPath.startsWith(prefix.toLowerCase()))) return true; - return /(?:^|\/)(src|lib|bin|app|server|api|functions|handlers|controllers|services|supabase)\//.test(lowerPath); -} - // --------------------------------------------------------------------------- // Focused expanded terms (trace mode) // --------------------------------------------------------------------------- diff --git a/src/search/types.ts b/src/search/types.ts index 93ea8fa..f2df3cb 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -18,6 +18,29 @@ export interface SearchResult { wikiPagesUsed?: string[]; } +export interface ContextOriginalRef { + chunkId: string; + filePath: string; + startLine: number; + endLine: number; + name: string; + kind: string; + language: string; +} + +export interface ContextCompressionMetadata { + enabled: boolean; + mode: "off" | "auto" | "always"; + tokensBeforeCompression: number; + tokensAfterCompression: number; + tokensSaved: number; + savingsRatio: number; + fullChunks: number; + compressedChunks: number; + originalRefs: ContextOriginalRef[]; + strategies: Record; +} + export interface SearchOptions { limit?: number; tokenBudget?: number; @@ -39,6 +62,7 @@ export interface AssembledContext { chunks: SearchResult[]; routeStyle?: "standard" | "concept" | "flow" | "deep"; deliveryMode?: "code_context" | "summary_only"; + compression?: ContextCompressionMetadata; } export interface HookDebugRecord { @@ -72,4 +96,5 @@ export interface HookDebugRecord { dominantFamily?: string; familyConfidence?: number; deferredReason?: string; + compression?: ContextCompressionMetadata; } diff --git a/src/search/utils.ts b/src/search/utils.ts index f6f37ed..a1399b2 100644 --- a/src/search/utils.ts +++ b/src/search/utils.ts @@ -1,3 +1,6 @@ +import { escapeRegExp } from "../core/strings.js"; +import { splitIdentifierTokens } from "./targets.js"; + /** * Common English stop words shared across search modules. * Union of words from seed.ts and ranker.ts to avoid duplicate definitions. @@ -21,7 +24,6 @@ export const STOP_WORDS = new Set([ ]); const CODE_DELIMITER_RE = /[^a-z0-9_./-]+/; -const CAMEL_BOUNDARY_RE = /(?(); +const MAX_VARIANT_REGEX_CACHE = 500; + +function getVariantBoundaryRegex(lowerVariant: string): RegExp { + const cached = VARIANT_REGEX_CACHE.get(lowerVariant); + if (cached) return cached; + if (VARIANT_REGEX_CACHE.size >= MAX_VARIANT_REGEX_CACHE) VARIANT_REGEX_CACHE.clear(); + const pattern = new RegExp(`(^|[^a-z0-9])${escapeRegExp(lowerVariant)}(?=$|[^a-z0-9])`, "i"); + VARIANT_REGEX_CACHE.set(lowerVariant, pattern); + return pattern; +} + +const NORMALIZED_TEXT_CACHE = new Map(); +const MAX_NORMALIZED_TEXT_CACHE = 1000; + +function normalizeMatchText(text: string): string { + if (NORMALIZED_TEXT_CACHE.has(text)) return NORMALIZED_TEXT_CACHE.get(text)!; + if (NORMALIZED_TEXT_CACHE.size >= MAX_NORMALIZED_TEXT_CACHE) NORMALIZED_TEXT_CACHE.clear(); + const normalized = text .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .replace(/[^a-zA-Z0-9]+/g, " ") .toLowerCase() .trim(); + NORMALIZED_TEXT_CACHE.set(text, normalized); + return normalized; +} + +export function textMatchesQueryTerm(text: string, term: string): boolean { + const normalizedText = normalizeMatchText(text); const compactText = normalizedText.replace(/\s+/g, ""); return getQueryTermVariants(term).some((variant) => { const lowerVariant = variant.toLowerCase(); - const boundaryPattern = new RegExp(`(^|[^a-z0-9])${escapeRegExp(lowerVariant)}(?=$|[^a-z0-9])`, "i"); + const boundaryPattern = getVariantBoundaryRegex(lowerVariant); if (boundaryPattern.test(normalizedText)) return true; const compactVariant = lowerVariant.replace(/[^a-z0-9]+/g, ""); @@ -845,6 +862,3 @@ export function textMatchesQueryTerm(text: string, term: string): boolean { }); } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/src/storage/chunk-store.ts b/src/storage/chunk-store.ts index 37c3b4c..df3c7f4 100644 --- a/src/storage/chunk-store.ts +++ b/src/storage/chunk-store.ts @@ -34,6 +34,9 @@ export class ChunkStore { private selectLanguageCountsStmt!: Database.Statement; private selectSiblingsStmt!: Database.Statement; private selectChunksByFilePathStmt!: Database.Statement; + private selectChunksByIdsStmt!: Database.Statement; + private selectChunkScoringByIdsStmt!: Database.Statement; + private selectChunksByNamesStmt!: Database.Statement; private clearFilesStmt!: Database.Statement; private clearChunksStmt!: Database.Statement; @@ -108,6 +111,17 @@ export class ChunkStore { AND kind != 'file' AND name != '' ORDER BY start_line ASC` ); + const inPlaceholders = Array.from({ length: ChunkStore.SQLITE_PARAM_LIMIT }, () => "?").join(","); + this.selectChunksByIdsStmt = this.db.prepare( + `SELECT * FROM chunks WHERE id IN (${inPlaceholders})` + ); + this.selectChunkScoringByIdsStmt = this.db.prepare( + `SELECT id, file_path, name, kind, parent_name, indexed_at, file_mtime, start_line, end_line + FROM chunks WHERE id IN (${inPlaceholders})` + ); + this.selectChunksByNamesStmt = this.db.prepare( + `SELECT * FROM chunks WHERE name IN (${inPlaceholders})` + ); this.clearFilesStmt = this.db.prepare(`DELETE FROM files`); this.clearChunksStmt = this.db.prepare(`DELETE FROM chunks`); } @@ -154,13 +168,7 @@ export class ChunkStore { const results: ChunkScoringInfo[] = []; for (let i = 0; i < ids.length; i += ChunkStore.SQLITE_PARAM_LIMIT) { const batch = ids.slice(i, i + ChunkStore.SQLITE_PARAM_LIMIT); - const placeholders = batch.map(() => "?").join(","); - const rows = this.db - .prepare( - `SELECT id, file_path, name, kind, parent_name, indexed_at, file_mtime, start_line, end_line - FROM chunks WHERE id IN (${placeholders})` - ) - .all(...batch) as Array>; + const rows = this.selectChunkScoringByIdsStmt.all(...this.padToLimit(batch)) as Array>; results.push(...rows.map((row) => ({ id: row.id as string, filePath: row.file_path as string, @@ -181,10 +189,7 @@ export class ChunkStore { const results: StoredChunk[] = []; for (let i = 0; i < ids.length; i += ChunkStore.SQLITE_PARAM_LIMIT) { const batch = ids.slice(i, i + ChunkStore.SQLITE_PARAM_LIMIT); - const placeholders = batch.map(() => "?").join(","); - const rows = this.db - .prepare(`SELECT * FROM chunks WHERE id IN (${placeholders})`) - .all(...batch) as Array>; + const rows = this.selectChunksByIdsStmt.all(...this.padToLimit(batch)) as Array>; results.push(...rows.map((row) => this.mapRow(row))); } return results; @@ -230,10 +235,7 @@ export class ChunkStore { const results: StoredChunk[] = []; for (let i = 0; i < names.length; i += ChunkStore.SQLITE_PARAM_LIMIT) { const batch = names.slice(i, i + ChunkStore.SQLITE_PARAM_LIMIT); - const placeholders = batch.map(() => "?").join(","); - const rows = this.db - .prepare(`SELECT * FROM chunks WHERE name IN (${placeholders})`) - .all(...batch) as Array>; + const rows = this.selectChunksByNamesStmt.all(...this.padToLimit(batch)) as Array>; results.push(...rows.map((row) => this.mapRow(row))); } return results; @@ -294,6 +296,12 @@ export class ChunkStore { })(); } + private padToLimit(values: string[]): unknown[] { + const bindings: unknown[] = values.slice(); + while (bindings.length < ChunkStore.SQLITE_PARAM_LIMIT) bindings.push(null); + return bindings; + } + private mapRow(row: Record): StoredChunk { return { id: row.id as string, diff --git a/src/storage/community-store.ts b/src/storage/community-store.ts index fc19564..1c5c7f6 100644 --- a/src/storage/community-store.ts +++ b/src/storage/community-store.ts @@ -46,7 +46,10 @@ export interface TopologySnapshot { } export class CommunityStore { + private static readonly SQLITE_PARAM_LIMIT = 900; + private getCommunityForChunkStmt!: Database.Statement; + private getMembershipsByChunksStmt!: Database.Statement; private getCommunityStmt!: Database.Statement; private getAllCommunitiesStmt!: Database.Statement; private getTopSurprisesStmt!: Database.Statement; @@ -114,6 +117,10 @@ export class CommunityStore { this.getCommunityForChunkStmt = this.db.prepare( `SELECT community_id FROM community_memberships WHERE chunk_id = ?` ); + const membershipPlaceholders = Array.from({ length: CommunityStore.SQLITE_PARAM_LIMIT }, () => "?").join(","); + this.getMembershipsByChunksStmt = this.db.prepare( + `SELECT chunk_id, community_id FROM community_memberships WHERE chunk_id IN (${membershipPlaceholders})` + ); this.getCommunityStmt = this.db.prepare( `SELECT id, node_count, cohesion, label, computed_at FROM communities WHERE id = ?` ); @@ -188,6 +195,25 @@ export class CommunityStore { return row?.community_id; } + getMembershipsForChunks(chunkIds: string[]): Map { + const result = new Map(); + if (chunkIds.length === 0) return result; + const limit = CommunityStore.SQLITE_PARAM_LIMIT; + for (let i = 0; i < chunkIds.length; i += limit) { + const batch = chunkIds.slice(i, i + limit); + const bindings: unknown[] = batch.slice(); + while (bindings.length < limit) bindings.push(null); + const rows = this.getMembershipsByChunksStmt.all(...bindings) as Array<{ + chunk_id: string; + community_id: string; + }>; + for (const row of rows) { + if (!result.has(row.chunk_id)) result.set(row.chunk_id, row.community_id); + } + } + return result; + } + getCommunityInfo(communityId: string): CommunityRecord | undefined { const row = this.getCommunityStmt.get(communityId) as { id: string; node_count: number; cohesion: number; label: string | null; computed_at: string; diff --git a/src/storage/fts-store.ts b/src/storage/fts-store.ts index 080f866..c90025e 100644 --- a/src/storage/fts-store.ts +++ b/src/storage/fts-store.ts @@ -77,10 +77,13 @@ export class FTSStore { VALUES (?, ?, ?, ?, ?, ?)` ); this.removeByFileStmt = this.db.prepare(`DELETE FROM chunks_fts WHERE raw_file_path = ?`); + // Columns: id(0), name(1), file_path(2), content(3), kind(4), raw_file_path(5) + // Weight name > content > kind; zero out path/id columns so low-cardinality + // kind and path fields don't distort BM25 relevance. this.searchStmt = this.db.prepare( `SELECT id, rank FROM chunks_fts WHERE chunks_fts MATCH ? - ORDER BY rank + ORDER BY bm25(chunks_fts, 0, 10, 0, 5, 0.1, 0) LIMIT ?` ); this.countStmt = this.db.prepare( @@ -258,7 +261,8 @@ export class FTSStore { } this._dfCache.set(term, row.cnt); return row.cnt; - } catch { + } catch (err) { + getLogger().warn({ err, term }, "FTS getDocFreq failed; treating term as absent"); return 0; } } @@ -270,7 +274,8 @@ export class FTSStore { rank: number; }>; return rows.map((r) => ({ id: r.id, rank: r.rank })); - } catch { + } catch (err) { + getLogger().warn({ err, query: ftsQuery }, "FTS query failed; returning empty results"); return []; } } diff --git a/src/storage/memory-store.ts b/src/storage/memory-store.ts index 9d59d9b..04f8a4a 100644 --- a/src/storage/memory-store.ts +++ b/src/storage/memory-store.ts @@ -12,6 +12,7 @@ import { resolve, dirname } from "path"; import { mkdirSync } from "fs"; import type Database from "better-sqlite3"; import { openSqliteWithRecovery } from "./sqlite-utils.js"; +import { getLogger } from "../core/logger.js"; import type { Memory, MemoryCompactionOptions, @@ -581,8 +582,8 @@ export class MemoryStore { rank: number; }>; return rows.map((r) => ({ id: r.id, rank: r.rank })); - } catch { - // FTS query syntax error — return empty + } catch (err) { + getLogger().warn({ err }, "Memory FTS query failed; returning empty results"); return []; } } @@ -702,7 +703,10 @@ export class MemoryStore { const bConfidence = b.confidence ?? 0; if (bConfidence !== aConfidence) return bConfidence - aConfidence; if (b.accessCount !== a.accessCount) return b.accessCount - a.accessCount; - return new Date(b.indexedAt).getTime() - new Date(a.indexedAt).getTime(); + // Guard malformed timestamps: non-finite → epoch (0) so NaN never poisons the sort. + const aIndexed = new Date(a.indexedAt).getTime(); + const bIndexed = new Date(b.indexedAt).getTime(); + return (Number.isFinite(bIndexed) ? bIndexed : 0) - (Number.isFinite(aIndexed) ? aIndexed : 0); }); const keep = sorted[0]; @@ -724,7 +728,10 @@ export class MemoryStore { if (memory.status !== "active") continue; if (keepPinned && memory.pinned) continue; if (memory.class !== "episode") continue; - const ageMs = now - new Date(memory.indexedAt).getTime(); + // Guard malformed timestamps: treat non-finite indexedAt as age 0 (skip), + // so a corrupt timestamp can't force-archive an episode. + const indexedAtMs = new Date(memory.indexedAt).getTime(); + const ageMs = Number.isFinite(indexedAtMs) ? now - indexedAtMs : 0; if (ageMs <= archiveAfterMs) continue; this.archive(memory.id, `Archived episode older than ${archiveEpisodeOlderThanDays} days`); archived++; diff --git a/src/storage/metadata-store.ts b/src/storage/metadata-store.ts index de78e78..4e1c8c6 100644 --- a/src/storage/metadata-store.ts +++ b/src/storage/metadata-store.ts @@ -96,10 +96,12 @@ export class MetadataStore { removeFile(path: string): void { this.db.transaction(() => { + const chunkIds = this.getChunkIdsForFile(path); this.chunks.removeFile(path); this.callEdges.removeCallEdgesForFile(path); this.imports.removeImportsForFile(path); this.semantic.removeByFile(path); + this.cascadeDeleteFileGraphData(path, chunkIds); })(); } @@ -107,10 +109,12 @@ export class MetadataStore { if (paths.length === 0) return; this.db.transaction(() => { for (const path of paths) { + const chunkIds = this.getChunkIdsForFile(path); this.chunks.removeFile(path); this.callEdges.removeCallEdgesForFile(path); this.imports.removeImportsForFile(path); this.semantic.removeByFile(path); + this.cascadeDeleteFileGraphData(path, chunkIds); } })(); } @@ -121,11 +125,42 @@ export class MetadataStore { removeChunksForFile(filePath: string): void { this.db.transaction(() => { + const chunkIds = this.getChunkIdsForFile(filePath); this.chunks.removeChunksForFile(filePath); this.semantic.removeByFile(filePath); + this.cascadeDeleteFileGraphData(filePath, chunkIds); })(); } + // Capture chunk ids for a file before the chunks rows are deleted, so graph + // rows keyed by chunk_id can be cleaned up in the same transaction. + private getChunkIdsForFile(filePath: string): string[] { + const rows = this.db + .prepare(`SELECT id FROM chunks WHERE file_path = ?`) + .all(filePath) as Array<{ id: string }>; + return rows.map((r) => r.id); + } + + // Cascade-delete targets (by file_path) and topology rows (by chunk_id) that + // would otherwise be orphaned when a file/chunks are removed. Must run inside + // the caller's transaction. target_aliases cascade via FK ON DELETE CASCADE. + private cascadeDeleteFileGraphData(filePath: string, chunkIds: string[]): void { + this.db.prepare(`DELETE FROM targets WHERE file_path = ?`).run(filePath); + if (chunkIds.length === 0) return; + const placeholders = chunkIds.map(() => "?").join(","); + this.db + .prepare(`DELETE FROM community_memberships WHERE chunk_id IN (${placeholders})`) + .run(...chunkIds); + this.db + .prepare(`DELETE FROM community_god_nodes WHERE chunk_id IN (${placeholders})`) + .run(...chunkIds); + this.db + .prepare( + `DELETE FROM community_surprises WHERE source_chunk_id IN (${placeholders}) OR target_chunk_id IN (${placeholders})` + ) + .run(...chunkIds, ...chunkIds); + } + getChunk(id: string): StoredChunk | undefined { return this.chunks.getChunk(id); } @@ -357,6 +392,12 @@ export class MetadataStore { return this.communities.getCommunityForChunk(chunkId); } + /** Bulk variant of getCommunityForChunk — resolves many chunk ids in batched + * queries (vs N roundtrips). Used by the visualize/lens hot path. */ + getMembershipsForChunks(chunkIds: string[]): Map { + return this.communities.getMembershipsForChunks(chunkIds); + } + getCommunityInfo(communityId: string): CommunityRecord | undefined { return this.communities.getCommunityInfo(communityId); } diff --git a/src/storage/semantic-store.ts b/src/storage/semantic-store.ts index 6e2e074..6e77cf3 100644 --- a/src/storage/semantic-store.ts +++ b/src/storage/semantic-store.ts @@ -15,6 +15,7 @@ export class SemanticStore { private clearChunkFeaturesStmt!: Database.Statement; private clearFileFeaturesStmt!: Database.Statement; private clearChunkTagsStmt!: Database.Statement; + private readonly inStatementsByTemplate = new Map(); constructor(private readonly db: Database.Database) {} @@ -281,14 +282,25 @@ export class SemanticStore { sqlTemplate: string ): Array> { const SQLITE_PARAM_LIMIT = 900; + const stmt = this.getCachedInStatement(sqlTemplate); const results: Array> = []; for (let i = 0; i < ids.length; i += SQLITE_PARAM_LIMIT) { const batch = ids.slice(i, i + SQLITE_PARAM_LIMIT); - const placeholders = batch.map(() => "?").join(","); - const sql = sqlTemplate.replace("__IDS__", placeholders); - const rows = this.db.prepare(sql).all(...batch) as Array>; + const bindings: unknown[] = batch.slice(); + while (bindings.length < SQLITE_PARAM_LIMIT) bindings.push(null); + const rows = stmt.all(...bindings) as Array>; results.push(...rows); } return results; } + + private getCachedInStatement(sqlTemplate: string): Database.Statement { + let stmt = this.inStatementsByTemplate.get(sqlTemplate); + if (!stmt) { + const placeholders = Array.from({ length: 900 }, () => "?").join(","); + stmt = this.db.prepare(sqlTemplate.replace("__IDS__", placeholders)); + this.inStatementsByTemplate.set(sqlTemplate, stmt); + } + return stmt; + } } diff --git a/src/storage/target-store.ts b/src/storage/target-store.ts index 689a294..08c57f6 100644 --- a/src/storage/target-store.ts +++ b/src/storage/target-store.ts @@ -7,10 +7,17 @@ import type { } from "./types.js"; export class TargetStore { + private static readonly SQLITE_PARAM_LIMIT = 900; + private replaceTargetStmt!: Database.Statement; private replaceAliasStmt!: Database.Statement; private deleteTargetsStmt!: Database.Statement; private deleteAliasesStmt!: Database.Statement; + private selectTargetByIdStmt!: Database.Statement; + private selectTargetsByFilePathStmt!: Database.Statement; + private selectTargetsByIdsStmt!: Database.Statement; + private selectTargetsBySubsystemStmt!: Database.Statement; + private selectAliasesByNormalizedStmt!: Database.Statement; constructor(private readonly db: Database.Database) {} @@ -56,6 +63,30 @@ export class TargetStore { ); this.deleteTargetsStmt = this.db.prepare(`DELETE FROM targets`); this.deleteAliasesStmt = this.db.prepare(`DELETE FROM target_aliases`); + + const inPlaceholders = Array.from({ length: TargetStore.SQLITE_PARAM_LIMIT }, () => "?").join(","); + this.selectTargetByIdStmt = this.db.prepare(`SELECT * FROM targets WHERE id = ?`); + this.selectTargetsByFilePathStmt = this.db.prepare( + `SELECT * FROM targets WHERE file_path = ? ORDER BY confidence DESC` + ); + this.selectTargetsByIdsStmt = this.db.prepare( + `SELECT * FROM targets WHERE id IN (${inPlaceholders})` + ); + this.selectTargetsBySubsystemStmt = this.db.prepare( + `SELECT * FROM targets WHERE subsystem IN (${inPlaceholders}) ORDER BY confidence DESC` + ); + this.selectAliasesByNormalizedStmt = this.db.prepare( + `SELECT + t.*, + a.alias, + a.normalized_alias, + a.source, + a.weight + FROM target_aliases a + JOIN targets t ON t.id = a.target_id + WHERE a.normalized_alias IN (${inPlaceholders}) + ORDER BY a.weight DESC, t.confidence DESC, LENGTH(a.normalized_alias) DESC` + ); } replaceAll(targets: StoredTarget[], aliases: StoredTargetAlias[]): void { @@ -87,17 +118,19 @@ export class TargetStore { } findTargetById(id: string): StoredTarget | undefined { - const row = this.db.prepare(`SELECT * FROM targets WHERE id = ?`).get(id) as Record | undefined; + const row = this.selectTargetByIdStmt.get(id) as Record | undefined; return row ? this.mapTarget(row) : undefined; } getTargetsByIds(ids: string[]): StoredTarget[] { if (ids.length === 0) return []; - const placeholders = ids.map(() => "?").join(","); - const rows = this.db - .prepare(`SELECT * FROM targets WHERE id IN (${placeholders})`) - .all(...ids) as Array>; - return rows.map((row) => this.mapTarget(row)); + const results: StoredTarget[] = []; + for (let i = 0; i < ids.length; i += TargetStore.SQLITE_PARAM_LIMIT) { + const batch = ids.slice(i, i + TargetStore.SQLITE_PARAM_LIMIT); + const rows = this.selectTargetsByIdsStmt.all(...this.padToLimit(batch)) as Array>; + results.push(...rows.map((row) => this.mapTarget(row))); + } + return results; } clearAll(): void { @@ -113,58 +146,56 @@ export class TargetStore { kinds?: TargetKind[] ): ResolvedTargetAliasHit[] { if (normalizedAliases.length === 0) return []; - const aliasPlaceholders = normalizedAliases.map(() => "?").join(","); - const kindClause = kinds && kinds.length > 0 - ? ` AND t.kind IN (${kinds.map(() => "?").join(",")})` - : ""; - const rows = this.db - .prepare( - `SELECT - t.*, - a.alias, - a.normalized_alias, - a.source, - a.weight - FROM target_aliases a - JOIN targets t ON t.id = a.target_id - WHERE a.normalized_alias IN (${aliasPlaceholders})${kindClause} - ORDER BY a.weight DESC, t.confidence DESC, LENGTH(a.normalized_alias) DESC - LIMIT ?` - ) - .all( - ...normalizedAliases, - ...(kinds ?? []), - limit - ) as Array>; - - return rows.map((row) => ({ - target: this.mapTarget(row), - alias: row.alias as string, - normalizedAlias: row.normalized_alias as string, - source: row.source as StoredTargetAlias["source"], - weight: row.weight as number, - })); + const kindSet = kinds && kinds.length > 0 ? new Set(kinds) : null; + const rows: Array> = []; + for (let i = 0; i < normalizedAliases.length; i += TargetStore.SQLITE_PARAM_LIMIT) { + const batch = normalizedAliases.slice(i, i + TargetStore.SQLITE_PARAM_LIMIT); + const batchRows = this.selectAliasesByNormalizedStmt.all(...this.padToLimit(batch)) as Array>; + rows.push(...batchRows); + } + + const hits: ResolvedTargetAliasHit[] = []; + for (const row of rows) { + const target = this.mapTarget(row); + if (kindSet && !kindSet.has(target.kind)) continue; + hits.push({ + target, + alias: row.alias as string, + normalizedAlias: row.normalized_alias as string, + source: row.source as StoredTargetAlias["source"], + weight: row.weight as number, + }); + } + + hits.sort((a, b) => { + if (b.weight !== a.weight) return b.weight - a.weight; + if (b.target.confidence !== a.target.confidence) return b.target.confidence - a.target.confidence; + return b.normalizedAlias.length - a.normalizedAlias.length; + }); + return hits.slice(0, limit); } findTargetsByFilePath(filePath: string): StoredTarget[] { - const rows = this.db - .prepare(`SELECT * FROM targets WHERE file_path = ? ORDER BY confidence DESC`) - .all(filePath) as Array>; + const rows = this.selectTargetsByFilePathStmt.all(filePath) as Array>; return rows.map((row) => this.mapTarget(row)); } findTargetsBySubsystem(subsystems: string[], limit = 25): StoredTarget[] { if (subsystems.length === 0) return []; - const placeholders = subsystems.map(() => "?").join(","); - const rows = this.db - .prepare( - `SELECT * FROM targets - WHERE subsystem IN (${placeholders}) - ORDER BY confidence DESC - LIMIT ?` - ) - .all(...subsystems, limit) as Array>; - return rows.map((row) => this.mapTarget(row)); + const results: StoredTarget[] = []; + for (let i = 0; i < subsystems.length; i += TargetStore.SQLITE_PARAM_LIMIT) { + const batch = subsystems.slice(i, i + TargetStore.SQLITE_PARAM_LIMIT); + const rows = this.selectTargetsBySubsystemStmt.all(...this.padToLimit(batch)) as Array>; + results.push(...rows.map((row) => this.mapTarget(row))); + } + results.sort((a, b) => b.confidence - a.confidence); + return results.slice(0, limit); + } + + private padToLimit(values: string[]): unknown[] { + const bindings: unknown[] = values.slice(); + while (bindings.length < TargetStore.SQLITE_PARAM_LIMIT) bindings.push(null); + return bindings; } private mapTarget(row: Record): StoredTarget { diff --git a/src/storage/vector-store.ts b/src/storage/vector-store.ts index 7bc1494..0b44415 100644 --- a/src/storage/vector-store.ts +++ b/src/storage/vector-store.ts @@ -203,7 +203,27 @@ export class VectorStore { getLogger().debug({ err }, "VectorStore.upsert: failed to delete existing records"); } - await table.add(normalizedRecords); + // LanceDB has no multi-op transaction; if add fails after delete, the + // records are gone. Attempt a restore re-add, log loudly, then re-throw + // so the caller's merkle/retry logic handles it (failing is safer than + // silently losing data). + try { + await table.add(normalizedRecords); + } catch (addError) { + getLogger().error( + { err: addError }, + "VectorStore.upsert: add failed after delete; attempting restore re-add" + ); + try { + await table.add(normalizedRecords); + } catch (restoreErr) { + getLogger().error( + { err: restoreErr }, + "VectorStore.upsert: restore re-add also failed; records may be lost" + ); + } + throw addError; + } } // Build ANN index once table is large enough (skip for empty-vector records) @@ -270,15 +290,19 @@ export class VectorStore { .limit(limit) .toArray(); - return results.map((r) => ({ - id: r.id as string, - score: Math.max(0, 1 - ((r._distance as number) ?? 0)), // Convert distance to similarity, clamped - filePath: r.filePath as string, - name: r.name as string, - kind: r.kind as string, - startLine: r.startLine as number, - endLine: r.endLine as number, - })); + return results.map((r) => { + const distance = r._distance as number; + return { + id: r.id as string, + // LanceDB index default metric is L2 (squared): similarity = 1/(1+d) + score: Number.isFinite(distance) && distance >= 0 ? 1 / (1 + distance) : 0, + filePath: r.filePath as string, + name: r.name as string, + kind: r.kind as string, + startLine: r.startLine as number, + endLine: r.endLine as number, + }; + }); } catch (err) { if (this.isCorruptionError(err)) { await this.recoverFromCorruption(); diff --git a/src/visualize/data-extractor.ts b/src/visualize/data-extractor.ts index ea94dc8..06739d9 100644 --- a/src/visualize/data-extractor.ts +++ b/src/visualize/data-extractor.ts @@ -7,8 +7,9 @@ import { basename } from "path"; import { readFileSync } from "fs"; import type { MetadataStore } from "../storage/metadata-store.js"; import type { MemoryStore } from "../storage/memory-store.js"; -import { buildAdjacencyGraph } from "../analysis/graph-builder.js"; -import { buildProductAreas, deriveBusinessDisplayName, evaluateBusinessPresentation } from "../business/product-areas.js"; +import { buildAdjacencyGraph, type GraphNode } from "../analysis/graph-builder.js"; +import { buildProductAreas, deriveBusinessDisplayName, deriveBusinessDisplaySummary, evaluateBusinessPresentation } from "../business/product-areas.js"; +import { slugify, extractSectionText, extractBulletSection, humanizeSlug } from "../core/strings.js"; import type { DashboardData, DashboardMeta, @@ -55,6 +56,13 @@ export function extractDashboardData( ? buildAdjacencyGraph(metadata) : { nodes: new Map(), adjacency: new Map(), nodeCount: 0, edgeCount: 0 }; + // Index graph nodes by name+file (first match wins, mirroring Array.find order) + const nodeByNameFile = new Map(); + for (const gn of graph.nodes.values()) { + const key = `${gn.name}\x00${gn.filePath}`; + if (!nodeByNameFile.has(key)) nodeByNameFile.set(key, gn); + } + // --- Communities --- const rawCommunities = metadata.getAllCommunities(maxCommunities); const allChunks = includeGraphDetails ? metadata.getAllChunks() : []; @@ -62,12 +70,22 @@ export function extractDashboardData( // Build chunk→community and community→chunks maps (use chunk.id for DB lookup) const chunkCommunityMap = new Map(); const communityChunks = new Map>(); - for (const chunk of allChunks) { - const cid = metadata.getCommunityForChunk(chunk.id); - if (cid) { - chunkCommunityMap.set(chunk.name, cid); - if (!communityChunks.has(cid)) communityChunks.set(cid, []); - communityChunks.get(cid)!.push(chunk); + if (allChunks.length > 0) { + const bulkMemberships = (metadata as { + getMembershipsForChunks?: (chunkIds: string[]) => Map; + }).getMembershipsForChunks; + const membershipMap = bulkMemberships + ? bulkMemberships(allChunks.map((chunk) => chunk.id)) + : null; + for (const chunk of allChunks) { + const cid = membershipMap + ? membershipMap.get(chunk.id) + : metadata.getCommunityForChunk(chunk.id); + if (cid) { + chunkCommunityMap.set(chunk.name, cid); + if (!communityChunks.has(cid)) communityChunks.set(cid, []); + communityChunks.get(cid)!.push(chunk); + } } } @@ -112,10 +130,7 @@ export function extractDashboardData( name: m.name, kind: m.kind, filePath: m.filePath, - degree: graph.nodes.get( - // Find the graph node by name match - Array.from(graph.nodes.values()).find((gn) => gn.name === m.name && gn.filePath === m.filePath)?.chunkId ?? "" - )?.degree ?? 0, + degree: nodeByNameFile.get(`${m.name}\x00${m.filePath}`)?.degree ?? 0, })) .sort((a, b) => b.degree - a.degree); @@ -206,8 +221,8 @@ export function extractDashboardData( const rawSurprises = metadata.getTopSurprises(maxSurprises); const surprises: SurpriseViz[] = rawSurprises.map((s) => { // Resolve chunk names and files - const srcNode = Array.from(graph.nodes.values()).find((n) => n.chunkId === s.sourceChunkId); - const tgtNode = Array.from(graph.nodes.values()).find((n) => n.chunkId === s.targetChunkId); + const srcNode = graph.nodes.get(s.sourceChunkId); + const tgtNode = graph.nodes.get(s.targetChunkId); return { sourceName: srcNode?.name ?? s.sourceChunkId, @@ -254,7 +269,7 @@ export function extractDashboardData( relatedSymbols: page.relatedSymbols ?? [], relatedFiles: page.relatedFiles ?? [], confidence: page.confidence ?? 0, - sourceCommit: "", // extracted from frontmatter on disk, not stored in DB + sourceCommit: readFrontmatterSourceCommit(page.filePath), }; wikiPages.push(wikiPage); @@ -269,6 +284,9 @@ export function extractDashboardData( } const productAreas = buildProductAreas(businessPages); + // Set of wiki node names for O(1) edge endpoint filtering + const wikiNodeNames = new Set(wikiGraphNodes.map((n) => n.name)); + // --- Meta --- const meta: DashboardMeta = { projectName: basename(opts.projectRoot ?? process.cwd()), @@ -300,8 +318,7 @@ export function extractDashboardData( wikiPages, wikiGraphNodes, wikiGraphEdges: wikiGraphEdges.filter((edge) => - wikiGraphNodes.some((node) => node.name === edge.source) && - wikiGraphNodes.some((node) => node.name === edge.target) + wikiNodeNames.has(edge.source) && wikiNodeNames.has(edge.target) ), businessPages, productAreas, @@ -347,6 +364,16 @@ function readFrontmatterPageType(filePath: string): string | null { } } +function readFrontmatterSourceCommit(filePath: string): string { + try { + const raw = readFileSync(filePath, "utf-8"); + const match = raw.match(/sourceCommit:\s*"?([a-f0-9]+)"?/); + return match?.[1] ?? ""; + } catch { + return ""; + } +} + function toBusinessPageViz(page: WikiPageViz): BusinessPageViz { const capability = firstNonEmpty(extractSectionText(page.content, "Capability"), humanizeSlug(page.name)); const businessTerms = extractBulletSection(page.content, "Business terms"); @@ -360,7 +387,7 @@ function toBusinessPageViz(page: WikiPageViz): BusinessPageViz { dataConcepts, supportingSymbols: page.relatedSymbols, }); - const displaySummary = deriveBusinessDisplaySummary(displayName, page.summary, businessOutcome, userActions, dataConcepts); + const displaySummary = deriveBusinessDisplaySummary({ displayName, summary: page.summary, businessOutcome, userActions, dataConcepts }); const presentation = evaluateBusinessPresentation({ name: page.name, capability, @@ -403,85 +430,7 @@ function toBusinessPageViz(page: WikiPageViz): BusinessPageViz { }; } -function deriveBusinessDisplaySummary( - displayName: string, - summary: string, - businessOutcome: string, - userActions: string[], - dataConcepts: string[] -): string { - const outcome = cleanBusinessText(businessOutcome); - if (outcome) return `${displayName}: ${outcome}`; - const action = userActions.map(cleanBusinessText).find(Boolean); - if (action) return `${displayName}: Users can ${lowercaseSentence(action)}.`; - const cleanSummary = cleanBusinessText(summary); - if (cleanSummary) return cleanSummary; - const concepts = dataConcepts.map(cleanBusinessText).filter(Boolean).slice(0, 3); - if (concepts.length > 0) return `${displayName}: Supports ${concepts.join(", ")}.`; - return `${displayName}: Product behavior inferred from code evidence.`; -} - -function cleanBusinessText(value: string): string { - return value - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") - .replace(/[_-]+/g, " ") - .replace(/`[^`\s]*\.(?:ts|tsx|js|jsx|mjs|cjs|sql|json|mdx?|ya?ml)(?::\d+(?:-\d+)?)?`/gi, "the product implementation") - .replace(/\b(?:src|apps|packages|backend|frontend)\/[A-Za-z0-9_./-]+/gi, "the product implementation") - .replace(/\b(?:backend|frontend):\s*/gi, "") - .replace(/\b[A-Za-z0-9_-]+\.(?:ts|tsx|js|jsx|mjs|cjs|sql|json|mdx?|ya?ml)\b/gi, "the product implementation") - .replace(/\s+/g, " ") - .trim(); -} - -function lowercaseSentence(value: string): string { - if (!value) return value; - return `${value[0]!.toLowerCase()}${value.slice(1).replace(/\.$/, "")}`; -} - -function extractSectionText(content: string, heading: string): string { - const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); - if (!match?.[1]) return ""; - return match[1] - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("- ")) - .join(" ") - .trim(); -} - -function extractBulletSection(content: string, heading: string): string[] { - const match = content.match(new RegExp(`## ${escapeRegExp(heading)}\\n([\\s\\S]*?)(?=\\n## |$)`)); - if (!match?.[1]) return []; - return match[1] - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("- ")) - .map((line) => line.slice(2).trim()) - .filter((line) => line.length > 0); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function humanizeSlug(name: string): string { - return name - .replace(/^business-/, "") - .split("-") - .filter(Boolean) - .map((part) => part[0] ? `${part[0].toUpperCase()}${part.slice(1)}` : part) - .join(" "); -} - function firstNonEmpty(primary: string, fallback: string): string { return primary.trim() || fallback; } -function slugify(label: string): string { - return label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); -} diff --git a/src/wiki/auto-capture.ts b/src/wiki/auto-capture.ts index 2fd87ff..82d59eb 100644 --- a/src/wiki/auto-capture.ts +++ b/src/wiki/auto-capture.ts @@ -6,6 +6,7 @@ */ import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; import { writeManagedMemoryFile, safeMemorySlug } from "../memory/files.js"; import type { MemoryIndexer } from "../memory/indexer.js"; import type { MemoryStore } from "../storage/memory-store.js"; @@ -51,8 +52,8 @@ export class WikiAutoCapture { // Check if page already exists and is fresh enough const existing = this.store.getByName(slug); - if (existing) { - const existingCommit = this.extractSourceCommit(existing.content); + if (existing && sourceCommit && existing.filePath) { + const existingCommit = this.extractSourceCommitFromFile(existing.filePath); if (existingCommit === sourceCommit) return null; // No code changes since last capture } @@ -110,8 +111,8 @@ export class WikiAutoCapture { const sourceCommit = this.getHeadCommit(); const existing = this.store.getByName(slug); - if (existing) { - const existingCommit = this.extractSourceCommit(existing.content); + if (existing && sourceCommit && existing.filePath) { + const existingCommit = this.extractSourceCommitFromFile(existing.filePath); if (existingCommit === sourceCommit) return null; } @@ -165,8 +166,15 @@ export class WikiAutoCapture { } } - private extractSourceCommit(content: string): string | undefined { - const match = content.match(/sourceCommit:\s*"?([a-f0-9]+)"?/); - return match?.[1]; + // Read sourceCommit from the raw memory file on disk (frontmatter is stripped + // from the in-memory content, so scraping existing.content never matched). + private extractSourceCommitFromFile(filePath: string): string | undefined { + try { + const raw = readFileSync(filePath, "utf-8"); + const match = raw.match(/sourceCommit:\s*"?([a-f0-9]+)"?/); + return match?.[1]; + } catch { + return undefined; + } } } diff --git a/src/wiki/business.ts b/src/wiki/business.ts index 79ef62a..67a50d8 100644 --- a/src/wiki/business.ts +++ b/src/wiki/business.ts @@ -1,5 +1,6 @@ import { basename } from "path"; import type { CommunityRecord, GodNodeRecord, SurpriseRecord } from "../storage/metadata-store.js"; +import { slugify } from "../core/strings.js"; export interface BusinessCommunityMember { name: string; @@ -911,14 +912,6 @@ function isLowSignalSymbol(name: string): boolean { return /^(test|describe|beforeeach|aftereach|render|forwardref_handler|test_handler|describe_handler)$/i.test(name); } -function slugify(label: string): string { - return label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); -} - function unique(values: string[]): string[] { return Array.from(new Set(values.filter(Boolean))); } diff --git a/src/wiki/generator.ts b/src/wiki/generator.ts index 57eabb8..bc9a881 100644 --- a/src/wiki/generator.ts +++ b/src/wiki/generator.ts @@ -17,6 +17,7 @@ import { writeManagedMemoryFile } from "../memory/files.js"; import { resolveAllLinks } from "./links.js"; import { getLogger } from "../core/logger.js"; import { buildBusinessPages } from "./business.js"; +import { slugify } from "../core/strings.js"; const WIKI_GENERATOR_VERSION = "deterministic-wiki-v3-business-presentation"; const GENERATED_WIKI_PREFIXES = /^(business-|community-|hub-|surprises-)/; @@ -145,7 +146,7 @@ export class WikiGenerator { members.length > 30 ? `\n_...and ${members.length - 30} more_` : "", ].join("\n"); - const writeResult = this.writePage(slug, { + const writeResult = await this.writePage(slug, { description: `Code community: ${community.label} (${community.nodeCount} nodes, cohesion ${community.cohesion.toFixed(2)})`, pageType: "community", content, @@ -186,7 +187,7 @@ export class WikiGenerator { const links = communityLink ? [communityLink] : []; - const writeResult = this.writePage(slug, { + const writeResult = await this.writePage(slug, { description: `Hub node: ${hub.name} (${hub.degree} edges) in ${hub.filePath}`, pageType: "hub", content, @@ -218,7 +219,7 @@ export class WikiGenerator { ); for (const businessPage of businessPages) { - const writeResult = this.writePage(businessPage.slug, { + const writeResult = await this.writePage(businessPage.slug, { description: businessPage.description, pageType: "business", content: businessPage.content, @@ -267,7 +268,7 @@ export class WikiGenerator { surpriseLines, ].join("\n"); - const writeResult = this.writePage(slug, { + const writeResult = await this.writePage(slug, { description: `${surprises.length} surprising cross-module connections in the codebase`, pageType: "module", content, @@ -298,7 +299,7 @@ export class WikiGenerator { return result; } - private writePage( + private async writePage( slug: string, input: { description: string; @@ -311,7 +312,7 @@ export class WikiGenerator { sourceCommit: string; confidence: number; } - ): "written" | "updated" | "skipped" { + ): Promise<"written" | "updated" | "skipped"> { const fingerprint = this.pageFingerprint(input); // Skip write only if both the source commit and generated output match. @@ -348,8 +349,9 @@ export class WikiGenerator { content: input.content, }); - // Index the file and update wiki links - this.indexer.indexFile(filePath).catch((err) => + // Index the file and update wiki links — await so indexing completes before + // removeStaleGeneratedPages() runs, avoiding a deletion-vs-indexing race. + await this.indexer.indexFile(filePath).catch((err) => getLogger().warn({ err, slug }, "Wiki page indexing failed") ); this.memoryStore.setWikiLinks(slug, allLinks); @@ -428,14 +430,6 @@ export class WikiGenerator { } } -function slugify(label: string): string { - return label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80); -} - function uniqueValues(arr: string[]): string[] { return Array.from(new Set(arr)); } diff --git a/test/analysis/call-graph-deep-ast.test.ts b/test/analysis/call-graph-deep-ast.test.ts new file mode 100644 index 0000000..e6d8568 --- /dev/null +++ b/test/analysis/call-graph-deep-ast.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "path"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { initTreeSitter, getLanguage, createParser } from "../../src/parser/tree-sitter.js"; +import { extractCallEdges } from "../../src/analysis/call-graph.js"; +import { chunkFileWithCalls } from "../../src/parser/chunker.js"; +import type Parser from "web-tree-sitter"; + +// regression: extractCallEdges must walk deeply nested ASTs without overflowing +// the V8 call stack. walkForCallNodes is now iterative (explicit stack), so it +// handles depths (10k+) that previously caused "Maximum call stack size exceeded". + +function buildNestedCallSource(depth: number): string { + return `function deep() {\n ${"a(".repeat(depth)}0${")".repeat(depth)};\n}\n`; +} + +async function parseSource( + source: string +): Promise<{ tree: Parser.Tree; parser: Parser }> { + await initTreeSitter(); + const lang = await getLanguage("typescript"); + if (!lang) throw new Error("typescript grammar not available"); + const parser = createParser(lang); + const tree = parser.parse(source); + if (!tree) { + parser.delete(); + throw new Error("parser returned null tree"); + } + return { tree, parser }; +} + +function findFirstNodeByType( + node: Parser.SyntaxNode, + type: string +): Parser.SyntaxNode | undefined { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) { + const found = findFirstNodeByType(child, type); + if (found) return found; + } + } + return undefined; +} + +describe("call-graph deep AST handling", () => { + it("completes without RangeError on a 5000-deep nested call chain", async () => { + // 5000 is above V8's default recursion limit (~3500-4000 for these frames), + // so a recursive walk would overflow; the iterative stack handles it. + // (Depth is bounded for parse-time — tree-sitter parses deep nests slowly.) + const depth = 5000; + const source = buildNestedCallSource(depth); + const { tree, parser } = await parseSource(source); + + try { + const fnNode = findFirstNodeByType(tree.rootNode, "function_declaration"); + expect(fnNode).toBeDefined(); + + // regression: must not throw "Maximum call stack size exceeded". + expect(() => { + extractCallEdges(fnNode!, "chunk-deep", "src/deep.ts", [ + "call_expression", + "new_expression", + ]); + }).not.toThrow(); + } finally { + tree.delete(); + parser.delete(); + } + }, 90000); + + it("extracts call edges from a moderately nested chain", async () => { + const depth = 100; + const source = buildNestedCallSource(depth); + const { tree, parser } = await parseSource(source); + + try { + const fnNode = findFirstNodeByType(tree.rootNode, "function_declaration"); + expect(fnNode).toBeDefined(); + + const edges = extractCallEdges(fnNode!, "chunk-mid", "src/mid.ts", [ + "call_expression", + "new_expression", + ]); + + // Each nested a() is a unique call edge (deduped by receiver:targetName:callType) + // All are "a" with no receiver, so they dedupe to a single edge. + expect(edges.length).toBeGreaterThanOrEqual(1); + expect(edges[0].targetName).toBe("a"); + } finally { + tree.delete(); + parser.delete(); + } + }, 15000); + + it("chunkFileWithCalls handles a deeply nested source file without crashing", async () => { + const depth = 500; + const dir = mkdtempSync(resolve(tmpdir(), "callgraph-deep-")); + const filePath = resolve(dir, "deep.ts"); + writeFileSync(filePath, buildNestedCallSource(depth)); + + try { + const result = await chunkFileWithCalls(filePath, dir); + + expect(result.chunks.length).toBeGreaterThanOrEqual(1); + // The deeply nested a() calls should produce at least one call edge + if (result.callEdges.length > 0) { + expect(result.callEdges.some((e) => e.targetName === "a")).toBe(true); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 30000); +}); diff --git a/test/analysis/semantic-features-predicate.test.ts b/test/analysis/semantic-features-predicate.test.ts new file mode 100644 index 0000000..00fe95e --- /dev/null +++ b/test/analysis/semantic-features-predicate.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { extractSemanticFeatures } from "../../src/analysis/semantic-features.js"; +import type { CodeChunk } from "../../src/parser/types.js"; +import type { CallEdge } from "../../src/analysis/call-graph.js"; + +type FeatureChunk = CodeChunk & { indexedAt: string; fileMtime?: string }; + +function makeFeatureChunk( + overrides: Partial = {} +): FeatureChunk { + return { + id: "chunk-1", + filePath: "src/predicates.ts", + name: "someFunction", + kind: "function", + content: "function someFunction() { return true; }", + startLine: 1, + endLine: 5, + language: "typescript", + indexedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeCallEdge(overrides: Partial = {}): CallEdge { + return { + sourceChunkId: "caller-1", + targetName: "foo", + callType: "call", + filePath: "src/caller.ts", + line: 1, + ...overrides, + }; +} + +describe("semantic-features predicate boundary", () => { + // regression: isPredicate must require a camelCase/underscore boundary + // after the prefix so island/checkout/canvas are not classified as predicates + it("classifies isAuthenticated as a predicate but not island/checkout/canvas", () => { + const chunks: FeatureChunk[] = [ + makeFeatureChunk({ + id: "chk-auth", + name: "isAuthenticated", + content: "function isAuthenticated(req): boolean { if (req.user) return true; return false; }", + }), + makeFeatureChunk({ + id: "chk-island", + name: "island", + content: "function island(geometry) { return geometry.area; }", + }), + makeFeatureChunk({ + id: "chk-checkout", + name: "checkout", + content: "function checkout(cart) { return cart.total; }", + }), + makeFeatureChunk({ + id: "chk-canvas", + name: "canvas", + content: "function canvas(width, height) { return { width, height }; }", + }), + ]; + + const { chunkFeatures } = extractSemanticFeatures(chunks, [], new Map()); + + const auth = chunkFeatures.find((f) => f.chunkId === "chk-auth")!; + const island = chunkFeatures.find((f) => f.chunkId === "chk-island")!; + const checkout = chunkFeatures.find((f) => f.chunkId === "chk-checkout")!; + const canvas = chunkFeatures.find((f) => f.chunkId === "chk-canvas")!; + + expect(auth.isPredicate).toBe(true); + expect(island.isPredicate).toBe(false); + expect(checkout.isPredicate).toBe(false); + expect(canvas.isPredicate).toBe(false); + }); + + it("counts only isAuthenticated in file-level predicateCount", () => { + const chunks: FeatureChunk[] = [ + makeFeatureChunk({ + id: "chk-auth", + name: "isAuthenticated", + filePath: "src/auth.ts", + content: "function isAuthenticated(): boolean { return true; }", + }), + makeFeatureChunk({ + id: "chk-island", + name: "island", + filePath: "src/auth.ts", + content: "function island() { return 1; }", + }), + makeFeatureChunk({ + id: "chk-checkout", + name: "checkout", + filePath: "src/auth.ts", + content: "function checkout() { return 1; }", + }), + makeFeatureChunk({ + id: "chk-canvas", + name: "canvas", + filePath: "src/auth.ts", + content: "function canvas() { return 1; }", + }), + ]; + + const { fileFeatures } = extractSemanticFeatures(chunks, [], new Map()); + const fileFeature = fileFeatures.find((f) => f.filePath === "src/auth.ts")!; + + // regression: only isAuthenticated should contribute, not the false positives + expect(fileFeature.predicateCount).toBe(1); + }); + + it("accepts other valid predicate prefixes with proper boundary", () => { + const chunks: FeatureChunk[] = [ + makeFeatureChunk({ + id: "chk-has", + name: "hasPermission", + content: "function hasPermission(): boolean { return true; }", + }), + makeFeatureChunk({ + id: "chk-can", + name: "canEdit", + content: "function canEdit(): boolean { return true; }", + }), + makeFeatureChunk({ + id: "chk-should", + name: "shouldRetry", + content: "function shouldRetry(): boolean { return true; }", + }), + ]; + + const { chunkFeatures } = extractSemanticFeatures(chunks, [], new Map()); + + expect(chunkFeatures.find((f) => f.chunkId === "chk-has")!.isPredicate).toBe(true); + expect(chunkFeatures.find((f) => f.chunkId === "chk-can")!.isPredicate).toBe(true); + expect(chunkFeatures.find((f) => f.chunkId === "chk-should")!.isPredicate).toBe(true); + }); + + it("does not classify test/doc/registry functions as predicates even with matching names", () => { + const chunks: FeatureChunk[] = [ + makeFeatureChunk({ + id: "chk-test", + name: "isAuthenticated", + filePath: "test/auth.test.ts", + content: "function isAuthenticated() { return true; }", + }), + makeFeatureChunk({ + id: "chk-doc", + name: "isAuthenticated", + filePath: "docs/auth.md", + content: "function isAuthenticated() { return true; }", + }), + ]; + + const { chunkFeatures } = extractSemanticFeatures(chunks, [], new Map()); + + expect(chunkFeatures.find((f) => f.chunkId === "chk-test")!.isPredicate).toBe(false); + expect(chunkFeatures.find((f) => f.chunkId === "chk-doc")!.isPredicate).toBe(false); + }); + + it("counts callsPredicateCount for a caller that calls isAuthenticated", () => { + const callerChunk = makeFeatureChunk({ + id: "caller-chunk", + name: "handleRequest", + content: "function handleRequest() { if (isAuthenticated()) return true; }", + }); + + const edges: CallEdge[] = [ + makeCallEdge({ sourceChunkId: "caller-chunk", targetName: "isAuthenticated" }), + ]; + + const { chunkFeatures } = extractSemanticFeatures( + [callerChunk], + edges, + new Map() + ); + + const caller = chunkFeatures.find((f) => f.chunkId === "caller-chunk")!; + expect(caller.callsPredicateCount).toBe(1); + }); +}); diff --git a/test/daemon/mcp-server.test.ts b/test/daemon/mcp-server.test.ts index 05338d4..58d661a 100644 --- a/test/daemon/mcp-server.test.ts +++ b/test/daemon/mcp-server.test.ts @@ -57,6 +57,19 @@ function makeMockPipeline(overrides?: Partial): any { } function makeMockMetadata(overrides?: Partial): any { + const chunk = { + id: "c1", + name: "processRequest", + filePath: "src/server.ts", + kind: "function_declaration", + startLine: 10, + endLine: 25, + content: "function processRequest(req) { return req; }", + docstring: undefined, + parentName: undefined, + language: "typescript", + indexedAt: "2025-01-01T00:00:00.000Z", + }; return { getStats: () => ({ totalFiles: 5, totalChunks: 22, languages: { typescript: 22 } }), getStat: (_key: string) => "2025-01-01T00:00:00.000Z", @@ -64,6 +77,8 @@ function makeMockMetadata(overrides?: Partial): any { getConventions: () => null, getLatencyPercentiles: () => ({ avg: 12, p50: 10, p95: 25, count: 100 }), getAllChunks: () => [], + getChunk: (id: string) => (id === chunk.id ? chunk : undefined), + findChunksByFilePath: (filePath: string) => (filePath === chunk.filePath ? [chunk] : []), getAllResolvedCallEdges: () => [], getAllCommunities: () => [], getGodNodes: () => [], @@ -265,9 +280,108 @@ describe("MCP server tools (3G)", () => { expect(Array.isArray(parsed)).toBe(true); expect(parsed.length).toBeGreaterThan(0); expect(parsed[0].name).toBe("processRequest"); + expect(parsed[0].id).toBe("c1"); expect(parsed[0].filePath).toBe("src/server.ts"); }); + it("search_context returns assembled context with compression metadata", async () => { + const search = makeMockSearch({ + searchWithContext: async () => ({ + text: "## Relevant codebase context\n\n- `function compacted` (src/server.ts:10-25, chunkId `c1`, typescript)\n", + tokenCount: 42, + routeStyle: "standard", + deliveryMode: "code_context", + chunks: [ + { + id: "c1", + name: "processRequest", + filePath: "src/server.ts", + kind: "function_declaration", + startLine: 10, + endLine: 25, + score: 0.92, + content: "function processRequest(req) { return req; }", + language: "typescript", + }, + ], + compression: { + enabled: true, + mode: "auto", + tokensBeforeCompression: 100, + tokensAfterCompression: 42, + tokensSaved: 58, + savingsRatio: 0.58, + fullChunks: 0, + compressedChunks: 1, + originalRefs: [ + { + chunkId: "c1", + filePath: "src/server.ts", + startLine: 10, + endLine: 25, + name: "processRequest", + kind: "function_declaration", + language: "typescript", + }, + ], + strategies: { code: 1 }, + }, + }), + }); + const pipeline = makeMockPipeline(); + const metadata = makeMockMetadata(); + const config = makeConfig(); + + const handlers = await captureToolHandlers(search, pipeline, metadata, config); + const handler = handlers.get("search_context"); + expect(handler).toBeDefined(); + + const result = await handler!({ query: "how does request processing work", tokenBudget: 500 }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.text).toContain("Relevant codebase context"); + expect(parsed.tokenCount).toBe(42); + expect(parsed.chunksIncluded).toBe(1); + expect(parsed.selectedFiles).toEqual(["src/server.ts"]); + expect(parsed.compression.compressedChunks).toBe(1); + expect(parsed.compression.originalRefs[0].chunkId).toBe("c1"); + }); + + it("read_code_chunk returns original source by chunk id", async () => { + const handlers = await captureToolHandlers( + makeMockSearch(), + makeMockPipeline(), + makeMockMetadata(), + makeConfig() + ); + const handler = handlers.get("read_code_chunk"); + expect(handler).toBeDefined(); + + const result = await handler!({ chunkId: "c1" }); + expect(result.content).toHaveLength(1); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.id).toBe("c1"); + expect(parsed.content).toContain("function processRequest"); + expect(parsed.startLine).toBe(10); + }); + + it("read_code_chunk returns original source by file and line range", async () => { + const handlers = await captureToolHandlers( + makeMockSearch(), + makeMockPipeline(), + makeMockMetadata(), + makeConfig() + ); + const handler = handlers.get("read_code_chunk"); + expect(handler).toBeDefined(); + + const result = await handler!({ filePath: "src/server.ts", startLine: 12, endLine: 13 }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.id).toBe("c1"); + expect(parsed.filePath).toBe("src/server.ts"); + }); + it("index_codebase with no paths calls indexAll", async () => { let indexAllCalled = false; const pipeline = makeMockPipeline({ @@ -753,6 +867,8 @@ The product completes the capability.`, expect(parsed.tree.coverage).toHaveProperty("overall"); expect(parsed.tokenCount).toBeGreaterThan(0); expect(parsed.chunksIncluded).toBeGreaterThanOrEqual(1); + expect(parsed.compression).toBeDefined(); + expect(parsed.compression.enabled).toBe(true); }); it("explain_flow returns message when no seed found", async () => { diff --git a/test/hooks/prompt-context-routes.test.ts b/test/hooks/prompt-context-routes.test.ts index a1d27c2..4131145 100644 --- a/test/hooks/prompt-context-routes.test.ts +++ b/test/hooks/prompt-context-routes.test.ts @@ -110,6 +110,38 @@ describe("handlePromptContext — route integration", () => { expect(result.advisoryText).toContain("Reporecall Guidance"); }); + it("mentions active compression in advisory metadata", async () => { + const compressedContext: AssembledContext = { + ...makeAssembledContext("## context", 80), + compression: { + enabled: true, + mode: "auto", + tokensBeforeCompression: 180, + tokensAfterCompression: 80, + tokensSaved: 100, + savingsRatio: 0.55, + fullChunks: 1, + compressedChunks: 2, + originalRefs: [], + strategies: { code: 2 }, + }, + }; + + const result = await handlePromptContextDetailed( + "how does main work", + makeSearch({ + searchWithContext: async () => compressedContext, + }), + makeConfig(), + undefined, + undefined, + "trace" + ); + + expect(result.advisoryText).toContain("Compressed 2 secondary chunks"); + expect(result.advisoryText).toContain("read_code_chunk"); + }); + it("lookup mode uses existing searchWithContext behavior", async () => { let searchCalled = false; const search = makeSearch({ diff --git a/test/indexer/embedder-retry.test.ts b/test/indexer/embedder-retry.test.ts new file mode 100644 index 0000000..8fa92f5 --- /dev/null +++ b/test/indexer/embedder-retry.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { OllamaEmbedder } from "../../src/indexer/embedder.js"; + +interface MockResponse { + ok: boolean; + status: number; + text: () => Promise; + json: () => Promise; + headers: Map; +} + +function mockResponse(opts: { + ok: boolean; + status: number; + text?: string; + body?: unknown; + retryAfter?: string | null; +}): MockResponse { + const headers = new Map(); + if (opts.retryAfter !== undefined && opts.retryAfter !== null) { + headers.set("retry-after", opts.retryAfter); + } + return { + ok: opts.ok, + status: opts.status, + text: async () => opts.text ?? "", + json: async () => opts.body, + headers, + }; +} + +describe("OllamaEmbedder retry policy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // regression: non-retryable 4xx (400) must reject immediately without + // retrying, instead of looping on every attempt. + it("rejects on a 400 without retrying (fetch called exactly once)", async () => { + const fetchMock = vi.fn(async () => + mockResponse({ ok: false, status: 400, text: "bad request" }) + ); + vi.stubGlobal("fetch", fetchMock); + + const embedder = new OllamaEmbedder("test-model", "http://localhost:11434", 384); + await expect(embedder.embed(["x"])).rejects.toThrow(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + // regression: 429 is retryable and must honor Retry-After then succeed. + it("retries a 429 with Retry-After: 0 and succeeds on the second call", async () => { + const responses: MockResponse[] = [ + mockResponse({ ok: false, status: 429, text: "rate limited", retryAfter: "0" }), + mockResponse({ ok: true, status: 200, body: { embeddings: [[0.1, 0.2]] } }), + ]; + let call = 0; + const fetchMock = vi.fn(async () => responses[call++]!); + vi.stubGlobal("fetch", fetchMock); + + const embedder = new OllamaEmbedder("test-model", "http://localhost:11434", 384); + const result = await embedder.embed(["x"]); + + expect(result).toEqual([[0.1, 0.2]]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + // regression: transient network failures (fetch rejects with TypeError) must + // be retried, then succeed. + it("retries network TypeErrors twice then succeeds (fetch called 3x)", async () => { + let call = 0; + const fetchMock = vi.fn(async () => { + call++; + if (call <= 2) throw new TypeError("network error"); + return mockResponse({ + ok: true, + status: 200, + body: { embeddings: [[0.3, 0.4]] }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const embedder = new OllamaEmbedder("test-model", "http://localhost:11434", 384); + const result = await embedder.embed(["x"]); + + expect(result).toEqual([[0.3, 0.4]]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/test/indexer/merkle-ctime.test.ts b/test/indexer/merkle-ctime.test.ts new file mode 100644 index 0000000..c686a1e --- /dev/null +++ b/test/indexer/merkle-ctime.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, statSync, utimesSync } from "fs"; +import { resolve, join } from "path"; +import { tmpdir } from "os"; +import { MerkleTree } from "../../src/indexer/merkle.js"; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "merkle-ctime-test-")); +} + +function writeFile(dir: string, name: string, content: string): string { + const absPath = resolve(dir, name); + writeFileSync(absPath, content, "utf-8"); + return absPath; +} + +describe("MerkleTree mtime+ctime cache fix", () => { + let dataDir: string; + let projectDir: string; + + beforeEach(() => { + dataDir = makeTempDir(); + projectDir = makeTempDir(); + }); + + afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + // regression: Phase 0 — a content change must not be masked by an unchanged mtime + it("detects a content change even when mtime is forcibly preserved (git-checkout / touch -r)", async () => { + const merkle = new MerkleTree(dataDir); + const absA = writeFile(projectDir, "checkout.ts", "export const v = 1;"); + const files = [{ relativePath: "checkout.ts", absolutePath: absA }]; + + const first = await merkle.computeChanges(files); + expect(first.changes).toHaveLength(1); + expect(first.changes[0].type).toBe("added"); + merkle.applyPendingState(first.pendingState); + merkle.save(); + + const beforeStat = statSync(absA); + const preservedAtime = beforeStat.atimeMs; + const preservedMtime = beforeStat.mtimeMs; + + // Rewrite content, then restore the original mtime/atime like `touch -r` / `git checkout`. + writeFile(projectDir, "checkout.ts", "export const v = 999; // rewrote content"); + utimesSync(absA, new Date(preservedAtime), new Date(preservedMtime)); + + const merkle2 = new MerkleTree(dataDir); + const { changes } = await merkle2.computeChanges(files); + + const entry = changes.find((c) => c.path === "checkout.ts"); + expect(entry).toBeDefined(); + expect(entry!.type).toBe("modified"); + }); + + it("detects a size-changing rewrite with mtime preserved via utimes", async () => { + const merkle = new MerkleTree(dataDir); + const absA = writeFile(projectDir, "sized.ts", "const a = 1;\n"); + const files = [{ relativePath: "sized.ts", absolutePath: absA }]; + + const first = await merkle.computeChanges(files); + expect(first.changes[0].type).toBe("added"); + merkle.applyPendingState(first.pendingState); + + const beforeStat = statSync(absA); + const preservedAtime = beforeStat.atimeMs; + const preservedMtime = beforeStat.mtimeMs; + + writeFile(projectDir, "sized.ts", "const a = 1;\nconst b = 2;\nconst c = 3;\n"); + utimesSync(absA, new Date(preservedAtime), new Date(preservedMtime)); + + const { changes } = await merkle.computeChanges(files); + const entry = changes.find((c) => c.path === "sized.ts"); + expect(entry).toBeDefined(); + expect(entry!.type).toBe("modified"); + }); + + it("still skips a genuinely unchanged file (mtime + ctime both stable)", async () => { + const merkle = new MerkleTree(dataDir); + const absA = writeFile(projectDir, "stable.ts", "const stable = true;"); + const files = [{ relativePath: "stable.ts", absolutePath: absA }]; + + const first = await merkle.computeChanges(files); + merkle.applyPendingState(first.pendingState); + + const { changes } = await merkle.computeChanges(files); + expect(changes).toHaveLength(0); + }); +}); diff --git a/test/memory/search-status-leak.test.ts b/test/memory/search-status-leak.test.ts new file mode 100644 index 0000000..2eb6d8d --- /dev/null +++ b/test/memory/search-status-leak.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { MemoryStore } from "../../src/storage/memory-store.js"; +import { MemorySearch } from "../../src/memory/search.js"; +import type { Memory } from "../../src/memory/types.js"; + +function makeMemory(overrides: Partial = {}): Memory { + const id = overrides.id ?? "test-id"; + return { + id, + name: "status_leak_memory", + description: "Status filter leak regression", + type: "feedback", + content: "shared keyword test for regression", + filePath: `/tmp/memory/${id}.md`, + indexedAt: new Date().toISOString(), + fileMtime: new Date().toISOString(), + accessCount: 0, + lastAccessed: "", + importance: 1.0, + tags: "", + ...overrides, + }; +} + +describe("MemorySearch — status filter leak", () => { + let dataDir: string; + let store: MemoryStore; + let search: MemorySearch; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "mem-status-leak-")); + store = new MemoryStore(dataDir); + search = new MemorySearch(store); + + store.upsert( + makeMemory({ + id: "active", + content: "shared keyword test for regression", + fingerprint: "active-fp", + status: "active", + }) + ); + store.upsert( + makeMemory({ + id: "archived", + content: "shared keyword test for regression", + fingerprint: "archived-fp", + status: "archived", + }) + ); + }); + + afterEach(() => { + store.close(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + // regression: archived memories leaked into default-active results because + // the status filter only ran when `options.statuses` was explicitly provided. + it("excludes archived memories on the default (active) path", async () => { + const results = await search.search("keyword"); + + expect(results.some((r) => r.id === "active")).toBe(true); + expect(results.every((r) => r.status === "active")).toBe(true); + expect(results.some((r) => r.id === "archived")).toBe(false); + }); + + it("includes archived memories when statuses=[\"archived\"] is requested", async () => { + const results = await search.search("keyword", { statuses: ["archived"] }); + + expect(results.some((r) => r.id === "archived")).toBe(true); + expect(results.some((r) => r.id === "active")).toBe(false); + }); +}); diff --git a/test/search/architecture-strategy.test.ts b/test/search/architecture-strategy.test.ts new file mode 100644 index 0000000..608586f --- /dev/null +++ b/test/search/architecture-strategy.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { MetadataStore } from "../../src/storage/metadata-store.js"; +import { FTSStore } from "../../src/storage/fts-store.js"; +import { + ArchitectureStrategy, + compileConceptBundles, + BROAD_INVENTORY_RE, + SUBSYSTEM_INVENTORY_FAMILIES, + STRICT_WORKFLOW_FAMILY_COHESION, + INVENTORY_GENERIC_TARGET_ALIAS_TERMS, + INVENTORY_STRUCTURAL_TERMS, + ADJACENT_WORKFLOW_FAMILIES, +} from "../../src/search/architecture-strategy.js"; +import { + chunkToSearchResult, + isImplementationPath, +} from "../../src/search/shared/mappers.js"; +import type { SearchResult } from "../../src/search/types.js"; + +function makeConfig(overrides: Record = {}): any { + return { + conceptBundles: [], + implementationPaths: ["src/", "lib/", "bin/"], + ...overrides, + }; +} + +function makeResult(overrides: Partial = {}): SearchResult { + return { + id: "x", + score: 0.9, + filePath: "src/auth/session.ts", + name: "validateSession", + kind: "function_declaration", + startLine: 1, + endLine: 20, + content: "export function validateSession() {}", + language: "typescript", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Pure module-level exports +// --------------------------------------------------------------------------- + +describe("architecture-strategy pure exports", () => { + describe("compileConceptBundles", () => { + it("compiles pattern strings into case-insensitive regexes", () => { + const [bundle] = compileConceptBundles([ + { kind: "lifecycle", pattern: "\\bshutdown\\b", symbols: ["stop"], maxChunks: 4 }, + ]); + expect(bundle).toBeDefined(); + expect(bundle.pattern).toBeInstanceOf(RegExp); + expect(bundle.pattern.flags).toContain("i"); + expect(bundle.pattern.test("Graceful SHUTDOWN")).toBe(true); + expect(bundle.pattern.test("startup")).toBe(false); + }); + + it("preserves kind, symbols, and maxChunks", () => { + const [bundle] = compileConceptBundles([ + { kind: "search_pipeline", pattern: "search", symbols: ["a", "b"], maxChunks: 6 }, + ]); + expect(bundle.kind).toBe("search_pipeline"); + expect(bundle.symbols).toEqual(["a", "b"]); + expect(bundle.maxChunks).toBe(6); + }); + + it("returns [] for undefined / empty input", () => { + expect(compileConceptBundles(undefined as any)).toEqual([]); + expect(compileConceptBundles([])).toEqual([]); + }); + }); + + describe("BROAD_INVENTORY_RE", () => { + it("matches inventory-style phrases", () => { + expect(BROAD_INVENTORY_RE.test("which files implement auth")).toBe(true); + expect(BROAD_INVENTORY_RE.test("what files handle billing")).toBe(true); + expect(BROAD_INVENTORY_RE.test("list files that power the API")).toBe(true); + }); + + it("rejects non-inventory queries", () => { + expect(BROAD_INVENTORY_RE.test("how does auth work")).toBe(false); + expect(BROAD_INVENTORY_RE.test("validateSession implementation")).toBe(false); + }); + }); + + describe("workflow-family constants", () => { + it("exports the expected family sets and maps", () => { + expect(SUBSYSTEM_INVENTORY_FAMILIES.has("search")).toBe(true); + expect(STRICT_WORKFLOW_FAMILY_COHESION.has("auth")).toBe(true); + expect(STRICT_WORKFLOW_FAMILY_COHESION.has("billing")).toBe(true); + expect(STRICT_WORKFLOW_FAMILY_COHESION.has("workflow")).toBe(true); + expect(INVENTORY_GENERIC_TARGET_ALIAS_TERMS.has("route")).toBe(true); + expect(INVENTORY_STRUCTURAL_TERMS.has("which")).toBe(true); + expect(ADJACENT_WORKFLOW_FAMILIES.auth).toContain("routing"); + expect(ADJACENT_WORKFLOW_FAMILIES.generation).toContain("storage"); + }); + }); + + describe("shared pure mappers", () => { + it("chunkToSearchResult maps all fields with a given score", () => { + const chunk = { + id: "c1", + filePath: "src/a.ts", + name: "fn", + kind: "function_declaration", + startLine: 3, + endLine: 9, + content: "function fn() {}", + docstring: "docs", + parentName: "Cls", + language: "typescript", + indexedAt: new Date().toISOString(), + }; + const result = chunkToSearchResult(chunk, 0.42); + expect(result.id).toBe("c1"); + expect(result.score).toBe(0.42); + expect(result.filePath).toBe("src/a.ts"); + expect(result.docstring).toBe("docs"); + expect(result.parentName).toBe("Cls"); + expect(result.language).toBe("typescript"); + }); + + it("isImplementationPath matches configured and conventional prefixes", () => { + expect(isImplementationPath("src/auth.ts")).toBe(true); + expect(isImplementationPath("lib/util.ts")).toBe(true); + expect(isImplementationPath("app/server/index.ts")).toBe(true); + expect(isImplementationPath("supabase/functions/handler.ts")).toBe(true); + expect(isImplementationPath("docs/readme.md")).toBe(false); + }); + + it("isImplementationPath honors a custom implementationPaths list", () => { + expect(isImplementationPath("app/x.ts", ["app/"])).toBe(true); + // "src/" is conventional but not in this custom list prefix; still matched by regex + expect(isImplementationPath("src/x.ts", ["app/"])).toBe(true); + expect(isImplementationPath("notes/a.md", ["app/"])).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// ArchitectureStrategy class — construction, store management, pure helpers +// --------------------------------------------------------------------------- + +describe("ArchitectureStrategy construction and store management", () => { + let dir: string; + let metadata: MetadataStore; + let fts: FTSStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-arch-")); + metadata = new MetadataStore(dir); + fts = new FTSStore(dir); + }); + + afterEach(() => { + metadata.close(); + fts.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("constructs with real stores and a minimal config", () => { + const strategy = new ArchitectureStrategy({ metadata, config: makeConfig(), ftsStore: fts }); + expect(strategy).toBeInstanceOf(ArchitectureStrategy); + expect(strategy.lastBroadSelection).toBeNull(); + }); + + it("compiles conceptBundles through the constructor", () => { + const strategy = new ArchitectureStrategy({ + metadata, + config: makeConfig({ + conceptBundles: [ + { kind: "lifecycle", pattern: "\\bshutdown\\b", symbols: ["stop"], maxChunks: 3 }, + ], + }), + ftsStore: fts, + }); + // A lifecycle query against a chunk named "stop" should be surfaced as a + // concept result, proving the bundle was compiled and wired in. + metadata.upsertChunk({ + id: "stop-chunk", + filePath: "src/runtime.ts", + name: "stop", + kind: "method_definition", + startLine: 1, + endLine: 10, + content: "async stop() {}", + language: "typescript", + indexedAt: new Date().toISOString(), + }); + const profile = strategy.buildBroadQueryProfile("how does graceful shutdown work"); + const concept = strategy.buildBroadConceptResults( + "how does graceful shutdown work", + false, + profile + ); + expect(concept.length).toBeGreaterThan(0); + expect(concept.some((r) => r.name === "stop")).toBe(true); + }); + + it("updateStores swaps the active metadata and fts references", () => { + const dirB = mkdtempSync(join(tmpdir(), "mem-arch-b-")); + const metadataB = new MetadataStore(dirB); + const ftsB = new FTSStore(dirB); + try { + // Seed store A with one import neighbor, store B with a different one. + metadata.upsertImports([ + { filePath: "src/core.ts", importedName: "a", sourceModule: "./a", resolvedPath: "neighborA", isDefault: false, isNamespace: false }, + ]); + metadataB.upsertImports([ + { filePath: "src/core.ts", importedName: "b", sourceModule: "./b", resolvedPath: "neighborB", isDefault: false, isNamespace: false }, + ]); + + const strategy = new ArchitectureStrategy({ metadata, config: makeConfig(), ftsStore: fts }); + expect(strategy.collectBroadImportNeighbors("src/core.ts")).toEqual(["neighborA"]); + + strategy.updateStores(metadataB, ftsB); + expect(strategy.collectBroadImportNeighbors("src/core.ts")).toEqual(["neighborB"]); + } finally { + metadataB.close(); + ftsB.close(); + rmSync(dirB, { recursive: true, force: true }); + } + }); +}); + +describe("ArchitectureStrategy layer/path detection helpers", () => { + let dir: string; + let metadata: MetadataStore; + let fts: FTSStore; + let strategy: ArchitectureStrategy; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-arch-layers-")); + metadata = new MetadataStore(dir); + fts = new FTSStore(dir); + strategy = new ArchitectureStrategy({ metadata, config: makeConfig(), ftsStore: fts }); + }); + + afterEach(() => { + metadata.close(); + fts.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("detectWorkflowLayers assigns layers by path/name and defaults to core", () => { + expect(strategy.detectWorkflowLayers("src/pages/home.tsx", "homePage")).toContain("ui"); + expect(strategy.detectWorkflowLayers("src/hooks/useauth.ts", "useAuth")).toContain("state"); + expect(strategy.detectWorkflowLayers("src/api/router.ts", "router")).toContain("routing"); + expect(strategy.detectWorkflowLayers("src/api/server.ts", "createServer")).toContain("backend"); + expect(strategy.detectWorkflowLayers("src/lib/helpers.ts", "formatError")).toContain("shared"); + // Unknown shape → core fallback + expect(strategy.detectWorkflowLayers("some/random.ts", "thing")).toEqual(["core"]); + }); + + it("isUtilityLikePath flags lib/shared/core/utils paths", () => { + expect(strategy.isUtilityLikePath("src/lib/x.ts", "x")).toBe(true); + expect(strategy.isUtilityLikePath("src/shared/y.ts", "format")).toBe(true); + expect(strategy.isUtilityLikePath("src/utils/clean.ts", "clean")).toBe(true); + expect(strategy.isUtilityLikePath("src/utils/clean.ts", "Helpers")).toBe(true); + expect(strategy.isUtilityLikePath("src/auth/login.ts", "login")).toBe(false); + }); + + it("isObservabilitySidecarPath flags metrics/logger/telemetry", () => { + expect(strategy.isObservabilitySidecarPath("src/daemon/metrics.ts", "incrementRequest")).toBe(true); + expect(strategy.isObservabilitySidecarPath("src/core/logger.ts", "getLogger")).toBe(true); + expect(strategy.isObservabilitySidecarPath("src/auth/session.ts", "validateSession")).toBe(false); + }); + + it("isBroadOrchestratorLikePath flags orchestrator/pipeline/entry names", () => { + expect(strategy.isBroadOrchestratorLikePath("src/indexer/pipeline.ts", "IndexingPipeline")).toBe(true); + expect(strategy.isBroadOrchestratorLikePath("src/cli/index.ts", "main")).toBe(true); + expect(strategy.isBroadOrchestratorLikePath("src/auth/session.ts", "validateSession")).toBe(false); + }); +}); + +describe("ArchitectureStrategy query helpers", () => { + let dir: string; + let metadata: MetadataStore; + let fts: FTSStore; + let strategy: ArchitectureStrategy; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-arch-query-")); + metadata = new MetadataStore(dir); + fts = new FTSStore(dir); + strategy = new ArchitectureStrategy({ metadata, config: makeConfig(), ftsStore: fts }); + }); + + afterEach(() => { + metadata.close(); + fts.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("buildBroadPhrases builds non-generic bigrams/trigrams", () => { + const phrases = strategy.buildBroadPhrases(["auth", "session", "login"]); + expect(phrases).toContain("auth session"); + expect(phrases).toContain("session login"); + expect(phrases).toContain("auth session login"); + }); + + it("buildBroadPhrases drops pairs composed entirely of generic terms", () => { + // "flow" and "path" are both generic → no phrase should be emitted + const phrases = strategy.buildBroadPhrases(["flow", "path"]); + expect(phrases).toEqual([]); + }); + + it("mergeBroadResults dedupes by id keeping the highest score and sorts desc", () => { + const merged = strategy.mergeBroadResults( + [ + makeResult({ id: "a", score: 0.5 }), + makeResult({ id: "b", score: 0.9 }), + ], + [ + makeResult({ id: "a", score: 0.8 }), + makeResult({ id: "c", score: 0.3 }), + ] + ); + expect(merged.map((r) => r.id)).toEqual(["b", "a", "c"]); + expect(merged.find((r) => r.id === "a")!.score).toBe(0.8); + }); + + it("buildBroadQueryProfile returns a populated profile", () => { + const profile = strategy.buildBroadQueryProfile("how does the authentication flow work"); + expect(Array.isArray(profile.tokens)).toBe(true); + expect(profile.tokens.length).toBeGreaterThan(0); + expect(profile.inventoryMode).toBe(false); + expect(profile.lifecycleMode).toBe(false); + expect(profile.phrases).toBeInstanceOf(Array); + expect(profile.allowedFamilies).toBeInstanceOf(Set); + expect(profile.surfaceBias).toBeDefined(); + }); + + it("buildBroadQueryProfile detects inventory mode for inventory queries", () => { + const profile = strategy.buildBroadQueryProfile("which files implement auth"); + expect(profile.inventoryMode).toBe(true); + }); + + it("buildBroadQueryProfile detects lifecycle mode for shutdown queries", () => { + const profile = strategy.buildBroadQueryProfile("how does graceful shutdown work"); + expect(profile.lifecycleMode).toBe(true); + }); + + it("isCallbackNoiseTarget flags useCallback/navigation/perf when not requested", () => { + const profile = strategy.buildBroadQueryProfile("how does authentication work"); + expect(strategy.isCallbackNoiseTarget("src/hooks/useauth.ts", "usecallbackhandler", profile)).toBe(true); + expect(strategy.isCallbackNoiseTarget("src/navigation/index.ts", "nav", profile)).toBe(true); + // Not noise when the query actually asks about callbacks + const cbProfile = strategy.buildBroadQueryProfile("how does the auth callback work"); + expect(strategy.isCallbackNoiseTarget("src/hooks/useauth.ts", "usecallbackhandler", cbProfile)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// selectBroadWorkflowBundle — the main public entry point +// --------------------------------------------------------------------------- + +describe("ArchitectureStrategy.selectBroadWorkflowBundle", () => { + let dir: string; + let metadata: MetadataStore; + let fts: FTSStore; + let strategy: ArchitectureStrategy; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-arch-bundle-")); + metadata = new MetadataStore(dir); + fts = new FTSStore(dir); + strategy = new ArchitectureStrategy({ metadata, config: makeConfig(), ftsStore: fts }); + }); + + afterEach(() => { + metadata.close(); + fts.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns an array and is deterministic on an empty input list", () => { + const out = strategy.selectBroadWorkflowBundle("how does auth work", []); + expect(Array.isArray(out)).toBe(true); + expect(strategy.lastBroadSelection).not.toBeNull(); + expect(strategy.lastBroadSelection!.broadMode).toBe("workflow"); + }); + + it("returns an array without throwing on a small input list", () => { + const results = [ + makeResult({ id: "a", score: 0.9 }), + makeResult({ id: "b", score: 0.8, filePath: "src/hooks/useauth.ts", name: "useAuth" }), + ]; + const out = strategy.selectBroadWorkflowBundle("how does authentication work", results); + expect(Array.isArray(out)).toBe(true); + expect(strategy.lastBroadSelection).not.toBeNull(); + }); + + it("selects an auth-centered bundle for a known auth-family keyword", () => { + const results = [ + makeResult({ id: "page", score: 0.95, filePath: "src/pages/Auth.tsx", name: "AuthPage" }), + makeResult({ id: "hook", score: 0.9, filePath: "src/hooks/useAuth.tsx", name: "useAuth" }), + makeResult({ id: "cb", score: 0.88, filePath: "src/pages/AuthCallback.tsx", name: "AuthCallback" }), + makeResult({ id: "api", score: 0.84, filePath: "supabase/functions/auth/index.ts", name: "authenticateRequest" }), + ]; + const out = strategy.selectBroadWorkflowBundle( + "add logging to every step in the authentication flow", + results + ); + expect(Array.isArray(out)).toBe(true); + const diag = strategy.lastBroadSelection!; + expect(diag.broadMode).toBe("workflow"); + // When delivered as code context the auth files should be present + if (diag.deliveryMode === "code_context") { + const paths = out.map((r) => r.filePath); + expect(paths).toContain("src/pages/AuthCallback.tsx"); + } else { + expect(diag.deferredReason).toBeTruthy(); + } + }); + + it("produces consistent output across repeated identical calls (determinism)", () => { + const results = [ + makeResult({ id: "a", score: 0.9 }), + makeResult({ id: "b", score: 0.7, filePath: "src/api/auth.ts", name: "login" }), + ]; + const first = strategy.selectBroadWorkflowBundle("authentication flow", results); + const second = strategy.selectBroadWorkflowBundle("authentication flow", results); + expect(second.map((r) => r.id)).toEqual(first.map((r) => r.id)); + }); +}); diff --git a/test/search/context-assembler-deep-route.test.ts b/test/search/context-assembler-deep-route.test.ts new file mode 100644 index 0000000..e8973be --- /dev/null +++ b/test/search/context-assembler-deep-route.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { assembleDeepRouteContext } from "../../src/search/context-assembler.js"; +import type { SearchResult } from "../../src/search/types.js"; + +function makeResult(id: string, score: number): SearchResult { + return { + id, + score, + filePath: `src/${id}.ts`, + name: id, + kind: "function", + startLine: 1, + endLine: 5, + content: `function ${id}() { return true; }`, + language: "typescript", + }; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +describe("assembleDeepRouteContext — duplicate file-list fix", () => { + // regression: the deep route header rebuilds its own file list, but the base + // assembleContext output still carried its own "> Files included:" line, + // emitting the file list twice. + it("emits the \"> Files included:\" line exactly once", () => { + const chunks = [ + makeResult("alpha", 1.0), + makeResult("beta", 0.9), + makeResult("gamma", 0.8), + ]; + + const ctx = assembleDeepRouteContext(chunks, 2000); + + expect(countOccurrences(ctx.text, "> Files included:")).toBe(1); + }); + + it("returns a finite positive token count and the deep route style", () => { + const chunks = [ + makeResult("alpha", 1.0), + makeResult("beta", 0.9), + ]; + + const ctx = assembleDeepRouteContext(chunks, 2000); + + expect(Number.isFinite(ctx.tokenCount)).toBe(true); + expect(ctx.tokenCount).toBeGreaterThan(0); + expect(ctx.routeStyle).toBe("deep"); + }); +}); diff --git a/test/search/context-assembler.test.ts b/test/search/context-assembler.test.ts index fee84b8..ac83bf5 100644 --- a/test/search/context-assembler.test.ts +++ b/test/search/context-assembler.test.ts @@ -108,6 +108,150 @@ describe("assembleContext — maxChunks", () => { }); }); +describe("assembleContext — evidence compression", () => { + function makeLargeResult( + id: string, + language: string, + filePath: string, + signature: string, + bodyLine: string, + score: number + ): SearchResult { + const filler = Array.from({ length: 70 }, (_, i) => + ` // filler implementation detail ${i} auth token route session` + ).join("\n"); + return { + id, + score, + filePath, + name: id, + kind: "function", + startLine: 10, + endLine: 90, + content: `${signature}\n${bodyLine}\n${filler}\n}`, + docstring: `Handles ${id}`, + language, + }; + } + + it("compresses lower-ranked multilingual chunks with reversible chunk refs", () => { + const results: SearchResult[] = [ + makeLargeResult( + "authController", + "typescript", + "src/auth/controller.ts", + "export async function authController(req: Request) {", + " const token = req.headers.get('authorization');", + 1 + ), + makeLargeResult( + "verify_token", + "python", + "services/auth.py", + "def verify_token(token: str):", + " raise ValueError('unauthorized token')", + 0.98 + ), + makeLargeResult( + "AuthRoute", + "go", + "cmd/server/auth.go", + "func AuthRoute(router *gin.Engine) {", + " router.GET(\"/auth/callback\", callback)", + 0.97 + ), + makeLargeResult( + "check_session", + "rust", + "crates/auth/src/lib.rs", + "pub fn check_session(token: &str) -> Result<(), AuthError> {", + " panic!(\"invalid auth token\")", + 0.96 + ), + ]; + + const ctx = assembleContext(results, 100_000, { + scoreFloorRatio: 0, + query: "auth token route", + compressionRank: 1, + contextCompressionEnabled: true, + contextCompressionPreserveTopChunks: 1, + contextCompressionMinChunkTokens: 1, + contextCompressionTargetRatio: 0.95, + }); + + expect(ctx.text).toContain("export async function authController"); + expect(ctx.text).toContain("chunkId `verify_token`"); + expect(ctx.text).toContain("chunkId `AuthRoute`"); + expect(ctx.text).toContain("chunkId `check_session`"); + expect(ctx.text).toContain("L10: def verify_token"); + expect(ctx.text).toContain('"/auth/callback"'); + expect(ctx.compression?.compressedChunks).toBeGreaterThanOrEqual(3); + expect(ctx.compression?.originalRefs.map((ref) => ref.chunkId)).toEqual([ + "verify_token", + "AuthRoute", + "check_session", + ]); + expect(ctx.compression?.tokensSaved).toBeGreaterThan(0); + }); + + it("can disable compression even when compressionRank is set", () => { + const results = [ + makeLargeResult( + "full_one", + "typescript", + "src/full.ts", + "export function full_one() {", + " return '/auth/full';", + 1 + ), + makeLargeResult( + "full_two", + "python", + "src/full.py", + "def full_two():", + " return '/auth/full'", + 0.99 + ), + ]; + + const ctx = assembleContext(results, 100_000, { + scoreFloorRatio: 0, + compressionRank: 1, + contextCompressionEnabled: false, + contextCompressionMinChunkTokens: 1, + }); + + expect(ctx.text).toContain("```python"); + expect(ctx.text).toContain("def full_two"); + expect(ctx.text).not.toContain("chunkId `full_two`"); + expect(ctx.compression?.compressedChunks).toBe(0); + }); + + it("lets route compressionRank override global preserved chunk count", () => { + const results: SearchResult[] = [ + makeLargeResult("primary", "typescript", "src/primary.ts", "export function primary() {", " return '/auth/primary';", 1), + makeLargeResult("secondary", "typescript", "src/secondary.ts", "export function secondary() {", " return '/auth/secondary';", 0.99), + makeLargeResult("third", "typescript", "src/third.ts", "export function third() {", " return '/auth/third';", 0.98), + ]; + + const ctx = assembleContext(results, 100_000, { + scoreFloorRatio: 0, + query: "auth route", + compressionRank: 1, + contextCompressionEnabled: true, + contextCompressionPreserveTopChunks: 3, + contextCompressionMinChunkTokens: 1, + contextCompressionTargetRatio: 0.95, + }); + + expect(ctx.text).toContain("export function primary"); + expect(ctx.text).toContain("chunkId `secondary`"); + expect(ctx.text).toContain("chunkId `third`"); + expect(ctx.compression?.originalRefs.map((ref) => ref.chunkId)).toEqual(["secondary", "third"]); + }); +}); + describe("assembleContext — directive header", () => { it("includes directive header by default", () => { const results = [makeResult("a", 1.0)]; diff --git a/test/search/context-prioritization.test.ts b/test/search/context-prioritization.test.ts new file mode 100644 index 0000000..451ec65 --- /dev/null +++ b/test/search/context-prioritization.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { + isImplementationPath, + isImplementationChunk, + prioritizeForHookContext, +} from "../../src/search/context-prioritization.js"; +import type { SearchResult } from "../../src/search/types.js"; +import type { MemoryConfig } from "../../src/core/config.js"; +import type { MetadataStore } from "../../src/storage/metadata-store.js"; + +function makeResult(over: Partial = {}): SearchResult { + return { + id: "c1", + score: 1, + filePath: "src/foo.ts", + name: "foo", + kind: "function", + startLine: 1, + endLine: 10, + content: "", + language: "typescript", + ...over, + }; +} + +const minimalConfig = { + implementationPaths: ["src/", "lib/", "bin/"], + testPenaltyFactor: 0.5, +} as unknown as MemoryConfig; + +const noMetadata = {} as MetadataStore; + +describe("isImplementationPath", () => { + it("returns true for source files under default implementation paths", () => { + expect(isImplementationPath("src/foo.ts")).toBe(true); + expect(isImplementationPath("lib/util.ts")).toBe(true); + expect(isImplementationPath("bin/run.ts")).toBe(true); + }); + + it("returns true for implementation dirs matched anywhere in the path", () => { + expect(isImplementationPath("packages/app/src/index.ts")).toBe(true); + expect(isImplementationPath("server/handlers/route.ts")).toBe(true); + }); + + it("returns false for tests and node_modules under defaults", () => { + expect(isImplementationPath("test/foo.test.ts")).toBe(false); + expect(isImplementationPath("node_modules/x/index.ts")).toBe(false); + expect(isImplementationPath("docs/readme.md")).toBe(false); + }); + + it("respects a custom implementationPaths argument", () => { + // Path matches only via the custom prefix, not the built-in regex. + expect(isImplementationPath("custom/foo.ts", ["custom/"])).toBe(true); + expect(isImplementationPath("custom/foo.ts", ["other/"])).toBe(false); + expect(isImplementationPath("custom/foo.ts", ["custom/", "other/"])).toBe(true); + }); +}); + +describe("isImplementationChunk", () => { + it("delegates to isImplementationPath using the chunk's filePath", () => { + expect(isImplementationChunk(makeResult({ filePath: "src/a.ts" }))).toBe(true); + expect(isImplementationChunk(makeResult({ filePath: "test/a.test.ts" }))).toBe(false); + }); + + it("forwards a custom implementationPaths argument", () => { + expect( + isImplementationChunk(makeResult({ filePath: "custom/a.ts" }), ["custom/"]) + ).toBe(true); + expect( + isImplementationChunk(makeResult({ filePath: "custom/a.ts" }), ["other/"]) + ).toBe(false); + }); +}); + +describe("prioritizeForHookContext", () => { + it("returns a deterministic, stable order across repeated calls", () => { + const results = [ + makeResult({ id: "a", filePath: "src/a.ts", name: "a", score: 1 }), + makeResult({ id: "b", filePath: "src/b.ts", name: "b", score: 1 }), + ]; + + const first = prioritizeForHookContext("a b", results, minimalConfig, undefined, noMetadata); + const second = prioritizeForHookContext("a b", results, minimalConfig, undefined, noMetadata); + + expect(first.map((r) => r.id)).toEqual(second.map((r) => r.id)); + expect(first).toHaveLength(results.length); + }); + + it("attaches a finite hookScore to every result and sorts by it descending", () => { + const results = [ + makeResult({ id: "doc", filePath: "docs/readme.md", name: "readme", score: 5 }), + makeResult({ id: "impl", filePath: "src/auth.ts", name: "authenticate", score: 5 }), + ]; + + const ordered = prioritizeForHookContext("authenticate", results, minimalConfig, undefined, noMetadata); + + for (const r of ordered) { + expect(typeof r.hookScore).toBe("number"); + expect(Number.isFinite(r.hookScore)).toBe(true); + } + for (let i = 1; i < ordered.length; i++) { + expect(ordered[i - 1]!.hookScore!).toBeGreaterThanOrEqual(ordered[i]!.hookScore!); + } + }); + + it("ranks an implementation chunk above a doc chunk with equal base score", () => { + const results = [ + makeResult({ id: "doc", filePath: "docs/readme.md", name: "readme", score: 5 }), + makeResult({ id: "impl", filePath: "src/auth.ts", name: "authenticate", score: 5 }), + ]; + + const ordered = prioritizeForHookContext("authenticate", results, minimalConfig, undefined, noMetadata); + + expect(ordered[0]!.id).toBe("impl"); + }); + + it("does not throw and preserves the input length with empty results", () => { + const ordered = prioritizeForHookContext("anything", [], minimalConfig, undefined, noMetadata); + expect(ordered).toEqual([]); + }); +}); diff --git a/test/search/evidence-compressor.test.ts b/test/search/evidence-compressor.test.ts new file mode 100644 index 0000000..7920650 --- /dev/null +++ b/test/search/evidence-compressor.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { + compressEvidenceChunk, + type EvidenceCompressionOptions, +} from "../../src/search/evidence-compressor.js"; +import type { SearchResult } from "../../src/search/types.js"; + +function makeChunk(over: Partial = {}): SearchResult { + return { + id: "chk1", + score: 1, + filePath: "src/auth.ts", + name: "authenticate", + kind: "function", + startLine: 10, + endLine: 14, + content: [ + "export function authenticate(user: string, token: string) {", + " if (!token) throw new Error('invalid token');", + " return verify(token);", + "}", + ].join("\n"), + language: "typescript", + ...over, + }; +} + +const FULL_OPTIONS: EvidenceCompressionOptions = { + query: "authenticate token", + minChunkTokens: 0, + targetRatio: 1, +}; + +function countEvidenceLines(text: string): number { + const matches = text.match(/^ - L\d+: /gm); + return matches ? matches.length : 0; +} + +describe("compressEvidenceChunk", () => { + it("returns a well-formed result with the required-options path", () => { + const result = compressEvidenceChunk(makeChunk(), FULL_OPTIONS); + + expect(typeof result.text).toBe("string"); + expect(result.text.length).toBeGreaterThan(0); + expect(["code", "search_result", "config_or_data", "text"]).toContain(result.strategy); + expect(result.strategy).toBe("code"); + expect(result.originalRef.chunkId).toBe("chk1"); + expect(result.originalRef.filePath).toBe("src/auth.ts"); + expect(result.originalRef.name).toBe("authenticate"); + expect(result.text).toContain("strategy: code"); + }); + + it("exposes at least one selected evidence line for matching query terms", () => { + const result = compressEvidenceChunk(makeChunk(), FULL_OPTIONS); + expect(countEvidenceLines(result.text)).toBeGreaterThan(0); + expect(result.text).toContain("authenticate"); + }); + + it("caps selected evidence lines at the code-strategy limit (8)", () => { + const lines: string[] = []; + for (let i = 0; i < 20; i++) { + // Each line mentions the chunk name so it scores and becomes a candidate. + lines.push(`export function authenticate_step_${i}() { return ${i}; }`); + } + const chunk = makeChunk({ content: lines.join("\n"), endLine: 10 + lines.length - 1 }); + + const result = compressEvidenceChunk(chunk, FULL_OPTIONS); + expect(countEvidenceLines(result.text)).toBeLessThanOrEqual(8); + }); + + it("does not crash with query only (no minChunkTokens/targetRatio semantics exercised)", () => { + const result = compressEvidenceChunk(makeChunk(), { + query: "authenticate", + minChunkTokens: 0, + targetRatio: 1, + }); + expect(result.text.length).toBeGreaterThan(0); + }); + + it("falls back to a summary line when no evidence line scores", () => { + const chunk = makeChunk({ + content: " \n \n", + name: "authenticate", + }); + const result = compressEvidenceChunk(chunk, { + query: undefined, + minChunkTokens: 0, + targetRatio: 1, + }); + expect(result.text.length).toBeGreaterThan(0); + expect(result.text).toContain("chunkId"); + }); +}); diff --git a/test/search/flow-context.test.ts b/test/search/flow-context.test.ts index 4ed7cb9..e1e1069 100644 --- a/test/search/flow-context.test.ts +++ b/test/search/flow-context.test.ts @@ -152,6 +152,70 @@ describe("assembleFlowContext", () => { expect(result.text).toContain("validateCredentials"); }); + it("compresses secondary flow chunks with reversible chunk refs", () => { + const calleeOne = makeTreeNode({ + chunkId: "callee-1", + name: "validateCredentials", + filePath: "src/auth/validator.ts", + direction: "down", + depth: 1, + }); + const calleeTwo = makeTreeNode({ + chunkId: "callee-2", + name: "loadSessionToken", + filePath: "src/auth/session.ts", + direction: "down", + depth: 1, + }); + const largeSessionBody = [ + "export function loadSessionToken(request: Request) {", + " const token = request.headers.get('authorization');", + " if (!token) throw new Error('unauthorized session token');", + ...Array.from({ length: 50 }, (_, i) => ` // route auth session implementation detail ${i}`), + " return token;", + "}", + ].join("\n"); + const tree = makeTree({ + downTree: [calleeOne, calleeTwo], + nodeCount: 3, + }); + const metadata = makeMetadata({ + "seed-1": makeChunk("seed-1", "handleLogin", "function handleLogin() { validateCredentials(); loadSessionToken(); }", { + filePath: "src/auth/handler.ts", + startLine: 45, + endLine: 89, + }), + "callee-1": makeChunk("callee-1", "validateCredentials", "function validateCredentials(user, pass) { return true; }", { + filePath: "src/auth/validator.ts", + startLine: 5, + endLine: 20, + }), + "callee-2": makeChunk("callee-2", "loadSessionToken", largeSessionBody, { + filePath: "src/auth/session.ts", + startLine: 30, + endLine: 90, + }), + }); + + const result = assembleFlowContext( + tree, + metadata as any, + 10000, + "how does handleLogin validate credentials", + { + contextCompressionEnabled: true, + contextCompressionMode: "auto", + contextCompressionMinChunkTokens: 1, + contextCompressionTargetRatio: 0.95, + } + ); + + expect(result.text).toContain("chunkId `callee-2`"); + expect(result.text).toContain("L30: export function loadSessionToken"); + expect(result.compression?.compressedChunks).toBeGreaterThanOrEqual(1); + expect(result.compression?.originalRefs.map((ref) => ref.chunkId)).toContain("callee-2"); + }); + it("respects token budget — seed always included, extras trimmed", () => { const callerNode = makeTreeNode({ chunkId: "caller-1", diff --git a/test/search/pipeline-core-lock.test.ts b/test/search/pipeline-core-lock.test.ts new file mode 100644 index 0000000..41d1bae --- /dev/null +++ b/test/search/pipeline-core-lock.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "vitest"; +import { RetrievalPipeline } from "../../src/search/pipeline-core.js"; +import { ReadWriteLock } from "../../src/core/rwlock.js"; +import type { MemoryConfig } from "../../src/core/config.js"; +import type { EmbeddingProvider, EmbeddingVector } from "../../src/indexer/types.js"; +import type { VectorStore } from "../../src/storage/vector-store.js"; +import type { FTSStore } from "../../src/storage/fts-store.js"; +import type { MetadataStore } from "../../src/storage/metadata-store.js"; + +function makeConfig(): MemoryConfig { + return { + projectRoot: "/tmp", + dataDir: "/tmp/.memory", + embeddingProvider: "local", + embeddingModel: "test", + embeddingDimensions: 384, + ollamaUrl: "http://localhost:11434", + extensions: [".ts"], + ignorePatterns: [], + maxFileSize: 100000, + batchSize: 32, + contextBudget: 0, + maxContextChunks: 0, + sessionBudget: 2000, + searchWeights: { vector: 0.5, keyword: 0.3, recency: 0.2 }, + rrfK: 60, + graphExpansion: false, + graphDiscountFactor: 0.6, + siblingExpansion: false, + siblingDiscountFactor: 0.4, + reranking: false, + rerankingModel: "", + rerankTopK: 25, + codeBoostFactor: 1.5, + testPenaltyFactor: 0.3, + anonymousPenaltyFactor: 0.5, + debounceMs: 2000, + shutdownTimeoutMs: 10000, + port: 37222, + implementationPaths: ["src/"], + factExtractors: [], + conceptBundles: [], + memory: false, + memoryBudget: 500, + memoryDirs: [], + memoryWatch: false, + memoryCodeFloorRatio: 0.8, + memoryHotBudget: 120, + memoryWorkingBudget: 80, + memoryEpisodeBudget: 150, + memoryArchiveDays: 30, + memoryCompactionHours: 6, + memoryWritableDir: ".memory/mem", + memoryAutoCreate: false, + memoryFactPromotionThreshold: 3, + memoryWorkingHistoryLimit: 1, + wikiBudget: 400, + wikiMaxPages: 3, + capabilityEvidence: false, + genericCapabilityHydration: false, + contextCompressionEnabled: false, + contextCompressionMode: "off", + contextCompressionPreserveTopChunks: 1, + contextCompressionMinChunkTokens: 100, + contextCompressionTargetRatio: 0.75, + }; +} + +function makeFakeEmbedder(): EmbeddingProvider { + return { + embed: async (_texts: string[]): Promise => { + await new Promise((r) => setTimeout(r, 1)); + [[0.1, 0.2, 0.3]]; + return [[0.1, 0.2, 0.3]]; + }, + dimensions: () => 384, + isEnabled: () => true, + }; +} + +function makeFakeVectorStore(prefix: string): VectorStore { + return { + search: async (_vec: EmbeddingVector, _limit: number) => { + await new Promise((r) => setTimeout(r, 1)); + return [ + { id: `${prefix}-v1`, score: 0.9 }, + { id: `${prefix}-v2`, score: 0.7 }, + ]; + }, + } as unknown as VectorStore; +} + +function makeFakeFtsStore(prefix: string): FTSStore { + return { + search: (_q: string, _limit: number) => [ + { id: `${prefix}-f1`, rank: 1 }, + { id: `${prefix}-f2`, rank: 2 }, + ], + } as unknown as FTSStore; +} + +function makeFakeMetadata(): MetadataStore { + return { + getChunkScoringInfo: () => [], + } as unknown as MetadataStore; +} + +describe("RetrievalPipeline ReadWriteLock wiring", () => { + // regression: updateStores is async and guarded by the write lock; retrieve + // is guarded by the read lock — concurrent calls must interleave safely + it("updateStores returns a Promise", () => { + const pipeline = new RetrievalPipeline({ + embedder: makeFakeEmbedder(), + vectorStore: makeFakeVectorStore("init"), + ftsStore: makeFakeFtsStore("init"), + metadata: makeFakeMetadata(), + config: makeConfig(), + lock: new ReadWriteLock(), + }); + + const result = pipeline.updateStores( + makeFakeVectorStore("new"), + makeFakeFtsStore("new"), + makeFakeMetadata() + ); + expect(result).toBeInstanceOf(Promise); + }); + + it("concurrent retrieve calls do not throw during an updateStores", async () => { + const lock = new ReadWriteLock(); + const pipeline = new RetrievalPipeline({ + embedder: makeFakeEmbedder(), + vectorStore: makeFakeVectorStore("init"), + ftsStore: makeFakeFtsStore("init"), + metadata: makeFakeMetadata(), + config: makeConfig(), + lock, + }); + + // Kick off an updateStores (write lock) concurrently with many retrieves (read lock) + const update = pipeline.updateStores( + makeFakeVectorStore("new"), + makeFakeFtsStore("new"), + makeFakeMetadata() + ); + + const retrieves: Promise[] = []; + for (let i = 0; i < 20; i++) { + retrieves.push(pipeline.retrieve(`query-${i}`, false)); + } + + const results = await Promise.all([ + update.then(() => "update-ok"), + ...retrieves.map((p) => + p.then( + () => "retrieve-ok", + () => "retrieve-failed" + ) + ), + ]); + + expect(results[0]).toBe("update-ok"); + for (let i = 1; i < results.length; i++) { + expect(results[i]).toBe("retrieve-ok"); + } + }); + + it("retrieve returns consistent results from a single store snapshot", async () => { + const lock = new ReadWriteLock(); + const pipeline = new RetrievalPipeline({ + embedder: makeFakeEmbedder(), + vectorStore: makeFakeVectorStore("snap"), + ftsStore: makeFakeFtsStore("snap"), + metadata: makeFakeMetadata(), + config: makeConfig(), + lock, + }); + + const result = await pipeline.retrieve("test query", false); + expect(result.vectorResults).toHaveLength(2); + expect(result.keywordResults).toHaveLength(2); + expect(result.vectorResults[0].id).toBe("snap-v1"); + }); + + it("completes without deadlock under interleaved read/write load", async () => { + const lock = new ReadWriteLock(); + const pipeline = new RetrievalPipeline({ + embedder: makeFakeEmbedder(), + vectorStore: makeFakeVectorStore("init"), + ftsStore: makeFakeFtsStore("init"), + metadata: makeFakeMetadata(), + config: makeConfig(), + lock, + }); + + const ops: Promise[] = []; + for (let i = 0; i < 50; i++) { + if (i % 10 === 5) { + ops.push( + pipeline + .updateStores( + makeFakeVectorStore(`gen-${i}`), + makeFakeFtsStore(`gen-${i}`), + makeFakeMetadata() + ) + .then(() => `write-${i}`) + ); + } else { + ops.push( + pipeline.retrieve(`q-${i}`, false).then( + () => `read-${i}`, + () => `read-${i}-err` + ) + ); + } + } + + const results = await Promise.all(ops); + // Every op must resolve (no deadlock / hang) + expect(results).toHaveLength(50); + for (const r of results) { + expect(r).toBeDefined(); + } + }); + + it("works without a lock (backward-compatible path)", async () => { + const pipeline = new RetrievalPipeline({ + embedder: makeFakeEmbedder(), + vectorStore: makeFakeVectorStore("nolock"), + ftsStore: makeFakeFtsStore("nolock"), + metadata: makeFakeMetadata(), + config: makeConfig(), + }); + + const result = await pipeline.retrieve("no lock", false); + expect(result.vectorResults[0].id).toBe("nolock-v1"); + + await pipeline.updateStores( + makeFakeVectorStore("swapped"), + makeFakeFtsStore("swapped"), + makeFakeMetadata() + ); + }); +}); diff --git a/test/search/ranker-nan-regression.test.ts b/test/search/ranker-nan-regression.test.ts new file mode 100644 index 0000000..dab75d5 --- /dev/null +++ b/test/search/ranker-nan-regression.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { reciprocalRankFusion } from "../../src/search/ranker.js"; + +function makeVector(...ids: string[]) { + return ids.map((id, i) => ({ id, score: 1 - i * 0.1 })); +} + +function makeKeyword(...ids: string[]) { + return ids.map((id, i) => ({ id, rank: i + 1 })); +} + +describe("reciprocalRankFusion — malformed recency date must not cascade NaN", () => { + // regression: a single malformed date in chunkDates previously yielded NaN, + // corrupting the entire result set via the non-transitive sort comparator. + it("produces only finite scores and a strictly descending result set", () => { + const now = new Date(); + const recent = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(); + + const ranked = reciprocalRankFusion( + makeVector("a", "b", "c"), + makeKeyword("a", "b", "c"), + { + vectorWeight: 0.5, + keywordWeight: 0.3, + recencyWeight: 0.5, + k: 60, + chunkDates: new Map([ + ["a", "not-a-date"], + ["b", recent], + ["c", recent], + ]), + } + ); + + for (const item of ranked) { + expect(Number.isFinite(item.score)).toBe(true); + } + + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1]!.score).toBeGreaterThanOrEqual(ranked[i]!.score); + } + }); + + it("drops the malformed-date item's recency boost without zeroing its base score", () => { + const recent = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + + const ranked = reciprocalRankFusion( + makeVector("a", "b"), + makeKeyword("a", "b"), + { + vectorWeight: 0.5, + keywordWeight: 0.3, + recencyWeight: 0.5, + k: 60, + chunkDates: new Map([ + ["a", "not-a-date"], + ["b", recent], + ]), + } + ); + + const a = ranked.find((r) => r.id === "a")!; + expect(Number.isFinite(a.score)).toBe(true); + expect(a.score).toBeGreaterThan(0); + }); +}); + +describe("reciprocalRankFusion — k validation fallback", () => { + // regression: a non-positive / non-finite k produced Infinity or negative + // RRF scores; it must fall back to the canonical 60. + for (const badK of [-1, 0, NaN]) { + it(`falls back to k=60 for k=${badK}`, () => { + const ranked = reciprocalRankFusion( + makeVector("a", "b"), + makeKeyword("a", "b"), + { + vectorWeight: 0.5, + keywordWeight: 0.3, + recencyWeight: 0, + k: badK, + } + ); + + expect(ranked.length).toBe(2); + for (const item of ranked) { + expect(Number.isFinite(item.score)).toBe(true); + expect(item.score).toBeGreaterThan(0); + } + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1]!.score).toBeGreaterThanOrEqual(ranked[i]!.score); + } + }); + } +}); diff --git a/test/storage/fts-store-bm25.test.ts b/test/storage/fts-store-bm25.test.ts new file mode 100644 index 0000000..24574c8 --- /dev/null +++ b/test/storage/fts-store-bm25.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { FTSStore } from "../../src/storage/fts-store.js"; + +describe("FTSStore BM25 column weighting", () => { + let dataDir: string; + let store: FTSStore; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "mem-fts-bm25-")); + store = new FTSStore(dataDir); + }); + + afterEach(() => { + store.close(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + // regression: name column (weight 10) must outrank kind column (weight 0.1) + it("ranks a name-field match above a kind-only match for the same query term", () => { + store.bulkUpsert([ + { + id: "by-name", + name: "billing", + filePath: "src/by-name.ts", + content: "helper utility string processing unrelated text", + kind: "method", + }, + { + id: "by-kind", + name: "generic", + filePath: "src/by-kind.ts", + content: "helper utility string processing unrelated text", + kind: "billing", + }, + ]); + + const results = store.search("billing", 10); + expect(results.length).toBeGreaterThanOrEqual(2); + // The chunk whose NAME contains "billing" must rank above the one whose + // only match is in the low-weight kind column. + const nameIdx = results.findIndex((r) => r.id === "by-name"); + const kindIdx = results.findIndex((r) => r.id === "by-kind"); + expect(nameIdx).toBeLessThan(kindIdx); + expect(results[0].id).toBe("by-name"); + }); + + it("confirms the weighting with multiple name vs kind pairs", () => { + const chunks: Array<{ + id: string; + name: string; + filePath: string; + content: string; + kind: string; + }> = []; + for (let i = 0; i < 5; i++) { + chunks.push({ + id: `name-${i}`, + name: "billing", + filePath: `src/name-${i}.ts`, + content: "shared common text filler unrelated", + kind: "method", + }); + chunks.push({ + id: `kind-${i}`, + name: "generic", + filePath: `src/kind-${i}.ts`, + content: "shared common text filler unrelated", + kind: "billing", + }); + } + store.bulkUpsert(chunks); + + const results = store.search("billing", 20); + // Every name-match should appear before every kind-only match + const nameRanks = results + .filter((r) => r.id.startsWith("name-")) + .map((r) => results.indexOf(r)); + const kindRanks = results + .filter((r) => r.id.startsWith("kind-")) + .map((r) => results.indexOf(r)); + + for (const nr of nameRanks) { + for (const kr of kindRanks) { + expect(nr).toBeLessThan(kr); + } + } + }); +}); diff --git a/test/storage/metadata-store-cascade.test.ts b/test/storage/metadata-store-cascade.test.ts new file mode 100644 index 0000000..df1789a --- /dev/null +++ b/test/storage/metadata-store-cascade.test.ts @@ -0,0 +1,243 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { randomUUID } from "crypto"; +import { MetadataStore } from "../../src/storage/metadata-store.js"; +import type { StoredChunk } from "../../src/storage/types.js"; + +function tmpDir(): string { + const dir = join(tmpdir(), `metadata-cascade-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeChunk(overrides: Partial = {}): StoredChunk { + return { + id: randomUUID(), + filePath: "src/cascade.ts", + name: "myFunction", + kind: "function_declaration", + startLine: 1, + endLine: 10, + content: "function myFunction() {}", + docstring: undefined, + parentName: undefined, + language: "typescript", + indexedAt: new Date().toISOString(), + fileMtime: undefined, + ...overrides, + }; +} + +describe("MetadataStore cascade delete", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const d of dirs) { + try { + rmSync(d, { recursive: true }); + } catch { + // ignore + } + } + dirs.length = 0; + }); + + it("removes targets and community memberships when removeFile is called", () => { + const dir = tmpDir(); + dirs.push(dir); + const store = new MetadataStore(dir); + + const filePath = "src/billing.ts"; + const chunkA = makeChunk({ id: "chunk-a", filePath, name: "processPayment" }); + const chunkB = makeChunk({ id: "chunk-b", filePath, name: "createInvoice" }); + + store.upsertFile(filePath, "hash-1"); + store.upsertChunk(chunkA); + store.upsertChunk(chunkB); + + // Insert a target keyed by file_path + store.replaceAllTargets( + [ + { + id: "target-1", + kind: "symbol", + canonicalName: "processPayment", + normalizedName: "processpayment", + filePath, + ownerChunkId: "chunk-a", + subsystem: "billing", + confidence: 0.95, + }, + ], + [] + ); + + // Insert a community membership keyed by chunk_id + store.replaceTopology({ + communities: [ + { + id: "comm-1", + nodeCount: 2, + cohesion: 0.8, + label: "billing", + computedAt: new Date().toISOString(), + }, + ], + memberships: [ + { chunkId: "chunk-a", communityId: "comm-1" }, + { chunkId: "chunk-b", communityId: "comm-1" }, + ], + surprises: [], + godNodes: [ + { + chunkId: "chunk-a", + name: "processPayment", + filePath, + degree: 5, + communityId: "comm-1", + }, + ], + questions: [], + computedAt: new Date().toISOString(), + }); + + // regression: cascade-delete should clean up targets and topology rows + expect(store.findTargetsByFilePath(filePath)).toHaveLength(1); + expect(store.getCommunityForChunk("chunk-a")).toBe("comm-1"); + + store.removeFile(filePath); + + // Targets must be gone — no orphans + expect(store.findTargetsByFilePath(filePath)).toEqual([]); + // Community memberships must be gone + expect(store.getCommunityForChunk("chunk-a")).toBeUndefined(); + expect(store.getCommunityForChunk("chunk-b")).toBeUndefined(); + // God nodes must be gone + expect(store.getGodNodes().filter((g) => g.chunkId === "chunk-a")).toHaveLength(0); + // Chunks themselves must be gone + expect(store.findChunksByFilePath(filePath)).toHaveLength(0); + + store.close(); + }); + + it("removes targets and community memberships when removeFiles is called in batch", () => { + const dir = tmpDir(); + dirs.push(dir); + const store = new MetadataStore(dir); + + const filePath = "src/auth.ts"; + const chunk = makeChunk({ id: "chunk-x", filePath, name: "login" }); + + store.upsertFile(filePath, "hash-2"); + store.upsertChunk(chunk); + + store.replaceAllTargets( + [ + { + id: "target-2", + kind: "symbol", + canonicalName: "login", + normalizedName: "login", + filePath, + ownerChunkId: "chunk-x", + confidence: 0.9, + }, + ], + [] + ); + + store.replaceTopology({ + communities: [ + { + id: "comm-2", + nodeCount: 1, + cohesion: 0.5, + label: null, + computedAt: new Date().toISOString(), + }, + ], + memberships: [{ chunkId: "chunk-x", communityId: "comm-2" }], + surprises: [], + godNodes: [], + questions: [], + computedAt: new Date().toISOString(), + }); + + expect(store.findTargetsByFilePath(filePath)).toHaveLength(1); + expect(store.getCommunityForChunk("chunk-x")).toBe("comm-2"); + + store.removeFiles([filePath]); + + expect(store.findTargetsByFilePath(filePath)).toEqual([]); + expect(store.getCommunityForChunk("chunk-x")).toBeUndefined(); + + store.close(); + }); + + it("preserves targets and memberships for other files", () => { + const dir = tmpDir(); + dirs.push(dir); + const store = new MetadataStore(dir); + + const deletePath = "src/old.ts"; + const keepPath = "src/active.ts"; + + store.upsertFile(deletePath, "h1"); + store.upsertFile(keepPath, "h2"); + store.upsertChunk(makeChunk({ id: "c-del", filePath: deletePath })); + store.upsertChunk(makeChunk({ id: "c-keep", filePath: keepPath })); + + store.replaceAllTargets( + [ + { + id: "t-del", + kind: "symbol", + canonicalName: "old", + normalizedName: "old", + filePath: deletePath, + confidence: 0.5, + }, + { + id: "t-keep", + kind: "symbol", + canonicalName: "active", + normalizedName: "active", + filePath: keepPath, + confidence: 0.9, + }, + ], + [] + ); + + store.replaceTopology({ + communities: [ + { + id: "comm-x", + nodeCount: 2, + cohesion: 0.7, + label: null, + computedAt: new Date().toISOString(), + }, + ], + memberships: [ + { chunkId: "c-del", communityId: "comm-x" }, + { chunkId: "c-keep", communityId: "comm-x" }, + ], + surprises: [], + godNodes: [], + questions: [], + computedAt: new Date().toISOString(), + }); + + store.removeFile(deletePath); + + expect(store.findTargetsByFilePath(deletePath)).toEqual([]); + expect(store.findTargetsByFilePath(keepPath)).toHaveLength(1); + expect(store.getCommunityForChunk("c-del")).toBeUndefined(); + expect(store.getCommunityForChunk("c-keep")).toBe("comm-x"); + + store.close(); + }); +}); diff --git a/test/storage/stores-coverage.test.ts b/test/storage/stores-coverage.test.ts new file mode 100644 index 0000000..c87c29b --- /dev/null +++ b/test/storage/stores-coverage.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { openSqliteWithRecovery } from "../../src/storage/sqlite-utils.js"; +import { ChunkStore } from "../../src/storage/chunk-store.js"; +import { SemanticStore } from "../../src/storage/semantic-store.js"; +import { TargetStore } from "../../src/storage/target-store.js"; +import { CommunityStore } from "../../src/storage/community-store.js"; +import { StatsStore } from "../../src/storage/stats-store.js"; +import type Database from "better-sqlite3"; + +function openDb(dir: string, name: string): Database.Database { + return openSqliteWithRecovery(join(dir, name)); +} + +describe("SemanticStore", () => { + let dir: string; + let db: Database.Database; + let store: SemanticStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-semantic-")); + db = openDb(dir, "semantic.db"); + // SemanticStore.initSchema prepares a statement that joins the `chunks` + // table, so the chunk schema must exist first (mirrors MetadataStore boot). + new ChunkStore(db).initSchema(); + store = new SemanticStore(db); + store.initSchema(); + }); + + afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("round-trips chunk features", () => { + const feature = { + chunkId: "chunk-1", + filePath: "src/auth.ts", + returnsBoolean: true, + branchCount: 3, + guardCount: 1, + throwsCount: 2, + earlyReturnCount: 1, + callsPredicateCount: 4, + callerCount: 5, + calleeCount: 2, + isPredicate: true, + isValidator: true, + isGuard: false, + isController: false, + isRegistry: false, + isUiComponent: false, + writesState: true, + writesNetwork: false, + writesStorage: true, + docLike: false, + testLike: false, + }; + store.replaceChunkFeatures([feature]); + + const fetched = store.getChunkFeaturesByIds(["chunk-1"]); + expect(fetched).toHaveLength(1); + expect(fetched[0].chunkId).toBe("chunk-1"); + expect(fetched[0].branchCount).toBe(3); + expect(fetched[0].isPredicate).toBe(true); + expect(fetched[0].writesStorage).toBe(true); + }); + + it("replaces (not appends) chunk features for a file on re-upsert", () => { + store.replaceChunkFeatures([ + { chunkId: "a", filePath: "f.ts", returnsBoolean: false, branchCount: 1, guardCount: 0, throwsCount: 0, earlyReturnCount: 0, callsPredicateCount: 0, callerCount: 0, calleeCount: 0, isPredicate: false, isValidator: false, isGuard: false, isController: false, isRegistry: false, isUiComponent: false, writesState: false, writesNetwork: false, writesStorage: false, docLike: false, testLike: false }, + { chunkId: "b", filePath: "f.ts", returnsBoolean: false, branchCount: 1, guardCount: 0, throwsCount: 0, earlyReturnCount: 0, callsPredicateCount: 0, callerCount: 0, calleeCount: 0, isPredicate: false, isValidator: false, isGuard: false, isController: false, isRegistry: false, isUiComponent: false, writesState: false, writesNetwork: false, writesStorage: false, docLike: false, testLike: false }, + ]); + expect(store.getChunkFeaturesByIds(["a", "b"])).toHaveLength(2); + + // Re-upserting only one chunk for the same file wipes the other + store.replaceChunkFeatures([ + { chunkId: "b", filePath: "f.ts", returnsBoolean: false, branchCount: 9, guardCount: 0, throwsCount: 0, earlyReturnCount: 0, callsPredicateCount: 0, callerCount: 0, calleeCount: 0, isPredicate: false, isValidator: false, isGuard: false, isController: false, isRegistry: false, isUiComponent: false, writesState: false, writesNetwork: false, writesStorage: false, docLike: false, testLike: false }, + ]); + const fetched = store.getChunkFeaturesByIds(["a", "b"]); + expect(fetched).toHaveLength(1); + expect(fetched[0].chunkId).toBe("b"); + expect(fetched[0].branchCount).toBe(9); + }); + + it("round-trips file features and chunk tags", () => { + store.replaceFileFeatures([ + { filePath: "src/auth.ts", predicateCount: 2, validatorCount: 1, guardCount: 0, controllerCount: 0, registryCount: 0, uiComponentCount: 0, writesStateCount: 3, writesNetworkCount: 0, writesStorageCount: 1, docLike: false, testLike: false }, + ]); + store.replaceChunkTags([ + { chunkId: "c1", filePath: "src/auth.ts", tag: "predicate", weight: 0.9 }, + { chunkId: "c1", filePath: "src/auth.ts", tag: "guard", weight: 0.5 }, + ]); + + const files = store.getFileFeatures(["src/auth.ts"]); + expect(files).toHaveLength(1); + expect(files[0].predicateCount).toBe(2); + + const tags = store.getChunkTagsByIds(["c1"]); + expect(tags).toHaveLength(2); + // ordered by weight DESC then tag ASC + expect(tags[0].tag).toBe("predicate"); + expect(tags[0].weight).toBe(0.9); + }); + + it("removeByFile clears features and tags for that file", () => { + store.replaceChunkFeatures([ + { chunkId: "a", filePath: "src/x.ts", returnsBoolean: false, branchCount: 1, guardCount: 0, throwsCount: 0, earlyReturnCount: 0, callsPredicateCount: 0, callerCount: 0, calleeCount: 0, isPredicate: false, isValidator: false, isGuard: false, isController: false, isRegistry: false, isUiComponent: false, writesState: false, writesNetwork: false, writesStorage: false, docLike: false, testLike: false }, + ]); + store.replaceChunkTags([ + { chunkId: "a", filePath: "src/x.ts", tag: "t", weight: 1 }, + ]); + store.removeByFile("src/x.ts"); + + expect(store.getChunkFeaturesByIds(["a"])).toHaveLength(0); + expect(store.getChunkTagsByIds(["a"])).toHaveLength(0); + }); + + it("returns empty arrays for empty id lists and supports clearAll", () => { + expect(store.getChunkFeaturesByIds([])).toEqual([]); + expect(store.getChunkTagsByIds([])).toEqual([]); + expect(store.getFileFeatures([])).toEqual([]); + + store.replaceChunkFeatures([ + { chunkId: "a", filePath: "f.ts", returnsBoolean: false, branchCount: 1, guardCount: 0, throwsCount: 0, earlyReturnCount: 0, callsPredicateCount: 0, callerCount: 0, calleeCount: 0, isPredicate: false, isValidator: false, isGuard: false, isController: false, isRegistry: false, isUiComponent: false, writesState: false, writesNetwork: false, writesStorage: false, docLike: false, testLike: false }, + ]); + expect(store.getChunkFeaturesByIds(["a"])).toHaveLength(1); + store.clearAll(); + expect(store.getChunkFeaturesByIds(["a"])).toHaveLength(0); + }); + + it("no-ops when replacing empty arrays", () => { + store.replaceChunkFeatures([]); + store.replaceFileFeatures([]); + store.replaceChunkTags([]); + expect(store.getChunkFeaturesByIds(["any"])).toHaveLength(0); + }); +}); + +describe("TargetStore", () => { + let dir: string; + let db: Database.Database; + let store: TargetStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-target-")); + db = openDb(dir, "target.db"); + store = new TargetStore(db); + store.initSchema(); + }); + + afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + function seedTargets() { + store.replaceAll( + [ + { + id: "file_module:src/auth/session.ts", + kind: "file_module" as const, + canonicalName: "session", + normalizedName: "auth session", + filePath: "src/auth/session.ts", + ownerChunkId: "session-chunk", + subsystem: "auth", + confidence: 0.95, + }, + { + id: "endpoint:supabase/functions/auth/index.ts", + kind: "endpoint" as const, + canonicalName: "authenticate", + normalizedName: "authenticate", + filePath: "supabase/functions/auth/index.ts", + subsystem: "auth", + confidence: 0.9, + }, + ], + [ + { + targetId: "file_module:src/auth/session.ts", + alias: "session", + normalizedAlias: "auth session", + source: "file_path" as const, + weight: 0.96, + }, + { + targetId: "endpoint:supabase/functions/auth/index.ts", + alias: "authenticate", + normalizedAlias: "authenticate", + source: "slug" as const, + weight: 0.9, + }, + ] + ); + } + + it("inserts and finds targets by id", () => { + seedTargets(); + const found = store.findTargetById("file_module:src/auth/session.ts"); + expect(found).toBeDefined(); + expect(found!.canonicalName).toBe("session"); + expect(found!.subsystem).toBe("auth"); + + expect(store.findTargetById("missing")).toBeUndefined(); + }); + + it("getTargetsByIds returns matching targets and empty for empty input", () => { + seedTargets(); + expect(store.getTargetsByIds([])).toEqual([]); + const many = store.getTargetsByIds([ + "file_module:src/auth/session.ts", + "endpoint:supabase/functions/auth/index.ts", + "nope", + ]); + expect(many).toHaveLength(2); + }); + + it("resolves aliases and orders by weight desc", () => { + seedTargets(); + const hits = store.resolveAliases(["auth session", "authenticate"]); + expect(hits.length).toBeGreaterThanOrEqual(2); + // higher weight first + expect(hits[0].weight).toBeGreaterThanOrEqual(hits[1].weight); + expect(hits.some((h) => h.target.canonicalName === "session")).toBe(true); + }); + + it("resolveAliases returns empty for empty input and respects kind filter", () => { + seedTargets(); + expect(store.resolveAliases([])).toEqual([]); + + const endpoints = store.resolveAliases(["authenticate"], 25, ["endpoint"]); + expect(endpoints).toHaveLength(1); + expect(endpoints[0].target.kind).toBe("endpoint"); + + const none = store.resolveAliases(["authenticate"], 25, ["file_module"]); + expect(none).toHaveLength(0); + }); + + it("findTargetsByFilePath and findTargetsBySubsystem work", () => { + seedTargets(); + expect(store.findTargetsByFilePath("src/auth/session.ts")).toHaveLength(1); + const bySubsystem = store.findTargetsBySubsystem(["auth"]); + expect(bySubsystem).toHaveLength(2); + expect(bySubsystem[0].confidence).toBeGreaterThanOrEqual(bySubsystem[1].confidence); + expect(store.findTargetsBySubsystem([])).toEqual([]); + }); + + it("replaceAll wipes previous data and clearAll empties everything", () => { + seedTargets(); + expect(store.findTargetById("file_module:src/auth/session.ts")).toBeDefined(); + + store.replaceAll([], []); + expect(store.findTargetById("file_module:src/auth/session.ts")).toBeUndefined(); + expect(store.resolveAliases(["auth session"])).toEqual([]); + + seedTargets(); + store.clearAll(); + expect(store.findTargetById("file_module:src/auth/session.ts")).toBeUndefined(); + }); +}); + +describe("CommunityStore", () => { + let dir: string; + let db: Database.Database; + let store: CommunityStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-community-")); + db = openDb(dir, "community.db"); + store = new CommunityStore(db); + store.initSchema(); + }); + + afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + const computedAt = new Date().toISOString(); + + function seedTopology() { + store.replaceTopology({ + communities: [ + { id: "c1", nodeCount: 12, cohesion: 0.8, label: "auth", computedAt }, + { id: "c2", nodeCount: 5, cohesion: 0.6, label: null, computedAt }, + ], + memberships: [ + { chunkId: "chunk-a", communityId: "c1" }, + { chunkId: "chunk-b", communityId: "c1" }, + { chunkId: "chunk-c", communityId: "c2" }, + ], + surprises: [ + { sourceChunkId: "chunk-a", targetChunkId: "chunk-c", score: 9, reasons: ["cross-module"], relation: "calls", computedAt }, + ], + godNodes: [ + { chunkId: "chunk-a", name: "authenticate", filePath: "src/auth.ts", degree: 20, communityId: "c1" }, + ], + questions: [ + { type: "weak_spot", question: "Why does auth call storage?", why: "cross-community edge" }, + ], + computedAt, + }); + } + + it("getCommunityForChunk resolves membership", () => { + seedTopology(); + expect(store.getCommunityForChunk("chunk-a")).toBe("c1"); + expect(store.getCommunityForChunk("chunk-c")).toBe("c2"); + expect(store.getCommunityForChunk("unknown")).toBeUndefined(); + }); + + it("getCommunityInfo and getAllCommunities return records", () => { + seedTopology(); + const info = store.getCommunityInfo("c1"); + expect(info).toBeDefined(); + expect(info!.nodeCount).toBe(12); + expect(info!.label).toBe("auth"); + + const all = store.getAllCommunities(); + expect(all).toHaveLength(2); + // ordered by node_count DESC + expect(all[0].id).toBe("c1"); + }); + + it("getTopSurprises, getGodNodes, getSuggestedQuestions round-trip", () => { + seedTopology(); + const surprises = store.getTopSurprises(); + expect(surprises).toHaveLength(1); + expect(surprises[0].reasons).toEqual(["cross-module"]); + expect(surprises[0].relation).toBe("calls"); + + const gods = store.getGodNodes(); + expect(gods).toHaveLength(1); + expect(gods[0].degree).toBe(20); + + const qs = store.getSuggestedQuestions(); + expect(qs).toHaveLength(1); + expect(qs[0].type).toBe("weak_spot"); + }); + + it("replaceTopology replaces and clearAll empties", () => { + seedTopology(); + expect(store.getCommunityForChunk("chunk-a")).toBe("c1"); + + store.replaceTopology({ + communities: [{ id: "c9", nodeCount: 1, cohesion: 0.1, label: null, computedAt }], + memberships: [{ chunkId: "z", communityId: "c9" }], + surprises: [], + godNodes: [], + questions: [], + computedAt, + }); + expect(store.getCommunityForChunk("chunk-a")).toBeUndefined(); + expect(store.getCommunityForChunk("z")).toBe("c9"); + expect(store.getAllCommunities()).toHaveLength(1); + + store.clearAll(); + expect(store.getAllCommunities()).toHaveLength(0); + expect(store.getGodNodes()).toHaveLength(0); + }); +}); + +describe("StatsStore", () => { + let dir: string; + let db: Database.Database; + let store: StatsStore; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mem-stats-")); + db = openDb(dir, "stats.db"); + store = new StatsStore(db); + store.initSchema(); + }); + + afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("setStat/getStat persist string values", () => { + store.setStat("last_index", "2024-01-01"); + expect(store.getStat("last_index")).toBe("2024-01-01"); + expect(store.getStat("missing")).toBeUndefined(); + }); + + it("setStat overwrites existing values", () => { + store.setStat("counter", "1"); + store.setStat("counter", "42"); + expect(store.getStat("counter")).toBe("42"); + }); + + it("incrementStat accumulates numeric deltas", () => { + store.incrementStat("searches", 1); + store.incrementStat("searches", 4); + store.incrementStat("searches"); + // value stored as TEXT; parse numerically to be robust to "6" vs "6.0" + expect(Number(store.getStat("searches"))).toBe(6); + }); + + it("incrementRouteStat increments per-route keys", () => { + store.incrementRouteStat("standard"); + store.incrementRouteStat("standard"); + store.incrementRouteStat("concept"); + expect(Number(store.getStat("route_standard_count"))).toBe(2); + expect(Number(store.getStat("route_concept_count"))).toBe(1); + }); + + it("recordLatency/getLatencyPercentiles compute aggregates", () => { + expect(store.getLatencyPercentiles()).toEqual({ avg: 0, p50: 0, p95: 0, count: 0 }); + + store.recordLatency(10); + store.recordLatency(20); + store.recordLatency(30); + + const stats = store.getLatencyPercentiles(); + expect(stats.count).toBe(3); + expect(stats.avg).toBe(20); + expect(stats.p50).toBe(20); + expect(stats.p95).toBe(30); + }); + + it("prunes latency history to the most recent 1000 entries", () => { + for (let i = 1; i <= 1050; i++) store.recordLatency(i); + const stats = store.getLatencyPercentiles(); + expect(stats.count).toBe(1000); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cde7a7c..cb49a13 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,11 @@ export default defineConfig({ test: { globals: false, testTimeout: 30000, + // Benchmark/stress tests under test/benchmark/* measure latency and memory + // and are unreliable when run concurrently with the rest of the suite + // (contention inflates p95/heap numbers and causes flaky failures). They + // are excluded from the default `npm test` run and executed in isolation + // via the dedicated `npm run benchmark:memory` / stress scripts. + exclude: ["**/node_modules/**", "**/dist/**", "test/benchmark/**"], }, });