diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9ee828f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,15 @@ +# Core architecture and contracts +/src/core/ @clopca +/src/contracts/ @clopca +/src/search/ @clopca +/src/modes/ @clopca +/src/ai/ @clopca + +# Adapter surfaces +/src/adapters/ @clopca + +# Governance and CI +/.github/workflows/ @clopca +/scripts/ @clopca +/docs/architecture/ @clopca +/docs/engineering/ @clopca diff --git a/.github/platform-adapter-runbook.md b/.github/platform-adapter-runbook.md index 658099f..056505f 100644 --- a/.github/platform-adapter-runbook.md +++ b/.github/platform-adapter-runbook.md @@ -1,87 +1,46 @@ # Platform Adapter Runbook -This runbook covers operational startup, verification, and troubleshooting for Claude Code/Cursor adapter workers and compatibility validation. +This runbook covers startup, verification, and troubleshooting for Claude Code and Cursor adapter workers. ## Scope - Adapter workers: `open-mem-claude-code`, `open-mem-cursor` -- Bridge transports: stdio + optional HTTP -- External compatibility evidence workflow and release gate +- Bridge transports: stdio and optional HTTP +- Runtime diagnostics and smoke validation -## Startup (Ops-Safe) +## Startup 1. Build binaries: - `bun run build` 2. Enable adapters: - `OPEN_MEM_PLATFORM_CLAUDE_CODE=true` - `OPEN_MEM_PLATFORM_CURSOR=true` -3. Start workers (one process per platform): +3. Start workers: - `bunx open-mem-claude-code --project /path/to/project` - `bunx open-mem-cursor --project /path/to/project` -4. Optional HTTP mode for bridge checks: +4. Optional HTTP mode: - `bunx open-mem-claude-code --project /path/to/project --http-port 37877` -## Verification Workflow +## Verification -1. Run external verification harness: - - `bun run compat:verify --artifacts-dir artifacts/external-compat` - - Auto-detect installed client versions and run verification: - - `bun run compat:verify:auto --artifacts-dir artifacts/external-compat` -2. Render matrix from latest report: - - `bun run compat:matrix` -3. Run worker bridge smoke checks: - - `bun run compat:smoke --artifacts-dir artifacts/external-compat` -4. Check release gate: - - `bun run compat:gate` - -## Evidence Contract - -Each verification run must produce: - -- `external-compat-report.json` -- `transcripts//.jsonl` -- `logs/.log` -- `summary.md` -- `checksums.txt` - -CI bundles these as `external-compat-.zip`. - -## Failure Taxonomy - -### `CLIENT_PROTOCOL_DRIFT` - -- Trigger: MCP lifecycle/JSON-RPC mismatch. -- Remediation: patch MCP compatibility handling; add/update transcript fixtures; rerun verify. - -### `WORKER_BRIDGE_REGRESSION` - -- Trigger: `health`/`flush`/`shutdown` semantics or bridge transport failures. -- Remediation: patch `src/platform-worker.ts`; rerun smoke script; add targeted integration regression. - -### `ENVIRONMENT_DEPENDENCY` - -- Trigger: missing client binaries/version metadata on runner. -- Remediation: fix runner provisioning and version export (`OPEN_MEM_CLAUDE_CODE_VERSION`, `OPEN_MEM_CURSOR_VERSION`). - -### `NON_DETERMINISTIC_OUTPUT` - -- Trigger: unstable output/transcript causing assertion flake. -- Remediation: normalize volatile fields and tighten deterministic assertions. +1. Run integration tests: + - `bun test tests/integration/platform-worker.test.ts` + - `bun test tests/integration/platform-parity.test.ts` +2. Validate API/queue/runtime checks: + - `bun run quality:gate` + - `bun run test:servers` +3. If HTTP bridge is enabled, verify worker status endpoint: + - `curl http://localhost:37877/v1/health` ## Troubleshooting -- `ENV_MISSING_DIST_*`: run `bun run build` before verification scripts. -- `STALE_REPORT`: regenerate report with `compat:verify`; commit updated `docs/compatibility/external-compat-latest.json`. -- `MATRIX_MISMATCH`: run `compat:matrix` and commit updated `docs/mcp-compatibility-matrix.md`. -- `CLIENT_VERSION_UNKNOWN`: run `bun run compat:detect-versions` to inspect what is detected, then ensure relevant CLI/app is installed on the runner. -- Worker HTTP failures: verify port availability and local firewall constraints on runner host. - -## Soak Procedure (7-Day) - -- Run `external-compat` workflow nightly for 7 consecutive days. -- Publish weekly soak summary file: - - `docs/compatibility/soak-YYYY-MM-DD.md` -- GA requires: - - 7 successful runs, - - p95 targets met (`MCP < 250ms`, `worker ingest < 100ms`), - - failure rate < 1%. +- Worker not ingesting events: + - Ensure adapter env flag is enabled. + - Ensure input is newline-delimited JSON. + - Check for `UNSUPPORTED_EVENT` or `INVALID_JSON` responses. +- HTTP bridge not responding: + - Confirm port availability. + - Confirm process is running. +- Queue not draining: + - Check `/v1/health` and `/v1/metrics`. + - Run diagnostics endpoint: `GET /v1/diagnostics`. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9882b10 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## Summary + +- + +## Quality Gates + +- [ ] `bun run typecheck` +- [ ] `bun run lint` +- [ ] `bun run check:boundaries` +- [ ] `bun run quality:gate` + +## Change Checklist + +### API +- [ ] Shared schema updated (if needed) +- [ ] Contract tests updated +- [ ] Docs updated + +### Storage +- [ ] Migration compatibility validated +- [ ] Data integrity checks added/updated + +### Adapters +- [ ] No duplicated business logic introduced in adapters +- [ ] Contract conformance preserved across MCP/HTTP/OpenCode + +### Governance +- [ ] ADR linked for structural changes diff --git a/.github/workflows/architecture-health.yml b/.github/workflows/architecture-health.yml new file mode 100644 index 0000000..5366657 --- /dev/null +++ b/.github/workflows/architecture-health.yml @@ -0,0 +1,25 @@ +name: architecture-health + +on: + schedule: + - cron: "0 7 1 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install + run: bun install + - name: Generate report + run: bun run report:architecture > architecture-health.json + - name: Upload report + uses: actions/upload-artifact@v4 + with: + name: architecture-health + path: architecture-health.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9adcb2a..51f642b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,18 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Install run: bun install + - name: Typecheck + run: bun run typecheck + - name: Lint + run: bun run lint - name: Boundaries run: bun run check:boundaries + - name: Contract Drift + run: bun run check:contracts + - name: Architecture Fitness + run: bun run check:architecture + - name: Migration Compatibility + run: bun run check:migration - name: Quality Gate run: bun run quality:gate - name: Tests diff --git a/.github/workflows/external-compat.yml b/.github/workflows/external-compat.yml deleted file mode 100644 index c94f253..0000000 --- a/.github/workflows/external-compat.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: external-compat - -on: - schedule: - - cron: "0 6 * * *" - workflow_dispatch: - inputs: - claude_code_version: - description: "Claude Code version under test" - required: false - default: "unknown" - cursor_version: - description: "Cursor version under test" - required: false - default: "unknown" - -jobs: - verify: - runs-on: [self-hosted, macOS] - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - name: Install - run: bun install - - name: Build - run: bun run build - - name: Verify external clients - env: - OPEN_MEM_CLAUDE_CODE_VERSION: ${{ inputs.claude_code_version }} - OPEN_MEM_CURSOR_VERSION: ${{ inputs.cursor_version }} - run: bun run compat:verify --artifacts-dir .artifacts/external-compat - - name: Render compatibility matrix snapshot - run: | - bun run compat:matrix --report .artifacts/external-compat/external-compat-report.json --matrix docs/mcp-compatibility-matrix.md - cp docs/mcp-compatibility-matrix.md .artifacts/external-compat/mcp-compatibility-matrix.md - - name: Smoke worker lifecycle/bridge - run: bun run compat:smoke --artifacts-dir .artifacts/external-compat - - name: Build artifact bundle - run: | - cd .artifacts - zip -r external-compat-${{ github.run_id }}.zip external-compat - - name: Upload artifact bundle - uses: actions/upload-artifact@v4 - with: - name: external-compat-${{ github.run_id }} - path: .artifacts/external-compat-${{ github.run_id }}.zip - if-no-files-found: error diff --git a/.github/workflows/nightly-matrix.yml b/.github/workflows/nightly-matrix.yml new file mode 100644 index 0000000..b4b6933 --- /dev/null +++ b/.github/workflows/nightly-matrix.yml @@ -0,0 +1,22 @@ +name: nightly-matrix + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + structural-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install + run: bun install + - name: Run nightly checks + run: | + bun run typecheck + bun run check:boundaries + bun run check:contracts + bun run check:architecture + bun run quality:gate diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 18e5f9b..b7a456b 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -6,12 +6,26 @@ on: workflow_dispatch: jobs: - compat-gate: + quality-gate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - name: Install run: bun install - - name: External compatibility release gate - run: bun run compat:gate + - name: Typecheck + run: bun run typecheck + - name: Lint + run: bun run lint + - name: Boundaries + run: bun run check:boundaries + - name: Contract Drift + run: bun run check:contracts + - name: Architecture Fitness + run: bun run check:architecture + - name: Migration Compatibility + run: bun run check:migration + - name: Quality Gate + run: bun run quality:gate + - name: Test Suite + run: bun run test:repo diff --git a/bun.lock b/bun.lock index aa8fa18..d67435e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,6 @@ "@ai-sdk/google": "^3.0.22", "@openrouter/ai-sdk-provider": "^2.1.1", "ai": "^6.0.77", - "open-mem": "^0.9.0", "sqlite-vec": "^0.1.7-alpha.2", "zod": "^4.3.6", }, @@ -94,8 +93,6 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - "open-mem": ["open-mem@0.9.0", "", { "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.51", "@ai-sdk/anthropic": "^3.0.38", "@ai-sdk/google": "^3.0.22", "ai": "^6.0.77", "sqlite-vec": "^0.1.7-alpha.2", "zod": "^4.3.6" }, "bin": { "open-mem-mcp": "dist/mcp.js", "open-mem-daemon": "dist/daemon.js", "open-mem-maintenance": "dist/maintenance.js", "open-mem-claude-code": "dist/claude-code.js", "open-mem-cursor": "dist/cursor.js" } }, "sha512-PGp8X/xw14LsaWcBOKDbz6Lz665ISgAJMOT57X9Jg+jigKN56/7Dw/wRU5fxmWzSeS1t3J7xEUiMv5h1hZagDQ=="], - "sqlite-vec": ["sqlite-vec@0.1.7-alpha.2", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", "sqlite-vec-darwin-x64": "0.1.7-alpha.2", "sqlite-vec-linux-arm64": "0.1.7-alpha.2", "sqlite-vec-linux-x64": "0.1.7-alpha.2", "sqlite-vec-windows-x64": "0.1.7-alpha.2" } }, "sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ=="], "sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw=="], diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 875166e..0ab8365 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -59,7 +59,6 @@ export default defineConfig({ { text: "HTTP API", link: "/api" }, { text: "Dashboard", link: "/dashboard" }, { text: "Platform Adapters", link: "/platforms" }, - { text: "MCP Compatibility", link: "/mcp-compatibility-matrix" }, ], }, { @@ -67,6 +66,9 @@ export default defineConfig({ items: [ { text: "Privacy & Security", link: "/privacy" }, { text: "Troubleshooting", link: "/troubleshooting" }, + { text: "Quality Gates", link: "/engineering/quality-gates" }, + { text: "Deprecation Policy", link: "/engineering/deprecation-policy" }, + { text: "Technical Debt Policy", link: "/engineering/technical-debt-policy" }, { text: "Changelog", link: "/changelog" }, { text: "Contributing", diff --git a/docs/api.md b/docs/api.md index d1b4f71..b955aef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -277,6 +277,56 @@ Response: } ``` +### Readiness + +``` +GET /v1/readiness +``` + +Returns whether the system is ready to serve memory operations, plus reasons when not ready. + +### Diagnostics + +``` +GET /v1/diagnostics +``` + +Runs setup diagnostics (provider config, adapter enablement, db path, dashboard config). + +### Tools Guide + +``` +GET /v1/tools/guide +``` + +Returns canonical tool workflow guidance and contract metadata. + +### Queue Status + +``` +GET /v1/queue +``` + +Operator endpoint for queue state. Localhost access only. + +### Trigger Queue Processing + +``` +POST /v1/queue/process +``` + +Triggers one queue processing batch. Localhost access only. + +Response: + +```json +{ + "data": { "processed": 4 }, + "error": null, + "meta": {} +} +``` + ### Metrics ``` diff --git a/docs/architecture.md b/docs/architecture.md index 168426e..e660784 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ The memory lifecycle has three phases: 1. **Capture** — OpenCode hooks (`tool.execute.after`, `chat.message`) intercept tool outputs and user prompts, redact sensitive content, and enqueue pending observations. 2. **Processing** — On `session.idle`, the queue processor batches pending items and sends them to the AI compressor. Each raw capture is distilled into a typed observation with title, narrative, concepts, and importance. Embeddings and entity extraction run in parallel when configured. -3. **Retrieval** — At session start, the context injector assembles a token-budgeted index from recent observations and injects it into the system prompt. During the session, `memory.find` performs hybrid search and `memory.get` fetches full observation details on demand. +3. **Retrieval** — At session start, the context injector assembles a token-budgeted index from recent observations and injects it into the system prompt. During the session, `mem-find` performs hybrid search and `mem-get` fetches full observation details on demand. ## Module Structure @@ -77,8 +77,8 @@ SQLite remains local and project-scoped (`.open-mem/memory.db`), with optional u Observation lineage is immutable: -1. `memory.revise` creates a new revision row and marks the prior active row as superseded -2. `memory.remove` writes a tombstone (`deleted_at`) on the active row +1. `mem-revise` creates a new revision row and marks the prior active row as superseded +2. `mem-remove` writes a tombstone (`deleted_at`) on the active row 3. Default retrieval/search returns only active rows (`superseded_by IS NULL` and `deleted_at IS NULL`) Schema baseline includes v10 migration columns: @@ -107,7 +107,7 @@ If no API key is set, a fallback compressor extracts basic metadata without AI. open-mem injects a compact index into the system prompt at session start. Each entry shows a type icon, title, token cost, and related files — giving the agent a map of what's in memory without consuming the full context window. -The agent sees *what* exists and decides *what to fetch* using `memory.find` and `memory.get`. This minimizes context window usage while providing full access to all stored observations. +The agent sees *what* exists and decides *what to fetch* using `mem-find` and `mem-get`. This minimizes context window usage while providing full access to all stored observations. ### Token ROI Tracking @@ -135,11 +135,11 @@ Queue runtime controls switching between modes and liveness fallback. ### OpenCode - **Hooks**: `tool.execute.after`, `chat.message`, `event`, `experimental.chat.system.transform`, `experimental.session.compacting` -- **Tools**: All 9 `memory.*` tools +- **Tools**: All 9 `mem-*` tools ### MCP Server -- Same 9 tools over stdin/stdout JSON-RPC (`memory.*` namespace) +- Same 9 tools over stdin/stdout JSON-RPC (`mem-*` namespace) - Strict lifecycle support with protocol-version negotiation ### Dashboard (HTTP) diff --git a/docs/architecture/adr/ADR-0001-architecture-principles.md b/docs/architecture/adr/ADR-0001-architecture-principles.md new file mode 100644 index 0000000..669759a --- /dev/null +++ b/docs/architecture/adr/ADR-0001-architecture-principles.md @@ -0,0 +1,30 @@ +# ADR-0001: Architecture Principles + +- Status: Accepted +- Date: 2026-02-16 +- Owners: core maintainers + +## Context +open-mem is evolving into a multi-surface memory platform with MCP, HTTP, and platform adapters. +Without strict architecture principles, feature additions can create cross-layer coupling and long-term debt. + +## Decision +1. Domain and contracts must be adapter-agnostic. +2. Shared schemas are the source of truth across MCP/HTTP/OpenCode tool surfaces. +3. Core orchestration should compose extension points, not embed specialized logic directly. +4. Breaking external API changes require explicit versioning and deprecation metadata. +5. Every structural change requires an ADR. + +## Consequences +- Positive: + - Easier long-term maintenance and safer refactors. + - Less duplicated logic across transports. +- Negative: + - More upfront design and review overhead. + - Slightly slower short-term feature delivery. + +## Alternatives Considered +1. Keep architecture guidance informal in PR descriptions: + - Rejected because decisions become hard to audit and easy to bypass over time. +2. Optimize only for short-term delivery with minimal governance: + - Rejected because it increases coupling and raises future refactor cost. diff --git a/docs/architecture/adr/ADR-TEMPLATE.md b/docs/architecture/adr/ADR-TEMPLATE.md new file mode 100644 index 0000000..2557ad4 --- /dev/null +++ b/docs/architecture/adr/ADR-TEMPLATE.md @@ -0,0 +1,20 @@ +# ADR-XXXX: [Title] + +- Status: Proposed | Accepted | Superseded +- Date: YYYY-MM-DD +- Owners: @team-or-user + +## Context +Problem statement and constraints. + +## Decision +What we decided and why. + +## Consequences +- Positive: +- Negative: +- Follow-up: + +## Alternatives Considered +1. Option A +2. Option B diff --git a/docs/changelog.md b/docs/changelog.md index 8089c04..f50e160 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -48,13 +48,12 @@ All notable changes to open-mem are documented here. For the full changelog incl - Import-boundary validation script (`bun run check:boundaries`) ### Changed -- Tool names renamed from `mem-*` prefix to `memory.*` namespace (e.g., `mem-search` → `memory.find`, `mem-save` → `memory.create`) -- `memory.revise` now uses immutable revision semantics -- `memory.remove` now uses tombstone semantics (soft-delete) +- Tooling and docs now use the canonical `mem-*` contract names (`mem-find`, `mem-create`, etc.) across transports. +- `mem-revise` now uses immutable revision semantics +- `mem-remove` now uses tombstone semantics (soft-delete) - Schema baseline extended to v10 (`scope`, `revision_of`, `deleted_at` + indexes) ### Removed -- Internal backward-compatibility guarantees with pre-`0.7.0` schema internals - Package self-dependency - Legacy server files — replaced by adapter architecture diff --git a/docs/compatibility/external-compat-latest.json b/docs/compatibility/external-compat-latest.json deleted file mode 100644 index cb71e1b..0000000 --- a/docs/compatibility/external-compat-latest.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "schemaVersion": "1.0.0", - "generatedAt": "2026-02-08T01:07:27.649Z", - "gitSha": "15066cc3ef6b65b46c3427c59cc92a1e4b56f66b", - "environment": { - "os": "darwin", - "arch": "arm64", - "bunVersion": "1.3.0", - "runner": "local" - }, - "policy": { - "freshnessDays": 7, - "versionScope": "latest-stable-only", - "verificationScope": "macOS self-hosted verification only" - }, - "clients": [ - { - "name": "claude-code", - "transport": "stdio", - "protocolVersion": "2024-11-05", - "status": "supported", - "version": { - "detected": "2.0.76", - "source": "env" - }, - "requiredScenarios": [ - { - "id": "happy-path-handshake-tool", - "name": "initialize + notifications/initialized + tools/list + tools/call + ping", - "passed": true, - "durationMs": 111.18 - }, - { - "id": "strict-preinit-reject", - "name": "strict rejects tools/list before initialize", - "passed": true, - "durationMs": 71.16 - }, - { - "id": "validation-error-determinism", - "name": "invalid tool args return structured VALIDATION_ERROR", - "passed": true, - "durationMs": 68.26 - }, - { - "id": "unknown-method-mapping", - "name": "unknown method maps to -32601", - "passed": true, - "durationMs": 67.37 - } - ], - "knownLimitations": [ - "None." - ], - "artifacts": { - "transcriptsDir": "transcripts/claude-code", - "logFile": "logs/claude-code.log" - } - }, - { - "name": "cursor", - "transport": "stdio", - "protocolVersion": "2024-11-05", - "status": "supported", - "version": { - "detected": "2.4.28", - "source": "env" - }, - "requiredScenarios": [ - { - "id": "happy-path-handshake-tool", - "name": "initialize + notifications/initialized + tools/list + tools/call + ping", - "passed": true, - "durationMs": 69.49 - }, - { - "id": "strict-preinit-reject", - "name": "strict rejects tools/list before initialize", - "passed": true, - "durationMs": 64.67 - }, - { - "id": "validation-error-determinism", - "name": "invalid tool args return structured VALIDATION_ERROR", - "passed": true, - "durationMs": 66.08 - }, - { - "id": "unknown-method-mapping", - "name": "unknown method maps to -32601", - "passed": true, - "durationMs": 71.14 - } - ], - "knownLimitations": [ - "None." - ], - "artifacts": { - "transcriptsDir": "transcripts/cursor", - "logFile": "logs/cursor.log" - } - } - ], - "summary": { - "scenarioCount": 8, - "failedScenarios": 0, - "failureRate": 0, - "allRequiredPassed": true, - "mcpToolCallP95Ms": 111.18, - "workerEventIngestP95Ms": 7.13 - }, - "slo": { - "mcpToolCallP95TargetMs": 250, - "workerEventIngestP95TargetMs": 100, - "externalFailureRateTarget": 0.01, - "met": true - }, - "failureTaxonomy": [ - { - "code": "CLIENT_PROTOCOL_DRIFT", - "description": "Lifecycle or JSON-RPC behavior mismatch with claimed protocol contract.", - "remediation": "Patch MCP compatibility handling, add/update transcript fixtures, rerun verification." - }, - { - "code": "WORKER_BRIDGE_REGRESSION", - "description": "Platform worker bridge command/transport behavior mismatch.", - "remediation": "Patch platform worker bridge implementation, rerun smoke checks, add targeted regression tests." - }, - { - "code": "ENVIRONMENT_DEPENDENCY", - "description": "Client binary/version unavailable or runtime dependency missing on runner.", - "remediation": "Fix runner provisioning/install scripts and pin verified client setup before re-running." - }, - { - "code": "NON_DETERMINISTIC_OUTPUT", - "description": "Unstable output shape prevents deterministic verification.", - "remediation": "Normalize volatile fields in harness output and tighten fixture assertions to stable keys." - } - ] -} diff --git a/docs/configuration.md b/docs/configuration.md index d53e2e5..b4e6c04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,7 +72,6 @@ Settings are resolved in this order (later sources override earlier ones): | Variable | Default | Description | |---|---|---| -| `OPEN_MEM_MCP_COMPAT_MODE` | `strict` | MCP mode: `strict` or `legacy` | | `OPEN_MEM_MCP_PROTOCOL_VERSION` | `2024-11-05` | Preferred MCP protocol version | | `OPEN_MEM_MCP_SUPPORTED_PROTOCOLS` | `2024-11-05` | Comma-separated supported versions | diff --git a/docs/dashboard.md b/docs/dashboard.md index 71a03f4..09498e6 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -32,7 +32,7 @@ Groups observations by coding session. Shows session duration, message count, an ### Search -Full-text and semantic search across all observations. Supports filtering by type, date range, and importance. Results are ranked using the same hybrid search (FTS5 + vector + RRF) as the `memory.find` tool. +Full-text and semantic search across all observations. Supports filtering by type, date range, and importance. Results are ranked using the same hybrid search (FTS5 + vector + RRF) as the `mem-find` tool. ### Stats diff --git a/docs/engineering/deprecation-policy.md b/docs/engineering/deprecation-policy.md new file mode 100644 index 0000000..8188f7c --- /dev/null +++ b/docs/engineering/deprecation-policy.md @@ -0,0 +1,14 @@ +# Deprecation Policy + +## Lifecycle +1. Introduced +2. Deprecated (warning + replacement) +3. Removed (major version only) + +## Rules +1. Every deprecated API/tool/field must include: + - Deprecation message + - Replacement path + - Planned removal version +2. `/v1` remains stable unless a `/v2` is introduced. +3. Internal refactors may break internal APIs, but external changes must be explicit. diff --git a/docs/engineering/quality-gates.md b/docs/engineering/quality-gates.md new file mode 100644 index 0000000..9d67569 --- /dev/null +++ b/docs/engineering/quality-gates.md @@ -0,0 +1,21 @@ +# Quality Gates + +All PRs must pass: + +1. `bun run typecheck` +2. `bun run lint` +3. `bun run check:boundaries` +4. `bun run check:contracts` +5. `bun run check:architecture` +6. `bun run check:migration` (for storage/contract changes) +7. `bun run quality:gate` + +## Change-specific requirements + +- API changes: + - Update schemas and tests. + - Update docs (`docs/openapi.v1.json`, tool docs). +- Storage changes: + - Migration test + integrity test. +- Adapter changes: + - Contract conformance + smoke test. diff --git a/docs/engineering/technical-debt-policy.md b/docs/engineering/technical-debt-policy.md new file mode 100644 index 0000000..611a7f2 --- /dev/null +++ b/docs/engineering/technical-debt-policy.md @@ -0,0 +1,15 @@ +# Technical Debt Policy + +## Temporary workaround requirements +Any temporary workaround must include: +- Linked issue ID +- Owner +- Expiry milestone/date + +## Prohibited +- Anonymous `TODO`/`FIXME` in core modules +- Workarounds without removal criteria + +## Review cadence +- Monthly architecture health review +- Debt report generated by `scripts/architecture-health-report.ts` diff --git a/docs/getting-started.md b/docs/getting-started.md index 9aa9610..e8d3232 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -103,7 +103,7 @@ From your second session, you'll see a memory block injected into the system pro 🐛 [bugfix] Fix off-by-one in pagination (~95 tokens) — src/api/list.ts ``` -The agent can then use `memory.find` and `memory.get` to fetch full details about any observation. +The agent can then use `mem-find` and `mem-get` to fetch full details about any observation. ## Using the Tools @@ -113,7 +113,7 @@ Ask your agent to search memory naturally: > "What do we know about the pricing module?" -The agent will use `memory.find` to find relevant observations. +The agent will use `mem-find` to find relevant observations. ### Save Important Context @@ -121,24 +121,24 @@ Ask the agent to remember something: > "Remember that we decided to use SQLite instead of PostgreSQL for the local cache." -The agent will use `memory.create` to create a manual observation. +The agent will use `mem-create` to create a manual observation. ### View Session History > "Show me what we worked on in recent sessions." -The agent will use `memory.history` to display session history. +The agent will use `mem-history` to display session history. ### Recall Full Details > "Get the full details on observation #abc123." -The agent will use `memory.get` to fetch the complete observation. +The agent will use `mem-get` to fetch the complete observation. ## Verify It Works 1. **Check plugin is loaded** — look for `[open-mem]` messages in OpenCode logs -2. **Check observations exist** — use the `memory.history` tool after your first session +2. **Check observations exist** — use the `mem-history` tool after your first session 3. **Check context injection** — look for the memory block at the start of your second session ## External Platform Workers @@ -159,6 +159,6 @@ Workers consume newline-delimited JSON events on stdin and write into the same p ## Next Steps - [Architecture](/architecture) — understand how open-mem works internally -- [Memory Tools](/tools) — reference for all 9 MCP tools +- [Memory Tools](/tools) — reference for all memory tools - [Configuration](/configuration) — all environment variables and options - [Privacy & Security](/privacy) — how your data is protected diff --git a/docs/mcp-compatibility-matrix.md b/docs/mcp-compatibility-matrix.md deleted file mode 100644 index 3315bfc..0000000 --- a/docs/mcp-compatibility-matrix.md +++ /dev/null @@ -1,49 +0,0 @@ -# MCP Compatibility Matrix - -This matrix tracks observed compatibility of `open-mem` MCP behavior across clients. - -## Verification Policy - -A client row is marked `Supported` only when all of the following are true: - -- Latest stable client version is captured in verification evidence. -- Required external scenario suite passes on self-hosted macOS runner. -- Evidence bundle is attached to CI (`external-compat-.zip`) and machine-parseable. -- Known limitations section is explicit (can be `None.`). -- Verification evidence age is <= 7 days at release time. - -Platform scope for this matrix is currently **macOS-only verification**. Other OS targets remain unverified until dedicated runs are added. - - -| Client | Transport | Protocol Version | Client Version | Status | Verified On | Notes | Known Limitations | -|---|---|---:|---|---|---|---|---| -| OpenCode MCP client | stdio | 2024-11-05 | n/a | Supported | Internal regression suite | Full lifecycle and transcript harness coverage. | None. | -| Claude Code MCP integration | stdio | 2024-11-05 | 2.0.76 | Supported | 2026-02-08T01:07:27.649Z | 4/4 required scenarios passed | None. | -| Cursor MCP integration | stdio | 2024-11-05 | 2.4.28 | Supported | 2026-02-08T01:07:27.649Z | 4/4 required scenarios passed | None. | - - -## Compatibility Rules - -- `initialize` negotiates protocol version and rejects unsupported versions with JSON-RPC `-32602`. -- `notifications/initialized` is accepted as notification (no response body). -- In `strict` mode, methods other than `initialize` are rejected before initialization (`-32002`). -- Tool parameter validation errors are returned as structured `VALIDATION_ERROR` envelopes in MCP tool results. -- Unknown RPC methods return JSON-RPC `-32601`. - -## Required External Scenarios - -- Happy path handshake + tool call. -- Strict pre-init rejection. -- Validation error determinism. -- Unknown method mapping. -- Worker invalid JSON handling + recovery. -- Session ingest parity outcome check. - -## Test Coverage - -Internal coverage remains in: - -- `tests/servers/mcp-server.test.ts` -- `tests/servers/mcp-transcripts.test.ts` -- `tests/integration/platform-parity.test.ts` -- `tests/integration/platform-worker.test.ts` diff --git a/docs/openapi.v1.json b/docs/openapi.v1.json index d8f9bf2..58053bd 100644 --- a/docs/openapi.v1.json +++ b/docs/openapi.v1.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "open-mem API", - "version": "0.7.0" + "version": "1.0.0" }, "paths": { "/v1/config/schema": { "get": { "summary": "Config field schema" } }, @@ -24,6 +24,11 @@ "/v1/memory/import": { "post": { "summary": "Import memory" } }, "/v1/memory/stats": { "get": { "summary": "Memory stats" } }, "/v1/health": { "get": { "summary": "Runtime health summary" } }, + "/v1/readiness": { "get": { "summary": "Runtime readiness status" } }, + "/v1/diagnostics": { "get": { "summary": "Setup and configuration diagnostics" } }, + "/v1/tools/guide": { "get": { "summary": "Canonical tool workflow and contract metadata" } }, + "/v1/queue": { "get": { "summary": "Queue operator status (localhost only)" } }, + "/v1/queue/process": { "post": { "summary": "Trigger queue processing batch (localhost only)" } }, "/v1/metrics": { "get": { "summary": "Runtime metrics and queue diagnostics" } }, "/v1/platforms": { "get": { "summary": "Platform adapter capabilities and enabled state" } }, "/v1/modes": { "get": { "summary": "List mode presets" } }, diff --git a/docs/platforms.md b/docs/platforms.md index 5020fdb..015d98b 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -21,7 +21,7 @@ open-mem registers these OpenCode hooks: ## MCP Server Mode -open-mem includes a standalone MCP (Model Context Protocol) server that exposes all 9 memory tools to any MCP-compatible AI client. +open-mem includes a standalone MCP (Model Context Protocol) server that exposes all memory tools to any MCP-compatible AI client. ### Running the Server @@ -47,10 +47,10 @@ Add to your MCP client config: ### Protocol Details - **Transport**: stdin/stdout, JSON-RPC 2.0 -- **Tools exposed**: `memory.find`, `memory.create`, `memory.history`, `memory.get`, `memory.transfer.export`, `memory.transfer.import`, `memory.revise`, `memory.remove`, `memory.help` +- **Tools exposed**: `mem-find`, `mem-create`, `mem-history`, `mem-get`, `mem-export`, `mem-import`, `mem-maintenance`, `mem-revise`, `mem-remove`, `mem-help` - **Lifecycle**: `initialize` → `notifications/initialized` → `tools/list` / `tools/call` - **Protocol version**: `2024-11-05` (negotiated during initialize) -- **Compat mode**: `strict` (requires initialize before tool calls) or `legacy` +- **Initialization requirement**: strict (tool calls are rejected until `initialize` + `notifications/initialized`) ## Claude Code Adapter diff --git a/docs/privacy.md b/docs/privacy.md index 3452371..cebe597 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -98,7 +98,7 @@ export OPEN_MEM_RETENTION_DAYS=0 ## Observation Lineage -When you use `memory.remove`, observations are **soft-deleted** (tombstoned), not hard-deleted. This preserves lineage for audit purposes while hiding them from search and recall. +When you use `mem-remove`, observations are **soft-deleted** (tombstoned), not hard-deleted. This preserves lineage for audit purposes while hiding them from search and recall. To fully purge all data: diff --git a/docs/schemas/external-compat-report.schema.json b/docs/schemas/external-compat-report.schema.json deleted file mode 100644 index 387cc10..0000000 --- a/docs/schemas/external-compat-report.schema.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://open-mem.dev/schemas/external-compat-report.schema.json", - "title": "Open Mem External Compatibility Report", - "type": "object", - "additionalProperties": false, - "required": [ - "schemaVersion", - "generatedAt", - "gitSha", - "environment", - "policy", - "clients", - "summary", - "slo", - "failureTaxonomy" - ], - "properties": { - "schemaVersion": { "type": "string", "const": "1.0.0" }, - "generatedAt": { "type": "string", "format": "date-time" }, - "gitSha": { "type": "string", "minLength": 7 }, - "environment": { - "type": "object", - "additionalProperties": false, - "required": ["os", "arch", "bunVersion", "runner"], - "properties": { - "os": { "type": "string" }, - "arch": { "type": "string" }, - "bunVersion": { "type": "string" }, - "runner": { "type": "string" } - } - }, - "policy": { - "type": "object", - "additionalProperties": false, - "required": ["freshnessDays", "versionScope", "verificationScope"], - "properties": { - "freshnessDays": { "type": "integer", "minimum": 1 }, - "versionScope": { "type": "string", "enum": ["latest-stable-only"] }, - "verificationScope": { "type": "string" } - } - }, - "clients": { - "type": "array", - "minItems": 2, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "name", - "transport", - "protocolVersion", - "status", - "version", - "requiredScenarios", - "knownLimitations", - "artifacts" - ], - "properties": { - "name": { "type": "string", "enum": ["claude-code", "cursor"] }, - "transport": { "type": "string", "const": "stdio" }, - "protocolVersion": { "type": "string", "const": "2024-11-05" }, - "status": { - "type": "string", - "enum": ["supported", "expected-supported", "failed"] - }, - "version": { - "type": "object", - "additionalProperties": false, - "required": ["detected", "source"], - "properties": { - "detected": { "type": "string", "minLength": 1 }, - "source": { "type": "string", "enum": ["env", "manual", "unknown"] } - } - }, - "requiredScenarios": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "name", "passed", "durationMs"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "name": { "type": "string", "minLength": 1 }, - "passed": { "type": "boolean" }, - "durationMs": { "type": "number", "minimum": 0 }, - "failureCode": { "type": "string" }, - "details": { "type": "string" }, - "metrics": { - "type": "object", - "additionalProperties": false, - "properties": { - "p95Ms": { "type": "number", "minimum": 0 }, - "samples": { "type": "integer", "minimum": 0 } - } - } - } - } - }, - "knownLimitations": { - "type": "array", - "items": { "type": "string" } - }, - "artifacts": { - "type": "object", - "additionalProperties": false, - "required": ["transcriptsDir", "logFile"], - "properties": { - "transcriptsDir": { "type": "string", "minLength": 1 }, - "logFile": { "type": "string", "minLength": 1 } - } - } - } - } - }, - "summary": { - "type": "object", - "additionalProperties": false, - "required": [ - "scenarioCount", - "failedScenarios", - "failureRate", - "allRequiredPassed", - "mcpToolCallP95Ms", - "workerEventIngestP95Ms" - ], - "properties": { - "scenarioCount": { "type": "integer", "minimum": 0 }, - "failedScenarios": { "type": "integer", "minimum": 0 }, - "failureRate": { "type": "number", "minimum": 0 }, - "allRequiredPassed": { "type": "boolean" }, - "mcpToolCallP95Ms": { "type": "number", "minimum": 0 }, - "workerEventIngestP95Ms": { "type": "number", "minimum": 0 } - } - }, - "slo": { - "type": "object", - "additionalProperties": false, - "required": [ - "mcpToolCallP95TargetMs", - "workerEventIngestP95TargetMs", - "externalFailureRateTarget", - "met" - ], - "properties": { - "mcpToolCallP95TargetMs": { "type": "number", "const": 250 }, - "workerEventIngestP95TargetMs": { "type": "number", "const": 100 }, - "externalFailureRateTarget": { "type": "number", "const": 0.01 }, - "met": { "type": "boolean" } - } - }, - "failureTaxonomy": { - "type": "array", - "minItems": 4, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["code", "description", "remediation"], - "properties": { - "code": { - "type": "string", - "enum": [ - "CLIENT_PROTOCOL_DRIFT", - "WORKER_BRIDGE_REGRESSION", - "ENVIRONMENT_DEPENDENCY", - "NON_DETERMINISTIC_OUTPUT" - ] - }, - "description": { "type": "string" }, - "remediation": { "type": "string" } - } - } - } - } -} diff --git a/docs/search.md b/docs/search.md index 0e53be7..cfd59ce 100644 --- a/docs/search.md +++ b/docs/search.md @@ -1,10 +1,10 @@ # Search -open-mem's search goes beyond simple keyword matching. When you call `memory.find`, it can combine full-text search, vector similarity, knowledge graph traversal, and LLM reranking to surface the most relevant observations — even when you don't remember the exact words used. +open-mem's search goes beyond simple keyword matching. When you call `mem-find`, it can combine full-text search, vector similarity, knowledge graph traversal, and LLM reranking to surface the most relevant observations — even when you don't remember the exact words used. ## How Search Works -`memory.find` runs a multi-stage pipeline: +`mem-find` runs a multi-stage pipeline: 1. **Query parsing** — extracts keywords and intent from your search query 2. **FTS5 full-text search** — fast keyword matching against titles, narratives, concepts, and facts @@ -88,27 +88,27 @@ The reranker respects the rate limiter and fallback chain like all other AI oper Best for finding specific terms, function names, or file paths: -``` -memory.find({ query: "calculateDiscount" }) -memory.find({ query: "src/pricing.ts" }) +```ts +mem-find({ query: "calculateDiscount" }) +mem-find({ query: "src/pricing.ts" }) ``` ### Concept Search Best for finding observations about a topic: -``` -memory.find({ query: "authentication flow" }) -memory.find({ query: "database migration strategy" }) +```ts +mem-find({ query: "authentication flow" }) +mem-find({ query: "database migration strategy" }) ``` ### Type-Filtered Search Narrow results to specific observation types: -``` -memory.find({ query: "pricing", type: "decision" }) -memory.find({ query: "login", type: "bugfix" }) +```ts +mem-find({ query: "pricing", type: "decision" }) +mem-find({ query: "login", type: "bugfix" }) ``` Available types: `decision`, `bugfix`, `feature`, `refactor`, `discovery`, `change`. @@ -128,5 +128,5 @@ No manual configuration is needed — if your provider supports embeddings, they - **Be specific** — "pricing calculation bug in cart" works better than "bug" - **Use file paths** — searching for a file path finds all observations related to that file - **Filter by type** — if you know you're looking for a decision, filter to reduce noise -- **Start broad, then narrow** — use `memory.find` with a broad query, then `memory.get` for details -- **Combine with history** — use `memory.history` with an anchor to see temporal context around a result +- **Start broad, then narrow** — use `mem-find` with a broad query, then `mem-get` for details +- **Combine with history** — use `mem-history` with an anchor to see temporal context around a result diff --git a/docs/tools.md b/docs/tools.md index acd38dc..c83c712 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,202 +1,181 @@ # Memory Tools -open-mem provides 9 MCP tools for interacting with memory. These tools are available in OpenCode, the MCP server, and the HTTP API. +open-mem exposes 10 memory tools across OpenCode, MCP, and platform adapters. ## Overview | Tool | Purpose | |---|---| -| [`memory.find`](#memory-find) | Search observations using hybrid search | -| [`memory.create`](#memory-create) | Manually save an observation | -| [`memory.history`](#memory-history) | Browse session timeline | -| [`memory.get`](#memory-get) | Fetch full observation details by ID | -| [`memory.revise`](#memory-revise) | Update an existing observation (immutable revision) | -| [`memory.remove`](#memory-remove) | Soft-delete an observation (tombstone) | -| [`memory.transfer.export`](#memory-transfer-export) | Export memories as JSON | -| [`memory.transfer.import`](#memory-transfer-import) | Import memories from JSON | -| [`memory.help`](#memory-help) | Show workflow guidance | +| [`mem-find`](#mem-find) | Search observations using hybrid retrieval | +| [`mem-history`](#mem-history) | Browse timeline across sessions | +| [`mem-get`](#mem-get) | Fetch full observation details by ID | +| [`mem-create`](#mem-create) | Save a manual observation | +| [`mem-revise`](#mem-revise) | Create a new immutable revision | +| [`mem-remove`](#mem-remove) | Tombstone an observation | +| [`mem-export`](#mem-export) | Export observations and summaries | +| [`mem-import`](#mem-import) | Import observations and summaries | +| [`mem-maintenance`](#mem-maintenance) | Run folder-context maintenance actions | +| [`mem-help`](#mem-help) | Show memory workflow guidance | ## Recommended Workflow -``` -memory.find → memory.history → memory.get +```text +mem-find -> mem-history -> mem-get ``` -1. **Search** with `memory.find` to discover relevant observations -2. **Browse** with `memory.history` to see session context -3. **Fetch** with `memory.get` to read full details +1. Run `mem-find` to locate candidate observations. +2. Inspect `mem-history` to understand timeline context. +3. Use `mem-get` to pull complete details when needed. -## memory.find {#memory-find} +## mem-find -Search through past observations and session summaries. Uses hybrid search (FTS5 + vector embeddings) when an embedding-capable provider is configured, or FTS5-only otherwise. +Search memories by query with optional filters. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `query` | string | yes | Search query — keywords, phrases, or file paths | -| `type` | enum | no | Filter by type: `decision`, `bugfix`, `feature`, `refactor`, `discovery`, `change` | -| `limit` | number | no | Max results (1–50, default: 10) | +| `query` | string | yes | Search text | +| `scope` | `project \| user \| all` | no | Memory scope (default `project`) | +| `types` | `ObservationType[]` | no | Restrict by observation types | +| `limit` | number | no | Max results `1..50` (default `10`) | +| `cursor` | string | no | Cursor for pagination | +| `include` | object | no | Extra metadata flags (`snippets`, `scores`, `relations`) | ### Example +```js +mem-find({ query: "pricing logic", types: ["refactor"], limit: 5 }) ``` -memory.find({ query: "pricing logic", type: "refactor", limit: 5 }) -``` - -Returns matching observations with ID, title, type, relevance score, and associated files. -## memory.create {#memory-create} +## mem-history -Manually save an important observation to memory. Use this for decisions, discoveries, gotchas, or anything the AI should remember across sessions. +Browse recent sessions or center timeline around an anchor observation. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `title` | string | yes | Brief title (max 80 chars) | -| `type` | enum | yes | Observation type: `decision`, `bugfix`, `feature`, `refactor`, `discovery`, `change` | -| `narrative` | string | yes | Detailed description of what to remember | -| `concepts` | string[] | no | Related concepts/tags for search | -| `files` | string[] | no | Related file paths | +| `limit` | number | no | Sessions to return `1..20` (default `5`) | +| `cursor` | string | no | Cursor for pagination | +| `sessionId` | string | no | Restrict to one session | +| `anchor` | string | no | Observation ID anchor | +| `depthBefore` | number | no | Observations before anchor `0..20` (default `5`) | +| `depthAfter` | number | no | Observations after anchor `0..20` (default `5`) | ### Example -``` -memory.create({ - title: "Use SQLite instead of PostgreSQL for local cache", - type: "decision", - narrative: "Chose SQLite for zero-dependency local storage. PostgreSQL adds operational complexity that doesn't justify the benefits for a single-user local tool.", - concepts: ["database", "architecture", "sqlite"], - files: ["src/db/store.ts"] -}) +```js +mem-history({ anchor: "obs-123", depthBefore: 3, depthAfter: 3 }) ``` -## memory.history {#memory-history} +## mem-get -View a timeline of past coding sessions, or center the view around a specific observation for cross-session navigation. +Fetch full observation records by IDs. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `limit` | number | no | Number of recent sessions (1–20, default: 5) | -| `sessionId` | string | no | Show details for a specific session | -| `anchor` | string | no | Observation ID to center the timeline around (cross-session view) | -| `depthBefore` | number | no | Observations to show before anchor (0–20, default: 5) | -| `depthAfter` | number | no | Observations to show after anchor (0–20, default: 5) | +| `ids` | string[] | yes | Observation IDs | +| `includeHistory` | boolean | no | Include lineage context (default `false`) | +| `limit` | number | no | Max returned `1..50` (default `10`) | ### Example -``` -// Recent sessions -memory.history({ limit: 5 }) - -// Anchor around a specific observation -memory.history({ anchor: "abc-123", depthBefore: 3, depthAfter: 3 }) +```js +mem-get({ ids: ["obs-123", "obs-456"], includeHistory: true }) ``` -## memory.get {#memory-get} +## mem-create -Fetch full observation details by ID. Use after `memory.find` or `memory.history` to get complete narratives, facts, concepts, and file lists. +Create a manual observation. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `ids` | string[] | yes | Observation IDs to fetch | -| `limit` | number | no | Maximum number of results (1–50, default: 10) | +| `title` | string | yes | Observation title | +| `type` | `ObservationType` | yes | Observation type | +| `narrative` | string | yes | Long-form detail | +| `concepts` | string[] | no | Concepts/tags | +| `files` | string[] | no | Related files | +| `importance` | number | no | Priority `1..5` | +| `scope` | `project \| user` | no | Target scope (default `project`) | ### Example +```js +mem-create({ + title: "Prefer SQLite for local cache", + type: "decision", + narrative: "SQLite keeps deployment local-first with lower ops overhead.", + concepts: ["architecture", "storage"], + files: ["src/db/store.ts"] +}) ``` -memory.get({ ids: ["abc-123", "def-456"] }) -``` - -Returns full observation details including narrative, facts, concepts, files, and metadata. -## memory.revise {#memory-revise} +## mem-revise -Update an existing project observation by ID. This is **immutable**: the update creates a new revision and supersedes the previous active revision. The original is preserved for audit history. +Create a new immutable revision for an observation. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Observation ID to update | +| `id` | string | yes | Observation ID to revise | | `title` | string | no | Updated title | | `narrative` | string | no | Updated narrative | -| `type` | enum | no | Updated observation type | -| `concepts` | string[] | no | Updated concepts/tags | -| `importance` | number | no | Updated importance (1–5) | - -### Example - -``` -memory.revise({ - id: "abc-123", - narrative: "Updated: we switched from SQLite WAL to journal mode due to NFS issues.", - concepts: ["database", "sqlite", "nfs"] -}) -``` +| `type` | `ObservationType` | no | Updated type | +| `concepts` | string[] | no | Updated concepts | +| `importance` | number | no | Updated importance `1..5` | +| `reason` | string | no | Revision rationale | -## memory.remove {#memory-remove} +## mem-remove -Tombstone an existing project observation by ID. This is a **soft delete**: the observation is hidden from default recall/search but retained for lineage tracking. +Tombstone an observation (soft delete). ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Observation ID to delete | +| `id` | string | yes | Observation ID | +| `reason` | string | no | Deletion reason | -### Example +## mem-export -``` -memory.remove({ id: "abc-123" }) -``` - -## memory.transfer.export {#memory-transfer-export} - -Export project memories (observations and session summaries) as portable JSON for backup or transfer between machines. +Export project memory as JSON. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `format` | enum | no | Export format (currently `json` only) | -| `type` | enum | no | Filter by observation type | -| `limit` | number | no | Maximum observations to export | - -### Example +| `scope` | `project` | no | Export scope (default `project`) | +| `type` | `ObservationType` | no | Filter by type | +| `limit` | number | no | Max observations | +| `format` | `json` | no | Output format (default `json`) | -``` -memory.transfer.export({ type: "decision", limit: 100 }) -``` - -## memory.transfer.import {#memory-transfer-import} +## mem-import -Import observations and summaries from a JSON export. Skips duplicates by ID. +Import JSON memory payload. ### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `data` | string | yes | JSON string from a `memory.transfer.export` output | +| `payload` | string | yes | JSON string from `mem-export` | +| `mode` | `skip \| merge \| replace` | no | Import mode (default `skip`) | -### Example +## mem-maintenance -``` -memory.transfer.import({ data: '{"observations": [...], "summaries": [...]}' }) -``` +Run folder-context maintenance actions. -## memory.help {#memory-help} +### Parameters -Returns a short workflow guide for using memory tools effectively. No parameters required. +| Parameter | Type | Required | Description | +|---|---|---|---| +| `action` | enum | yes | `folderContextDryRun`, `folderContextClean`, `folderContextRebuild`, or `folderContextPurge` | -### What It Returns +## mem-help -- The recommended `memory.find` → `memory.history` → `memory.get` workflow -- Write patterns (`memory.create`, `memory.revise`) -- Data management patterns (`memory.transfer.export`, `memory.transfer.import`, `memory.remove`) -- Tips for effective memory usage +No arguments. Returns workflow guidance and tool usage patterns. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 87ee0a9..8caab40 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,7 +31,7 @@ rm -rf .open-mem/ ::: tip This deletes all stored observations. If you want to preserve data first, export it: ``` -memory.transfer.export({ format: "json" }) +mem-export({ format: "json" }) ``` ::: @@ -39,7 +39,7 @@ memory.transfer.export({ format: "json" }) 1. **Check plugin is loaded** — look for `[open-mem]` messages in OpenCode logs 2. **Check context injection is enabled** — ensure `OPEN_MEM_CONTEXT_INJECTION` is not set to `false` -3. **Check observations exist** — use the `memory.history` tool +3. **Check observations exist** — use the `mem-history` tool 4. **First session has no context** — observations must be captured before they can be injected ## High Memory Usage diff --git a/package.json b/package.json index 89e322b..967a8bd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "open-mem-mcp": "./dist/mcp.js", "open-mem-daemon": "./dist/daemon.js", "open-mem-maintenance": "./dist/maintenance.js", + "open-mem-doctor": "./dist/doctor.js", "open-mem-claude-code": "./dist/claude-code.js", "open-mem-cursor": "./dist/cursor.js" }, @@ -26,12 +27,13 @@ "CHANGELOG.md" ], "scripts": { - "build": "bun run clean && bun run build:bundle && bun run build:mcp && bun run build:daemon && bun run build:maintenance && bun run build:claude-code && bun run build:cursor && bun run build:dashboard && bun run build:types", + "build": "bun run clean && bun run build:bundle && bun run build:mcp && bun run build:daemon && bun run build:maintenance && bun run build:doctor && bun run build:claude-code && bun run build:cursor && bun run build:dashboard && bun run build:types", "build:dashboard": "cd dashboard && bun install && bun run build && cp -r dist ../dist/dashboard", "build:bundle": "bun build src/index.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:mcp": "bun build src/mcp.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:daemon": "bun build src/daemon.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:maintenance": "bun build src/maintenance.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", + "build:doctor": "bun build src/doctor.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:claude-code": "bun build src/claude-code.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:cursor": "bun build src/cursor.ts --outdir dist --target bun --format esm --minify --external bun:sqlite --external ai --external @ai-sdk/anthropic --external @ai-sdk/amazon-bedrock --external @ai-sdk/openai --external @ai-sdk/google --external @openrouter/ai-sdk-provider --external sqlite-vec --external zod", "build:types": "bun x tsc --declaration --emitDeclarationOnly --outDir dist", @@ -43,14 +45,12 @@ "typecheck": "bun x tsc --noEmit", "check:boundaries": "bun run scripts/check-import-boundaries.ts && bun run scripts/check-deprecated-paths.ts", "quality:gate": "bun run scripts/quality-gate.ts", + "check:contracts": "bun run scripts/check-contract-drift.ts", + "check:migration": "bun run scripts/check-migration-compat.ts", + "check:architecture": "bun run scripts/check-architecture-fitness.ts", + "report:architecture": "bun run scripts/architecture-health-report.ts", "bench:search": "bun run scripts/benchmark-search.ts", "bench:platform": "bun run scripts/benchmark-platform-normalization.ts", - "compat:verify": "bun run scripts/verify-external-clients.ts", - "compat:detect-versions": "bun run scripts/detect-client-versions.ts", - "compat:verify:auto": "bun run scripts/verify-external-clients-auto.ts", - "compat:matrix": "bun run scripts/render-compat-matrix.ts", - "compat:smoke": "bun run scripts/smoke-platform-workers.ts", - "compat:gate": "bun run scripts/check-external-compat-gate.ts", "docs:dev": "cd docs && npx vitepress dev", "docs:build": "cd docs && npx vitepress build", "lint": "bun x biome check src/", @@ -87,7 +87,6 @@ "@ai-sdk/google": "^3.0.22", "@openrouter/ai-sdk-provider": "^2.1.1", "ai": "^6.0.77", - "open-mem": "^0.9.0", "sqlite-vec": "^0.1.7-alpha.2", "zod": "^4.3.6" }, diff --git a/scripts/architecture-health-report.ts b/scripts/architecture-health-report.ts new file mode 100644 index 0000000..774958d --- /dev/null +++ b/scripts/architecture-health-report.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env bun + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) walk(full, files); + else if (full.endsWith(".ts") || full.endsWith(".md")) files.push(full); + } + return files; +} + +const files = walk(join(process.cwd(), "src")); +let todoCount = 0; +let fixmeCount = 0; + +for (const file of files) { + const content = readFileSync(file, "utf-8"); + todoCount += (content.match(/\bTODO\b/g) ?? []).length; + fixmeCount += (content.match(/\bFIXME\b/g) ?? []).length; +} + +const report = { + generatedAt: new Date().toISOString(), + sourceFiles: files.length, + todoCount, + fixmeCount, +}; + +console.log(JSON.stringify(report, null, 2)); diff --git a/scripts/check-architecture-fitness.ts b/scripts/check-architecture-fitness.ts new file mode 100644 index 0000000..a0bce99 --- /dev/null +++ b/scripts/check-architecture-fitness.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + walk(fullPath, files); + continue; + } + if (fullPath.endsWith(".ts")) files.push(fullPath); + } + return files; +} + +function listTsFilesIfPresent(dir: string): string[] { + return existsSync(dir) ? walk(dir) : []; +} + +function hasAdapterImportLeak(content: string): boolean { + const patterns = [ + /\bfrom\s+["'][^"']*\/adapters\/[^"']*["']/, + /\bimport\(\s*["'][^"']*\/adapters\/[^"']*["']\s*\)/, + /\brequire\(\s*["'][^"']*\/adapters\/[^"']*["']\s*\)/, + ]; + return patterns.some((pattern) => pattern.test(content)); +} + +const coreFiles = listTsFilesIfPresent(join(process.cwd(), "src/core")); +const searchFiles = listTsFilesIfPresent(join(process.cwd(), "src/search")); +const modeFiles = listTsFilesIfPresent(join(process.cwd(), "src/modes")); +const criticalFiles = [...coreFiles, ...searchFiles, ...modeFiles]; + +const forbidden = [/TODO(?!\s*\(.*issue)/, /FIXME(?!\s*\(.*issue)/]; +for (const file of criticalFiles) { + const content = readFileSync(file, "utf-8"); + for (const regex of forbidden) { + if (regex.test(content)) { + console.error(`[architecture-fitness] anonymous TODO/FIXME found in ${file}`); + process.exit(1); + } + } + + if (hasAdapterImportLeak(content)) { + console.error(`[architecture-fitness] adapter import leaked into core/search/modes: ${file}`); + process.exit(1); + } +} + +console.log("[architecture-fitness] passed"); diff --git a/scripts/check-contract-drift.ts b/scripts/check-contract-drift.ts new file mode 100644 index 0000000..00667ee --- /dev/null +++ b/scripts/check-contract-drift.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun + +import { TOOL_CONTRACTS } from "../src/contracts/schemas"; + +const expected = [ + "mem-find", + "mem-history", + "mem-get", + "mem-create", + "mem-revise", + "mem-remove", + "mem-export", + "mem-import", + "mem-maintenance", + "mem-help", +]; + +const actual = TOOL_CONTRACTS.map((tool) => tool.name); + +if (actual.length !== expected.length || expected.some((tool) => !actual.includes(tool))) { + console.error("[contract-drift] Tool contract list drift detected."); + console.error("expected:", expected.join(", ")); + console.error("actual:", actual.join(", ")); + process.exit(1); +} + +for (const tool of TOOL_CONTRACTS) { + if (!tool.description || tool.description.trim().length < 10) { + console.error(`[contract-drift] Missing or too-short description for ${tool.name}`); + process.exit(1); + } +} + +console.log("[contract-drift] passed"); diff --git a/scripts/check-external-compat-gate.ts b/scripts/check-external-compat-gate.ts deleted file mode 100644 index 4e0f184..0000000 --- a/scripts/check-external-compat-gate.ts +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bun - -import { readFile } from "node:fs/promises"; -import { - readJsonFile, - toDate, - validateExternalCompatReport, - type ExternalCompatReport, -} from "./external-compat"; - -function parseArgs() { - const args = Bun.argv.slice(2); - const pick = (flag: string, fallback: string) => { - const idx = args.indexOf(flag); - return idx >= 0 ? (args[idx + 1] ?? fallback) : fallback; - }; - return { - reportPath: pick("--report", "docs/compatibility/external-compat-latest.json"), - matrixPath: pick("--matrix", "docs/mcp-compatibility-matrix.md"), - freshnessDays: Number(pick("--freshness-days", "7")), - }; -} - -export async function checkExternalCompatGate(options?: { - reportPath?: string; - matrixPath?: string; - freshnessDays?: number; -}): Promise<{ ok: boolean; errors: string[] }> { - const reportPath = options?.reportPath ?? "docs/compatibility/external-compat-latest.json"; - const matrixPath = options?.matrixPath ?? "docs/mcp-compatibility-matrix.md"; - const freshnessDays = options?.freshnessDays ?? 7; - const errors: string[] = []; - - const report = await readJsonFile(reportPath); - const reportValidationErrors = validateExternalCompatReport(report); - errors.push(...reportValidationErrors.map((e) => `REPORT_SCHEMA:${e}`)); - - const now = new Date(); - const generatedAt = toDate(report.generatedAt); - const ageMs = now.getTime() - generatedAt.getTime(); - if (ageMs > freshnessDays * 24 * 60 * 60 * 1000) { - errors.push(`STALE_REPORT: generatedAt=${report.generatedAt} freshnessDays=${freshnessDays}`); - } - - for (const client of report.clients) { - if (client.status !== "supported") { - errors.push(`CLIENT_NOT_SUPPORTED:${client.name}:${client.status}`); - } - if (client.version.detected === "unknown") { - errors.push(`CLIENT_VERSION_UNKNOWN:${client.name}`); - } - for (const scenario of client.requiredScenarios) { - if (!scenario.passed) { - errors.push(`SCENARIO_FAILED:${client.name}:${scenario.id}:${scenario.failureCode ?? "unknown"}`); - } - } - } - - const matrix = await readFile(matrixPath, "utf8"); - for (const client of report.clients) { - const label = client.name === "claude-code" ? "Claude Code MCP integration" : "Cursor MCP integration"; - const expectedStatus = - client.status === "supported" - ? "Supported" - : client.status === "failed" - ? "Failed" - : "Expected Supported"; - const rowMustContain = `${label} | stdio | 2024-11-05 | ${client.version.detected} | ${expectedStatus}`; - if (!matrix.includes(rowMustContain)) { - errors.push(`MATRIX_MISMATCH:${client.name}: expected row fragment '${rowMustContain}'`); - } - } - - return { ok: errors.length === 0, errors }; -} - -async function main() { - const args = parseArgs(); - const result = await checkExternalCompatGate(args); - if (!result.ok) { - console.error("[check-external-compat-gate] failed"); - for (const error of result.errors) console.error(` - ${error}`); - process.exit(1); - } - console.log("[check-external-compat-gate] passed"); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[check-external-compat-gate] fatal", error); - process.exit(1); - }); -} diff --git a/scripts/check-migration-compat.ts b/scripts/check-migration-compat.ts new file mode 100644 index 0000000..f96bc32 --- /dev/null +++ b/scripts/check-migration-compat.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const required = [ + join(process.cwd(), "src/db/schema.ts"), + join(process.cwd(), "src/db/database.ts"), + join(process.cwd(), "tests/db/helpers.ts"), +]; + +const missing = required.filter((file) => !existsSync(file)); +if (missing.length > 0) { + for (const file of missing) { + console.error(`[migration-compat] required file missing: ${file}`); + } + process.exit(1); +} + +console.log("[migration-compat] basic migration compatibility checks passed"); diff --git a/scripts/detect-client-versions.ts b/scripts/detect-client-versions.ts deleted file mode 100644 index 7034f2a..0000000 --- a/scripts/detect-client-versions.ts +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bun - -import { existsSync } from "node:fs"; - -export interface DetectionResult { - claudeCodeVersion: string; - cursorVersion: string; - source: { - claudeCode: string; - cursor: string; - }; -} - -function parseVersion(text: string): string | null { - const match = text.match(/\b(\d+\.\d+\.\d+(?:[-+][\w.-]+)?|\d+\.\d+)\b/); - return match?.[1] ?? null; -} - -function tryCommand(command: string[], source: string): { version: string; source: string } | null { - const proc = Bun.spawnSync(command, { stdout: "pipe", stderr: "pipe" }); - if (proc.exitCode !== 0) return null; - const out = `${Buffer.from(proc.stdout).toString("utf8")}\n${Buffer.from(proc.stderr).toString("utf8")}`; - const parsed = parseVersion(out); - if (!parsed) return null; - return { version: parsed, source }; -} - -function detectClaudeCodeVersion(): { version: string; source: string } { - const probes: Array<{ cmd: string[]; source: string }> = [ - { cmd: ["claude", "--version"], source: "claude --version" }, - { cmd: ["claude", "version"], source: "claude version" }, - { cmd: ["claude-code", "--version"], source: "claude-code --version" }, - { cmd: ["claude-code", "version"], source: "claude-code version" }, - ]; - for (const probe of probes) { - const found = tryCommand(probe.cmd, probe.source); - if (found) return found; - } - return { version: "unknown", source: "not-detected" }; -} - -function detectCursorVersion(): { version: string; source: string } { - const probes: Array<{ cmd: string[]; source: string }> = [ - { cmd: ["cursor", "--version"], source: "cursor --version" }, - { cmd: ["cursor", "version"], source: "cursor version" }, - { cmd: ["cursor-cli", "--version"], source: "cursor-cli --version" }, - ]; - for (const probe of probes) { - const found = tryCommand(probe.cmd, probe.source); - if (found) return found; - } - - if (process.platform === "darwin") { - const appPath = "/Applications/Cursor.app/Contents/Info.plist"; - if (existsSync(appPath)) { - const plist = tryCommand( - ["defaults", "read", "/Applications/Cursor.app/Contents/Info", "CFBundleShortVersionString"], - "Cursor.app Info.plist", - ); - if (plist) return plist; - } - } - - return { version: "unknown", source: "not-detected" }; -} - -export function detectClientVersions(): DetectionResult { - const claude = detectClaudeCodeVersion(); - const cursor = detectCursorVersion(); - return { - claudeCodeVersion: claude.version, - cursorVersion: cursor.version, - source: { - claudeCode: claude.source, - cursor: cursor.source, - }, - }; -} - -async function main() { - const detected = detectClientVersions(); - console.log(JSON.stringify(detected, null, 2)); - console.log(`OPEN_MEM_CLAUDE_CODE_VERSION=${detected.claudeCodeVersion}`); - console.log(`OPEN_MEM_CURSOR_VERSION=${detected.cursorVersion}`); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[detect-client-versions] fatal", error); - process.exit(1); - }); -} diff --git a/scripts/external-compat.ts b/scripts/external-compat.ts deleted file mode 100644 index 2e662fc..0000000 --- a/scripts/external-compat.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, relative } from "node:path"; - -export type ClientName = "claude-code" | "cursor"; - -export interface ScenarioResult { - id: string; - name: string; - passed: boolean; - durationMs: number; - failureCode?: string; - details?: string; - metrics?: { - p95Ms?: number; - samples?: number; - }; -} - -export interface ExternalCompatReport { - schemaVersion: "1.0.0"; - generatedAt: string; - gitSha: string; - environment: { - os: string; - arch: string; - bunVersion: string; - runner: string; - }; - policy: { - freshnessDays: number; - versionScope: "latest-stable-only"; - verificationScope: string; - }; - clients: Array<{ - name: ClientName; - transport: "stdio"; - protocolVersion: "2024-11-05"; - status: "supported" | "expected-supported" | "failed"; - version: { - detected: string; - source: "env" | "manual" | "unknown"; - }; - requiredScenarios: ScenarioResult[]; - knownLimitations: string[]; - artifacts: { - transcriptsDir: string; - logFile: string; - }; - }>; - summary: { - scenarioCount: number; - failedScenarios: number; - failureRate: number; - allRequiredPassed: boolean; - mcpToolCallP95Ms: number; - workerEventIngestP95Ms: number; - }; - slo: { - mcpToolCallP95TargetMs: 250; - workerEventIngestP95TargetMs: 100; - externalFailureRateTarget: 0.01; - met: boolean; - }; - failureTaxonomy: Array<{ - code: - | "CLIENT_PROTOCOL_DRIFT" - | "WORKER_BRIDGE_REGRESSION" - | "ENVIRONMENT_DEPENDENCY" - | "NON_DETERMINISTIC_OUTPUT"; - description: string; - remediation: string; - }>; -} - -export interface MatrixClientSummary { - name: ClientName; - version: string; - status: "supported" | "expected-supported" | "failed"; - verifiedOn: string; - knownLimitations: string[]; - notes: string; -} - -export function percentile95(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1); - return Number((sorted[index] ?? 0).toFixed(2)); -} - -export function classifyFailure(code?: string): ExternalCompatReport["failureTaxonomy"][number]["code"] { - if (!code) return "NON_DETERMINISTIC_OUTPUT"; - if (code.startsWith("MCP_") || code.startsWith("ASSERT_")) return "CLIENT_PROTOCOL_DRIFT"; - if (code.startsWith("WORKER_") || code.startsWith("BRIDGE_")) return "WORKER_BRIDGE_REGRESSION"; - if (code.startsWith("ENV_")) return "ENVIRONMENT_DEPENDENCY"; - return "NON_DETERMINISTIC_OUTPUT"; -} - -export function getFailureTaxonomy(): ExternalCompatReport["failureTaxonomy"] { - return [ - { - code: "CLIENT_PROTOCOL_DRIFT", - description: "Lifecycle or JSON-RPC behavior mismatch with claimed protocol contract.", - remediation: - "Patch MCP compatibility handling, add/update transcript fixtures, rerun verification.", - }, - { - code: "WORKER_BRIDGE_REGRESSION", - description: "Platform worker bridge command/transport behavior mismatch.", - remediation: - "Patch platform worker bridge implementation, rerun smoke checks, add targeted regression tests.", - }, - { - code: "ENVIRONMENT_DEPENDENCY", - description: "Client binary/version unavailable or runtime dependency missing on runner.", - remediation: - "Fix runner provisioning/install scripts and pin verified client setup before re-running.", - }, - { - code: "NON_DETERMINISTIC_OUTPUT", - description: "Unstable output shape prevents deterministic verification.", - remediation: - "Normalize volatile fields in harness output and tighten fixture assertions to stable keys.", - }, - ]; -} - -export async function readJsonFile(filePath: string): Promise { - const raw = await readFile(filePath, "utf8"); - return JSON.parse(raw) as T; -} - -export async function writeJsonFile(filePath: string, value: unknown): Promise { - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -export async function writeTextFile(filePath: string, value: string): Promise { - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, value, "utf8"); -} - -export function assertExists(path: string, label: string): void { - if (!existsSync(path)) throw new Error(`ENV_MISSING_${label}: ${path}`); -} - -export function rel(base: string, target: string): string { - return relative(base, target) || "."; -} - -export function toDate(value: string): Date { - const d = new Date(value); - if (Number.isNaN(d.getTime())) throw new Error(`Invalid date: ${value}`); - return d; -} - -export function validateExternalCompatReport(report: ExternalCompatReport): string[] { - const errors: string[] = []; - if (report.schemaVersion !== "1.0.0") errors.push("schemaVersion must be 1.0.0"); - if (!report.generatedAt) errors.push("generatedAt is required"); - if (!report.gitSha) errors.push("gitSha is required"); - if (report.clients.length < 2) errors.push("clients must include claude-code and cursor"); - const names = new Set(report.clients.map((c) => c.name)); - if (!names.has("claude-code")) errors.push("missing claude-code entry"); - if (!names.has("cursor")) errors.push("missing cursor entry"); - for (const client of report.clients) { - if (client.requiredScenarios.length === 0) errors.push(`${client.name}: missing requiredScenarios`); - if (!client.artifacts.logFile) errors.push(`${client.name}: missing logFile`); - if (!client.artifacts.transcriptsDir) errors.push(`${client.name}: missing transcriptsDir`); - } - if (report.policy.freshnessDays <= 0) errors.push("freshnessDays must be > 0"); - return errors; -} - -export function summarizeMatrixClients(report: ExternalCompatReport): MatrixClientSummary[] { - return report.clients.map((client) => { - const passed = client.requiredScenarios.filter((s) => s.passed).length; - const total = client.requiredScenarios.length; - return { - name: client.name, - version: client.version.detected, - status: client.status, - verifiedOn: report.generatedAt, - knownLimitations: client.knownLimitations, - notes: `${passed}/${total} required scenarios passed`, - }; - }); -} diff --git a/scripts/quality-gate.ts b/scripts/quality-gate.ts index 4fd1eb6..c1ca6d7 100644 --- a/scripts/quality-gate.ts +++ b/scripts/quality-gate.ts @@ -3,17 +3,17 @@ import { resolve } from "node:path"; const checks = [ - ["bun", "test", "search/quality-regression.test.ts"], - ["bun", "test", "search/latency-budget.test.ts"], + ["bun", "test", "search/quality-regression.test.ts"], + ["bun", "test", "search/latency-budget.test.ts"], ]; const testsCwd = resolve(process.cwd(), "tests"); for (const cmd of checks) { - const proc = Bun.spawnSync(cmd, { cwd: testsCwd, stdout: "inherit", stderr: "inherit" }); - if (proc.exitCode !== 0) { - console.error(`[quality-gate] failed in tests/: ${cmd.join(" ")}`); - process.exit(proc.exitCode ?? 1); - } + const proc = Bun.spawnSync(cmd, { cwd: testsCwd, stdout: "inherit", stderr: "inherit" }); + if (proc.exitCode !== 0) { + console.error(`[quality-gate] failed in tests/: ${cmd.join(" ")}`); + process.exit(proc.exitCode ?? 1); + } } console.log("[quality-gate] passed"); diff --git a/scripts/render-compat-matrix.ts b/scripts/render-compat-matrix.ts deleted file mode 100644 index a4a4a3f..0000000 --- a/scripts/render-compat-matrix.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bun - -import { readFile, writeFile } from "node:fs/promises"; -import { - readJsonFile, - summarizeMatrixClients, - type ExternalCompatReport, -} from "./external-compat"; - -const START = ""; -const END = ""; - -function formatStatus(status: string): string { - switch (status) { - case "supported": - return "Supported"; - case "failed": - return "Failed"; - default: - return "Expected Supported"; - } -} - -function renderGeneratedBlock(report: ExternalCompatReport): string { - const rows = summarizeMatrixClients(report) - .map((client) => { - const label = client.name === "claude-code" ? "Claude Code MCP integration" : "Cursor MCP integration"; - const limitations = client.knownLimitations.length ? client.knownLimitations.join("; ") : "None."; - return `| ${label} | stdio | ${report.clients.find((c) => c.name === client.name)?.protocolVersion ?? "2024-11-05"} | ${client.version} | ${formatStatus(client.status)} | ${client.verifiedOn} | ${client.notes} | ${limitations} |`; - }) - .join("\n"); - - return [ - START, - "| Client | Transport | Protocol Version | Client Version | Status | Verified On | Notes | Known Limitations |", - "|---|---|---:|---|---|---|---|---|", - "| OpenCode MCP client | stdio | 2024-11-05 | n/a | Supported | Internal regression suite | Full lifecycle and transcript harness coverage. | None. |", - rows, - END, - ].join("\n"); -} - -export async function renderCompatMatrix(options?: { - reportPath?: string; - matrixPath?: string; -}): Promise { - const reportPath = options?.reportPath ?? "docs/compatibility/external-compat-latest.json"; - const matrixPath = options?.matrixPath ?? "docs/mcp-compatibility-matrix.md"; - - const report = await readJsonFile(reportPath); - const block = renderGeneratedBlock(report); - const current = await readFile(matrixPath, "utf8"); - - const hasMarkers = current.includes(START) && current.includes(END); - let next: string; - if (hasMarkers) { - next = current.replace(new RegExp(`${START}[\\s\\S]*?${END}`), block); - } else { - next = `${current.trim()}\n\n${block}\n`; - } - await writeFile(matrixPath, next, "utf8"); - return block; -} - -async function main() { - const args = Bun.argv.slice(2); - const reportPath = args.includes("--report") - ? args[args.indexOf("--report") + 1] - : undefined; - const matrixPath = args.includes("--matrix") - ? args[args.indexOf("--matrix") + 1] - : undefined; - - await renderCompatMatrix({ reportPath, matrixPath }); - console.log("[render-compat-matrix] updated", matrixPath ?? "docs/mcp-compatibility-matrix.md"); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[render-compat-matrix] fatal", error); - process.exit(1); - }); -} diff --git a/scripts/smoke-platform-workers.ts b/scripts/smoke-platform-workers.ts deleted file mode 100644 index dad3793..0000000 --- a/scripts/smoke-platform-workers.ts +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env bun - -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createServer } from "node:net"; -import { performance } from "node:perf_hooks"; -import { - assertExists, - percentile95, - readJsonFile, - writeJsonFile, - writeTextFile, - type ClientName, -} from "./external-compat"; - -interface WorkerFixture { - client: ClientName; - events: unknown[]; -} - -interface SmokeResult { - client: ClientName; - stdioPassed: boolean; - httpPassed: boolean; - invalidJsonRecoveryPassed: boolean; - eventP95Ms: number; - errors: string[]; -} - -function parseArgs() { - const args = Bun.argv.slice(2); - const artifactsDir = args.includes("--artifacts-dir") - ? args[args.indexOf("--artifacts-dir") + 1] - : "artifacts/external-compat"; - return { artifactsDir }; -} - -async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - server.close(); - reject(new Error("Could not resolve free port")); - return; - } - const port = addr.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); - server.on("error", reject); - }); -} - -function workerEntry(client: ClientName): string { - return client === "claude-code" ? "dist/claude-code.js" : "dist/cursor.js"; -} - -async function runStdioSmoke( - client: ClientName, - projectPath: string, - fixture: WorkerFixture, -): Promise<{ passed: boolean; invalidJsonRecovery: boolean; p95Ms: number; transcript: string[]; errors: string[] }> { - const entry = workerEntry(client); - const proc = Bun.spawn(["bun", "run", entry, "--project", projectPath], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - OPEN_MEM_COMPRESSION: "false", - OPEN_MEM_PLATFORM_CLAUDE_CODE: "true", - OPEN_MEM_PLATFORM_CURSOR: "true", - }, - }); - - const inputLines = [ - "not-json", - JSON.stringify({ command: "health", id: "h1" }), - ...fixture.events.map((event) => JSON.stringify(event)), - JSON.stringify({ command: "flush", id: "f1" }), - JSON.stringify({ command: "shutdown", id: "s1" }), - ]; - - const durations: number[] = []; - for (const line of inputLines) { - const start = performance.now(); - proc.stdin.write(`${line}\n`); - durations.push(performance.now() - start); - } - proc.stdin.end(); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exit = await proc.exited; - const responseLines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); - - const errors: string[] = []; - if (exit !== 0) errors.push(`WORKER_STDIO_EXIT_${exit}:${stderr}`); - if (!responseLines.some((line) => line.includes("INVALID_JSON"))) { - errors.push("WORKER_STDIO_INVALID_JSON_NOT_REPORTED"); - } - if (!responseLines.some((line) => line.includes('"id":"h1"') && line.includes('"ok":true'))) { - errors.push("WORKER_STDIO_HEALTH_FAILED"); - } - if (!responseLines.some((line) => line.includes('"id":"f1"') && line.includes('"ok":true'))) { - errors.push("WORKER_STDIO_FLUSH_FAILED"); - } - if (!responseLines.some((line) => line.includes('"id":"s1"') && line.includes('"ok":true'))) { - errors.push("WORKER_STDIO_SHUTDOWN_FAILED"); - } - const okCount = responseLines.filter((line) => line.includes('"ok":true')).length; - if (okCount < fixture.events.length + 3) { - errors.push("WORKER_STDIO_EVENT_FAILED"); - } - - const invalidJsonRecovery = - responseLines.some((line) => line.includes("INVALID_JSON")) && - responseLines.some((line) => line.includes('"id":"h1"') && line.includes('"ok":true')); - - return { - passed: errors.length === 0, - invalidJsonRecovery, - p95Ms: percentile95(durations), - transcript: responseLines, - errors, - }; -} - -async function runHttpSmoke( - client: ClientName, - projectPath: string, - fixture: WorkerFixture, -): Promise<{ passed: boolean; p95Ms: number; transcript: string[]; errors: string[] }> { - const entry = workerEntry(client); - const port = await getFreePort(); - const proc = Bun.spawn(["bun", "run", entry, "--project", projectPath, "--http-port", String(port)], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - OPEN_MEM_COMPRESSION: "false", - OPEN_MEM_PLATFORM_CLAUDE_CODE: "true", - OPEN_MEM_PLATFORM_CURSOR: "true", - }, - }); - - const baseUrl = `http://127.0.0.1:${port}`; - const transcript: string[] = []; - const durations: number[] = []; - const errors: string[] = []; - - await Bun.sleep(250); - - const hit = async (method: "GET" | "POST", path: string, body?: unknown) => { - const start = performance.now(); - const res = await fetch(`${baseUrl}${path}`, { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - durations.push(performance.now() - start); - transcript.push(`${method} ${path} ${res.status} ${text}`); - return { status: res.status, text }; - }; - - const health = await hit("GET", "/v1/health"); - if (health.status !== 200 || !health.text.includes('"ok":true')) errors.push("WORKER_HTTP_HEALTH_FAILED"); - - for (const event of fixture.events) { - const sent = await hit("POST", "/v1/events", { command: "event", payload: event }); - if (sent.status !== 200 || !sent.text.includes('"ok":true')) errors.push("WORKER_HTTP_EVENT_FAILED"); - } - - const flush = await hit("POST", "/v1/events", { command: "flush", id: "hf1" }); - if (flush.status !== 200 || !flush.text.includes('"ok":true')) errors.push("WORKER_HTTP_FLUSH_FAILED"); - - const shutdown = await hit("POST", "/v1/events", { command: "shutdown", id: "hs1" }); - if (shutdown.status !== 200 || !shutdown.text.includes('"ok":true')) errors.push("WORKER_HTTP_SHUTDOWN_FAILED"); - - proc.stdin.end(); - const exit = await proc.exited; - if (exit !== 0) { - const stderr = await new Response(proc.stderr).text(); - errors.push(`WORKER_HTTP_EXIT_${exit}:${stderr}`); - } - - return { - passed: errors.length === 0, - p95Ms: percentile95(durations), - transcript, - errors, - }; -} - -export async function runWorkerSmoke(artifactsDir: string): Promise { - assertExists("dist/claude-code.js", "DIST_CLAUDE_WORKER"); - assertExists("dist/cursor.js", "DIST_CURSOR_WORKER"); - - const projectDir = await mkdtemp(join(tmpdir(), "open-mem-worker-smoke-")); - try { - const results: SmokeResult[] = []; - for (const client of ["claude-code", "cursor"] as const) { - const fixture = await readJsonFile( - `tests/fixtures/external-clients/${client}-worker.json`, - ); - const stdio = await runStdioSmoke(client, projectDir, fixture); - const http = await runHttpSmoke(client, projectDir, fixture); - - await writeTextFile( - `${artifactsDir}/transcripts/${client}/worker-stdio.jsonl`, - `${stdio.transcript.join("\n")}\n`, - ); - await writeTextFile( - `${artifactsDir}/transcripts/${client}/worker-http.jsonl`, - `${http.transcript.join("\n")}\n`, - ); - - await writeTextFile( - `${artifactsDir}/logs/${client}-worker.log`, - [...stdio.errors, ...http.errors].join("\n"), - ); - - results.push({ - client, - stdioPassed: stdio.passed, - httpPassed: http.passed, - invalidJsonRecoveryPassed: stdio.invalidJsonRecovery, - // Use HTTP bridge latency as the ingest timing signal; stdio writes are not round-trip timed. - eventP95Ms: http.p95Ms, - errors: [...stdio.errors, ...http.errors], - }); - } - - await writeJsonFile(`${artifactsDir}/worker-smoke.json`, { results }); - return results; - } finally { - await rm(projectDir, { recursive: true, force: true }); - } -} - -async function main() { - const { artifactsDir } = parseArgs(); - const results = await runWorkerSmoke(artifactsDir); - const failed = results.some( - (r) => !r.stdioPassed || !r.httpPassed || !r.invalidJsonRecoveryPassed, - ); - if (failed) { - console.error("[smoke-platform-workers] failed", JSON.stringify(results, null, 2)); - process.exit(1); - } - console.log("[smoke-platform-workers] passed", JSON.stringify(results, null, 2)); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[smoke-platform-workers] fatal", error); - process.exit(1); - }); -} diff --git a/scripts/verify-external-clients-auto.ts b/scripts/verify-external-clients-auto.ts deleted file mode 100644 index 9957151..0000000 --- a/scripts/verify-external-clients-auto.ts +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bun - -import { detectClientVersions } from "./detect-client-versions"; -import { renderCompatMatrix } from "./render-compat-matrix"; -import { verifyExternalClients } from "./verify-external-clients"; - -function parseArgs() { - const args = Bun.argv.slice(2); - const pick = (flag: string, fallback: string) => { - const index = args.indexOf(flag); - return index >= 0 ? (args[index + 1] ?? fallback) : fallback; - }; - return { - artifactsDir: pick("--artifacts-dir", "artifacts/external-compat"), - project: pick("--project", process.cwd()), - }; -} - -async function main() { - const args = parseArgs(); - const detected = detectClientVersions(); - - process.env.OPEN_MEM_CLAUDE_CODE_VERSION = detected.claudeCodeVersion; - process.env.OPEN_MEM_CURSOR_VERSION = detected.cursorVersion; - - const report = await verifyExternalClients({ - artifactsDir: args.artifactsDir, - project: args.project, - runner: process.env.GITHUB_ACTIONS === "true" ? "github-actions-self-hosted-macos" : "local", - }); - - await renderCompatMatrix({ - reportPath: "docs/compatibility/external-compat-latest.json", - matrixPath: "docs/mcp-compatibility-matrix.md", - }); - - console.log("[verify-external-clients-auto] done", { - detected, - statuses: report.clients.map((c) => ({ name: c.name, status: c.status, version: c.version.detected })), - }); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[verify-external-clients-auto] fatal", error); - process.exit(1); - }); -} diff --git a/scripts/verify-external-clients.ts b/scripts/verify-external-clients.ts deleted file mode 100644 index e39f237..0000000 --- a/scripts/verify-external-clients.ts +++ /dev/null @@ -1,402 +0,0 @@ -#!/usr/bin/env bun - -import { createHash } from "node:crypto"; -import { mkdir, readFile } from "node:fs/promises"; -import { performance } from "node:perf_hooks"; -import { runWorkerSmoke } from "./smoke-platform-workers"; -import { - assertExists, - classifyFailure, - getFailureTaxonomy, - percentile95, - readJsonFile, - rel, - validateExternalCompatReport, - writeJsonFile, - writeTextFile, - type ClientName, - type ExternalCompatReport, -} from "./external-compat"; - -interface MtpAssertion { - responseId: number; - contains: string[]; -} - -interface MtpScenario { - id: string; - name: string; - messages: Array>; - assertions: MtpAssertion[]; -} - -interface MtpFixture { - client: ClientName; - protocolVersion: string; - scenarios: MtpScenario[]; -} - -function parseArgs() { - const args = Bun.argv.slice(2); - const pick = (flag: string, fallback: string) => { - const index = args.indexOf(flag); - return index >= 0 ? (args[index + 1] ?? fallback) : fallback; - }; - return { - artifactsDir: pick("--artifacts-dir", "artifacts/external-compat"), - project: pick("--project", process.cwd()), - runner: process.env.GITHUB_ACTIONS === "true" ? "github-actions-self-hosted-macos" : "local", - }; -} - -async function runMcpScenario( - scenario: MtpScenario, - projectPath: string, - client: ClientName, - artifactsDir: string, -): Promise<{ passed: boolean; durationMs: number; details?: string; failureCode?: string; transcriptLines: string[] }> { - const input = `${scenario.messages.map((m) => JSON.stringify(m)).join("\n")}\n`; - const start = performance.now(); - const proc = Bun.spawn(["bun", "run", "dist/mcp.js", "--project", projectPath], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - OPEN_MEM_COMPRESSION: "false", - OPEN_MEM_MCP_COMPAT_MODE: "strict", - }, - }); - - proc.stdin.write(input); - proc.stdin.end(); - - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exit = await proc.exited; - const durationMs = Number((performance.now() - start).toFixed(2)); - if (exit !== 0) { - return { - passed: false, - durationMs, - failureCode: "MCP_PROCESS_EXIT", - details: stderr, - transcriptLines: stdout.split("\n").filter(Boolean), - }; - } - - const responseLines = stdout.split("\n").map((line) => line.trim()).filter(Boolean); - const responses = responseLines.map((line) => { - try { - return JSON.parse(line) as Record; - } catch { - return null; - } - }); - - const malformed = responses.findIndex((value) => value === null); - if (malformed >= 0) { - return { - passed: false, - durationMs, - failureCode: "MCP_RESPONSE_NOT_JSON", - details: `Malformed JSON line index=${malformed}`, - transcriptLines: responseLines, - }; - } - - for (const assertion of scenario.assertions) { - const response = responses.find( - (item) => item && typeof item.id === "number" && item.id === assertion.responseId, - ) as Record | undefined; - if (!response) { - return { - passed: false, - durationMs, - failureCode: "ASSERT_RESPONSE_MISSING", - details: `Response id ${assertion.responseId} missing`, - transcriptLines: responseLines, - }; - } - const serialized = JSON.stringify(response); - for (const token of assertion.contains) { - if (!serialized.includes(token)) { - return { - passed: false, - durationMs, - failureCode: "ASSERT_CONTAINS_FAILED", - details: `Expected token '${token}' in response id=${assertion.responseId}`, - transcriptLines: responseLines, - }; - } - } - } - - await writeTextFile( - `${artifactsDir}/transcripts/${client}/${scenario.id}.jsonl`, - `${scenario.messages - .map((m) => JSON.stringify({ direction: "request", payload: m })) - .join("\n")}\n${responseLines - .map((line) => JSON.stringify({ direction: "response", payload: JSON.parse(line) })) - .join("\n")}\n`, - ); - - return { passed: true, durationMs, transcriptLines: responseLines }; -} - -function getClientVersion(client: ClientName): { detected: string; source: "env" | "manual" | "unknown" } { - const envName = client === "claude-code" ? "OPEN_MEM_CLAUDE_CODE_VERSION" : "OPEN_MEM_CURSOR_VERSION"; - const value = process.env[envName]?.trim(); - if (!value) return { detected: "unknown", source: "unknown" }; - return { detected: value, source: "env" }; -} - -function statusFromScenarios( - allPassed: boolean, - version: { detected: string }, -): "supported" | "expected-supported" | "failed" { - if (!allPassed) return "failed"; - if (version.detected === "unknown") return "expected-supported"; - return "supported"; -} - -export async function verifyExternalClients(options?: { - artifactsDir?: string; - project?: string; - runner?: string; -}): Promise { - assertExists("dist/mcp.js", "DIST_MCP"); - assertExists("dist/claude-code.js", "DIST_CLAUDE_WORKER"); - assertExists("dist/cursor.js", "DIST_CURSOR_WORKER"); - - const artifactsDir = options?.artifactsDir ?? "artifacts/external-compat"; - const project = options?.project ?? process.cwd(); - const runner = options?.runner ?? "local"; - - await mkdir(`${artifactsDir}/transcripts`, { recursive: true }); - await mkdir(`${artifactsDir}/logs`, { recursive: true }); - - const clientResults: ExternalCompatReport["clients"] = []; - const toolCallDurations: number[] = []; - - for (const client of ["claude-code", "cursor"] as const) { - const fixture = await readJsonFile( - `tests/fixtures/external-clients/${client}-mcp.json`, - ); - const scenarioResults = [] as ExternalCompatReport["clients"][number]["requiredScenarios"]; - const logLines: string[] = []; - - for (const scenario of fixture.scenarios) { - const result = await runMcpScenario(scenario, project, client, artifactsDir); - if (scenario.id.includes("tool") || scenario.id.includes("validation")) { - toolCallDurations.push(result.durationMs); - } - scenarioResults.push({ - id: scenario.id, - name: scenario.name, - passed: result.passed, - durationMs: result.durationMs, - failureCode: result.failureCode, - details: result.details, - }); - if (!result.passed) { - logLines.push( - `[${scenario.id}] failed code=${result.failureCode ?? "unknown"} details=${result.details ?? "n/a"}`, - ); - } - } - - const version = getClientVersion(client); - const allPassed = scenarioResults.every((s) => s.passed); - const knownLimitations: string[] = []; - if (version.detected === "unknown") { - knownLimitations.push( - "Client version was not provided via environment; status remains Expected Supported.", - ); - } - if (!allPassed) { - const firstFailure = scenarioResults.find((s) => !s.passed); - knownLimitations.push( - `Required scenario failed: ${firstFailure?.id ?? "unknown"} (${firstFailure?.failureCode ?? "n/a"}).`, - ); - } - if (knownLimitations.length === 0) { - knownLimitations.push("None."); - } - - await writeTextFile(`${artifactsDir}/logs/${client}.log`, `${logLines.join("\n")}\n`); - - clientResults.push({ - name: client, - transport: "stdio", - protocolVersion: fixture.protocolVersion as "2024-11-05", - status: statusFromScenarios(allPassed, version), - version, - requiredScenarios: scenarioResults, - knownLimitations, - artifacts: { - transcriptsDir: `transcripts/${client}`, - logFile: `logs/${client}.log`, - }, - }); - } - - const smokeResults = await runWorkerSmoke(artifactsDir); - const workerDurations = smokeResults.map((r) => r.eventP95Ms); - const failedWorker = smokeResults.filter( - (r) => !r.stdioPassed || !r.httpPassed || !r.invalidJsonRecoveryPassed, - ); - - for (const failed of failedWorker) { - const client = clientResults.find((entry) => entry.name === failed.client); - if (!client) continue; - client.status = "failed"; - client.requiredScenarios.push({ - id: "worker-bridge-smoke", - name: "worker stdio/http bridge smoke", - passed: false, - durationMs: failed.eventP95Ms, - failureCode: "WORKER_BRIDGE_SMOKE_FAILED", - details: failed.errors.join("; "), - metrics: { p95Ms: failed.eventP95Ms, samples: 2 }, - }); - if (!client.knownLimitations.some((value) => value.includes("worker bridge"))) { - client.knownLimitations.push("Worker bridge smoke checks failed."); - } - } - - const allScenarios = clientResults.flatMap((client) => client.requiredScenarios); - const failedScenarios = allScenarios.filter((s) => !s.passed).length; - const failureRate = allScenarios.length === 0 ? 0 : Number((failedScenarios / allScenarios.length).toFixed(4)); - const mcpP95 = percentile95(toolCallDurations); - const workerP95 = percentile95(workerDurations); - const allRequiredPassed = failedScenarios === 0; - const sloMet = - mcpP95 < 250 && - workerP95 < 100 && - failureRate < 0.01 && - clientResults.every((client) => client.status === "supported"); - - const gitShaProcess = Bun.spawnSync(["git", "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }); - const gitSha = - gitShaProcess.exitCode === 0 - ? Buffer.from(gitShaProcess.stdout).toString("utf8").trim() - : "unknown"; - - const report: ExternalCompatReport = { - schemaVersion: "1.0.0", - generatedAt: new Date().toISOString(), - gitSha, - environment: { - os: process.platform, - arch: process.arch, - bunVersion: Bun.version, - runner, - }, - policy: { - freshnessDays: 7, - versionScope: "latest-stable-only", - verificationScope: "macOS self-hosted verification only", - }, - clients: clientResults, - summary: { - scenarioCount: allScenarios.length, - failedScenarios, - failureRate, - allRequiredPassed, - mcpToolCallP95Ms: mcpP95, - workerEventIngestP95Ms: workerP95, - }, - slo: { - mcpToolCallP95TargetMs: 250, - workerEventIngestP95TargetMs: 100, - externalFailureRateTarget: 0.01, - met: sloMet, - }, - failureTaxonomy: getFailureTaxonomy(), - }; - - const validationErrors = validateExternalCompatReport(report); - if (validationErrors.length > 0) { - throw new Error(`ENV_REPORT_SCHEMA_INVALID: ${validationErrors.join("; ")}`); - } - - await writeJsonFile(`${artifactsDir}/external-compat-report.json`, report); - - const summaryMd = [ - "# External Compatibility Verification Summary", - "", - `- Generated: ${report.generatedAt}`, - `- Git SHA: ${report.gitSha}`, - `- Runner: ${report.environment.runner}`, - `- MCP tool call p95: ${report.summary.mcpToolCallP95Ms}ms (target < 250ms)`, - `- Worker ingest p95: ${report.summary.workerEventIngestP95Ms}ms (target < 100ms)`, - `- Failure rate: ${report.summary.failureRate} (target < 0.01)`, - "", - "## Client Status", - ...report.clients.map( - (client) => - `- ${client.name}: ${client.status} (version=${client.version.detected}; limitations=${client.knownLimitations.join(" | ")})`, - ), - "", - "## Failure Taxonomy", - ...report.failureTaxonomy.map((f) => `- ${f.code}: ${f.description}`), - ].join("\n"); - await writeTextFile(`${artifactsDir}/summary.md`, `${summaryMd}\n`); - - const filesForChecksums = [ - `${artifactsDir}/external-compat-report.json`, - `${artifactsDir}/summary.md`, - `${artifactsDir}/worker-smoke.json`, - ]; - const checksums: string[] = []; - for (const file of filesForChecksums) { - const content = await readFile(file); - const digest = createHash("sha256").update(content).digest("hex"); - checksums.push(`${digest} ${rel(artifactsDir, file)}`); - } - await writeTextFile(`${artifactsDir}/checksums.txt`, `${checksums.join("\n")}\n`); - - await writeJsonFile("docs/compatibility/external-compat-latest.json", report); - const dateName = report.generatedAt.slice(0, 10); - await writeJsonFile(`docs/compatibility/external-compat-history/${dateName}.json`, report); - - return report; -} - -async function main() { - const args = parseArgs(); - const report = await verifyExternalClients({ - artifactsDir: args.artifactsDir, - project: args.project, - runner: args.runner, - }); - const failedStatuses = report.clients.filter((client) => client.status === "failed"); - if (failedStatuses.length > 0) { - const mapped = failedStatuses.flatMap((client) => - client.requiredScenarios - .filter((scenario) => !scenario.passed) - .map((scenario) => classifyFailure(scenario.failureCode)), - ); - console.error("[verify-external-clients] failed", { - clients: failedStatuses.map((client) => client.name), - categories: mapped, - }); - process.exit(1); - } - console.log("[verify-external-clients] completed", { - generatedAt: report.generatedAt, - statuses: report.clients.map((client) => ({ - name: client.name, - status: client.status, - version: client.version.detected, - })), - }); -} - -if (import.meta.main) { - main().catch((error) => { - console.error("[verify-external-clients] fatal", error); - process.exit(1); - }); -} diff --git a/src/adapters/http/server.ts b/src/adapters/http/server.ts index e7d82bc..f21586e 100644 --- a/src/adapters/http/server.ts +++ b/src/adapters/http/server.ts @@ -11,9 +11,17 @@ import { previewConfig, readProjectConfig, } from "../../config/store"; -import { fail, observationTypeSchema, ok } from "../../contracts/api"; -import type { MemoryEngine, RuntimeStatusSnapshot } from "../../core/contracts"; +import { + CONTRACT_VERSION, + fail, + observationTypeSchema, + ok, + TOOL_CONTRACTS, +} from "../../contracts/api"; +import type { HealthStatus, MemoryEngine, RuntimeStatusSnapshot } from "../../core/contracts"; import { getAvailableModes, loadMode } from "../../modes/loader"; +import { DefaultReadinessService } from "../../services/readiness"; +import { DefaultSetupDiagnosticsService } from "../../services/setup-diagnostics"; import type { ObservationType, OpenMemConfig } from "../../types"; export interface DashboardDeps { @@ -60,6 +68,60 @@ function redactConfig(config: OpenMemConfig): Record { return result; } +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function normalizeHostToken(value: string): string { + const token = value.trim().toLowerCase(); + if (token.startsWith("[")) { + const end = token.indexOf("]"); + return end > 0 ? token.slice(1, end) : token; + } + const colonCount = (token.match(/:/g) ?? []).length; + return colonCount <= 1 ? (token.split(":")[0] ?? token) : token; +} + +function isLoopbackHost(value: string): boolean { + return LOOPBACK_HOSTS.has(normalizeHostToken(value)); +} + +function isLocalRequest(c: Context): boolean { + // Primary isolation is at the listener level (dashboard binds to 127.0.0.1). + // This is an additional guard for local-only operator routes. + const hostHeader = c.req.header("host"); + if (!hostHeader) return false; + const [firstHost = ""] = hostHeader.split(","); + if (!isLoopbackHost(firstHost)) { + return false; + } + const forwardedFor = c.req.header("x-forwarded-for"); + if (!forwardedFor) return true; + const forwardedChain = forwardedFor + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return forwardedChain.every(isLoopbackHost); +} + +function buildRuntimeFallback(health: HealthStatus): RuntimeStatusSnapshot { + return { + status: health.status, + timestamp: health.timestamp, + uptimeMs: process.uptime() * 1000, + queue: { + mode: "in-process", + running: false, + processing: false, + pending: 0, + lastBatchDurationMs: 0, + lastProcessedAt: null, + lastFailedAt: null, + lastError: null, + }, + batches: { total: 0, processedItems: 0, failedItems: 0, avgDurationMs: 0 }, + enqueueCount: 0, + }; +} + export function createDashboardApp(deps: DashboardDeps): Hono { const { projectPath, @@ -69,6 +131,8 @@ export function createDashboardApp(deps: DashboardDeps): Hono { } = deps; const app = new Hono(); + const readinessService = new DefaultReadinessService(); + const diagnosticsService = new DefaultSetupDiagnosticsService(); app.get("/v1/memory/observations", (c) => { const limit = clampLimit(c.req.query("limit"), 50); @@ -133,16 +197,6 @@ export function createDashboardApp(deps: DashboardDeps): Hono { return c.json(fail("VALIDATION_ERROR", "Query parameter 'against' is required"), 400); const diff = memoryEngine.getRevisionDiff(id, againstId); if (!diff) return c.json(fail("NOT_FOUND", "One or both observations not found"), 404); - const version = c.req.query("version"); - if (version === "1") { - return c.json( - ok({ - baseId: diff.toId, - againstId: diff.fromId, - changes: diff.changedFields, - }), - ); - } return c.json(ok(diff)); }); @@ -241,24 +295,7 @@ export function createDashboardApp(deps: DashboardDeps): Hono { const health = memoryEngine.getHealth(); const metrics = memoryEngine.getMetrics(); const runtime = runtimeStatusProvider?.(); - const runtimeFallback = { - status: health.status, - timestamp: health.timestamp, - uptimeMs: process.uptime() * 1000, - queue: { - mode: "in-process", - running: false, - processing: false, - pending: 0, - lastBatchDurationMs: 0, - lastProcessedAt: null, - lastFailedAt: null, - lastError: null, - }, - batches: { total: 0, processedItems: 0, failedItems: 0, avgDurationMs: 0 }, - enqueueCount: 0, - } satisfies RuntimeStatusSnapshot; - const runtimeSnapshot = runtime ?? runtimeFallback; + const runtimeSnapshot = runtime ?? buildRuntimeFallback(health); return c.json( ok({ @@ -274,26 +311,68 @@ export function createDashboardApp(deps: DashboardDeps): Hono { ); }); + app.get("/v1/readiness", (c) => { + const health = memoryEngine.getHealth(); + const runtime = runtimeStatusProvider?.() ?? buildRuntimeFallback(health); + + const readiness = readinessService.evaluate({ + config: deps.config, + adapterStatuses: memoryEngine.getAdapterStatuses().map((adapter) => ({ + name: adapter.name, + enabled: adapter.enabled, + })), + runtime: { + status: runtime.status, + queue: { lastError: runtime.queue.lastError }, + }, + }); + + return c.json(ok(readiness), readiness.ready ? 200 : 503); + }); + + app.get("/v1/diagnostics", (c) => { + const diagnostics = diagnosticsService.run(deps.config); + return c.json(ok(diagnostics), diagnostics.ok ? 200 : 503); + }); + + app.get("/v1/tools/guide", (c) => { + return c.json( + ok({ + contractVersion: CONTRACT_VERSION, + workflow: { + recommended: ["mem-find", "mem-history", "mem-get"], + description: + "Start with compact discovery, then timeline context, then full detail fetch by IDs.", + }, + tools: TOOL_CONTRACTS, + }), + ); + }); + + app.get("/v1/queue", (c) => { + if (!isLocalRequest(c)) return c.json(fail("LOCKED_BY_ENV", "Localhost access required"), 403); + const health = memoryEngine.getHealth(); + const runtime = runtimeStatusProvider?.() ?? buildRuntimeFallback(health); + return c.json( + ok({ + contractVersion: CONTRACT_VERSION, + queue: runtime.queue, + batches: runtime.batches, + enqueueCount: runtime.enqueueCount, + }), + ); + }); + + app.post("/v1/queue/process", async (c) => { + if (!isLocalRequest(c)) return c.json(fail("LOCKED_BY_ENV", "Localhost access required"), 403); + const processed = await memoryEngine.processPending(); + return c.json(ok({ processed })); + }); + app.get("/v1/metrics", (c) => { const health = memoryEngine.getHealth(); const runtime = runtimeStatusProvider?.(); - const runtimeSnapshot = runtime ?? { - status: health.status, - timestamp: health.timestamp, - uptimeMs: process.uptime() * 1000, - queue: { - mode: "in-process", - running: false, - processing: false, - pending: 0, - lastBatchDurationMs: 0, - lastProcessedAt: null, - lastFailedAt: null, - lastError: null, - }, - batches: { total: 0, processedItems: 0, failedItems: 0, avgDurationMs: 0 }, - enqueueCount: 0, - }; + const runtimeSnapshot = runtime ?? buildRuntimeFallback(health); return c.json(ok(runtimeSnapshot)); }); diff --git a/src/adapters/mcp/server.ts b/src/adapters/mcp/server.ts index 5f5caa3..0c6d4da 100644 --- a/src/adapters/mcp/server.ts +++ b/src/adapters/mcp/server.ts @@ -1,6 +1,6 @@ import { createInterface } from "node:readline"; import { z } from "zod"; -import { fail, ok, toolSchemas } from "../../contracts/api"; +import { fail, ok, TOOL_CONTRACTS, toolSchemas } from "../../contracts/api"; import type { MemoryEngine } from "../../core/contracts"; interface JsonRpcRequest { @@ -36,7 +36,6 @@ interface McpToolResult { export interface McpServerDeps { memoryEngine: MemoryEngine; version: string; - compatibilityMode?: "strict" | "legacy"; protocolVersion?: string; supportedProtocolVersions?: string[]; } @@ -76,7 +75,6 @@ function asValidationError(error: z.ZodError): string { export class McpServer { private readonly memoryEngine: MemoryEngine; private readonly version: string; - private readonly compatibilityMode: "strict" | "legacy"; private readonly protocolVersion: string; private readonly supportedProtocolVersions: string[]; private initialized = false; @@ -85,7 +83,6 @@ export class McpServer { constructor(deps: McpServerDeps) { this.memoryEngine = deps.memoryEngine; this.version = deps.version; - this.compatibilityMode = deps.compatibilityMode ?? "strict"; this.protocolVersion = deps.protocolVersion ?? DEFAULT_PROTOCOL_VERSION; this.supportedProtocolVersions = deps.supportedProtocolVersions && deps.supportedProtocolVersions.length > 0 @@ -132,7 +129,7 @@ export class McpServer { return; } - if (!this.initialized && this.compatibilityMode === "strict") { + if (!this.initialized) { this.send({ jsonrpc: "2.0", id: msg.id, @@ -201,67 +198,11 @@ export class McpServer { } private getToolDefinitions(): McpToolDefinition[] { - return [ - { - name: "mem-find", - description: - "Search past memories — decisions, discoveries, gotchas, and session history. Use to recall context from previous sessions before starting work.", - inputSchema: toInputSchema(toolSchemas.find), - }, - { - name: "mem-history", - description: - "Browse session timeline and summaries. Use to understand what happened in recent sessions or drill into a specific session.", - inputSchema: toInputSchema(toolSchemas.history), - }, - { - name: "mem-get", - description: - "Fetch full memory details by ID. Use after mem-find or mem-history to get complete narratives, facts, and file lists.", - inputSchema: toInputSchema(toolSchemas.get), - }, - { - name: "mem-create", - description: - "Save an important observation to memory. Use for decisions + rationale, non-obvious gotchas, user preferences, or cross-session plans that auto-capture wouldn't understand the significance of.", - inputSchema: toInputSchema(toolSchemas.create), - }, - { - name: "mem-revise", - description: - "Update an existing memory with a new revision. Use when a previous decision changed, a gotcha was resolved, or information became outdated.", - inputSchema: toInputSchema(toolSchemas.revise), - }, - { - name: "mem-remove", - description: - "Tombstone an obsolete or incorrect memory. Use to clean up memories that are no longer accurate or relevant.", - inputSchema: toInputSchema(toolSchemas.remove), - }, - { - name: "mem-export", - description: - "Export project memories as portable JSON for backup or transfer between machines.", - inputSchema: toInputSchema(toolSchemas.transferExport), - }, - { - name: "mem-import", - description: "Import memories from a JSON export. Skips duplicates by default.", - inputSchema: toInputSchema(toolSchemas.transferImport), - }, - { - name: "mem-maintenance", - description: - "Run folder context maintenance — clean, rebuild, purge, or dry-run AGENTS.md files.", - inputSchema: toInputSchema(toolSchemas.maintenance), - }, - { - name: "mem-help", - description: - "Show detailed memory workflow guidance including when to save, what to save, and memory type reference.", - inputSchema: toInputSchema(toolSchemas.help), - }, - ]; + return TOOL_CONTRACTS.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: toInputSchema(toolSchemas[tool.schema]), + })); } private async handleToolCall( diff --git a/src/adapters/opencode/tools.ts b/src/adapters/opencode/tools.ts index 3e7d8dd..486bb15 100644 --- a/src/adapters/opencode/tools.ts +++ b/src/adapters/opencode/tools.ts @@ -1,4 +1,10 @@ -import { fail, ok, toolSchemas } from "../../contracts/api"; +import { + fail, + getToolContractByName, + ok, + type ToolContractName, + toolSchemas, +} from "../../contracts/api"; import type { MemoryEngine } from "../../core/contracts"; import type { SearchResult, ToolDefinition } from "../../types"; @@ -24,10 +30,17 @@ function mapSearchResults(results: SearchResult[], scope: "project" | "user" | " } export function createOpenCodeTools(engine: MemoryEngine): Record { + const descriptionOf = (toolName: ToolContractName): string => { + const contract = getToolContractByName(toolName); + if (!contract) { + throw new Error(`Missing tool contract metadata for ${toolName}`); + } + return contract.description; + }; + return { "mem-find": { - description: - "Search past memories — decisions, discoveries, gotchas, and session history. Use to recall context from previous sessions before starting work.", + description: descriptionOf("mem-find"), args: toolSchemas.find.shape, execute: async (rawArgs) => { try { @@ -47,8 +60,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -67,8 +79,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -81,8 +92,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -96,8 +106,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -111,8 +120,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -126,8 +134,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -140,7 +147,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -156,8 +163,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record { try { @@ -178,8 +184,7 @@ export function createOpenCodeTools(engine: MemoryEngine): Record toJson(ok({ guide: engine.guide() })), }, diff --git a/src/ai/fallback-policy.ts b/src/ai/fallback-policy.ts new file mode 100644 index 0000000..391c6df --- /dev/null +++ b/src/ai/fallback-policy.ts @@ -0,0 +1,37 @@ +// ============================================================================= +// open-mem — Provider Fallback Policy +// ============================================================================= + +import { isConfigError, isRetryable } from "./errors"; + +export interface FallbackDecisionInput { + error: unknown | null; + provider: string; + nextProvider?: string; + attemptIndex: number; + totalProviders: number; +} + +export interface ProviderFallbackPolicy { + onAttempt?(input: FallbackDecisionInput): void; + shouldFailover(input: FallbackDecisionInput): boolean; + onFailover(input: FallbackDecisionInput): void; + onFinalFailure?(input: FallbackDecisionInput): void; +} + +export class DefaultProviderFallbackPolicy implements ProviderFallbackPolicy { + shouldFailover(input: FallbackDecisionInput): boolean { + const { error, attemptIndex, totalProviders } = input; + if (isConfigError(error)) return false; + if (attemptIndex >= totalProviders - 1) return false; + return isRetryable(error); + } + + onFailover(input: FallbackDecisionInput): void { + const status = (input.error as Record)?.status ?? "unknown"; + if (!input.nextProvider) return; + console.error( + `[open-mem] Provider ${input.provider} failed (${status}), falling over to ${input.nextProvider}`, + ); + } +} diff --git a/src/ai/fallback.ts b/src/ai/fallback.ts index de6866e..7458490 100644 --- a/src/ai/fallback.ts +++ b/src/ai/fallback.ts @@ -3,7 +3,8 @@ // ============================================================================= import type { LanguageModelV2, LanguageModelV3 } from "@ai-sdk/provider"; -import { isConfigError, isRetryable } from "./errors"; +import { isConfigError } from "./errors"; +import { DefaultProviderFallbackPolicy, type ProviderFallbackPolicy } from "./fallback-policy"; // ----------------------------------------------------------------------------- // Types @@ -35,8 +36,12 @@ export class FallbackLanguageModel { readonly supportedUrls: ConcreteLanguageModel["supportedUrls"]; private providers: FallbackProvider[]; + private policy: ProviderFallbackPolicy; - constructor(providers: FallbackProvider[]) { + constructor( + providers: FallbackProvider[], + policy: ProviderFallbackPolicy = new DefaultProviderFallbackPolicy(), + ) { if (providers.length === 0) { throw new Error("At least one provider required"); } @@ -47,6 +52,7 @@ export class FallbackLanguageModel { this.modelId = primary.modelId; this.supportedUrls = primary.supportedUrls; this.providers = providers; + this.policy = policy; } // --------------------------------------------------------------------------- @@ -59,6 +65,13 @@ export class FallbackLanguageModel { for (let i = 0; i < this.providers.length; i++) { const provider = this.providers[i]; try { + this.policy.onAttempt?.({ + error: null, + provider: provider.name, + nextProvider: this.providers[i + 1]?.name, + attemptIndex: i, + totalProviders: this.providers.length, + }); return await (provider.model.doGenerate as (opts: unknown) => Promise)(options); } catch (error: unknown) { lastError = error; @@ -67,15 +80,20 @@ export class FallbackLanguageModel { throw error; } - if (isRetryable(error) && i < this.providers.length - 1) { - const nextProvider = this.providers[i + 1]; - const status = (error as Record).status ?? "unknown"; - console.error( - `[open-mem] Provider ${provider.name} failed (${status}), falling over to ${nextProvider.name}`, - ); + const nextProvider = this.providers[i + 1]?.name; + const decision = { + error, + provider: provider.name, + nextProvider, + attemptIndex: i, + totalProviders: this.providers.length, + }; + if (this.policy.shouldFailover(decision)) { + this.policy.onFailover(decision); continue; } + this.policy.onFinalFailure?.(decision); throw error; } } @@ -93,6 +111,13 @@ export class FallbackLanguageModel { for (let i = 0; i < this.providers.length; i++) { const provider = this.providers[i]; try { + this.policy.onAttempt?.({ + error: null, + provider: provider.name, + nextProvider: this.providers[i + 1]?.name, + attemptIndex: i, + totalProviders: this.providers.length, + }); return await (provider.model.doStream as (opts: unknown) => Promise)(options); } catch (error: unknown) { lastError = error; @@ -101,15 +126,20 @@ export class FallbackLanguageModel { throw error; } - if (isRetryable(error) && i < this.providers.length - 1) { - const nextProvider = this.providers[i + 1]; - const status = (error as Record).status ?? "unknown"; - console.error( - `[open-mem] Provider ${provider.name} failed (${status}), falling over to ${nextProvider.name}`, - ); + const nextProvider = this.providers[i + 1]?.name; + const decision = { + error, + provider: provider.name, + nextProvider, + attemptIndex: i, + totalProviders: this.providers.length, + }; + if (this.policy.shouldFailover(decision)) { + this.policy.onFailover(decision); continue; } + this.policy.onFinalFailure?.(decision); throw error; } } diff --git a/src/ai/provider.ts b/src/ai/provider.ts index 275dad8..da821a5 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -5,6 +5,7 @@ import type { LanguageModelV2, LanguageModelV3 } from "@ai-sdk/provider"; import type { EmbeddingModel, LanguageModel } from "ai"; import { FallbackLanguageModel } from "./fallback"; +import type { ProviderFallbackPolicy } from "./fallback-policy"; // ----------------------------------------------------------------------------- // Types @@ -163,6 +164,7 @@ export function buildFallbackConfigs(config: { fallbackProviders?: string[] }): export function createModelWithFallback( primaryConfig: ModelConfig, fallbackConfigs: ModelConfig[] = [], + policy?: ProviderFallbackPolicy, ): LanguageModel { const primary = createModel(primaryConfig); if (fallbackConfigs.length === 0) return primary; @@ -174,5 +176,5 @@ export function createModelWithFallback( model: createModel(config) as LanguageModelV2 | LanguageModelV3, })), ]; - return new FallbackLanguageModel(providers) as unknown as LanguageModel; + return new FallbackLanguageModel(providers, policy) as unknown as LanguageModel; } diff --git a/src/config.ts b/src/config.ts index 93a609e..e347221 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,8 +72,7 @@ const DEFAULT_CONFIG: OpenMemConfig = { platformClaudeCodeEnabled: false, platformCursorEnabled: false, - // MCP compatibility - mcpCompatibilityMode: "strict", + // MCP protocol mcpProtocolVersion: "2024-11-05", mcpSupportedProtocolVersions: ["2024-11-05"], @@ -154,8 +153,6 @@ function loadFromEnv(): Partial { if (process.env.OPEN_MEM_PLATFORM_OPENCODE === "false") env.platformOpenCodeEnabled = false; if (process.env.OPEN_MEM_PLATFORM_CLAUDE_CODE === "true") env.platformClaudeCodeEnabled = true; if (process.env.OPEN_MEM_PLATFORM_CURSOR === "true") env.platformCursorEnabled = true; - if (process.env.OPEN_MEM_MCP_COMPAT_MODE) - env.mcpCompatibilityMode = process.env.OPEN_MEM_MCP_COMPAT_MODE as "strict" | "legacy"; if (process.env.OPEN_MEM_MCP_PROTOCOL_VERSION) env.mcpProtocolVersion = process.env.OPEN_MEM_MCP_PROTOCOL_VERSION; if (process.env.OPEN_MEM_MCP_SUPPORTED_PROTOCOLS) diff --git a/src/config/store.ts b/src/config/store.ts index 4323b73..55c09dc 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -238,15 +238,6 @@ const FIELD_SCHEMA: ConfigFieldSchema[] = [ liveApply: false, restartRequired: true, }, - { - key: "mcpCompatibilityMode", - label: "MCP Compatibility Mode", - type: "string", - group: "Advanced", - liveApply: false, - restartRequired: true, - enum: ["strict", "legacy"], - }, { key: "mcpProtocolVersion", label: "MCP Protocol Version", @@ -317,7 +308,6 @@ const ENV_BY_KEY: Partial> = { platformOpenCodeEnabled: ["OPEN_MEM_PLATFORM_OPENCODE"], platformClaudeCodeEnabled: ["OPEN_MEM_PLATFORM_CLAUDE_CODE"], platformCursorEnabled: ["OPEN_MEM_PLATFORM_CURSOR"], - mcpCompatibilityMode: ["OPEN_MEM_MCP_COMPAT_MODE"], mcpProtocolVersion: ["OPEN_MEM_MCP_PROTOCOL_VERSION"], mcpSupportedProtocolVersions: ["OPEN_MEM_MCP_SUPPORTED_PROTOCOLS"], rerankingEnabled: ["OPEN_MEM_RERANKING"], diff --git a/src/contracts/api.ts b/src/contracts/api.ts index 048ed6b..bb3c9a4 100644 --- a/src/contracts/api.ts +++ b/src/contracts/api.ts @@ -1,13 +1,11 @@ -import { z } from "zod"; - -export const observationTypeSchema = z.enum([ - "decision", - "bugfix", - "feature", - "refactor", - "discovery", - "change", -]); +export type { ToolContractName } from "./schemas"; +export { + CONTRACT_VERSION, + getToolContractByName, + observationTypeSchema, + TOOL_CONTRACTS, + toolSchemas, +} from "./schemas"; export type ApiErrorCode = | "VALIDATION_ERROR" @@ -33,88 +31,3 @@ export function fail(code: ApiErrorCode, message: string, details?: unknown): Ap meta: {}, }; } - -export const toolSchemas = { - find: z.object({ - query: z.string().min(1), - scope: z.enum(["project", "user", "all"]).optional().default("project"), - types: z.array(observationTypeSchema).optional(), - limit: z.number().int().min(1).max(50).optional().default(10), - cursor: z.string().optional(), - include: z - .object({ - snippets: z.boolean().optional(), - scores: z.boolean().optional(), - relations: z.boolean().optional(), - }) - .optional(), - }), - history: z.object({ - limit: z.number().int().min(1).max(20).optional().default(5), - cursor: z.string().optional(), - sessionId: z.string().optional(), - anchor: z.string().optional().describe("Observation ID to center the timeline around"), - depthBefore: z - .number() - .int() - .min(0) - .max(20) - .optional() - .default(5) - .describe("Number of observations to show before the anchor"), - depthAfter: z - .number() - .int() - .min(0) - .max(20) - .optional() - .default(5) - .describe("Number of observations to show after the anchor"), - }), - get: z.object({ - ids: z.array(z.string()).min(1), - includeHistory: z.boolean().optional().default(false), - limit: z.number().int().min(1).max(50).optional().default(10), - }), - create: z.object({ - title: z.string(), - type: observationTypeSchema, - narrative: z.string(), - concepts: z.array(z.string()).optional(), - files: z.array(z.string()).optional(), - importance: z.number().int().min(1).max(5).optional(), - scope: z.enum(["project", "user"]).optional().default("project"), - }), - revise: z.object({ - id: z.string(), - title: z.string().optional(), - narrative: z.string().optional(), - type: observationTypeSchema.optional(), - concepts: z.array(z.string()).optional(), - importance: z.number().int().min(1).max(5).optional(), - reason: z.string().optional(), - }), - remove: z.object({ - id: z.string(), - reason: z.string().optional(), - }), - transferExport: z.object({ - scope: z.enum(["project"]).optional().default("project"), - type: observationTypeSchema.optional(), - limit: z.number().int().min(1).optional(), - format: z.enum(["json"]).optional().default("json"), - }), - transferImport: z.object({ - payload: z.string(), - mode: z.enum(["skip", "merge", "replace"]).optional().default("skip"), - }), - maintenance: z.object({ - action: z.enum([ - "folderContextDryRun", - "folderContextClean", - "folderContextRebuild", - "folderContextPurge", - ]), - }), - help: z.object({}), -}; diff --git a/src/contracts/schemas.ts b/src/contracts/schemas.ts new file mode 100644 index 0000000..e5c3fc0 --- /dev/null +++ b/src/contracts/schemas.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; + +export const CONTRACT_VERSION = "1.0.0"; + +export const observationTypeSchema = z.enum([ + "decision", + "bugfix", + "feature", + "refactor", + "discovery", + "change", +]); + +export const toolSchemas = { + find: z.object({ + query: z.string().min(1), + scope: z.enum(["project", "user", "all"]).optional().default("project"), + types: z.array(observationTypeSchema).optional(), + limit: z.number().int().min(1).max(50).optional().default(10), + cursor: z.string().optional(), + include: z + .object({ + snippets: z.boolean().optional(), + scores: z.boolean().optional(), + relations: z.boolean().optional(), + }) + .optional(), + }), + history: z.object({ + limit: z.number().int().min(1).max(20).optional().default(5), + cursor: z.string().optional(), + sessionId: z.string().optional(), + anchor: z.string().optional().describe("Observation ID to center the timeline around"), + depthBefore: z.number().int().min(0).max(20).optional().default(5), + depthAfter: z.number().int().min(0).max(20).optional().default(5), + }), + get: z.object({ + ids: z.array(z.string()).min(1), + includeHistory: z.boolean().optional().default(false), + limit: z.number().int().min(1).max(50).optional().default(10), + }), + create: z.object({ + title: z.string(), + type: observationTypeSchema, + narrative: z.string(), + concepts: z.array(z.string()).optional(), + files: z.array(z.string()).optional(), + importance: z.number().int().min(1).max(5).optional(), + scope: z.enum(["project", "user"]).optional().default("project"), + }), + revise: z.object({ + id: z.string(), + title: z.string().optional(), + narrative: z.string().optional(), + type: observationTypeSchema.optional(), + concepts: z.array(z.string()).optional(), + importance: z.number().int().min(1).max(5).optional(), + reason: z.string().optional(), + }), + remove: z.object({ + id: z.string(), + reason: z.string().optional(), + }), + transferExport: z.object({ + scope: z.enum(["project"]).optional().default("project"), + type: observationTypeSchema.optional(), + limit: z.number().int().min(1).optional(), + format: z.enum(["json"]).optional().default("json"), + }), + transferImport: z.object({ + payload: z.string(), + mode: z.enum(["skip", "merge", "replace"]).optional().default("skip"), + }), + maintenance: z.object({ + action: z.enum([ + "folderContextDryRun", + "folderContextClean", + "folderContextRebuild", + "folderContextPurge", + ]), + }), + help: z.object({}), +}; + +export type ToolSchemaName = keyof typeof toolSchemas; +export type ToolContractName = + | "mem-find" + | "mem-history" + | "mem-get" + | "mem-create" + | "mem-revise" + | "mem-remove" + | "mem-export" + | "mem-import" + | "mem-maintenance" + | "mem-help"; + +export interface ToolContractMetadata { + name: ToolContractName; + schema: ToolSchemaName; + description: string; + deprecated?: boolean; + deprecationMessage?: string; + replacement?: string; +} + +export const TOOL_CONTRACTS: ToolContractMetadata[] = [ + { + name: "mem-find", + schema: "find", + description: + "Search past memories — decisions, discoveries, gotchas, and session history. Use to recall context from previous sessions before starting work.", + }, + { + name: "mem-history", + schema: "history", + description: + "Browse session timeline and summaries. Use to understand what happened in recent sessions or drill into a specific session.", + }, + { + name: "mem-get", + schema: "get", + description: + "Fetch full memory details by ID. Use after mem-find or mem-history to get complete narratives, facts, and file lists.", + }, + { + name: "mem-create", + schema: "create", + description: + "Save an important observation to memory. Use for decisions + rationale, non-obvious gotchas, user preferences, or cross-session plans that auto-capture wouldn't understand the significance of.", + }, + { + name: "mem-revise", + schema: "revise", + description: + "Update an existing memory with a new revision. Use when a previous decision changed, a gotcha was resolved, or information became outdated.", + }, + { + name: "mem-remove", + schema: "remove", + description: + "Tombstone an obsolete or incorrect memory. Use to clean up memories that are no longer accurate or relevant.", + }, + { + name: "mem-export", + schema: "transferExport", + description: + "Export project memories as portable JSON for backup or transfer between machines.", + }, + { + name: "mem-import", + schema: "transferImport", + description: "Import memories from a JSON export. Skips duplicates by default.", + }, + { + name: "mem-maintenance", + schema: "maintenance", + description: + "Run folder context maintenance — clean, rebuild, purge, or dry-run AGENTS.md files.", + }, + { + name: "mem-help", + schema: "help", + description: + "Show detailed memory workflow guidance including when to save, what to save, and memory type reference.", + }, +]; + +export function getToolContractByName(name: string): ToolContractMetadata | null { + return TOOL_CONTRACTS.find((tool) => tool.name === name) ?? null; +} diff --git a/src/core/memory-engine.ts b/src/core/memory-engine.ts index 7028a1a..865253f 100644 --- a/src/core/memory-engine.ts +++ b/src/core/memory-engine.ts @@ -755,7 +755,7 @@ export class DefaultMemoryEngine implements MemoryEngine { getHealth(): HealthStatus { const runtime = this.runtimeSnapshotProvider?.(); - const queueStatus: "ok" | "degraded" = runtime && runtime.queue.lastError ? "degraded" : "ok"; + const queueStatus: "ok" | "degraded" = runtime?.queue.lastError ? "degraded" : "ok"; return { status: runtime?.status ?? "ok", diff --git a/src/db/observations.ts b/src/db/observations.ts index 188d6ca..0702312 100644 --- a/src/db/observations.ts +++ b/src/db/observations.ts @@ -404,7 +404,8 @@ export class ObservationRepository { ${hasProjectPath ? "AND s.project_path = ?" : ""} ORDER BY rank LIMIT ?`; - const params: (string | number)[] = [`concepts:${concept}`]; + const escapedConcept = concept.replace(/"/g, '""'); + const params: (string | number)[] = [`concepts:"${escapedConcept}"`]; if (hasProjectPath && projectPath) { params.push(projectPath); } diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..0065ee3 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +import { parseArgs } from "node:util"; +import { resolveConfig } from "./config"; +import { DefaultSetupDiagnosticsService } from "./services/setup-diagnostics"; +import { getCanonicalProjectPath } from "./utils/worktree"; + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + project: { type: "string", short: "p" }, + json: { type: "boolean", default: false }, + }, + allowPositionals: false, +}); + +const projectDir = typeof values.project === "string" ? values.project : process.cwd(); +const projectPath = getCanonicalProjectPath(projectDir); +const config = resolveConfig(projectPath); +const result = new DefaultSetupDiagnosticsService().run(config); + +if (values.json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(result.ok ? 0 : 1); +} + +for (const check of result.checks) { + const icon = check.status === "pass" ? "✅" : check.status === "warn" ? "⚠️" : "❌"; + console.log(`${icon} ${check.id}: ${check.message}`); + if (check.details) { + console.log(` ${JSON.stringify(check.details)}`); + } +} + +if (!result.ok) { + console.error("\nopen-mem doctor found blocking issues."); + process.exit(1); +} + +console.log("\nopen-mem doctor: all critical checks passed."); diff --git a/src/mcp.ts b/src/mcp.ts index 38bcbbc..7369bbd 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -111,7 +111,6 @@ const server = new McpServer({ maintenanceHistoryStore: maintenanceHistoryRepo, }), version: pkgJson.version, - compatibilityMode: config.mcpCompatibilityMode, protocolVersion: config.mcpProtocolVersion, supportedProtocolVersions: config.mcpSupportedProtocolVersions, }); diff --git a/src/modes/loader.ts b/src/modes/loader.ts index c0d77cf..2f173b3 100644 --- a/src/modes/loader.ts +++ b/src/modes/loader.ts @@ -1,76 +1,21 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { ModeConfig } from "../types"; +import { type ModeConfigSource, ModeResolverV2 } from "./resolver"; const MODES_DIR = join(import.meta.dir, "."); +const resolver = new ModeResolverV2(MODES_DIR); -let modeCache: Map | null = null; +let modeCache: Map | null = null; -function loadAllModes(): Map { +function loadAllModes(): Map { if (modeCache) return modeCache; - modeCache = new Map(); - - if (!existsSync(MODES_DIR)) return modeCache; - - for (const file of readdirSync(MODES_DIR)) { - if (!file.endsWith(".json")) continue; - try { - const raw = readFileSync(join(MODES_DIR, file), "utf-8"); - const parsed = JSON.parse(raw) as ModeConfig; - if (parsed.id && parsed.observationTypes && parsed.conceptVocabulary) { - modeCache.set(parsed.id, parsed); - } - } catch { - // Skip malformed mode files - } - } - + modeCache = resolver.loadAllRaw(); return modeCache; } export function loadMode(modeId: string): ModeConfig { - const modes = loadAllModes(); - const mode = modes.get(modeId); - if (mode) return mode; - - const fallback = modes.get("code"); - if (fallback) return fallback; - - return { - id: "code", - name: "Code", - description: "Default coding workflow mode", - observationTypes: ["decision", "bugfix", "feature", "refactor", "discovery", "change"], - conceptVocabulary: [ - "how-it-works", - "why-it-exists", - "what-changed", - "problem-solution", - "gotcha", - "pattern", - "trade-off", - ], - entityTypes: [ - "technology", - "library", - "pattern", - "concept", - "file", - "person", - "project", - "other", - ], - relationshipTypes: [ - "uses", - "depends_on", - "implements", - "extends", - "related_to", - "replaces", - "configures", - ], - }; + return resolver.resolveById(modeId, loadAllModes()); } export function getAvailableModes(): string[] { diff --git a/src/modes/resolver.ts b/src/modes/resolver.ts new file mode 100644 index 0000000..a7eb3d9 --- /dev/null +++ b/src/modes/resolver.ts @@ -0,0 +1,170 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ModeConfig } from "../types"; + +export interface ModeConfigSource { + id: string; + extends?: string; + locale?: string; + name?: string; + description?: string; + observationTypes?: string[]; + conceptVocabulary?: string[]; + entityTypes?: string[]; + relationshipTypes?: string[]; + promptOverrides?: Record; +} + +const DEFAULT_MODE: ModeConfig = { + id: "code", + name: "Code", + description: "Default coding workflow mode", + observationTypes: ["decision", "bugfix", "feature", "refactor", "discovery", "change"], + conceptVocabulary: [ + "how-it-works", + "why-it-exists", + "what-changed", + "problem-solution", + "gotcha", + "pattern", + "trade-off", + ], + entityTypes: [ + "technology", + "library", + "pattern", + "concept", + "file", + "person", + "project", + "other", + ], + relationshipTypes: [ + "uses", + "depends_on", + "implements", + "extends", + "related_to", + "replaces", + "configures", + ], +}; + +function cloneMode(mode: ModeConfig): ModeConfig { + return { + ...mode, + observationTypes: [...mode.observationTypes], + conceptVocabulary: [...mode.conceptVocabulary], + entityTypes: [...mode.entityTypes], + relationshipTypes: [...mode.relationshipTypes], + promptOverrides: mode.promptOverrides ? { ...mode.promptOverrides } : undefined, + }; +} + +function isValidModeSource(value: unknown): value is ModeConfigSource { + if (!value || typeof value !== "object") return false; + const v = value as Record; + const isStringArray = (x: unknown) => + Array.isArray(x) && x.every((item) => typeof item === "string"); + const isRecordOfString = (x: unknown) => + typeof x === "object" && + x !== null && + !Array.isArray(x) && + Object.values(x).every((item) => typeof item === "string"); + return ( + typeof v.id === "string" && + (v.extends === undefined || typeof v.extends === "string") && + (v.locale === undefined || typeof v.locale === "string") && + (v.name === undefined || typeof v.name === "string") && + (v.description === undefined || typeof v.description === "string") && + (v.observationTypes === undefined || isStringArray(v.observationTypes)) && + (v.conceptVocabulary === undefined || isStringArray(v.conceptVocabulary)) && + (v.entityTypes === undefined || isStringArray(v.entityTypes)) && + (v.relationshipTypes === undefined || isStringArray(v.relationshipTypes)) && + (v.promptOverrides === undefined || isRecordOfString(v.promptOverrides)) + ); +} + +function isCompleteRootMode(mode: ModeConfigSource): boolean { + return ( + typeof mode.name === "string" && + typeof mode.description === "string" && + Array.isArray(mode.observationTypes) && + Array.isArray(mode.conceptVocabulary) && + Array.isArray(mode.entityTypes) && + Array.isArray(mode.relationshipTypes) + ); +} + +function mergeMode(base: ModeConfig, override: ModeConfigSource): ModeConfig { + return { + ...base, + ...override, + id: override.id, + name: override.name ?? base.name, + description: override.description ?? base.description, + observationTypes: override.observationTypes ?? base.observationTypes, + conceptVocabulary: override.conceptVocabulary ?? base.conceptVocabulary, + entityTypes: override.entityTypes ?? base.entityTypes, + relationshipTypes: override.relationshipTypes ?? base.relationshipTypes, + promptOverrides: { + ...(base.promptOverrides ?? {}), + ...(override.promptOverrides ?? {}), + }, + }; +} + +export class ModeResolverV2 { + constructor(private readonly modesDir: string) {} + + loadAllRaw(): Map { + const modes = new Map(); + if (!existsSync(this.modesDir)) return modes; + + for (const file of readdirSync(this.modesDir)) { + if (!file.endsWith(".json")) continue; + const path = join(this.modesDir, file); + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw); + if (!isValidModeSource(parsed)) continue; + if (modes.has(parsed.id)) { + console.warn( + `[open-mem] Duplicate mode id "${parsed.id}" in ${path}; overriding previous definition.`, + ); + } + modes.set(parsed.id, parsed); + } catch { + // ignore malformed files + } + } + return modes; + } + + resolveById(id: string, rawModes: Map): ModeConfig { + const seen = new Set(); + let cycleDetected = false; + const resolveInner = (modeId: string): ModeConfig => { + if (seen.has(modeId)) { + cycleDetected = true; + return cloneMode(DEFAULT_MODE); + } + seen.add(modeId); + const mode = rawModes.get(modeId); + if (!mode) return cloneMode(DEFAULT_MODE); + if (!mode.extends) { + if (!isCompleteRootMode(mode)) return cloneMode(DEFAULT_MODE); + return mergeMode(cloneMode(DEFAULT_MODE), mode); + } + const parent = resolveInner(mode.extends); + if (cycleDetected) return cloneMode(DEFAULT_MODE); + return mergeMode(parent, mode); + }; + const resolved = resolveInner(id); + return cycleDetected ? cloneMode(DEFAULT_MODE) : cloneMode(resolved); + } +} + +export function getDefaultModeConfig(): ModeConfig { + return cloneMode(DEFAULT_MODE); +} diff --git a/src/search/orchestrator.ts b/src/search/orchestrator.ts index 02e4ec9..4bc6341 100644 --- a/src/search/orchestrator.ts +++ b/src/search/orchestrator.ts @@ -1,5 +1,5 @@ // ============================================================================= -// open-mem — Search Orchestrator (Multi-Strategy Search) +// open-mem — Search Orchestrator // ============================================================================= import type { EmbeddingModel } from "ai"; @@ -13,20 +13,15 @@ import type { SearchLineageRef, SearchResult, } from "../types"; -import { cosineSimilarity, generateEmbedding } from "./embeddings"; -import { passesFilters } from "./filters"; import { graphAugmentedSearch } from "./graph"; -import { hybridSearch } from "./hybrid"; +import { InMemorySearchStrategyRegistry, type SearchStrategyRegistry } from "./registry"; import type { Reranker } from "./reranker"; +import { executeFilterOnlyStrategy } from "./strategies/filter-only"; +import { executeHybridStrategy } from "./strategies/hybrid"; +import { executeSemanticStrategy } from "./strategies/semantic"; -// ----------------------------------------------------------------------------- -// Types -// ----------------------------------------------------------------------------- - -/** Available search strategies for the orchestrator. */ export type SearchStrategy = "filter-only" | "semantic" | "hybrid"; -/** Options for orchestrated search across project and user memory. */ export interface OrchestratedSearchOptions { strategy?: SearchStrategy; type?: ObservationType; @@ -42,15 +37,9 @@ export interface OrchestratedSearchOptions { files?: string[]; } -// ----------------------------------------------------------------------------- -// SearchOrchestrator -// ----------------------------------------------------------------------------- - -/** - * Coordinates multi-strategy search across FTS5, vector, graph, and user memory. - * Supports filter-only, semantic, and hybrid search with optional LLM reranking. - */ export class SearchOrchestrator { + private strategyRegistry: SearchStrategyRegistry; + constructor( private observations: ObservationRepository, private embeddingModel: EmbeddingModel | null, @@ -58,32 +47,71 @@ export class SearchOrchestrator { private reranker: Reranker | null = null, private userObservationRepo: UserObservationRepository | null = null, private entityRepo: EntityRepository | null = null, - ) {} + strategyRegistry: SearchStrategyRegistry | null = null, + ) { + this.strategyRegistry = + strategyRegistry ?? new InMemorySearchStrategyRegistry(); + + if (!this.strategyRegistry.get("filter-only")) { + this.strategyRegistry.register("filter-only", (options, context) => + executeFilterOnlyStrategy( + { + observations: this.observations, + embeddingModel: this.embeddingModel, + hasVectorExtension: this.hasVectorExtension, + }, + context.query, + options, + context.limit, + ), + ); + } + + if (!this.strategyRegistry.get("semantic")) { + this.strategyRegistry.register("semantic", (options, context) => + executeSemanticStrategy( + { + observations: this.observations, + embeddingModel: this.embeddingModel, + hasVectorExtension: this.hasVectorExtension, + }, + context.query, + options, + context.limit, + ), + ); + } + + if (!this.strategyRegistry.get("hybrid")) { + this.strategyRegistry.register("hybrid", (options, context) => + executeHybridStrategy( + { + observations: this.observations, + embeddingModel: this.embeddingModel, + hasVectorExtension: this.hasVectorExtension, + }, + context.query, + options, + context.limit, + ), + ); + } + } - /** Execute a search using the configured strategy, with graph augmentation and reranking. */ async search(query: string, options: OrchestratedSearchOptions): Promise { const strategy = options.strategy ?? "hybrid"; const limit = options.limit ?? 10; + const normalizedOptions = this.normalizeOptions(options); - let results: SearchResult[]; - switch (strategy) { - case "filter-only": - results = this.filterOnlySearch(query, options, limit); - break; - case "semantic": - results = await this.semanticSearch(query, options, limit); - break; - case "hybrid": - results = await this.hybridSearchStrategy(query, options, limit); - break; + const executor = this.strategyRegistry.get(strategy); + if (!executor) { + throw new Error(`Unknown search strategy: ${strategy}`); } - // Label project results - for (const r of results) { - r.source = "project"; - } + let results = await executor(normalizedOptions, { query, limit }); + + results = results.map((result) => ({ ...result, source: "project" as const })); - // Augment with graph-based entity traversal if (this.entityRepo && query.trim()) { results = await graphAugmentedSearch( query, @@ -94,9 +122,8 @@ export class SearchOrchestrator { ); } - // Merge user-level results when available if (this.userObservationRepo) { - const userResults = this.searchUserMemory(query, options, limit); + const userResults = this.searchUserMemory(query, limit); results = this.mergeResults(results, userResults, limit); } @@ -107,196 +134,23 @@ export class SearchOrchestrator { return results; } - // --------------------------------------------------------------------------- - // Strategies - // --------------------------------------------------------------------------- - - private filterOnlySearch( - query: string, - options: OrchestratedSearchOptions, - limit: number, - ): SearchResult[] { - // Concept-specific search - if (options.concept) { - const observations = this.observations.searchByConcept( - options.concept, - limit, - options.projectPath, - ); - return observations.map((obs) => ({ - observation: obs, - rank: 0, - snippet: obs.title, - rankingSource: "graph" as const, - explain: { - strategy: "filter-only", - matchedBy: ["concept-filter"], - }, - })); - } - - // File-specific search - if (options.file) { - const observations = this.observations.searchByFile(options.file, limit, options.projectPath); - return observations.map((obs) => ({ - observation: obs, - rank: 0, - snippet: obs.title, - rankingSource: "graph" as const, - explain: { - strategy: "filter-only", - matchedBy: ["file-filter"], - }, - })); - } - - // General FTS5 search with project isolation - return this.observations.search({ - query, - type: options.type, - limit, - projectPath: options.projectPath, - importanceMin: options.importanceMin, - importanceMax: options.importanceMax, - createdAfter: options.createdAfter, - createdBefore: options.createdBefore, - concepts: options.concepts, - files: options.files, - }); - } - - private async semanticSearch( - query: string, - options: OrchestratedSearchOptions, - limit: number, - ): Promise { - if (!this.embeddingModel) { - // Fall back to FTS5 when no embedding model available - return this.filterOnlySearch(query, options, limit); - } - - const queryEmbedding = await generateEmbedding(this.embeddingModel, query); - if (!queryEmbedding) { - return this.filterOnlySearch(query, options, limit); - } + private normalizeOptions(options: OrchestratedSearchOptions): OrchestratedSearchOptions { + const concepts = options.concept + ? Array.from(new Set([options.concept, ...(options.concepts ?? [])])) + : options.concepts; + const files = options.file + ? Array.from(new Set([options.file, ...(options.files ?? [])])) + : options.files; - if (this.hasVectorExtension) { - return this.nativeVectorSearch(queryEmbedding, options, limit); - } - - return this.jsFallbackVectorSearch(queryEmbedding, options, limit); - } - - private async hybridSearchStrategy( - query: string, - options: OrchestratedSearchOptions, - limit: number, - ): Promise { - return hybridSearch(query, this.observations, this.embeddingModel, { - type: options.type, - limit, - projectPath: options.projectPath, - hasVectorExtension: this.hasVectorExtension, - importanceMin: options.importanceMin, - importanceMax: options.importanceMax, - createdAfter: options.createdAfter, - createdBefore: options.createdBefore, - concepts: options.concepts, - files: options.files, - }); - } - - // --------------------------------------------------------------------------- - // Vector Search Helpers - // --------------------------------------------------------------------------- - - private nativeVectorSearch( - queryEmbedding: number[], - options: OrchestratedSearchOptions, - limit: number, - ): SearchResult[] { - try { - const candidates = this.observations.getVecEmbeddingMatches(queryEmbedding, limit * 3); - if (candidates.length === 0) return []; - - const results: SearchResult[] = []; - for (const { observationId, distance } of candidates) { - if (results.length >= limit) break; - - const obs = this.observations.getById(observationId); - if (!obs) continue; - if (!passesFilters(obs, options)) continue; - - results.push({ - observation: obs, - rank: distance - 1, - snippet: obs.title, - rankingSource: "vector", - explain: { - strategy: "semantic", - matchedBy: ["vector"], - vectorDistance: distance, - }, - }); - } - - return results; - } catch { - return []; - } - } - - private jsFallbackVectorSearch( - queryEmbedding: number[], - options: OrchestratedSearchOptions, - limit: number, - ): SearchResult[] { - const candidates = this.observations.getWithEmbeddings(options.projectPath, limit * 10); - if (candidates.length === 0) return []; - - const scored = candidates - .map((c) => ({ - id: c.id, - similarity: cosineSimilarity(queryEmbedding, c.embedding), - })) - .filter(({ similarity }) => similarity >= 0.3) - .sort((a, b) => b.similarity - a.similarity); - - const results: SearchResult[] = []; - for (const { id, similarity } of scored) { - if (results.length >= limit) break; - - const obs = this.observations.getById(id); - if (!obs) continue; - if (!passesFilters(obs, options)) continue; - - results.push({ - observation: obs, - rank: -similarity, - snippet: obs.title, - rankingSource: "vector", - explain: { - strategy: "semantic", - matchedBy: ["vector"], - vectorSimilarity: similarity, - }, - }); - } - - return results; + return { + ...options, + concepts, + files, + }; } - // --------------------------------------------------------------------------- - // User Memory Search - // --------------------------------------------------------------------------- - - private searchUserMemory( - query: string, - _options: OrchestratedSearchOptions, - limit: number, - ): SearchResult[] { + private searchUserMemory(query: string, limit: number): SearchResult[] { if (!this.userObservationRepo) return []; - try { const userResults = this.userObservationRepo.search({ query, limit }); return userResults.map(({ observation: userObs, rank }) => ({ @@ -320,13 +174,15 @@ export class SearchOrchestrator { userResults: SearchResult[], limit: number, ): SearchResult[] { - const seenIds = new Set(projectResults.map((r) => r.observation.id)); + const seenIds = new Set(projectResults.map((result) => result.observation.id)); const seenContent = new Set( - projectResults.map((r) => `${r.observation.title}::${r.observation.narrative}`), + projectResults.map( + (result) => `${result.observation.title}::${result.observation.narrative}`, + ), ); - const dedupedUserResults = userResults.filter((r) => { - if (seenIds.has(r.observation.id)) return false; - const contentKey = `${r.observation.title}::${r.observation.narrative}`; + const dedupedUserResults = userResults.filter((result) => { + if (seenIds.has(result.observation.id)) return false; + const contentKey = `${result.observation.title}::${result.observation.narrative}`; if (seenContent.has(contentKey)) return false; seenContent.add(contentKey); return true; @@ -357,19 +213,19 @@ function userObservationToObservation(userObs: UserObservation): Observation { } export function attachExplainability(results: SearchResult[]): SearchResult[] { - return results.map((r) => { + return results.map((result) => { const signals: SearchExplainSignal[] = []; - if (r.explain?.matchedBy) { - for (const source of r.explain.matchedBy) { + if (result.explain?.matchedBy) { + for (const source of result.explain.matchedBy) { if (source === "fts") { - const rawRank = r.explain.ftsRank; + const rawRank = result.explain.ftsRank; const normalizedScore = rawRank !== undefined && rawRank < 0 ? 1 / (1 + Math.abs(rawRank)) : rawRank; signals.push({ source: "fts", score: normalizedScore, label: "Full-text search" }); } else if (source === "vector") { signals.push({ source: "vector", - score: r.explain.vectorSimilarity ?? r.explain.vectorDistance, + score: result.explain.vectorSimilarity ?? result.explain.vectorDistance, label: "Vector similarity", }); } else if (source === "graph") { @@ -380,14 +236,14 @@ export function attachExplainability(results: SearchResult[]): SearchResult[] { } } - const lineage = findLineageRef(r.observation); + const lineage = findLineageRef(result.observation); return { - ...r, + ...result, explain: { - ...r.explain, - strategy: r.explain?.strategy ?? "hybrid", - matchedBy: r.explain?.matchedBy ?? [], + ...result.explain, + strategy: result.explain?.strategy ?? "hybrid", + matchedBy: result.explain?.matchedBy ?? [], signals, lineage, }, @@ -401,10 +257,6 @@ function findLineageRef(obs: Observation): SearchLineageRef | undefined { return { rootId: info.rootId, depth: info.depth }; } -/** - * Resolve immediate parent for lineage info (single hop). - * Full chain resolution is handled by getLineage() in the memory engine. - */ function computeLineageInfo(obs: Observation): { rootId: string; depth: number } { if (obs.revisionOf && obs.revisionOf !== obs.id) { return { rootId: obs.revisionOf, depth: 1 }; diff --git a/src/search/registry.ts b/src/search/registry.ts new file mode 100644 index 0000000..97538b9 --- /dev/null +++ b/src/search/registry.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// open-mem — Search Strategy Registry +// ============================================================================= + +import type { SearchResult } from "../types"; + +export type SearchStrategyId = "filter-only" | "semantic" | "hybrid" | (string & {}); + +export interface SearchStrategyContext { + query: string; + limit: number; +} + +export type SearchStrategyExecutor = ( + options: TOptions, + context: SearchStrategyContext, +) => Promise | SearchResult[]; + +export interface SearchStrategyRegistry { + register(strategy: SearchStrategyId, executor: SearchStrategyExecutor): void; + get(strategy: SearchStrategyId): SearchStrategyExecutor | null; + list(): SearchStrategyId[]; +} + +export class InMemorySearchStrategyRegistry implements SearchStrategyRegistry { + private readonly strategies = new Map>(); + + register(strategy: SearchStrategyId, executor: SearchStrategyExecutor): void { + this.strategies.set(strategy, executor); + } + + get(strategy: SearchStrategyId): SearchStrategyExecutor | null { + return this.strategies.get(strategy) ?? null; + } + + list(): SearchStrategyId[] { + return [...this.strategies.keys()]; + } +} diff --git a/src/search/strategies/filter-only.ts b/src/search/strategies/filter-only.ts new file mode 100644 index 0000000..b5362e6 --- /dev/null +++ b/src/search/strategies/filter-only.ts @@ -0,0 +1,132 @@ +import type { SearchResult } from "../../types"; +import type { StrategyDeps, StrategyOptions } from "./types"; + +function mergeUnique(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function mergeByObservationId(items: SearchResult[], limit: number): SearchResult[] { + const deduped: SearchResult[] = []; + const seen = new Set(); + for (const item of items) { + if (seen.has(item.observation.id)) continue; + seen.add(item.observation.id); + deduped.push(item); + if (deduped.length >= limit) break; + } + return deduped; +} + +function includesConcept(obsConcepts: string[], term: string): boolean { + const target = term.toLowerCase(); + return obsConcepts.some((concept) => concept.toLowerCase() === target); +} + +function includesFilePath(obsFiles: string[], term: string): boolean { + const target = term.toLowerCase(); + return obsFiles.some((file) => file.toLowerCase().includes(target)); +} + +function applyAdditionalFilters(results: SearchResult[], options: StrategyOptions): SearchResult[] { + const conceptTerms = mergeUnique([ + ...(options.concept ? [options.concept] : []), + ...(options.concepts ?? []), + ]); + const fileTerms = mergeUnique([ + ...(options.file ? [options.file] : []), + ...(options.files ?? []), + ]); + + return results.filter((result) => { + const obs = result.observation; + if (options.type && obs.type !== options.type) return false; + if (options.importanceMin !== undefined && obs.importance < options.importanceMin) return false; + if (options.importanceMax !== undefined && obs.importance > options.importanceMax) return false; + if (options.createdAfter && obs.createdAt < options.createdAfter) return false; + if (options.createdBefore && obs.createdAt > options.createdBefore) return false; + + if ( + conceptTerms.length > 0 && + !conceptTerms.some((term) => includesConcept(obs.concepts, term)) + ) { + return false; + } + + if (fileTerms.length > 0) { + const allFiles = [...obs.filesRead, ...obs.filesModified]; + if (!fileTerms.some((term) => includesFilePath(allFiles, term))) { + return false; + } + } + + return true; + }); +} + +export function executeFilterOnlyStrategy( + deps: StrategyDeps, + query: string, + options: StrategyOptions, + limit: number, +): SearchResult[] { + const conceptTerms = mergeUnique([ + ...(options.concept ? [options.concept] : []), + ...(options.concepts ?? []), + ]); + if (conceptTerms.length > 0) { + const matches = conceptTerms.flatMap((concept) => + deps.observations.searchByConcept(concept, limit, options.projectPath), + ); + const candidateResults = mergeByObservationId( + matches.map((obs) => ({ + observation: obs, + rank: 0, + snippet: obs.title, + rankingSource: "graph" as const, + explain: { + strategy: "filter-only", + matchedBy: ["concept-filter"], + }, + })), + limit, + ); + return applyAdditionalFilters(candidateResults, options).slice(0, limit); + } + + const fileTerms = mergeUnique([ + ...(options.file ? [options.file] : []), + ...(options.files ?? []), + ]); + if (fileTerms.length > 0) { + const matches = fileTerms.flatMap((file) => + deps.observations.searchByFile(file, limit, options.projectPath), + ); + const candidateResults = mergeByObservationId( + matches.map((obs) => ({ + observation: obs, + rank: 0, + snippet: obs.title, + rankingSource: "graph" as const, + explain: { + strategy: "filter-only", + matchedBy: ["file-filter"], + }, + })), + limit, + ); + return applyAdditionalFilters(candidateResults, options).slice(0, limit); + } + + return deps.observations.search({ + query, + type: options.type, + limit, + projectPath: options.projectPath, + importanceMin: options.importanceMin, + importanceMax: options.importanceMax, + createdAfter: options.createdAfter, + createdBefore: options.createdBefore, + concepts: options.concepts, + files: options.files, + }); +} diff --git a/src/search/strategies/hybrid.ts b/src/search/strategies/hybrid.ts new file mode 100644 index 0000000..0ad1fd0 --- /dev/null +++ b/src/search/strategies/hybrid.ts @@ -0,0 +1,22 @@ +import { hybridSearch } from "../hybrid"; +import type { StrategyDeps, StrategyOptions } from "./types"; + +export async function executeHybridStrategy( + deps: StrategyDeps, + query: string, + options: StrategyOptions, + limit: number, +) { + return hybridSearch(query, deps.observations, deps.embeddingModel, { + type: options.type, + limit, + projectPath: options.projectPath, + hasVectorExtension: deps.hasVectorExtension, + importanceMin: options.importanceMin, + importanceMax: options.importanceMax, + createdAfter: options.createdAfter, + createdBefore: options.createdBefore, + concepts: options.concepts, + files: options.files, + }); +} diff --git a/src/search/strategies/semantic.ts b/src/search/strategies/semantic.ts new file mode 100644 index 0000000..eb87982 --- /dev/null +++ b/src/search/strategies/semantic.ts @@ -0,0 +1,81 @@ +import type { SearchResult } from "../../types"; +import { cosineSimilarity, generateEmbedding } from "../embeddings"; +import { passesFilters } from "../filters"; +import { executeFilterOnlyStrategy } from "./filter-only"; +import type { StrategyDeps, StrategyOptions } from "./types"; + +export async function executeSemanticStrategy( + deps: StrategyDeps, + query: string, + options: StrategyOptions, + limit: number, +) { + if (!deps.embeddingModel) { + return executeFilterOnlyStrategy(deps, query, options, limit); + } + + const queryEmbedding = await generateEmbedding(deps.embeddingModel, query); + if (!queryEmbedding) { + return executeFilterOnlyStrategy(deps, query, options, limit); + } + + if (deps.hasVectorExtension) { + try { + const candidates = deps.observations.getVecEmbeddingMatches(queryEmbedding, limit * 3); + if (candidates.length === 0) return []; + + const results: SearchResult[] = []; + for (const { observationId, distance } of candidates) { + if (results.length >= limit) break; + const obs = deps.observations.getById(observationId); + if (!obs) continue; + if (!passesFilters(obs, options)) continue; + results.push({ + observation: obs, + rank: distance - 1, + snippet: obs.title, + rankingSource: "vector" as const, + explain: { + strategy: "semantic" as const, + matchedBy: ["vector"], + vectorDistance: distance, + }, + }); + } + return results; + } catch { + return executeFilterOnlyStrategy(deps, query, options, limit); + } + } + + const candidates = deps.observations.getWithEmbeddings(options.projectPath, limit * 10); + if (candidates.length === 0) return []; + + const scored = candidates + .map((candidate) => ({ + id: candidate.id, + similarity: cosineSimilarity(queryEmbedding, candidate.embedding), + })) + .filter(({ similarity }) => similarity >= 0.3) + .sort((a, b) => b.similarity - a.similarity); + + const results: SearchResult[] = []; + for (const { id, similarity } of scored) { + if (results.length >= limit) break; + const obs = deps.observations.getById(id); + if (!obs) continue; + if (!passesFilters(obs, options)) continue; + results.push({ + observation: obs, + rank: -similarity, + snippet: obs.title, + rankingSource: "vector" as const, + explain: { + strategy: "semantic" as const, + matchedBy: ["vector"], + vectorSimilarity: similarity, + }, + }); + } + return results; +} diff --git a/src/search/strategies/types.ts b/src/search/strategies/types.ts new file mode 100644 index 0000000..b25504d --- /dev/null +++ b/src/search/strategies/types.ts @@ -0,0 +1,23 @@ +import type { EmbeddingModel } from "ai"; +import type { ObservationRepository } from "../../db/observations"; +import type { ObservationType } from "../../types"; + +export interface StrategyOptions { + type?: ObservationType; + file?: string; + concept?: string; + limit?: number; + projectPath: string; + importanceMin?: number; + importanceMax?: number; + createdAfter?: string; + createdBefore?: string; + concepts?: string[]; + files?: string[]; +} + +export interface StrategyDeps { + observations: ObservationRepository; + embeddingModel: EmbeddingModel | null; + hasVectorExtension: boolean; +} diff --git a/src/services/readiness.ts b/src/services/readiness.ts new file mode 100644 index 0000000..eb23044 --- /dev/null +++ b/src/services/readiness.ts @@ -0,0 +1,58 @@ +import type { OpenMemConfig } from "../types"; + +export interface ReadinessInput { + config: OpenMemConfig; + adapterStatuses: Array<{ name: string; enabled: boolean }>; + runtime: { + status: "ok" | "degraded"; + queue: { + lastError: string | null; + }; + }; +} + +export interface ReadinessResult { + ready: boolean; + status: "ready" | "initializing" | "degraded"; + reasons: string[]; +} + +export interface ReadinessService { + evaluate(input: ReadinessInput): ReadinessResult; +} + +export class DefaultReadinessService implements ReadinessService { + evaluate(input: ReadinessInput): ReadinessResult { + const reasons: string[] = []; + + if (!input.adapterStatuses.some((adapter) => adapter.enabled)) { + reasons.push("No platform adapters are enabled."); + } + + if ( + input.config.compressionEnabled && + input.config.provider !== "bedrock" && + !input.config.apiKey + ) { + reasons.push("Compression is enabled but no provider API key is configured."); + } + + if (input.runtime.status === "degraded") { + reasons.push("Runtime status is degraded."); + } + + if (input.runtime.queue.lastError) { + reasons.push(`Queue reported an error: ${input.runtime.queue.lastError}`); + } + + if (reasons.length === 0) { + return { ready: true, status: "ready", reasons: [] }; + } + + return { + ready: false, + status: input.runtime.status === "degraded" ? "degraded" : "initializing", + reasons, + }; + } +} diff --git a/src/services/setup-diagnostics.ts b/src/services/setup-diagnostics.ts new file mode 100644 index 0000000..9cf1620 --- /dev/null +++ b/src/services/setup-diagnostics.ts @@ -0,0 +1,138 @@ +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; +import type { OpenMemConfig } from "../types"; + +export interface DiagnosticCheck { + id: string; + status: "pass" | "warn" | "fail"; + message: string; + details?: Record; +} + +export interface SetupDiagnosticsResult { + ok: boolean; + checks: DiagnosticCheck[]; +} + +export interface SetupDiagnosticsService { + run(config: OpenMemConfig): SetupDiagnosticsResult; +} + +export class DefaultSetupDiagnosticsService implements SetupDiagnosticsService { + run(config: OpenMemConfig): SetupDiagnosticsResult { + const checks: DiagnosticCheck[] = []; + + checks.push(this.checkDbDir(config)); + checks.push(this.checkProviderConfig(config)); + checks.push(this.checkVectorSupport(config)); + checks.push(this.checkAdapters(config)); + checks.push(this.checkDashboardPort(config)); + + const ok = checks.every((check) => check.status !== "fail"); + return { ok, checks }; + } + + private checkDbDir(config: OpenMemConfig): DiagnosticCheck { + const dir = dirname(config.dbPath); + return existsSync(dir) + ? { id: "db-dir", status: "pass", message: "Database directory exists.", details: { dir } } + : { + id: "db-dir", + status: "warn", + message: "Database directory does not exist yet. It will be created on first run.", + details: { dir }, + }; + } + + private checkProviderConfig(config: OpenMemConfig): DiagnosticCheck { + if (!config.compressionEnabled) { + return { + id: "provider-config", + status: "pass", + message: "Compression is disabled; provider API key is optional.", + }; + } + if (config.provider === "bedrock") { + return { + id: "provider-config", + status: "pass", + message: "Bedrock provider selected; API key is not required.", + }; + } + if (!config.apiKey) { + return { + id: "provider-config", + status: "fail", + message: "Compression is enabled but no provider API key is configured.", + details: { provider: config.provider }, + }; + } + return { + id: "provider-config", + status: "pass", + message: "Provider API key is configured.", + details: { provider: config.provider }, + }; + } + + private checkVectorSupport(config: OpenMemConfig): DiagnosticCheck { + const providerSupportsEmbeddings = + config.provider === "google" || config.provider === "openai" || config.provider === "bedrock"; + if (!providerSupportsEmbeddings) { + return { + id: "vector-support", + status: "warn", + message: "Provider does not support embeddings; search will use FTS-only fallback.", + details: { provider: config.provider }, + }; + } + return { + id: "vector-support", + status: "pass", + message: "Provider supports embeddings.", + details: { provider: config.provider, dimension: config.embeddingDimension }, + }; + } + + private checkAdapters(config: OpenMemConfig): DiagnosticCheck { + const enabled = [ + ["opencode", config.platformOpenCodeEnabled !== false], + ["claude-code", config.platformClaudeCodeEnabled === true], + ["cursor", config.platformCursorEnabled === true], + ].filter(([, flag]) => flag); + + if (enabled.length === 0) { + return { + id: "adapters", + status: "fail", + message: "No platform adapters are enabled.", + }; + } + + return { + id: "adapters", + status: "pass", + message: `Enabled adapters: ${enabled.map(([name]) => name).join(", ")}`, + }; + } + + private checkDashboardPort(config: OpenMemConfig): DiagnosticCheck { + if (!config.dashboardEnabled) { + return { id: "dashboard", status: "pass", message: "Dashboard is disabled." }; + } + if (config.dashboardPort < 1 || config.dashboardPort > 65535) { + return { + id: "dashboard", + status: "fail", + message: "Dashboard port is outside the valid range (1-65535).", + details: { dashboardPort: config.dashboardPort }, + }; + } + return { + id: "dashboard", + status: "pass", + message: "Dashboard port configuration looks valid.", + details: { dashboardPort: config.dashboardPort }, + }; + } +} diff --git a/src/types.ts b/src/types.ts index 01f66c1..ceb22a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -183,8 +183,7 @@ export interface OpenMemConfig { platformClaudeCodeEnabled?: boolean; // Enable Claude Code adapter surface platformCursorEnabled?: boolean; // Enable Cursor adapter surface - // MCP compatibility - mcpCompatibilityMode?: "strict" | "legacy"; + // MCP protocol mcpProtocolVersion?: string; mcpSupportedProtocolVersions?: string[]; @@ -429,10 +428,13 @@ export interface MaintenanceHistoryItem { /** Workflow mode configuration loaded from JSON files. */ export interface ModeConfig { id: string; + extends?: string; + locale?: string; name: string; description: string; observationTypes: string[]; conceptVocabulary: string[]; entityTypes: string[]; relationshipTypes: string[]; + promptOverrides?: Record; } diff --git a/tests/config.test.ts b/tests/config.test.ts index e8790f3..48a2a1a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -309,14 +309,12 @@ describe("Configuration", () => { expect(config.platformOpenCodeEnabled).toBe(false); }); - test("MCP compatibility env flags are parsed", () => { - process.env.OPEN_MEM_MCP_COMPAT_MODE = "legacy"; + test("MCP protocol env flags are parsed", () => { process.env.OPEN_MEM_MCP_PROTOCOL_VERSION = "2024-11-05"; process.env.OPEN_MEM_MCP_SUPPORTED_PROTOCOLS = "2024-11-05,2025-01-01"; const config = resolveConfig("/tmp/proj"); - expect(config.mcpCompatibilityMode).toBe("legacy"); expect(config.mcpProtocolVersion).toBe("2024-11-05"); expect(config.mcpSupportedProtocolVersions).toEqual(["2024-11-05", "2025-01-01"]); }); diff --git a/tests/contracts/schemas.test.ts b/tests/contracts/schemas.test.ts new file mode 100644 index 0000000..eb429f6 --- /dev/null +++ b/tests/contracts/schemas.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { CONTRACT_VERSION, TOOL_CONTRACTS, toolSchemas } from "../../src/contracts/schemas"; + +describe("Contract schemas", () => { + test("exports a non-empty contract version", () => { + expect(CONTRACT_VERSION.length).toBeGreaterThan(0); + }); + + test("tool contracts map to existing schemas", () => { + for (const tool of TOOL_CONTRACTS) { + expect(tool.name.startsWith("mem-")).toBe(true); + expect(toolSchemas[tool.schema]).toBeDefined(); + } + }); +}); diff --git a/tests/fixtures/external-clients/claude-code-worker.json b/tests/fixtures/external-clients/claude-code-worker.json deleted file mode 100644 index 7114710..0000000 --- a/tests/fixtures/external-clients/claude-code-worker.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "client": "claude-code", - "events": [ - { "type": "session.start", "sessionId": "sess-ext-1" }, - { "type": "tool.execute", "sessionId": "sess-ext-1", "callId": "call-1", "toolName": "Read", "output": "Read src/index.ts and confirmed strict MCP lifecycle behavior." }, - { "type": "chat.message", "sessionId": "sess-ext-1", "role": "user", "text": "Ensure external compatibility matrix evidence is reproducible." }, - { "type": "idle.flush", "sessionId": "sess-ext-1" }, - { "type": "session.end", "sessionId": "sess-ext-1" } - ] -} diff --git a/tests/fixtures/external-clients/cursor-worker.json b/tests/fixtures/external-clients/cursor-worker.json deleted file mode 100644 index 0477c92..0000000 --- a/tests/fixtures/external-clients/cursor-worker.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "client": "cursor", - "events": [ - { "eventName": "sessionStart", "session": "sess-ext-1" }, - { "eventName": "toolExecute", "session": "sess-ext-1", "invocationId": "call-1", "tool": "Read", "output": "Read src/index.ts and confirmed strict MCP lifecycle behavior." }, - { "eventName": "chatMessage", "session": "sess-ext-1", "role": "user", "message": "Ensure external compatibility matrix evidence is reproducible." }, - { "eventName": "idleFlush", "session": "sess-ext-1" }, - { "eventName": "sessionEnd", "session": "sess-ext-1" } - ] -} diff --git a/tests/modes/modes.test.ts b/tests/modes/modes.test.ts index 6ff351a..5d8acb3 100644 --- a/tests/modes/modes.test.ts +++ b/tests/modes/modes.test.ts @@ -59,6 +59,14 @@ describe("Workflow Modes", () => { ]); }); + test("fallback mode returns a defensive copy", () => { + const first = loadMode("nonexistent"); + first.observationTypes.push("temporary-test-type"); + + const second = loadMode("nonexistent"); + expect(second.observationTypes).not.toContain("temporary-test-type"); + }); + test("getAvailableModes() returns ['code', 'research']", () => { const modes = getAvailableModes(); diff --git a/tests/modes/resolver-v2.test.ts b/tests/modes/resolver-v2.test.ts new file mode 100644 index 0000000..c89ed0e --- /dev/null +++ b/tests/modes/resolver-v2.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { ModeResolverV2 } from "../../src/modes/resolver"; + +const tempDirs: string[] = []; + +function mkTempDir(): string { + const dir = `/tmp/open-mem-mode-resolver-${randomUUID()}`; + mkdirSync(dir, { recursive: true }); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ModeResolverV2", () => { + test("resolves inheritance using extends", () => { + const dir = mkTempDir(); + writeFileSync( + join(dir, "base.json"), + JSON.stringify({ + id: "base", + name: "Base", + description: "base", + observationTypes: ["decision"], + conceptVocabulary: ["pattern"], + entityTypes: ["project"], + relationshipTypes: ["related_to"], + }), + ); + writeFileSync( + join(dir, "child.json"), + JSON.stringify({ + id: "child", + extends: "base", + name: "Child", + description: "child", + observationTypes: ["feature"], + conceptVocabulary: ["what-changed"], + entityTypes: ["file"], + relationshipTypes: ["uses"], + promptOverrides: { language: "es" }, + }), + ); + + const resolver = new ModeResolverV2(dir); + const raw = resolver.loadAllRaw(); + const resolved = resolver.resolveById("child", raw); + expect(resolved.id).toBe("child"); + expect(resolved.observationTypes).toEqual(["feature"]); + expect(resolved.promptOverrides?.language).toBe("es"); + }); + + test("inherits array fields from parent when child omits them", () => { + const dir = mkTempDir(); + writeFileSync( + join(dir, "base.json"), + JSON.stringify({ + id: "base", + name: "Base", + description: "base", + observationTypes: ["decision"], + conceptVocabulary: ["pattern"], + entityTypes: ["project"], + relationshipTypes: ["related_to"], + }), + ); + writeFileSync( + join(dir, "child.json"), + JSON.stringify({ + id: "child", + extends: "base", + name: "Child", + description: "child", + }), + ); + + const resolver = new ModeResolverV2(dir); + const raw = resolver.loadAllRaw(); + const resolved = resolver.resolveById("child", raw); + expect(resolved.id).toBe("child"); + expect(resolved.observationTypes).toEqual(["decision"]); + expect(resolved.conceptVocabulary).toEqual(["pattern"]); + expect(resolved.entityTypes).toEqual(["project"]); + expect(resolved.relationshipTypes).toEqual(["related_to"]); + }); + + test("falls back safely on cyclic extends", () => { + const dir = mkTempDir(); + writeFileSync( + join(dir, "a.json"), + JSON.stringify({ + id: "a", + extends: "b", + name: "A", + description: "A", + observationTypes: ["feature"], + conceptVocabulary: ["pattern"], + entityTypes: ["file"], + relationshipTypes: ["uses"], + }), + ); + writeFileSync( + join(dir, "b.json"), + JSON.stringify({ + id: "b", + extends: "a", + name: "B", + description: "B", + observationTypes: ["decision"], + conceptVocabulary: ["trade-off"], + entityTypes: ["project"], + relationshipTypes: ["depends_on"], + }), + ); + + const resolver = new ModeResolverV2(dir); + const raw = resolver.loadAllRaw(); + const resolved = resolver.resolveById("a", raw); + expect(resolved.id).toBe("code"); + }); +}); diff --git a/tests/scripts/external-compat-scripts.test.ts b/tests/scripts/external-compat-scripts.test.ts deleted file mode 100644 index c39c231..0000000 --- a/tests/scripts/external-compat-scripts.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - validateExternalCompatReport, - type ExternalCompatReport, -} from "../../scripts/external-compat"; -import { renderCompatMatrix } from "../../scripts/render-compat-matrix"; -import { checkExternalCompatGate } from "../../scripts/check-external-compat-gate"; - -function sampleReport(overrides?: Partial): ExternalCompatReport { - const base: ExternalCompatReport = { - schemaVersion: "1.0.0", - generatedAt: new Date().toISOString(), - gitSha: "abc1234", - environment: { - os: "darwin", - arch: "arm64", - bunVersion: "1.3.0", - runner: "test", - }, - policy: { - freshnessDays: 7, - versionScope: "latest-stable-only", - verificationScope: "macOS self-hosted verification only", - }, - clients: [ - { - name: "claude-code", - transport: "stdio", - protocolVersion: "2024-11-05", - status: "supported", - version: { detected: "1.0.0", source: "env" }, - requiredScenarios: [ - { id: "s1", name: "scenario", passed: true, durationMs: 10 }, - ], - knownLimitations: ["None."], - artifacts: { transcriptsDir: "transcripts/claude-code", logFile: "logs/claude-code.log" }, - }, - { - name: "cursor", - transport: "stdio", - protocolVersion: "2024-11-05", - status: "supported", - version: { detected: "2.0.0", source: "env" }, - requiredScenarios: [ - { id: "s1", name: "scenario", passed: true, durationMs: 10 }, - ], - knownLimitations: ["None."], - artifacts: { transcriptsDir: "transcripts/cursor", logFile: "logs/cursor.log" }, - }, - ], - summary: { - scenarioCount: 2, - failedScenarios: 0, - failureRate: 0, - allRequiredPassed: true, - mcpToolCallP95Ms: 10, - workerEventIngestP95Ms: 5, - }, - slo: { - mcpToolCallP95TargetMs: 250, - workerEventIngestP95TargetMs: 100, - externalFailureRateTarget: 0.01, - met: true, - }, - failureTaxonomy: [ - { code: "CLIENT_PROTOCOL_DRIFT", description: "d", remediation: "r" }, - { code: "WORKER_BRIDGE_REGRESSION", description: "d", remediation: "r" }, - { code: "ENVIRONMENT_DEPENDENCY", description: "d", remediation: "r" }, - { code: "NON_DETERMINISTIC_OUTPUT", description: "d", remediation: "r" }, - ], - }; - return { ...base, ...overrides }; -} - -describe("external compatibility scripts", () => { - test("validateExternalCompatReport passes for well-formed report", () => { - const errors = validateExternalCompatReport(sampleReport()); - expect(errors).toEqual([]); - }); - - test("renderCompatMatrix deterministically updates generated block", async () => { - const dir = await mkdtemp(join(tmpdir(), "open-mem-render-matrix-")); - try { - const reportPath = join(dir, "report.json"); - const matrixPath = join(dir, "matrix.md"); - await writeFile(reportPath, `${JSON.stringify(sampleReport(), null, 2)}\n`, "utf8"); - await writeFile( - matrixPath, - "# Matrix\n\n\nold\n\n", - "utf8", - ); - - const first = await renderCompatMatrix({ reportPath, matrixPath }); - const second = await renderCompatMatrix({ reportPath, matrixPath }); - expect(first).toEqual(second); - - const content = await readFile(matrixPath, "utf8"); - expect(content).toContain("Claude Code MCP integration"); - expect(content).toContain("Cursor MCP integration"); - expect(content).toContain("| 1.0.0 | Supported |"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("checkExternalCompatGate rejects stale report", async () => { - const dir = await mkdtemp(join(tmpdir(), "open-mem-gate-")); - try { - const oldDate = new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(); - const report = sampleReport({ generatedAt: oldDate }); - const reportPath = join(dir, "report.json"); - const matrixPath = join(dir, "matrix.md"); - await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); - await writeFile( - matrixPath, - `| Claude Code MCP integration | stdio | 2024-11-05 | ${report.clients[0].version.detected} | Supported |\n| Cursor MCP integration | stdio | 2024-11-05 | ${report.clients[1].version.detected} | Supported |\n`, - "utf8", - ); - - const result = await checkExternalCompatGate({ reportPath, matrixPath, freshnessDays: 7 }); - expect(result.ok).toBe(false); - expect(result.errors.some((e) => e.startsWith("STALE_REPORT"))).toBe(true); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/search/orchestrator.test.ts b/tests/search/orchestrator.test.ts index c97c0a1..262eba1 100644 --- a/tests/search/orchestrator.test.ts +++ b/tests/search/orchestrator.test.ts @@ -193,6 +193,22 @@ describe("SearchOrchestrator", () => { expect(orchestratedResults[0].observation.id).toBe(directResults[0].observation.id); } }); + + test("hybrid strategy honors singular concept filter options", async () => { + seedProjectA(); + + const results = await orchestrator.search("project", { + strategy: "hybrid", + concept: "authentication", + projectPath: "/project/alpha", + limit: 10, + }); + + expect(results.length).toBeGreaterThanOrEqual(1); + for (const result of results) { + expect(result.observation.concepts).toContain("authentication"); + } + }); }); // ========================================================================= @@ -213,6 +229,22 @@ describe("SearchOrchestrator", () => { expect(results[0].observation.concepts).toContain("authentication"); }); + test("filter-only supports combined concept and concepts filters", async () => { + seedProjectA(); + + const results = await orchestrator.search("anything", { + strategy: "filter-only", + concept: "authentication", + concepts: ["hooks"], + projectPath: "/project/alpha", + }); + + expect(results.length).toBe(2); + const titles = results.map((result) => result.observation.title); + expect(titles.some((title) => title.includes("JWT authentication"))).toBe(true); + expect(titles.some((title) => title.includes("React component refactoring"))).toBe(true); + }); + test("filter-only with file uses searchByFile", async () => { seedProjectA(); @@ -226,6 +258,22 @@ describe("SearchOrchestrator", () => { expect(results[0].observation.filesModified).toContain("src/App.tsx"); }); + test("filter-only supports combined file and files filters", async () => { + seedProjectA(); + + const results = await orchestrator.search("anything", { + strategy: "filter-only", + file: "src/auth.ts", + files: ["src/App.tsx"], + projectPath: "/project/alpha", + }); + + expect(results.length).toBe(2); + const titles = results.map((result) => result.observation.title); + expect(titles.some((title) => title.includes("JWT authentication"))).toBe(true); + expect(titles.some((title) => title.includes("React component refactoring"))).toBe(true); + }); + test("filter-only without concept/file uses FTS5 search", async () => { seedProjectA(); diff --git a/tests/search/registry.test.ts b/tests/search/registry.test.ts new file mode 100644 index 0000000..178651f --- /dev/null +++ b/tests/search/registry.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { InMemorySearchStrategyRegistry } from "../../src/search/registry"; + +describe("InMemorySearchStrategyRegistry", () => { + test("registers and retrieves strategy executors", async () => { + const registry = new InMemorySearchStrategyRegistry<{ limit?: number }>(); + registry.register("custom", (_options, context) => [ + { + observation: { + id: "x", + sessionId: "s", + type: "discovery", + title: "Custom", + subtitle: "", + facts: [], + narrative: "n", + concepts: [], + filesRead: [], + filesModified: [], + rawToolOutput: "", + toolName: "tool", + createdAt: new Date().toISOString(), + tokenCount: 1, + discoveryTokens: 1, + importance: 3, + }, + rank: context.limit, + snippet: "snippet", + rankingSource: "fts", + }, + ]); + + const executor = registry.get("custom"); + expect(executor).not.toBeNull(); + const results = await executor!({}, { query: "q", limit: 5 }); + expect(results[0].observation.id).toBe("x"); + expect(results[0].rank).toBe(5); + }); +}); diff --git a/tests/servers/http-server.test.ts b/tests/servers/http-server.test.ts index 7ce38cf..1ac805b 100644 --- a/tests/servers/http-server.test.ts +++ b/tests/servers/http-server.test.ts @@ -256,6 +256,69 @@ describe("HTTP v1 contract", () => { expect(payload.data.memory).toHaveProperty("totalObservations"); }); + test("GET /v1/readiness returns readiness envelope", async () => { + const res = await app.request("/v1/readiness"); + expect([200, 503]).toContain(res.status); + const payload = parseEnvelope<{ ready: boolean; status: string; reasons: string[] }>( + await res.json(), + ); + expect(payload.error).toBeNull(); + expect(typeof payload.data.ready).toBe("boolean"); + }); + + test("GET /v1/diagnostics returns diagnostics report", async () => { + const res = await app.request("/v1/diagnostics"); + expect([200, 503]).toContain(res.status); + const payload = parseEnvelope<{ ok: boolean; checks: Array<{ id: string; status: string }> }>( + await res.json(), + ); + expect(payload.error).toBeNull(); + expect(payload.data.checks.length).toBeGreaterThan(0); + }); + + test("GET /v1/tools/guide returns canonical tool metadata", async () => { + const res = await app.request("/v1/tools/guide"); + expect(res.status).toBe(200); + const payload = parseEnvelope<{ + contractVersion: string; + tools: Array<{ name: string; description: string }>; + }>(await res.json()); + expect(payload.error).toBeNull(); + expect(payload.data.tools.some((tool) => tool.name === "mem-find")).toBe(true); + }); + + test("queue endpoints reject non-local host headers", async () => { + const queueRes = await app.request("/v1/queue", { headers: { host: "evil.example.com" } }); + expect(queueRes.status).toBe(403); + + const processRes = await app.request("/v1/queue/process", { + method: "POST", + headers: { host: "evil.example.com" }, + }); + expect(processRes.status).toBe(403); + }); + + test("queue endpoints allow explicit localhost host headers", async () => { + const queueRes = await app.request("/v1/queue", { headers: { host: "localhost:8787" } }); + expect(queueRes.status).toBe(200); + + const processRes = await app.request("/v1/queue/process", { + method: "POST", + headers: { host: "127.0.0.1:8787" }, + }); + expect(processRes.status).toBe(200); + }); + + test("queue endpoints reject forwarded non-loopback addresses", async () => { + const res = await app.request("/v1/queue", { + headers: { + host: "localhost:8787", + "x-forwarded-for": "127.0.0.1, 203.0.113.10", + }, + }); + expect(res.status).toBe(403); + }); + test("GET /v1/metrics returns runtime metrics envelope", async () => { const res = await app.request("/v1/metrics"); expect(res.status).toBe(200); @@ -439,41 +502,6 @@ describe("HTTP v1 contract", () => { expect(res.status).toBe(400); }); - test("GET /v1/memory/observations/:id/revision-diff supports v1 compatibility response", async () => { - sessionRepo.create("sess-diff-v1", TEST_PROJECT_PATH); - const first = observationRepo.create({ - sessionId: "sess-diff-v1", - type: "feature", - title: "Original title", - subtitle: "", - facts: [], - narrative: "Original narrative", - concepts: [], - filesRead: [], - filesModified: [], - rawToolOutput: "", - toolName: "tool", - tokenCount: 10, - discoveryTokens: 10, - }); - const second = observationRepo.update(first.id, { title: "Updated title" }); - expect(second).not.toBeNull(); - - const res = await app.request( - `/v1/memory/observations/${first.id}/revision-diff?against=${second!.id}&version=1`, - ); - expect(res.status).toBe(200); - const payload = parseEnvelope<{ - baseId: string; - againstId: string; - changes: Array<{ field: string; before: unknown; after: unknown }>; - }>(await res.json()); - expect(payload.error).toBeNull(); - expect(payload.data.baseId).toBe(first.id); - expect(payload.data.againstId).toBe(second!.id); - expect(payload.data.changes.length).toBeGreaterThan(0); - }); - test("GET /v1/adapters/status returns adapter list", async () => { const res = await app.request("/v1/adapters/status"); expect(res.status).toBe(200); diff --git a/tests/services/readiness.test.ts b/tests/services/readiness.test.ts new file mode 100644 index 0000000..79fb439 --- /dev/null +++ b/tests/services/readiness.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { DefaultReadinessService } from "../../src/services/readiness"; +import { getDefaultConfig } from "../../src/config"; + +describe("DefaultReadinessService", () => { + test("reports ready when runtime is healthy and an adapter is enabled", () => { + const config = { ...getDefaultConfig(), apiKey: "test-key" }; + const readiness = new DefaultReadinessService().evaluate({ + config, + adapterStatuses: [{ name: "opencode", enabled: true }], + runtime: { status: "ok", queue: { lastError: null } }, + }); + expect(readiness.ready).toBe(true); + expect(readiness.status).toBe("ready"); + }); + + test("reports degraded when runtime has queue error", () => { + const config = { ...getDefaultConfig(), apiKey: "test-key" }; + const readiness = new DefaultReadinessService().evaluate({ + config, + adapterStatuses: [{ name: "opencode", enabled: true }], + runtime: { status: "degraded", queue: { lastError: "boom" } }, + }); + expect(readiness.ready).toBe(false); + expect(readiness.status).toBe("degraded"); + expect(readiness.reasons.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/services/setup-diagnostics.test.ts b/tests/services/setup-diagnostics.test.ts new file mode 100644 index 0000000..96c7f0b --- /dev/null +++ b/tests/services/setup-diagnostics.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../../src/config"; +import { DefaultSetupDiagnosticsService } from "../../src/services/setup-diagnostics"; + +describe("DefaultSetupDiagnosticsService", () => { + test("returns fail when compression enabled without api key", () => { + const config = { ...getDefaultConfig(), apiKey: undefined, compressionEnabled: true }; + const report = new DefaultSetupDiagnosticsService().run(config); + expect(report.ok).toBe(false); + expect(report.checks.some((check) => check.id === "provider-config" && check.status === "fail")).toBe( + true, + ); + }); + + test("returns pass when provider configuration is valid", () => { + const config = { ...getDefaultConfig(), apiKey: "test-key", compressionEnabled: true }; + const report = new DefaultSetupDiagnosticsService().run(config); + expect(report.checks.some((check) => check.id === "provider-config" && check.status === "pass")).toBe( + true, + ); + }); +});