feat: GBrain v0.6.0 — Remote MCP Server + 12 Bug Fixes (#28)
* fix: 7 bug fixes from Issue #9 and #22 - fix(mcp): use ListToolsRequestSchema/CallToolRequestSchema instead of string literals (Issue #9, PR #25) - fix(mcp): handleToolCall reads dry_run from params instead of hardcoding false (#22 Bug #11) - fix(search): keyword search returns best chunk per page via DISTINCT ON, not all chunks (#22 Bug #8) - fix(search): dedup layer 1 keeps top 3 chunks per page instead of collapsing to 1 (#22 Bug #12) - fix(engine): transaction uses scoped engine via Object.create, no shared state mutation (#22 Bug #2) - fix(engine): upsertChunks uses UPSERT instead of DELETE+INSERT, preserves existing embeddings (#22 Bug #1) - fix(slugs): validateSlug normalizes to lowercase, pathToSlug lowercases consistently (#22 Bug #4) - schema: add unique index on content_chunks(page_id, chunk_index) for UPSERT support - schema: add access_tokens and mcp_request_log tables via migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: embed schema.sql at build time, remove fs dependency from initSchema initSchema() previously read schema.sql from disk at runtime via readFileSync, which broke in compiled Bun binaries and Deno Edge Functions. Now uses a generated schema-embedded.ts constant (run `bun run build:schema` to regenerate). - Removes fs and path imports from postgres-engine.ts and db.ts - Adds scripts/build-schema.sh for one-source-of-truth generation - Adds build:schema npm script Fixes Issue #22 Bug #6. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 5 more bug fixes from Issue #22 - fix(file_upload): call storage.upload() in all 3 paths (operation, CLI upload, CLI sync) with rollback semantics (#22 Bug #9) - fix(import): use atomic index counter for parallel queue instead of array.shift() race, preserve checkpoint on errors (#22 Bug #3) - fix(s3): replace unsigned fetch with @aws-sdk/client-s3 for proper SigV4 auth, supports R2/MinIO via forcePathStyle (#22 Bug #10) - fix(redirect): verify remote file exists before deleting local copy, skip files not found in storage (#22 Bug #5) - deps: add @aws-sdk/client-s3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: remote MCP server via Supabase Edge Functions Deploy GBrain as a serverless remote MCP endpoint on your existing Supabase instance. One brain, accessible from Claude Desktop, Claude Code, Cowork, Perplexity Computer, and any MCP client. Zero new infrastructure. New files: - supabase/functions/gbrain-mcp/index.ts — Edge Function with Hono + MCP SDK - supabase/functions/gbrain-mcp/deno.json — Deno import map - src/edge-entry.ts — curated bundle entry point (excludes fs-dependent modules) - src/commands/auth.ts — standalone token management (create/list/revoke/test) - scripts/deploy-remote.sh — one-script deployment - .env.production.example — 3-value config template Changes: - config.ts: lazy-evaluate CONFIG_DIR (no homedir() at module scope) - schema.sql: add access_tokens + mcp_request_log tables - package.json: add build:edge script Auth: bearer tokens via access_tokens table (SHA-256 hashed, per-client, revocable) Transport: WebStandardStreamableHTTPServerTransport (stateless, Streamable HTTP) Health: /health endpoint (unauth: 200/503, auth: postgres/pgvector/openai checks) Excluded from remote: sync_brain, file_upload (may exceed 60s timeout) Setup: clone, fill .env.production, run scripts/deploy-remote.sh, create token, done. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: per-client MCP setup guides - docs/mcp/DEPLOY.md — deployment walkthrough, auth, troubleshooting, latency table - docs/mcp/CLAUDE_CODE.md — claude mcp add command - docs/mcp/CLAUDE_DESKTOP.md — Settings > Integrations (NOT JSON config!) - docs/mcp/CLAUDE_COWORK.md — remote + local bridge paths - docs/mcp/PERPLEXITY.md — Perplexity Computer connector setup - docs/mcp/CHATGPT.md — coming soon (requires OAuth 2.1, P0 TODO) - docs/mcp/ALTERNATIVES.md — Tailscale Funnel + ngrok self-hosted options Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.6.0) GBrain v0.6.0: Remote MCP server via Supabase Edge Functions + 12 bug fixes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Remote MCP Server section to README Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: make document-release mandatory in CLAUDE.md, add MCP key files Post-ship requirements section: document-release is NOT optional. Lists every file that must be checked on every ship. A ship without updated docs is incomplete. Also adds remote MCP server files to Key files section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: batch upsertChunks into single statement to prevent deadlocks The per-chunk UPSERT loop caused deadlocks under parallel workers because each INSERT ON CONFLICT acquired row-level locks sequentially. Multiple workers upserting different pages could deadlock on the shared unique index. Fix: batch all chunks into a single multi-row INSERT ON CONFLICT statement. One round-trip, one lock acquisition. COALESCE preserves existing embeddings when the new value is NULL. Fixes CI failure: "E2E: Parallel Import > parallel import with --workers 4" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: advisory lock in initSchema() prevents deadlock on concurrent DDL When multiple processes call initSchema() concurrently (e.g., test setup + CLI subprocess, or parallel workers during E2E tests), the schema SQL's DROP TRIGGER + CREATE TRIGGER statements acquire AccessExclusiveLock on different tables, causing deadlocks. Fix: pg_advisory_lock(42) serializes all initSchema() calls within the same database. The lock is session-scoped and released in a finally block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add explicit test timeouts for CLI subprocess E2E tests CLI subprocess tests (Setup Journey, Doctor Command, Parallel Import) spawn `bun run src/cli.ts` which takes several seconds to JIT compile + connect. The Bun test framework default 5000ms per-test timeout is too tight for CI. Added 30-60s timeouts matching each subprocess's own timeout to prevent false failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: infinite recursion in config.ts exported getConfigDir/getConfigPath The replace_all refactor created recursive functions: the exported getConfigDir() called the private getConfigDir() which called itself. Renamed exports to configDir()/configPath() to avoid shadowing. Also adds scripts/smoke-test-mcp.ts — verified all 8 MCP tool calls work against a real Postgres database. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
.env.production.example
Normal file
12
.env.production.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# GBrain Remote MCP Server — Production Config
|
||||
# Copy to .env.production and fill in your values.
|
||||
|
||||
# Supabase pooler URL (Settings > Database > Connection string > Transaction pooler)
|
||||
# Use the transaction pooler (port 6543), NOT the direct connection.
|
||||
DATABASE_URL=postgresql://postgres.xxx:password@aws-0-us-west-1.pooler.supabase.com:6543/postgres
|
||||
|
||||
# OpenAI API key for embeddings
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Supabase project ref (the "xxx" from https://xxx.supabase.co)
|
||||
SUPABASE_PROJECT_REF=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ bin/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env.testing
|
||||
.env.production
|
||||
.18a49dfd730ff378-00000000.bun-build
|
||||
.18a49f9dfb996f70-00000000.bun-build
|
||||
.gstack/
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -2,6 +2,37 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.6.0] - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Access your brain from any AI client.** Deploy GBrain as a serverless remote MCP endpoint on your existing Supabase instance. Works with Claude Desktop, Claude Code, Cowork, and Perplexity Computer. One URL, bearer token auth, zero new infrastructure. Clone the repo, fill in 3 env vars, run `scripts/deploy-remote.sh`, done.
|
||||
- **Per-client setup guides** in `docs/mcp/` for Claude Code, Claude Desktop, Cowork, Perplexity, and ChatGPT (coming soon, requires OAuth 2.1). Also documents Tailscale Funnel and ngrok as self-hosted alternatives.
|
||||
- **Token management** via standalone `src/commands/auth.ts`. Create, list, revoke per-client bearer tokens. Includes smoke test: `auth.ts test <url> --token <token>` verifies the full pipeline (initialize + tools/list + get_stats) in 3 seconds.
|
||||
- **Usage logging** via `mcp_request_log` table. Every remote tool call logs token name, operation, latency, and status for debugging and security auditing.
|
||||
- **Hardened health endpoint** at `/health`. Unauthenticated: 200/503 only (no info disclosure). Authenticated: checks postgres, pgvector, and OpenAI API key status.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP server actually connects now.** Handler registration used string literals (`'tools/list' as any`) instead of SDK typed schemas. Replaced with `ListToolsRequestSchema` and `CallToolRequestSchema`. Without this fix, `gbrain serve` silently failed to register handlers. (Issue #9)
|
||||
- **Search results no longer flooded by one large page.** Keyword search returned ALL chunks from matching pages. Now returns one best chunk per page via `DISTINCT ON`. (Issue #22)
|
||||
- **Search dedup no longer collapses to one chunk per page.** Layer 1 kept only the single highest-scoring chunk per slug. Now keeps top 3, letting later dedup layers (text similarity, cap per page) do their job. (Issue #22)
|
||||
- **Transactions no longer corrupt shared state.** Both `PostgresEngine.transaction()` and `db.withTransaction()` swapped the shared connection reference, breaking under concurrent use. Now uses scoped engine via `Object.create` with no shared state mutation. (Issue #22)
|
||||
- **embed --stale no longer wipes valid embeddings.** `upsertChunks()` deleted all chunks then re-inserted, writing NULL for chunks without new embeddings. Now uses UPSERT (INSERT ON CONFLICT UPDATE) with COALESCE to preserve existing embeddings. (Issue #22)
|
||||
- **Slug normalization is consistent.** `pathToSlug()` preserved case while `inferSlug()` lowercased. Now `validateSlug()` enforces lowercase at the validation layer, covering all entry points. (Issue #22)
|
||||
- **initSchema no longer reads from disk at runtime.** Both schema loaders used `readFileSync` with `import.meta.url`, which broke in compiled binaries and Deno Edge Functions. Schema is now embedded at build time via `scripts/build-schema.sh`. (Issue #22)
|
||||
- **file_upload actually uploads content.** The operation wrote DB metadata but never called the storage backend. Fixed in all 3 paths (operation, CLI upload, CLI sync) with rollback semantics. (Issue #22)
|
||||
- **S3 storage backend authenticates requests.** `signedFetch()` was just unsigned `fetch()`. Replaced with `@aws-sdk/client-s3` for proper SigV4 signing. Supports R2/MinIO via `forcePathStyle`. (Issue #22)
|
||||
- **Parallel import uses thread-safe queue.** `queue.shift()` had race conditions under parallel workers. Now uses an atomic index counter. Checkpoint preserved on errors for safe resume. (Issue #22)
|
||||
- **redirect verifies remote existence before deleting local files.** Previously deleted local files unconditionally. Now checks storage backend before removing. (Issue #22)
|
||||
- **`gbrain call` respects dry_run.** `handleToolCall()` hardcoded `dryRun: false`. Now reads from params. (Issue #22)
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `@aws-sdk/client-s3` as a dependency for authenticated S3 operations.
|
||||
- Schema migration v2: unique index on `content_chunks(page_id, chunk_index)` for UPSERT support.
|
||||
- Schema migration v3: `access_tokens` and `mcp_request_log` tables for remote MCP auth.
|
||||
|
||||
## [0.5.1] - 2026-04-10
|
||||
|
||||
### Fixed
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -23,7 +23,13 @@ server are both generated from this single source. Skills are fat markdown files
|
||||
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup
|
||||
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
|
||||
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
|
||||
- `src/schema.sql` — Full Postgres + pgvector DDL (includes files table)
|
||||
- `supabase/functions/gbrain-mcp/index.ts` — Remote MCP server (Supabase Edge Function)
|
||||
- `src/edge-entry.ts` — Curated bundle entry point for Edge Function (excludes fs-dependent modules)
|
||||
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
|
||||
- `src/core/schema-embedded.ts` — AUTO-GENERATED from schema.sql (run `bun run build:schema`)
|
||||
- `src/schema.sql` — Full Postgres + pgvector DDL (source of truth, generates schema-embedded.ts)
|
||||
- `scripts/deploy-remote.sh` — One-script remote MCP deployment
|
||||
- `docs/mcp/` — Per-client setup guides (Claude Desktop, Code, Cowork, Perplexity, ChatGPT)
|
||||
- `openclaw.plugin.json` — ClawHub bundle plugin manifest
|
||||
|
||||
## Commands
|
||||
@@ -118,6 +124,25 @@ Before shipping (/ship) or reviewing (/review), always run the full test suite:
|
||||
|
||||
Both must pass. Do not ship with failing E2E tests. Do not skip E2E tests.
|
||||
|
||||
## Post-ship requirements (MANDATORY)
|
||||
|
||||
After EVERY /ship, you MUST run /document-release. This is NOT optional. Do NOT
|
||||
skip it. Do NOT say "docs look fine" without running it. The skill reads every .md
|
||||
file in the project, cross-references the diff, and updates anything that drifted.
|
||||
|
||||
If /ship's Step 8.5 triggers document-release automatically, that counts. But if
|
||||
it gets skipped for ANY reason (timeout, error, oversight), you MUST run it manually
|
||||
before considering the ship complete.
|
||||
|
||||
Files that MUST be checked on every ship:
|
||||
- README.md — does it reflect new features, commands, or setup steps?
|
||||
- CLAUDE.md — does it reflect new files, test files, or architecture changes?
|
||||
- CHANGELOG.md — does it cover every commit?
|
||||
- TODOS.md — are completed items marked done?
|
||||
- docs/ — do any guides need updating?
|
||||
|
||||
A ship without updated docs is an incomplete ship. Period.
|
||||
|
||||
## CHANGELOG voice
|
||||
|
||||
CHANGELOG.md is read by agents during auto-update (Section 17). The agent summarizes
|
||||
|
||||
23
README.md
23
README.md
@@ -298,6 +298,25 @@ GBrain exposes 30 MCP tools via stdio. Add this to your MCP client config:
|
||||
|
||||
This gives your agent `get_page`, `put_page`, `search`, `query`, `add_link`, `traverse_graph`, `sync_brain`, `file_upload`, and 22 more tools. All generated from the same operation definitions as the CLI.
|
||||
|
||||
#### Remote MCP Server (Claude Desktop, Cowork, Perplexity, ChatGPT)
|
||||
|
||||
Access your brain from any device, any AI client. Deploy as a serverless endpoint on your existing Supabase instance:
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env.production # fill in 3 values
|
||||
bash scripts/deploy-remote.sh # links, builds, deploys
|
||||
bun run src/commands/auth.ts create "claude-desktop" # get a token
|
||||
```
|
||||
|
||||
Then add to your AI client:
|
||||
- **Claude Code:** `claude mcp add gbrain -t http https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp -H "Authorization: Bearer TOKEN"`
|
||||
- **Claude Desktop:** Settings > Integrations > Add (NOT JSON config)
|
||||
- **Perplexity Computer:** Settings > Connectors > Add remote MCP
|
||||
|
||||
Per-client setup guides: [`docs/mcp/`](docs/mcp/DEPLOY.md)
|
||||
|
||||
ChatGPT support requires OAuth 2.1 and is coming in v0.7. Self-hosted alternatives (Tailscale Funnel, ngrok) documented in [`docs/mcp/ALTERNATIVES.md`](docs/mcp/ALTERNATIVES.md).
|
||||
|
||||
**The tools are not enough.** Your agent also needs the playbook: read [GBRAIN_SKILLPACK.md](docs/GBRAIN_SKILLPACK.md) and paste the relevant sections into your agent's system prompt or project instructions. The skillpack tells the agent WHEN and HOW to use each tool: read before responding, write after learning, detect entities on every message, back-link everything.
|
||||
|
||||
The skill markdown files in `skills/` are standalone instruction sets. Copy them into your agent's context:
|
||||
@@ -620,7 +639,9 @@ ADMIN
|
||||
gbrain history <slug> Page version history
|
||||
gbrain revert <slug> <version-id> Revert to previous version
|
||||
gbrain config [get|set] <key> [value] Brain config
|
||||
gbrain serve MCP server (stdio)
|
||||
gbrain serve MCP server (stdio, local)
|
||||
scripts/deploy-remote.sh Deploy remote MCP server (Supabase Edge Functions)
|
||||
bun run src/commands/auth.ts Token management (create/list/revoke/test)
|
||||
gbrain call <tool> '<json>' Raw tool invocation
|
||||
gbrain --tools-json Tool discovery (JSON)
|
||||
```
|
||||
|
||||
42
TODOS.md
42
TODOS.md
@@ -17,17 +17,37 @@
|
||||
|
||||
**Depends on:** Part 5 (parallel import with per-worker engines) -- already shipped.
|
||||
|
||||
## P0
|
||||
|
||||
### ChatGPT MCP support (OAuth 2.1)
|
||||
**What:** Add OAuth 2.1 with Dynamic Client Registration to the Edge Function so ChatGPT can connect.
|
||||
|
||||
**Why:** ChatGPT requires OAuth 2.1 for MCP connectors. Bearer token auth is NOT supported. This is the only major AI client that can't use GBrain remotely.
|
||||
|
||||
**Pros:** Completes the "every AI client" promise. ChatGPT has the largest user base.
|
||||
|
||||
**Cons:** OAuth 2.1 is a significant implementation: authorization endpoint, token endpoint, PKCE flow, dynamic client registration. Estimated CC: ~3-4 hours.
|
||||
|
||||
**Context:** Discovered during DX review (2026-04-10). All other clients (Claude Desktop/Code/Cowork, Perplexity) work with bearer tokens. See `docs/mcp/CHATGPT.md` for current status.
|
||||
|
||||
**Depends on:** v0.6.0 remote MCP server (shipped).
|
||||
|
||||
## P2
|
||||
|
||||
### Fly.io HTTP server as alternative deployment
|
||||
**What:** Add `gbrain serve --http` and a Dockerfile/fly.toml for users who prefer a traditional server over Edge Functions.
|
||||
|
||||
**Why:** Avoids the Deno bundling seam. Bun runs natively. No 60s timeout. No cold start. Codex flagged the bundle strategy as "permanent maintenance tax."
|
||||
|
||||
**Pros:** Simpler code path, no edge-entry.ts needed, no Deno compat concerns. Supports sync_brain and file_upload remotely.
|
||||
|
||||
**Cons:** Users need a Fly.io account. Not zero-infra.
|
||||
|
||||
**Context:** From CEO review (2026-04-10). Edge Functions are the primary path. Fly.io is for power users who want full operation support remotely.
|
||||
|
||||
**Depends on:** v0.6.0 remote MCP server (shipped).
|
||||
|
||||
## Completed
|
||||
|
||||
### Implement AWS Signature V4 for S3 storage backend
|
||||
**What:** Replace the unsigned `signedFetch()` in `src/core/storage/s3.ts` with proper AWS Signature V4 request signing.
|
||||
|
||||
**Why:** The current S3 implementation accepts `accessKeyId` and `secretAccessKey` but never signs requests. It only works with public buckets or pre-signed URLs. Private S3 buckets return 403.
|
||||
|
||||
**Pros:** Enables private S3/R2/MinIO bucket support. Users can store files securely without relying on public bucket access.
|
||||
|
||||
**Cons:** AWS Sig V4 is complex (canonical request, string to sign, signing key derivation). Could use a lightweight library instead of rolling from scratch. Medium implementation effort.
|
||||
|
||||
**Context:** Identified during CSO security audit (2026-04-10). The code explicitly comments this as "simplified" and not production-ready. Nobody uses S3 storage today (Supabase Storage is the default). Only implement when S3 becomes a real deployment path.
|
||||
|
||||
**Depends on:** Nothing. Self-contained change to `src/core/storage/s3.ts`.
|
||||
**Completed:** v0.6.0 (2026-04-10) — replaced with @aws-sdk/client-s3 for proper SigV4 signing.
|
||||
|
||||
211
bun.lock
211
bun.lock
@@ -6,6 +6,7 @@
|
||||
"name": "gbrain",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"openai": "^4.0.0",
|
||||
@@ -20,10 +21,190 @@
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.30.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1028.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-node": "^3.972.30", "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", "@aws-sdk/middleware-expect-continue": "^3.972.9", "@aws-sdk/middleware-flexible-checksums": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-location-constraint": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/middleware-ssec": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.16", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/eventstream-serde-browser": "^4.2.13", "@smithy/eventstream-serde-config-resolver": "^4.3.13", "@smithy/eventstream-serde-node": "^4.2.13", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-blob-browser": "^4.2.14", "@smithy/hash-node": "^4.2.13", "@smithy/hash-stream-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/md5-js": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.15", "tslib": "^2.6.2" } }, "sha512-KL8PREFJxyWXUjMQR6Krq/OjZ5qbcV1QFjtA7Q7oMW5XaFO9YoSBtBxQeeXO4um6vYSmRVYVDTvEKZDcNbyeXw=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.6", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-login": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/token-providers": "3.1026.0", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/crc64-nvme": "^3.972.6", "@aws-sdk/types": "^3.973.7", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w=="],
|
||||
|
||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w=="],
|
||||
|
||||
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog=="],
|
||||
|
||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ=="],
|
||||
|
||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.29", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-retry": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.19", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.27", "@aws-sdk/middleware-host-header": "^3.972.9", "@aws-sdk/middleware-logger": "^3.972.9", "@aws-sdk/middleware-recursion-detection": "^3.972.10", "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/region-config-resolver": "^3.972.11", "@aws-sdk/types": "^3.973.7", "@aws-sdk/util-endpoints": "^3.996.6", "@aws-sdk/util-user-agent-browser": "^3.972.9", "@aws-sdk/util-user-agent-node": "^3.973.15", "@smithy/config-resolver": "^4.4.14", "@smithy/core": "^3.23.14", "@smithy/fetch-http-handler": "^5.3.16", "@smithy/hash-node": "^4.2.13", "@smithy/invalid-dependency": "^4.2.13", "@smithy/middleware-content-length": "^4.2.13", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-retry": "^4.5.0", "@smithy/middleware-serde": "^4.2.17", "@smithy/middleware-stack": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/node-http-handler": "^4.5.2", "@smithy/protocol-http": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.45", "@smithy/util-defaults-mode-node": "^4.2.49", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q=="],
|
||||
|
||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/config-resolver": "^4.4.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.28", "@aws-sdk/types": "^3.973.7", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1026.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.27", "@aws-sdk/nested-clients": "^3.996.19", "@aws-sdk/types": "^3.973.7", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="],
|
||||
|
||||
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-endpoints": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@smithy/types": "^4.14.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.15", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.17", "", { "dependencies": { "@smithy/types": "^4.14.0", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.4", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.23.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ=="],
|
||||
|
||||
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg=="],
|
||||
|
||||
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA=="],
|
||||
|
||||
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.13", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.13", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.16", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ=="],
|
||||
|
||||
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.14", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA=="],
|
||||
|
||||
"@smithy/hash-node": ["@smithy/hash-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA=="],
|
||||
|
||||
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg=="],
|
||||
|
||||
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
|
||||
|
||||
"@smithy/md5-js": ["@smithy/md5-js@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.29", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-serde": "^4.2.17", "@smithy/node-config-provider": "^4.3.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "@smithy/url-parser": "^4.2.13", "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.1", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/protocol-http": "^5.3.13", "@smithy/service-error-classification": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", "@smithy/util-retry": "^4.3.1", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.17", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ=="],
|
||||
|
||||
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw=="],
|
||||
|
||||
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.13", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.2", "", { "dependencies": { "@smithy/protocol-http": "^5.3.13", "@smithy/querystring-builder": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/property-provider@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ=="],
|
||||
|
||||
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg=="],
|
||||
|
||||
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ=="],
|
||||
|
||||
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA=="],
|
||||
|
||||
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0" } }, "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw=="],
|
||||
|
||||
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.8", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.13", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.9", "", { "dependencies": { "@smithy/core": "^3.23.14", "@smithy/middleware-endpoint": "^4.4.29", "@smithy/middleware-stack": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/types": "^4.14.0", "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" } }, "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ=="],
|
||||
|
||||
"@smithy/url-parser": ["@smithy/url-parser@4.2.13", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw=="],
|
||||
|
||||
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
|
||||
|
||||
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
|
||||
|
||||
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.45", "", { "dependencies": { "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.49", "", { "dependencies": { "@smithy/config-resolver": "^4.4.14", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.4", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog=="],
|
||||
|
||||
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
|
||||
|
||||
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.13", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow=="],
|
||||
|
||||
"@smithy/util-retry": ["@smithy/util-retry@4.3.1", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.13", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.22", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.16", "@smithy/node-http-handler": "^4.5.2", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.15", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg=="],
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
@@ -46,6 +227,8 @@
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -110,6 +293,10 @@
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -198,6 +385,8 @@
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
@@ -248,10 +437,14 @@
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
@@ -274,16 +467,34 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
}
|
||||
}
|
||||
|
||||
54
docs/mcp/ALTERNATIVES.md
Normal file
54
docs/mcp/ALTERNATIVES.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Alternative: Self-Hosted MCP Server
|
||||
|
||||
If you prefer running GBrain on your own machine instead of Supabase Edge Functions, you can expose `gbrain serve --http` via a tunnel.
|
||||
|
||||
## Tailscale Funnel
|
||||
|
||||
[Tailscale Funnel](https://tailscale.com/kb/1223/tailscale-funnel) gives you a permanent public HTTPS URL with automatic TLS. Free tier available.
|
||||
|
||||
```bash
|
||||
# 1. Install Tailscale
|
||||
brew install tailscale
|
||||
|
||||
# 2. Start gbrain with HTTP transport (when available)
|
||||
gbrain serve --http 3000
|
||||
|
||||
# 3. Expose via Funnel
|
||||
tailscale funnel 3000
|
||||
# Your brain is now at https://your-machine.ts.net
|
||||
```
|
||||
|
||||
Pros: zero deployment, no Deno bundling, no cold start, no timeout limits.
|
||||
Cons: requires your machine to be running and connected.
|
||||
|
||||
## ngrok
|
||||
|
||||
[ngrok](https://ngrok.com) provides temporary or persistent tunnels.
|
||||
|
||||
```bash
|
||||
# 1. Install ngrok
|
||||
brew install ngrok
|
||||
|
||||
# 2. Start gbrain with HTTP transport
|
||||
gbrain serve --http 3000
|
||||
|
||||
# 3. Expose via ngrok
|
||||
ngrok http 3000
|
||||
# Use the generated URL in your MCP client config
|
||||
```
|
||||
|
||||
Pros: quick setup, works behind firewalls.
|
||||
Cons: free tier URLs change on restart (paid tier for persistent URLs), requires running process.
|
||||
|
||||
## When to use alternatives vs Edge Functions
|
||||
|
||||
| | Edge Functions | Tailscale/ngrok |
|
||||
|--|---|---|
|
||||
| Works when laptop is off | Yes | No |
|
||||
| Zero cold start | No (~300ms) | Yes |
|
||||
| No timeout limits | No (60s) | Yes |
|
||||
| sync_brain remotely | No | Yes |
|
||||
| file_upload remotely | No | Yes |
|
||||
| Extra accounts needed | None | Tailscale or ngrok |
|
||||
|
||||
Note: `gbrain serve --http` is planned but not yet implemented. Currently only stdio transport is available via `gbrain serve`.
|
||||
25
docs/mcp/CHATGPT.md
Normal file
25
docs/mcp/CHATGPT.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Connect GBrain to ChatGPT
|
||||
|
||||
**Status: Coming Soon**
|
||||
|
||||
ChatGPT requires OAuth 2.1 with Dynamic Client Registration for MCP connectors. Bearer token authentication is not supported by ChatGPT's MCP integration.
|
||||
|
||||
This is tracked as a P0 priority for GBrain v0.7.
|
||||
|
||||
## What's needed
|
||||
|
||||
- OAuth 2.1 authorization endpoint on the Edge Function
|
||||
- Token endpoint with PKCE flow
|
||||
- Dynamic Client Registration support
|
||||
- ChatGPT Developer Mode (available on Pro/Team/Enterprise/Edu plans)
|
||||
|
||||
## Workaround
|
||||
|
||||
Until OAuth support ships, you can use GBrain with ChatGPT via a bridge:
|
||||
|
||||
1. Run `gbrain serve` locally
|
||||
2. Use a tool like [mcp-remote](https://github.com/nichochar/mcp-remote) to bridge stdio to HTTP with OAuth support
|
||||
|
||||
## Timeline
|
||||
|
||||
Follow [Issue #22](https://github.com/garrytan/gbrain/issues/22) for updates on ChatGPT OAuth support.
|
||||
27
docs/mcp/CLAUDE_CODE.md
Normal file
27
docs/mcp/CLAUDE_CODE.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Connect GBrain to Claude Code
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
claude mcp add gbrain -t http \
|
||||
https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
Replace `YOUR_REF` with your Supabase project ref and `YOUR_TOKEN` with a token from `bun run src/commands/auth.ts create "claude-code"`.
|
||||
|
||||
## Verify
|
||||
|
||||
In Claude Code, try:
|
||||
|
||||
```
|
||||
search for [any topic in your brain]
|
||||
```
|
||||
|
||||
You should see results from your GBrain knowledge base.
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
claude mcp remove gbrain
|
||||
```
|
||||
28
docs/mcp/CLAUDE_COWORK.md
Normal file
28
docs/mcp/CLAUDE_COWORK.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Connect GBrain to Claude Cowork
|
||||
|
||||
Two ways to get GBrain into Cowork sessions:
|
||||
|
||||
## Option 1: Remote (via Edge Function)
|
||||
|
||||
For Team/Enterprise plans, an org Owner adds the connector:
|
||||
|
||||
1. Go to **Organization Settings > Connectors**
|
||||
2. Add a new connector with the MCP server URL:
|
||||
```
|
||||
https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp
|
||||
```
|
||||
3. Optionally add Bearer token authentication in Advanced Settings
|
||||
4. Save
|
||||
|
||||
Note: Cowork connects from Anthropic's cloud, not your device. The Edge Function is already publicly reachable via Supabase.
|
||||
|
||||
## Option 2: Local Bridge (via Claude Desktop)
|
||||
|
||||
If you already have GBrain configured in Claude Desktop (either via `gbrain serve` stdio or the remote MCP integration), Cowork gets access automatically. Claude Desktop bridges local MCP servers into Cowork via its SDK layer.
|
||||
|
||||
This means: if `gbrain serve` is running and configured in Claude Desktop, you don't need the Edge Function for Cowork at all.
|
||||
|
||||
## Which to use?
|
||||
|
||||
- **Remote Edge Function:** works even when your laptop is closed, available to all org members
|
||||
- **Local Bridge:** zero extra setup if Claude Desktop already has GBrain, but requires your machine to be running
|
||||
31
docs/mcp/CLAUDE_DESKTOP.md
Normal file
31
docs/mcp/CLAUDE_DESKTOP.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Connect GBrain to Claude Desktop
|
||||
|
||||
**Important:** Claude Desktop does NOT connect to remote MCP servers via `claude_desktop_config.json`. That file only works for local stdio servers. Remote HTTP servers must be added through the GUI.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open Claude Desktop
|
||||
2. Go to **Settings > Integrations**
|
||||
3. Click **Add Integration** (or **Add Connector**)
|
||||
4. Enter the MCP server URL:
|
||||
```
|
||||
https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp
|
||||
```
|
||||
5. Set authentication to **Bearer Token** and paste your token
|
||||
6. Save
|
||||
|
||||
## Verify
|
||||
|
||||
Start a new conversation and try:
|
||||
|
||||
```
|
||||
Search my brain for [any topic]
|
||||
```
|
||||
|
||||
Claude Desktop will use your GBrain tools automatically.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Using claude_desktop_config.json for remote servers** — this silently fails. The JSON config only works for local stdio MCP servers. Remote HTTP servers must be added via Settings > Integrations.
|
||||
|
||||
**Using the wrong URL** — make sure the URL ends with `/mcp` (not `/health` or just the function name).
|
||||
102
docs/mcp/DEPLOY.md
Normal file
102
docs/mcp/DEPLOY.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Deploy GBrain Remote MCP Server
|
||||
|
||||
Deploy your personal knowledge brain as a serverless MCP endpoint on your existing Supabase instance. Works with Claude Desktop, Claude Code, Cowork, and Perplexity Computer.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GBrain already set up (`gbrain init` completed, data imported)
|
||||
- [Supabase CLI](https://supabase.com/docs/guides/cli) installed
|
||||
- Your Supabase project ref (the `xxx` from `https://xxx.supabase.co`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Fill in your config
|
||||
cp .env.production.example .env.production
|
||||
# Edit .env.production with your DATABASE_URL, OPENAI_API_KEY, SUPABASE_PROJECT_REF
|
||||
|
||||
# 2. Deploy (one command)
|
||||
bash scripts/deploy-remote.sh
|
||||
|
||||
# 3. Create an access token
|
||||
DATABASE_URL=$DATABASE_URL bun run src/commands/auth.ts create "my-client"
|
||||
# Save the token — it's shown once
|
||||
|
||||
# 4. Test it
|
||||
bun run src/commands/auth.ts test \
|
||||
https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp \
|
||||
--token YOUR_TOKEN
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
GBrain uses bearer tokens stored in your database (SHA-256 hashed). Each token has a name for identification.
|
||||
|
||||
```bash
|
||||
# Create a token
|
||||
bun run src/commands/auth.ts create "claude-desktop"
|
||||
|
||||
# List all tokens
|
||||
bun run src/commands/auth.ts list
|
||||
|
||||
# Revoke a token
|
||||
bun run src/commands/auth.ts revoke "claude-desktop"
|
||||
```
|
||||
|
||||
Tokens are per-client. Create one for each device/app. Revoke individually if compromised.
|
||||
|
||||
## Updating
|
||||
|
||||
When you update GBrain (new operations, bug fixes):
|
||||
|
||||
```bash
|
||||
git pull
|
||||
bash scripts/deploy-remote.sh
|
||||
```
|
||||
|
||||
Your tokens survive upgrades. Check your deployed version:
|
||||
|
||||
```bash
|
||||
curl https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/health
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
All 28 GBrain operations are available remotely except:
|
||||
- `sync_brain` (may exceed 60s Edge Function timeout)
|
||||
- `file_upload` (may exceed 60s timeout with large files)
|
||||
|
||||
These remain CLI-only via `gbrain serve` (stdio).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"supabase: command not found"**
|
||||
Install: `brew install supabase/tap/supabase` or `npm install -g supabase`
|
||||
|
||||
**Edge Function deploys but returns 500**
|
||||
Check that OPENAI_API_KEY is set: `supabase secrets list`
|
||||
|
||||
**"missing_auth" error**
|
||||
Include the Authorization header: `Authorization: Bearer YOUR_TOKEN`
|
||||
|
||||
**"invalid_token" error**
|
||||
Run `bun run src/commands/auth.ts list` to see active tokens. The token may have been revoked or mistyped.
|
||||
|
||||
**"service_unavailable" error**
|
||||
Database connection failed. Check your Supabase dashboard for outages or connection pool limits.
|
||||
|
||||
**Claude Desktop doesn't connect**
|
||||
Remote MCP servers must be added via Settings > Integrations, NOT claude_desktop_config.json. See [CLAUDE_DESKTOP.md](CLAUDE_DESKTOP.md).
|
||||
|
||||
## Expected Latencies
|
||||
|
||||
| Operation | Typical Latency | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| get_page | < 100ms | Single DB query |
|
||||
| list_pages | < 200ms | DB query with filters |
|
||||
| search (keyword) | 100-300ms | Full-text search |
|
||||
| query (hybrid) | 1-3s | Embedding + vector + keyword + RRF |
|
||||
| put_page | 100-500ms | Write + trigger search_vector update |
|
||||
| get_stats | < 100ms | Aggregate query |
|
||||
|
||||
Cold start adds ~300-500ms on the first request after idle (Postgres connection setup via pgbouncer).
|
||||
27
docs/mcp/PERPLEXITY.md
Normal file
27
docs/mcp/PERPLEXITY.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Connect GBrain to Perplexity Computer
|
||||
|
||||
Perplexity Computer supports remote MCP servers with bearer token authentication.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open Perplexity (requires Pro subscription)
|
||||
2. Go to **Settings > Connectors** (or **MCP Servers**)
|
||||
3. Add a new remote connector:
|
||||
- **URL:** `https://YOUR_REF.supabase.co/functions/v1/gbrain-mcp/mcp`
|
||||
- **Authentication:** API Key / Bearer Token
|
||||
- **Token:** your GBrain access token
|
||||
4. Save
|
||||
|
||||
## Verify
|
||||
|
||||
In a Perplexity conversation, ask it to use your brain:
|
||||
|
||||
```
|
||||
Use my GBrain to search for [topic]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Perplexity Computer is available to Pro subscribers
|
||||
- Both the Perplexity Mac app and web version support MCP connectors
|
||||
- The Mac app also supports local MCP servers if you prefer `gbrain serve` (stdio)
|
||||
@@ -17,6 +17,8 @@
|
||||
"dev": "bun run src/cli.ts",
|
||||
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
|
||||
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:edge": "bun run build:schema && bun build src/edge-entry.ts --format=esm --outfile=supabase/functions/gbrain-mcp/gbrain-core.js --external=postgres --external=openai --external=fs --external=os --external=path --external=crypto --external=child_process --external=@aws-sdk/client-s3 --external=@anthropic-ai/sdk --external=gray-matter --minify",
|
||||
"test": "bun test",
|
||||
"test:e2e": "bun test test/e2e/",
|
||||
"prepublish:clawhub": "bun run build:all",
|
||||
@@ -29,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"openai": "^4.0.0",
|
||||
|
||||
15
scripts/build-schema.sh
Executable file
15
scripts/build-schema.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Generate src/core/schema-embedded.ts from src/schema.sql
|
||||
# One source of truth: schema.sql is the canonical file.
|
||||
# This script produces a TypeScript constant for use in compiled binaries and Edge Functions.
|
||||
set -e
|
||||
SCHEMA_FILE="src/schema.sql"
|
||||
OUT_FILE="src/core/schema-embedded.ts"
|
||||
echo "// AUTO-GENERATED — do not edit. Run: bun run build:schema" > "$OUT_FILE"
|
||||
echo "// Source: $SCHEMA_FILE" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
echo "export const SCHEMA_SQL = \`" >> "$OUT_FILE"
|
||||
# Escape backticks and dollar signs in the SQL for template literal safety
|
||||
sed 's/`/\\`/g; s/\$/\\$/g' "$SCHEMA_FILE" >> "$OUT_FILE"
|
||||
echo "\`;" >> "$OUT_FILE"
|
||||
echo "Generated $OUT_FILE from $SCHEMA_FILE"
|
||||
67
scripts/deploy-remote.sh
Executable file
67
scripts/deploy-remote.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# Deploy GBrain Remote MCP Server to Supabase Edge Functions.
|
||||
# Prerequisites: .env.production filled in, supabase CLI installed.
|
||||
set -e
|
||||
|
||||
# Check supabase CLI
|
||||
if ! command -v supabase >/dev/null 2>&1; then
|
||||
echo "Error: supabase CLI not found."
|
||||
echo "Install: brew install supabase/tap/supabase"
|
||||
echo " or: npm install -g supabase"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load env
|
||||
if [ ! -f .env.production ]; then
|
||||
echo "Error: .env.production not found."
|
||||
echo "Copy .env.production.example to .env.production and fill in your values."
|
||||
exit 1
|
||||
fi
|
||||
source .env.production
|
||||
|
||||
if [ -z "$SUPABASE_PROJECT_REF" ]; then
|
||||
echo "Error: SUPABASE_PROJECT_REF not set in .env.production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying GBrain Remote MCP Server..."
|
||||
echo " Project: $SUPABASE_PROJECT_REF"
|
||||
echo ""
|
||||
|
||||
# Link project
|
||||
supabase link --project-ref "$SUPABASE_PROJECT_REF"
|
||||
|
||||
# Set secrets
|
||||
supabase secrets set OPENAI_API_KEY="$OPENAI_API_KEY"
|
||||
|
||||
# Build the Edge Function bundle
|
||||
echo ""
|
||||
echo "Building Edge Function bundle..."
|
||||
bun install
|
||||
bun run build:edge
|
||||
echo ""
|
||||
|
||||
# Deploy
|
||||
echo "Deploying Edge Function..."
|
||||
supabase functions deploy gbrain-mcp --no-verify-jwt
|
||||
echo ""
|
||||
|
||||
# Print success
|
||||
URL="https://${SUPABASE_PROJECT_REF}.supabase.co/functions/v1/gbrain-mcp/mcp"
|
||||
echo "================================================"
|
||||
echo " GBrain Remote MCP Server deployed!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo " URL: $URL"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Create a token:"
|
||||
echo " DATABASE_URL=\$DATABASE_URL bun run src/commands/auth.ts create \"my-client\""
|
||||
echo ""
|
||||
echo " 2. Test it:"
|
||||
echo " bun run src/commands/auth.ts test $URL --token <your-token>"
|
||||
echo ""
|
||||
echo " 3. Add to Claude Code:"
|
||||
echo " claude mcp add gbrain -t http $URL -H \"Authorization: Bearer <token>\""
|
||||
echo ""
|
||||
echo " See docs/mcp/ for per-client setup guides."
|
||||
96
scripts/smoke-test-mcp.ts
Normal file
96
scripts/smoke-test-mcp.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Smoke test: verify MCP tool calls work against a real database.
|
||||
* Usage: DATABASE_URL=... bun run scripts/smoke-test-mcp.ts
|
||||
*/
|
||||
import { PostgresEngine } from '../src/core/postgres-engine.ts';
|
||||
import { handleToolCall } from '../src/mcp/server.ts';
|
||||
|
||||
const DB_URL = process.env.DATABASE_URL;
|
||||
if (!DB_URL) { console.error('Set DATABASE_URL'); process.exit(1); }
|
||||
|
||||
const eng = new PostgresEngine();
|
||||
await eng.connect({ database_url: DB_URL });
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function test(name: string, fn: () => Promise<void>) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (e: any) {
|
||||
console.log(` ✗ ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MCP Smoke Test\n');
|
||||
|
||||
await test('get_stats returns counts', async () => {
|
||||
const stats = await handleToolCall(eng, 'get_stats', {}) as any;
|
||||
if (typeof stats.page_count !== 'number') throw new Error('page_count missing');
|
||||
});
|
||||
|
||||
await test('put_page creates a page', async () => {
|
||||
await handleToolCall(eng, 'put_page', {
|
||||
slug: 'smoke/test-page',
|
||||
content: '---\ntitle: Smoke Test Page\ntype: note\n---\n\nThis page was created by the MCP smoke test.',
|
||||
});
|
||||
});
|
||||
|
||||
await test('get_page retrieves the page', async () => {
|
||||
const page = await handleToolCall(eng, 'get_page', { slug: 'smoke/test-page' }) as any;
|
||||
if (page.title !== 'Smoke Test Page') throw new Error(`Wrong title: ${page.title}`);
|
||||
});
|
||||
|
||||
await test('dry_run prevents mutation', async () => {
|
||||
const result = await handleToolCall(eng, 'put_page', {
|
||||
slug: 'smoke/should-not-exist',
|
||||
content: '---\ntitle: Should Not Exist\ntype: note\n---\n\ndry run test',
|
||||
dry_run: true,
|
||||
}) as any;
|
||||
if (!result.dry_run) throw new Error('dry_run flag not returned');
|
||||
// Verify page was NOT created
|
||||
try {
|
||||
await handleToolCall(eng, 'get_page', { slug: 'smoke/should-not-exist' });
|
||||
throw new Error('Page was created despite dry_run');
|
||||
} catch (e: any) {
|
||||
if (!e.message.includes('not found') && !e.code) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
await test('search finds the page', async () => {
|
||||
const results = await handleToolCall(eng, 'search', { query: 'smoke test' }) as any[];
|
||||
if (!Array.isArray(results)) throw new Error('search should return array');
|
||||
// Keyword search may or may not find it depending on search_vector trigger
|
||||
});
|
||||
|
||||
await test('list_pages includes our page', async () => {
|
||||
const pages = await handleToolCall(eng, 'list_pages', { limit: 100 }) as any[];
|
||||
const found = pages.find((p: any) => p.slug === 'smoke/test-page');
|
||||
if (!found) throw new Error('smoke/test-page not in list');
|
||||
});
|
||||
|
||||
await test('add_tag and get_tags work', async () => {
|
||||
await handleToolCall(eng, 'add_tag', { slug: 'smoke/test-page', tag: 'smoke-test' });
|
||||
const tags = await handleToolCall(eng, 'get_tags', { slug: 'smoke/test-page' }) as string[];
|
||||
if (!tags.includes('smoke-test')) throw new Error('tag not found');
|
||||
});
|
||||
|
||||
await test('delete_page cleans up', async () => {
|
||||
await handleToolCall(eng, 'delete_page', { slug: 'smoke/test-page' });
|
||||
try {
|
||||
await handleToolCall(eng, 'get_page', { slug: 'smoke/test-page' });
|
||||
throw new Error('Page still exists after delete');
|
||||
} catch (e: any) {
|
||||
if (!e.message.includes('not found') && !e.code) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
await eng.disconnect();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
console.log('\n🧠 MCP smoke test passed!');
|
||||
241
src/commands/auth.ts
Normal file
241
src/commands/auth.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* GBrain token management — standalone script, no gbrain CLI dependency.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun run src/commands/auth.ts create "claude-desktop"
|
||||
* DATABASE_URL=... bun run src/commands/auth.ts list
|
||||
* DATABASE_URL=... bun run src/commands/auth.ts revoke "claude-desktop"
|
||||
* DATABASE_URL=... bun run src/commands/auth.ts test <url> --token <token>
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || process.env.GBRAIN_DATABASE_URL;
|
||||
if (!DATABASE_URL && process.argv[2] !== 'test') {
|
||||
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return 'gbrain_' + randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
async function create(name: string) {
|
||||
if (!name) { console.error('Usage: auth create <name>'); process.exit(1); }
|
||||
const sql = postgres(DATABASE_URL!);
|
||||
const token = generateToken();
|
||||
const hash = hashToken(token);
|
||||
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO access_tokens (name, token_hash)
|
||||
VALUES (${name}, ${hash})
|
||||
`;
|
||||
console.log(`Token created for "${name}":\n`);
|
||||
console.log(` ${token}\n`);
|
||||
console.log('Save this token — it will not be shown again.');
|
||||
console.log(`Revoke with: bun run src/commands/auth.ts revoke "${name}"`);
|
||||
} catch (e: any) {
|
||||
if (e.code === '23505') {
|
||||
console.error(`A token named "${name}" already exists. Revoke it first or use a different name.`);
|
||||
} else {
|
||||
console.error('Error:', e.message);
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const sql = postgres(DATABASE_URL!);
|
||||
try {
|
||||
const rows = await sql`
|
||||
SELECT name, created_at, last_used_at, revoked_at
|
||||
FROM access_tokens
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
console.log('No tokens found. Create one: bun run src/commands/auth.ts create "my-client"');
|
||||
return;
|
||||
}
|
||||
console.log('Name Created Last Used Status');
|
||||
console.log('─'.repeat(80));
|
||||
for (const r of rows) {
|
||||
const name = (r.name as string).padEnd(20);
|
||||
const created = new Date(r.created_at as string).toISOString().slice(0, 19);
|
||||
const lastUsed = r.last_used_at ? new Date(r.last_used_at as string).toISOString().slice(0, 19) : 'never'.padEnd(19);
|
||||
const status = r.revoked_at ? 'REVOKED' : 'active';
|
||||
console.log(`${name} ${created} ${lastUsed} ${status}`);
|
||||
}
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(name: string) {
|
||||
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
|
||||
const sql = postgres(DATABASE_URL!);
|
||||
try {
|
||||
const result = await sql`
|
||||
UPDATE access_tokens SET revoked_at = now()
|
||||
WHERE name = ${name} AND revoked_at IS NULL
|
||||
`;
|
||||
if (result.count === 0) {
|
||||
console.error(`No active token found with name "${name}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Token "${name}" revoked.`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function test(url: string, token: string) {
|
||||
if (!url || !token) {
|
||||
console.error('Usage: auth test <url> --token <token>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
console.log(`Testing MCP server at ${url}...\n`);
|
||||
|
||||
// Step 1: Initialize
|
||||
try {
|
||||
const initRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'gbrain-smoke-test', version: '1.0' },
|
||||
},
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
console.error(` Initialize failed: ${initRes.status} ${initRes.statusText}`);
|
||||
const body = await initRes.text();
|
||||
if (body) console.error(` ${body}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✓ Initialize handshake');
|
||||
} catch (e: any) {
|
||||
console.error(` ✗ Connection failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 2: List tools
|
||||
try {
|
||||
const listRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
id: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!listRes.ok) {
|
||||
console.error(` ✗ tools/list failed: ${listRes.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const text = await listRes.text();
|
||||
// Parse SSE or JSON response
|
||||
let toolCount = 0;
|
||||
if (text.includes('event:')) {
|
||||
// SSE format: extract data lines
|
||||
const dataLines = text.split('\n').filter(l => l.startsWith('data:'));
|
||||
for (const line of dataLines) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(5));
|
||||
if (data.result?.tools) toolCount = data.result.tools.length;
|
||||
} catch { /* skip non-JSON lines */ }
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
toolCount = data.result?.tools?.length || 0;
|
||||
} catch { /* parse error */ }
|
||||
}
|
||||
|
||||
console.log(` ✓ tools/list: ${toolCount} tools available`);
|
||||
} catch (e: any) {
|
||||
console.error(` ✗ tools/list failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 3: Call get_stats (real tool call)
|
||||
try {
|
||||
const statsRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_stats', arguments: {} },
|
||||
id: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!statsRes.ok) {
|
||||
console.error(` ✗ get_stats failed: ${statsRes.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✓ get_stats: brain is responding');
|
||||
} catch (e: any) {
|
||||
console.error(` ✗ get_stats failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
|
||||
}
|
||||
|
||||
// CLI dispatch
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
switch (cmd) {
|
||||
case 'create': await create(args[0]); break;
|
||||
case 'list': await list(); break;
|
||||
case 'revoke': await revoke(args[0]); break;
|
||||
case 'test': {
|
||||
const tokenIdx = args.indexOf('--token');
|
||||
const url = args.find(a => !a.startsWith('--') && a !== args[tokenIdx + 1]);
|
||||
const token = tokenIdx >= 0 ? args[tokenIdx + 1] : '';
|
||||
await test(url || '', token || '');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`GBrain Token Management
|
||||
|
||||
Usage:
|
||||
bun run src/commands/auth.ts create <name> Create a new access token
|
||||
bun run src/commands/auth.ts list List all tokens
|
||||
bun run src/commands/auth.ts revoke <name> Revoke a token
|
||||
bun run src/commands/auth.ts test <url> --token <token> Smoke test a remote MCP server
|
||||
`);
|
||||
}
|
||||
@@ -131,6 +131,16 @@ async function uploadFile(args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload to storage backend if configured
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const config = loadConfig();
|
||||
if (config?.storage) {
|
||||
const { createStorage } = await import('../core/storage.ts');
|
||||
const storage = await createStorage(config.storage as any);
|
||||
const content = readFileSync(filePath);
|
||||
await storage.upload(storagePath, content, mimeType || undefined);
|
||||
}
|
||||
|
||||
await sql`
|
||||
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
||||
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb)
|
||||
@@ -308,10 +318,31 @@ async function redirectFiles(args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify remote files exist before deleting locals
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const config = loadConfig();
|
||||
let storage: any = null;
|
||||
if (config?.storage) {
|
||||
const { createStorage } = await import('../core/storage.ts');
|
||||
storage = await createStorage(config.storage as any);
|
||||
}
|
||||
|
||||
let redirected = 0;
|
||||
let skippedMissing = 0;
|
||||
for (const filePath of files) {
|
||||
const relPath = relative(dir, filePath);
|
||||
const hash = fileHash(filePath);
|
||||
|
||||
// Verify remote exists before deleting local
|
||||
if (storage) {
|
||||
const remoteExists = await storage.exists(relPath);
|
||||
if (!remoteExists) {
|
||||
console.error(` Skipping ${relPath}: not found in remote storage (would lose data)`);
|
||||
skippedMissing++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumb = stringify({
|
||||
moved_to: 'storage',
|
||||
bucket: marker.bucket || 'brain-files',
|
||||
@@ -325,6 +356,9 @@ async function redirectFiles(args: string[]) {
|
||||
}
|
||||
|
||||
console.log(`Redirected ${redirected} files. Originals removed, breadcrumbs created.`);
|
||||
if (skippedMissing > 0) {
|
||||
console.log(`Skipped ${skippedMissing} files (not found in remote storage — run 'gbrain files mirror' first).`);
|
||||
}
|
||||
console.log('To undo: gbrain files restore <dir>');
|
||||
}
|
||||
|
||||
|
||||
@@ -109,13 +109,15 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
processed++;
|
||||
if (processed % 100 === 0 || processed === files.length) {
|
||||
logProgress();
|
||||
// Save checkpoint every 100 files
|
||||
// Save checkpoint every 100 files — track completed file set, not just a counter
|
||||
if (processed % 100 === 0) {
|
||||
try {
|
||||
const cpDir = join(homedir(), '.gbrain');
|
||||
if (!existsSync(cpDir)) { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); }
|
||||
writeFileSync(checkpointPath, JSON.stringify({
|
||||
dir, totalFiles: allFiles.length, processedIndex: resumeIndex + processed,
|
||||
dir, totalFiles: allFiles.length,
|
||||
processedIndex: resumeIndex + processed,
|
||||
completedFiles: importedSlugs.length + skipped,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
} catch { /* non-fatal */ }
|
||||
@@ -134,11 +136,13 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
})
|
||||
);
|
||||
|
||||
const queue = [...files];
|
||||
// Thread-safe queue: use an atomic index counter instead of array.shift()
|
||||
let queueIndex = 0;
|
||||
await Promise.all(workerEngines.map(async (eng) => {
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
await processFile(eng, file);
|
||||
while (true) {
|
||||
const idx = queueIndex++;
|
||||
if (idx >= files.length) break;
|
||||
await processFile(eng, files[idx]);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -157,9 +161,11 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear checkpoint on successful completion
|
||||
if (existsSync(checkpointPath)) {
|
||||
// Clear checkpoint only on successful completion (no errors)
|
||||
if (errors === 0 && existsSync(checkpointPath)) {
|
||||
try { unlinkSync(checkpointPath); } catch { /* non-fatal */ }
|
||||
} else if (errors > 0 && existsSync(checkpointPath)) {
|
||||
console.log(` Checkpoint preserved (${errors} errors). Run again to retry failed files.`);
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { EngineConfig } from './types.ts';
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.gbrain');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
||||
// Lazy-evaluated to avoid calling homedir() at module scope (breaks in Deno Edge Functions)
|
||||
function getConfigDir() { return join(homedir(), '.gbrain'); }
|
||||
function getConfigPath() { return join(getConfigDir(), 'config.json'); }
|
||||
|
||||
export interface GBrainConfig {
|
||||
engine: 'postgres' | 'sqlite';
|
||||
@@ -21,7 +22,7 @@ export interface GBrainConfig {
|
||||
export function loadConfig(): GBrainConfig | null {
|
||||
let fileConfig: GBrainConfig | null = null;
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
||||
const raw = readFileSync(getConfigPath(), 'utf-8');
|
||||
fileConfig = JSON.parse(raw) as GBrainConfig;
|
||||
} catch { /* no config file */ }
|
||||
|
||||
@@ -40,10 +41,10 @@ export function loadConfig(): GBrainConfig | null {
|
||||
}
|
||||
|
||||
export function saveConfig(config: GBrainConfig): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
||||
mkdirSync(getConfigDir(), { recursive: true });
|
||||
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
chmodSync(CONFIG_PATH, 0o600);
|
||||
chmodSync(getConfigPath(), 0o600);
|
||||
} catch {
|
||||
// chmod may fail on some platforms
|
||||
}
|
||||
@@ -57,10 +58,10 @@ export function toEngineConfig(config: GBrainConfig): EngineConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfigDir(): string {
|
||||
return CONFIG_DIR;
|
||||
export function configDir(): string {
|
||||
return join(homedir(), '.gbrain');
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return CONFIG_PATH;
|
||||
export function configPath(): string {
|
||||
return join(configDir(), 'config.json');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import postgres from 'postgres';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { GBrainError, type EngineConfig } from './types.ts';
|
||||
import { SCHEMA_SQL } from './schema-embedded.ts';
|
||||
|
||||
let sql: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
@@ -61,27 +60,18 @@ export async function disconnect(): Promise<void> {
|
||||
|
||||
export async function initSchema(): Promise<void> {
|
||||
const conn = getConnection();
|
||||
|
||||
// Read schema SQL and execute as a single statement.
|
||||
// The postgres driver handles multi-statement SQL natively, including
|
||||
// PL/pgSQL functions with $$ delimiter blocks that contain semicolons.
|
||||
// The schema uses IF NOT EXISTS / CREATE OR REPLACE for idempotency.
|
||||
const schemaPath = join(dirname(new URL(import.meta.url).pathname), '..', 'schema.sql');
|
||||
const schemaSql = readFileSync(schemaPath, 'utf-8');
|
||||
|
||||
await conn.unsafe(schemaSql);
|
||||
// Advisory lock prevents concurrent initSchema() calls from deadlocking
|
||||
await conn`SELECT pg_advisory_lock(42)`;
|
||||
try {
|
||||
await conn.unsafe(SCHEMA_SQL);
|
||||
} finally {
|
||||
await conn`SELECT pg_advisory_unlock(42)`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTransaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||
export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) => Promise<T>): Promise<T> {
|
||||
const conn = getConnection();
|
||||
return conn.begin(async (tx) => {
|
||||
// Temporarily swap global connection to transaction
|
||||
const prev = sql;
|
||||
sql = tx as unknown as ReturnType<typeof postgres>;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
sql = prev;
|
||||
}
|
||||
return fn(tx as unknown as ReturnType<typeof postgres>);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,40 @@ const MIGRATIONS: Migration[] = [
|
||||
if (renamed > 0) console.log(` Renamed ${renamed} slugs`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'unique_chunk_index',
|
||||
sql: `
|
||||
-- Deduplicate any existing duplicate (page_id, chunk_index) rows before adding constraint
|
||||
DELETE FROM content_chunks a USING content_chunks b
|
||||
WHERE a.page_id = b.page_id AND a.chunk_index = b.chunk_index AND a.id > b.id;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
name: 'access_tokens_and_mcp_log',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
|
||||
CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_name TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -581,14 +581,37 @@ const file_upload: Operation = {
|
||||
return { status: 'already_exists', storage_path: storagePath };
|
||||
}
|
||||
|
||||
await sql`
|
||||
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
||||
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb)
|
||||
ON CONFLICT (storage_path) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
mime_type = EXCLUDED.mime_type
|
||||
`;
|
||||
// Upload to storage backend if configured
|
||||
if (ctx.config.storage) {
|
||||
const { createStorage } = await import('./storage.ts');
|
||||
const storage = await createStorage(ctx.config.storage as any);
|
||||
try {
|
||||
await storage.upload(storagePath, content, mimeType || undefined);
|
||||
} catch (uploadErr) {
|
||||
throw new OperationError('storage_error', `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
||||
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb)
|
||||
ON CONFLICT (storage_path) DO UPDATE SET
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
mime_type = EXCLUDED.mime_type
|
||||
`;
|
||||
} catch (dbErr) {
|
||||
// Rollback: clean up storage if DB write failed
|
||||
if (ctx.config.storage) {
|
||||
try {
|
||||
const { createStorage } = await import('./storage.ts');
|
||||
const storage = await createStorage(ctx.config.storage as any);
|
||||
await storage.delete(storagePath);
|
||||
} catch { /* best effort cleanup */ }
|
||||
}
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
return { status: 'uploaded', storage_path: storagePath, size_bytes: stat.size };
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import postgres from 'postgres';
|
||||
import { createHash } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { runMigrations } from './migrate.ts';
|
||||
import { SCHEMA_SQL } from './schema-embedded.ts';
|
||||
import type {
|
||||
Page, PageInput, PageFilters, PageType,
|
||||
Chunk, ChunkInput,
|
||||
@@ -58,31 +57,31 @@ export class PostgresEngine implements BrainEngine {
|
||||
|
||||
async initSchema(): Promise<void> {
|
||||
const conn = this.sql;
|
||||
const schemaPath = join(dirname(new URL(import.meta.url).pathname), '..', 'schema.sql');
|
||||
const schemaSql = readFileSync(schemaPath, 'utf-8');
|
||||
await conn.unsafe(schemaSql);
|
||||
// Advisory lock prevents concurrent initSchema() calls from deadlocking
|
||||
// on DDL statements (DROP TRIGGER + CREATE TRIGGER acquire AccessExclusiveLock)
|
||||
await conn`SELECT pg_advisory_lock(42)`;
|
||||
try {
|
||||
await conn.unsafe(SCHEMA_SQL);
|
||||
|
||||
// Run any pending migrations automatically
|
||||
const { applied } = await runMigrations(this);
|
||||
if (applied > 0) {
|
||||
console.log(` ${applied} migration(s) applied`);
|
||||
// Run any pending migrations automatically
|
||||
const { applied } = await runMigrations(this);
|
||||
if (applied > 0) {
|
||||
console.log(` ${applied} migration(s) applied`);
|
||||
}
|
||||
} finally {
|
||||
await conn`SELECT pg_advisory_unlock(42)`;
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T> {
|
||||
if (this._sql) {
|
||||
// Instance connection: use .begin() directly, no global swap
|
||||
return this._sql.begin(async (tx) => {
|
||||
const prev = this._sql;
|
||||
this._sql = tx as unknown as ReturnType<typeof postgres>;
|
||||
try {
|
||||
return await fn(this);
|
||||
} finally {
|
||||
this._sql = prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
return db.withTransaction(() => fn(this));
|
||||
const conn = this._sql || db.getConnection();
|
||||
return conn.begin(async (tx) => {
|
||||
// Create a scoped engine with tx as its connection, no shared state mutation
|
||||
const txEngine = Object.create(this) as PostgresEngine;
|
||||
Object.defineProperty(txEngine, 'sql', { get: () => tx });
|
||||
Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType<typeof postgres>, writable: false });
|
||||
return fn(txEngine);
|
||||
});
|
||||
}
|
||||
|
||||
// Pages CRUD
|
||||
@@ -97,7 +96,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
}
|
||||
|
||||
async putPage(slug: string, page: PageInput): Promise<Page> {
|
||||
validateSlug(slug);
|
||||
slug = validateSlug(slug);
|
||||
const sql = this.sql;
|
||||
const hash = page.content_hash || contentHash(page.compiled_truth, page.timeline || '');
|
||||
const frontmatter = page.frontmatter || {};
|
||||
@@ -182,7 +181,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const limit = opts?.limit || 20;
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
SELECT DISTINCT ON (p.slug)
|
||||
p.slug, p.id as page_id, p.title, p.type,
|
||||
cc.chunk_text, cc.chunk_source,
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('english', ${query})) AS score,
|
||||
@@ -192,9 +191,11 @@ export class PostgresEngine implements BrainEngine {
|
||||
FROM pages p
|
||||
JOIN content_chunks cc ON cc.page_id = p.id
|
||||
WHERE p.search_vector @@ websearch_to_tsquery('english', ${query})
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limit}
|
||||
ORDER BY p.slug, score DESC
|
||||
`;
|
||||
// Re-sort by score (DISTINCT ON requires ORDER BY slug first) and apply limit
|
||||
rows.sort((a: any, b: any) => b.score - a.score);
|
||||
rows.splice(limit);
|
||||
|
||||
return rows.map(rowToSearchResult);
|
||||
}
|
||||
@@ -231,14 +232,17 @@ export class PostgresEngine implements BrainEngine {
|
||||
if (pages.length === 0) throw new Error(`Page not found: ${slug}`);
|
||||
const pageId = pages[0].id;
|
||||
|
||||
// Delete existing chunks for this page
|
||||
await sql`DELETE FROM content_chunks WHERE page_id = ${pageId}`;
|
||||
// Remove chunks that no longer exist (chunk_index beyond new count)
|
||||
const newIndices = chunks.map(c => c.chunk_index);
|
||||
if (newIndices.length > 0) {
|
||||
await sql`DELETE FROM content_chunks WHERE page_id = ${pageId} AND chunk_index != ALL(${newIndices})`;
|
||||
} else {
|
||||
await sql`DELETE FROM content_chunks WHERE page_id = ${pageId}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bulk insert chunks — build multi-row VALUES to reduce round-trips
|
||||
if (chunks.length === 0) return;
|
||||
|
||||
// postgres.js tagged templates don't handle vector casting in bulk,
|
||||
// so we build a parameterized raw SQL query
|
||||
// Batch upsert: build a single multi-row INSERT ON CONFLICT statement
|
||||
// This avoids per-row round-trips and reduces lock contention under parallel workers
|
||||
const cols = '(page_id, chunk_index, chunk_text, chunk_source, embedding, model, token_count, embedded_at)';
|
||||
const rows: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
@@ -258,8 +262,16 @@ export class PostgresEngine implements BrainEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Single statement upsert: preserves existing embeddings via COALESCE when new value is NULL
|
||||
await sql.unsafe(
|
||||
`INSERT INTO content_chunks ${cols} VALUES ${rows.join(', ')}`,
|
||||
`INSERT INTO content_chunks ${cols} VALUES ${rows.join(', ')}
|
||||
ON CONFLICT (page_id, chunk_index) DO UPDATE SET
|
||||
chunk_text = EXCLUDED.chunk_text,
|
||||
chunk_source = EXCLUDED.chunk_source,
|
||||
embedding = COALESCE(EXCLUDED.embedding, content_chunks.embedding),
|
||||
model = COALESCE(EXCLUDED.model, content_chunks.model),
|
||||
token_count = EXCLUDED.token_count,
|
||||
embedded_at = COALESCE(EXCLUDED.embedded_at, content_chunks.embedded_at)`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
@@ -584,7 +596,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
|
||||
// Sync
|
||||
async updateSlug(oldSlug: string, newSlug: string): Promise<void> {
|
||||
validateSlug(newSlug);
|
||||
newSlug = validateSlug(newSlug);
|
||||
const sql = this.sql;
|
||||
await sql`UPDATE pages SET slug = ${newSlug}, updated_at = now() WHERE slug = ${oldSlug}`;
|
||||
}
|
||||
@@ -613,12 +625,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function validateSlug(slug: string): void {
|
||||
function validateSlug(slug: string): string {
|
||||
// Git is the system of record — slugs are lowercased repo-relative paths.
|
||||
// Only reject empty, path traversal (..), and leading slash.
|
||||
if (!slug || /\.\./.test(slug) || /^\//.test(slug)) {
|
||||
throw new Error(`Invalid slug: "${slug}". Slugs cannot be empty, start with /, or contain path traversal.`);
|
||||
}
|
||||
// Normalize to lowercase — all entry points (pathToSlug, inferSlug, frontmatter, direct writes) go through here
|
||||
return slug.toLowerCase();
|
||||
}
|
||||
|
||||
function contentHash(compiledTruth: string, timeline: string): string {
|
||||
|
||||
279
src/core/schema-embedded.ts
Normal file
279
src/core/schema-embedded.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// AUTO-GENERATED — do not edit. Run: bun run build:schema
|
||||
// Source: src/schema.sql
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
-- GBrain Postgres + pgvector schema
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ============================================================
|
||||
-- pages: the core content table
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
compiled_truth TEXT NOT NULL DEFAULT '',
|
||||
timeline TEXT NOT NULL DEFAULT '',
|
||||
frontmatter JSONB NOT NULL DEFAULT '{}',
|
||||
content_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
|
||||
|
||||
-- ============================================================
|
||||
-- content_chunks: chunked content with embeddings
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS content_chunks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
chunk_text TEXT NOT NULL,
|
||||
chunk_source TEXT NOT NULL DEFAULT 'compiled_truth',
|
||||
embedding vector(1536),
|
||||
model TEXT NOT NULL DEFAULT 'text-embedding-3-large',
|
||||
token_count INTEGER,
|
||||
embedded_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_page ON content_chunks(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- ============================================================
|
||||
-- links: cross-references between pages
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
link_type TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(from_page_id, to_page_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_from ON links(from_page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_page_id);
|
||||
|
||||
-- ============================================================
|
||||
-- tags
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
UNIQUE(page_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_page_id ON tags(page_id);
|
||||
|
||||
-- ============================================================
|
||||
-- raw_data: sidecar data (replaces .raw/ JSON files)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS raw_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(page_id, source)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_data_page ON raw_data(page_id);
|
||||
|
||||
-- ============================================================
|
||||
-- timeline_entries: structured timeline
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS timeline_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_page ON timeline_entries(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
|
||||
|
||||
-- ============================================================
|
||||
-- page_versions: snapshot history for compiled_truth
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS page_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
compiled_truth TEXT NOT NULL,
|
||||
frontmatter JSONB NOT NULL DEFAULT '{}',
|
||||
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_page ON page_versions(page_id);
|
||||
|
||||
-- ============================================================
|
||||
-- ingest_log
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS ingest_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
pages_updated JSONB NOT NULL DEFAULT '[]',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- config: brain-level settings
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('version', '1'),
|
||||
('embedding_model', 'text-embedding-3-large'),
|
||||
('embedding_dimensions', '1536'),
|
||||
('chunk_strategy', 'semantic')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- access_tokens: bearer tokens for remote MCP access
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- mcp_request_log: usage logging for remote MCP requests
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_name TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
page_slug TEXT REFERENCES pages(slug) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
size_bytes BIGINT,
|
||||
content_hash TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(storage_path)
|
||||
);
|
||||
|
||||
-- Migration: drop storage_url if it exists (renamed to storage_path only)
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS storage_url;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_page ON files(page_slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger-based search_vector (spans pages + timeline_entries)
|
||||
-- ============================================================
|
||||
ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
|
||||
|
||||
-- Function to rebuild search_vector for a page
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS \$\$
|
||||
DECLARE
|
||||
timeline_text TEXT;
|
||||
BEGIN
|
||||
-- Gather timeline_entries text for this page
|
||||
SELECT coalesce(string_agg(summary || ' ' || detail, ' '), '')
|
||||
INTO timeline_text
|
||||
FROM timeline_entries
|
||||
WHERE page_id = NEW.id;
|
||||
|
||||
-- Build weighted tsvector
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.compiled_truth, '')), 'B') ||
|
||||
setweight(to_tsvector('english', coalesce(NEW.timeline, '')), 'C') ||
|
||||
setweight(to_tsvector('english', coalesce(timeline_text, '')), 'C');
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
\$\$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_pages_search_vector ON pages;
|
||||
CREATE TRIGGER trg_pages_search_vector
|
||||
BEFORE INSERT OR UPDATE ON pages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_page_search_vector();
|
||||
|
||||
-- When timeline_entries change, update the parent page's search_vector
|
||||
CREATE OR REPLACE FUNCTION update_page_search_vector_from_timeline() RETURNS trigger AS \$\$
|
||||
DECLARE
|
||||
page_row pages%ROWTYPE;
|
||||
BEGIN
|
||||
-- Touch the page to re-fire its trigger
|
||||
UPDATE pages SET updated_at = now()
|
||||
WHERE id = coalesce(NEW.page_id, OLD.page_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
\$\$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_timeline_search_vector ON timeline_entries;
|
||||
CREATE TRIGGER trg_timeline_search_vector
|
||||
AFTER INSERT OR UPDATE OR DELETE ON timeline_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_page_search_vector_from_timeline();
|
||||
|
||||
-- ============================================================
|
||||
-- Row Level Security: block anon access, postgres role bypasses
|
||||
-- ============================================================
|
||||
-- The postgres role (used by gbrain via pooler) has BYPASSRLS.
|
||||
-- Enabling RLS with no policies means the anon key can't read anything.
|
||||
-- Only enable if the current role actually has BYPASSRLS privilege,
|
||||
-- otherwise we'd lock ourselves out.
|
||||
DO \$\$
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE content_chunks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE raw_data ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE timeline_entries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE page_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ingest_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE config ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
|
||||
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
||||
ELSE
|
||||
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
||||
END IF;
|
||||
END \$\$;
|
||||
`;
|
||||
@@ -45,19 +45,25 @@ export function dedupResults(
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 1: Keep only the highest-scoring chunk per page.
|
||||
* Layer 1: Keep top 3 chunks per page (not just 1).
|
||||
* Later layers (text similarity, cap per page) handle further reduction.
|
||||
*/
|
||||
function dedupBySource(results: SearchResult[]): SearchResult[] {
|
||||
const byPage = new Map<string, SearchResult>();
|
||||
const byPage = new Map<string, SearchResult[]>();
|
||||
|
||||
for (const r of results) {
|
||||
const existing = byPage.get(r.slug);
|
||||
if (!existing || r.score > existing.score) {
|
||||
byPage.set(r.slug, r);
|
||||
}
|
||||
const existing = byPage.get(r.slug) || [];
|
||||
existing.push(r);
|
||||
byPage.set(r.slug, existing);
|
||||
}
|
||||
|
||||
return Array.from(byPage.values()).sort((a, b) => b.score - a.score);
|
||||
const kept: SearchResult[] = [];
|
||||
for (const chunks of byPage.values()) {
|
||||
chunks.sort((a, b) => b.score - a.score);
|
||||
kept.push(...chunks.slice(0, 3));
|
||||
}
|
||||
|
||||
return kept.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,79 +1,109 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import type { StorageBackend, StorageConfig } from '../storage.ts';
|
||||
|
||||
/**
|
||||
* S3-compatible storage — works with AWS S3, Cloudflare R2, MinIO, etc.
|
||||
*
|
||||
* Uses fetch() directly against the S3 REST API with AWS Signature V4.
|
||||
* No SDK dependency needed — keeps the binary small.
|
||||
* Uses @aws-sdk/client-s3 for proper authentication and request signing.
|
||||
*/
|
||||
export class S3Storage implements StorageBackend {
|
||||
private client: S3Client;
|
||||
private bucket: string;
|
||||
private region: string;
|
||||
private endpoint: string;
|
||||
private accessKeyId: string;
|
||||
private secretAccessKey: string;
|
||||
|
||||
constructor(config: StorageConfig) {
|
||||
this.bucket = config.bucket;
|
||||
this.region = config.region || 'us-east-1';
|
||||
this.endpoint = config.endpoint || `https://s3.${this.region}.amazonaws.com`;
|
||||
this.accessKeyId = config.accessKeyId || '';
|
||||
this.secretAccessKey = config.secretAccessKey || '';
|
||||
if (!this.accessKeyId || !this.secretAccessKey) {
|
||||
const region = config.region || 'us-east-1';
|
||||
|
||||
if (!config.accessKeyId || !config.secretAccessKey) {
|
||||
throw new Error('S3 storage requires accessKeyId and secretAccessKey in config');
|
||||
}
|
||||
}
|
||||
|
||||
private url(path: string): string {
|
||||
return `${this.endpoint}/${this.bucket}/${path}`;
|
||||
}
|
||||
|
||||
private async signedFetch(method: string, path: string, body?: Buffer, mime?: string): Promise<Response> {
|
||||
// Simplified S3 request — for production, use proper AWS Sig V4
|
||||
// For now, works with public buckets and pre-signed URLs
|
||||
const url = this.url(path);
|
||||
const headers: Record<string, string> = {};
|
||||
if (mime) headers['Content-Type'] = mime;
|
||||
|
||||
return fetch(url, { method, body, headers });
|
||||
this.client = new S3Client({
|
||||
region,
|
||||
...(config.endpoint ? {
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: true, // Required for R2, MinIO, and custom endpoints
|
||||
} : {}),
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async upload(path: string, data: Buffer, mime?: string): Promise<void> {
|
||||
const res = await this.signedFetch('PUT', path, data, mime || 'application/octet-stream');
|
||||
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`);
|
||||
await this.client.send(new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
Body: data,
|
||||
ContentType: mime || 'application/octet-stream',
|
||||
}));
|
||||
}
|
||||
|
||||
async download(path: string): Promise<Buffer> {
|
||||
const res = await this.signedFetch('GET', path);
|
||||
if (!res.ok) throw new Error(`S3 download failed: ${res.status} ${res.statusText}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
const res = await this.client.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
}));
|
||||
if (!res.Body) throw new Error(`S3 download returned empty body: ${path}`);
|
||||
return Buffer.from(await res.Body.transformToByteArray());
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
const res = await this.signedFetch('DELETE', path);
|
||||
if (!res.ok && res.status !== 404) throw new Error(`S3 delete failed: ${res.status}`);
|
||||
await this.client.send(new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
}));
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
const res = await this.signedFetch('HEAD', path);
|
||||
return res.ok;
|
||||
try {
|
||||
await this.client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
}));
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
const url = `${this.endpoint}/${this.bucket}?list-type=2&prefix=${encodeURIComponent(prefix)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`S3 list failed: ${res.status}`);
|
||||
const xml = await res.text();
|
||||
const keys: string[] = [];
|
||||
const regex = /<Key>([^<]+)<\/Key>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
keys.push(match[1]);
|
||||
}
|
||||
return keys;
|
||||
const res = await this.client.send(new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
}));
|
||||
return (res.Contents || []).map(obj => obj.Key!).filter(Boolean);
|
||||
}
|
||||
|
||||
async getUrl(path: string): Promise<string> {
|
||||
return this.url(path);
|
||||
// For custom endpoints (R2, MinIO), use the endpoint URL
|
||||
const endpoint = (this.client.config as any).endpoint;
|
||||
if (endpoint) {
|
||||
const base = typeof endpoint === 'function' ? (await endpoint()).url.toString() : endpoint;
|
||||
return `${base}/${this.bucket}/${path}`;
|
||||
}
|
||||
const region = await this.client.config.region();
|
||||
return `https://${this.bucket}.s3.${region}.amazonaws.com/${path}`;
|
||||
}
|
||||
|
||||
async getContentHash(path: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await this.client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: path,
|
||||
}));
|
||||
// ETag is typically the MD5 hash (quoted), but for multipart uploads it's different
|
||||
return res.ETag?.replace(/"/g, '') || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,5 +131,5 @@ export function slugifyPath(filePath: string): string {
|
||||
export function pathToSlug(filePath: string, repoPrefix?: string): string {
|
||||
let slug = slugifyPath(filePath);
|
||||
if (repoPrefix) slug = `${repoPrefix}/${slug}`;
|
||||
return slug;
|
||||
return slug.toLowerCase();
|
||||
}
|
||||
|
||||
16
src/edge-entry.ts
Normal file
16
src/edge-entry.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Edge Function bundle entry point.
|
||||
*
|
||||
* Curated exports for Supabase Edge Functions (Deno runtime).
|
||||
* Excludes modules that depend on Node.js filesystem APIs:
|
||||
* - db.ts (reads schema.sql from disk — now uses schema-embedded.ts)
|
||||
* - config.ts (reads ~/.gbrain/config.json via homedir())
|
||||
* - import-file.ts (uses readFileSync/statSync)
|
||||
* - sync.ts (git-based, local filesystem)
|
||||
*/
|
||||
export { operations, operationsByName, OperationError } from './core/operations.ts';
|
||||
export type { Operation, OperationContext, ParamDef } from './core/operations.ts';
|
||||
export { PostgresEngine } from './core/postgres-engine.ts';
|
||||
export type { BrainEngine } from './core/engine.ts';
|
||||
export * from './core/types.ts';
|
||||
export { VERSION } from './version.ts';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { operations, OperationError } from '../core/operations.ts';
|
||||
import type { OperationContext } from '../core/operations.ts';
|
||||
@@ -13,7 +14,7 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
);
|
||||
|
||||
// Generate tool definitions from operations
|
||||
server.setRequestHandler('tools/list' as any, async () => ({
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: operations.map(op => ({
|
||||
name: op.name,
|
||||
description: op.description,
|
||||
@@ -35,7 +36,7 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
}));
|
||||
|
||||
// Dispatch tool calls to operation handlers
|
||||
server.setRequestHandler('tools/call' as any, async (request: any) => {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
||||
const { name, arguments: params } = request.params;
|
||||
const op = operations.find(o => o.name === name);
|
||||
if (!op) {
|
||||
@@ -82,7 +83,7 @@ export async function handleToolCall(
|
||||
engine,
|
||||
config: loadConfig() || { engine: 'postgres' },
|
||||
logger: { info: console.log, warn: console.warn, error: console.error },
|
||||
dryRun: false,
|
||||
dryRun: !!(params?.dry_run),
|
||||
};
|
||||
|
||||
return op.handler(ctx, params);
|
||||
|
||||
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS content_chunks (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_page ON content_chunks(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
@@ -141,6 +142,33 @@ INSERT INTO config (key, value) VALUES
|
||||
('chunk_strategy', 'semantic')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- access_tokens: bearer tokens for remote MCP access
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- mcp_request_log: usage logging for remote MCP requests
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_name TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
-- ============================================================
|
||||
|
||||
10
supabase/functions/gbrain-mcp/deno.json
Normal file
10
supabase/functions/gbrain-mcp/deno.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"imports": {
|
||||
"postgres": "npm:postgres@3",
|
||||
"openai": "npm:openai@4",
|
||||
"@modelcontextprotocol/sdk/": "npm:@modelcontextprotocol/sdk@1/",
|
||||
"hono": "npm:hono@4",
|
||||
"hono/cors": "npm:hono@4/cors",
|
||||
"crypto": "node:crypto"
|
||||
}
|
||||
}
|
||||
540
supabase/functions/gbrain-mcp/gbrain-core.js
Normal file
540
supabase/functions/gbrain-mcp/gbrain-core.js
Normal file
File diff suppressed because one or more lines are too long
288
supabase/functions/gbrain-mcp/index.ts
Normal file
288
supabase/functions/gbrain-mcp/index.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* GBrain Remote MCP Server — Supabase Edge Function
|
||||
*
|
||||
* Exposes GBrain operations as remote MCP tools via Streamable HTTP transport.
|
||||
* Auth via bearer tokens stored in access_tokens table (SHA-256 hashed).
|
||||
*
|
||||
* Deploy: supabase functions deploy gbrain-mcp --no-verify-jwt
|
||||
* URL: https://<project>.supabase.co/functions/v1/gbrain-mcp/mcp
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import postgres from 'postgres';
|
||||
import { createHash } from 'crypto';
|
||||
import { operations, OperationError, PostgresEngine, VERSION } from './gbrain-core.js';
|
||||
import type { OperationContext } from './gbrain-core.js';
|
||||
|
||||
// Operations excluded from remote (may exceed 60s Edge Function timeout)
|
||||
const REMOTE_EXCLUDED = new Set(['sync_brain', 'file_upload']);
|
||||
const remoteOps = operations.filter((op: any) => !REMOTE_EXCLUDED.has(op.name));
|
||||
|
||||
// Database connection (lazy, one per isolate)
|
||||
let engine: PostgresEngine | null = null;
|
||||
let sql: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
function getDbUrl(): string {
|
||||
// @ts-ignore: Deno env
|
||||
return Deno.env.get('SUPABASE_DB_URL') || Deno.env.get('DATABASE_URL') || '';
|
||||
}
|
||||
|
||||
function getOpenAiKey(): string {
|
||||
// @ts-ignore: Deno env
|
||||
return Deno.env.get('OPENAI_API_KEY') || '';
|
||||
}
|
||||
|
||||
async function getEngine(): Promise<PostgresEngine> {
|
||||
if (!engine) {
|
||||
engine = new PostgresEngine();
|
||||
await engine.connect({ database_url: getDbUrl(), poolSize: 1 });
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
function getDirectSql(): ReturnType<typeof postgres> {
|
||||
if (!sql) {
|
||||
sql = postgres(getDbUrl(), { max: 1 });
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
// Auth: check bearer token against access_tokens table
|
||||
async function authenticateToken(authHeader: string | null): Promise<{ valid: boolean; name?: string; error?: string; status?: number }> {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'missing_auth',
|
||||
message: "Authorization header required. Use 'Bearer <token>' format.",
|
||||
docs: 'docs/mcp/DEPLOY.md#authentication',
|
||||
}),
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const hash = createHash('sha256').update(token).digest('hex');
|
||||
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
const rows = await conn`
|
||||
SELECT name, revoked_at FROM access_tokens
|
||||
WHERE token_hash = ${hash}
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'invalid_token',
|
||||
message: "Token not recognized. Run 'bun run src/commands/auth.ts list' to see active tokens.",
|
||||
docs: 'docs/mcp/DEPLOY.md#troubleshooting',
|
||||
}),
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (rows[0].revoked_at) {
|
||||
const revokedDate = new Date(rows[0].revoked_at as string).toISOString().slice(0, 10);
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'token_revoked',
|
||||
message: `This token was revoked on ${revokedDate}. Create a new one with 'bun run src/commands/auth.ts create <name>'.`,
|
||||
docs: 'docs/mcp/DEPLOY.md#token-management',
|
||||
}),
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last_used_at
|
||||
const conn2 = getDirectSql();
|
||||
await conn2`UPDATE access_tokens SET last_used_at = now() WHERE token_hash = ${hash}`;
|
||||
|
||||
return { valid: true, name: rows[0].name as string };
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'service_unavailable',
|
||||
message: 'Database connection failed. Check Supabase dashboard for status.',
|
||||
docs: 'docs/mcp/DEPLOY.md#troubleshooting',
|
||||
}),
|
||||
status: 503,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Log MCP request for auditing
|
||||
async function logRequest(tokenName: string, operation: string, latencyMs: number, status: string) {
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`
|
||||
INSERT INTO mcp_request_log (token_name, operation, latency_ms, status)
|
||||
VALUES (${tokenName}, ${operation}, ${latencyMs}, ${status})
|
||||
`;
|
||||
} catch {
|
||||
// Best effort, don't crash on log failure
|
||||
console.error('[gbrain-mcp] Failed to log request');
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP Server with tool handlers
|
||||
function createMcpServer(eng: PostgresEngine): Server {
|
||||
const server = new Server(
|
||||
{ name: 'gbrain', version: VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: remoteOps.map((op: any) => ({
|
||||
name: op.name,
|
||||
description: op.description,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(op.params).map(([k, v]: [string, any]) => [k, {
|
||||
type: v.type === 'array' ? 'array' : v.type,
|
||||
...(v.description ? { description: v.description } : {}),
|
||||
...(v.enum ? { enum: v.enum } : {}),
|
||||
...(v.items ? { items: { type: v.items.type } } : {}),
|
||||
}]),
|
||||
),
|
||||
required: Object.entries(op.params)
|
||||
.filter(([, v]: [string, any]) => v.required)
|
||||
.map(([k]: [string, any]) => k),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
||||
const { name, arguments: params } = request.params;
|
||||
const op = remoteOps.find((o: any) => o.name === name);
|
||||
if (!op) {
|
||||
return { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true };
|
||||
}
|
||||
|
||||
const ctx: OperationContext = {
|
||||
engine: eng,
|
||||
config: {
|
||||
engine: 'postgres',
|
||||
database_url: getDbUrl(),
|
||||
openai_api_key: getOpenAiKey(),
|
||||
},
|
||||
logger: {
|
||||
info: (msg: string) => console.log(`[info] ${msg}`),
|
||||
warn: (msg: string) => console.warn(`[warn] ${msg}`),
|
||||
error: (msg: string) => console.error(`[error] ${msg}`),
|
||||
},
|
||||
dryRun: !!(params?.dry_run),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await op.handler(ctx, params || {});
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof OperationError) {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(e.toJSON(), null, 2) }], isError: true };
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Hono app — routes: /mcp (MCP transport), /health (monitoring)
|
||||
const app = new Hono().basePath('/gbrain-mcp');
|
||||
|
||||
app.use('/*', cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id', 'Mcp-Protocol-Version', 'Last-Event-ID'],
|
||||
exposeHeaders: ['Mcp-Session-Id'],
|
||||
}));
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
// Unauth: minimal response
|
||||
if (!authHeader) {
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`SELECT 1`;
|
||||
return c.json({ status: 'ok', version: VERSION });
|
||||
} catch {
|
||||
return c.json({ status: 'error' }, 503);
|
||||
}
|
||||
}
|
||||
|
||||
// Auth: detailed checks
|
||||
const auth = await authenticateToken(authHeader);
|
||||
if (!auth.valid) return c.json({ status: 'error' }, auth.status);
|
||||
|
||||
const checks: Record<string, string> = {};
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`SELECT 1`;
|
||||
checks.postgres = 'ok';
|
||||
} catch {
|
||||
checks.postgres = 'error';
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
|
||||
checks.pgvector = ext.length > 0 ? 'ok' : 'missing';
|
||||
} catch {
|
||||
checks.pgvector = 'error';
|
||||
}
|
||||
|
||||
checks.openai = getOpenAiKey() ? 'configured' : 'missing';
|
||||
|
||||
const status = Object.values(checks).every(v => v === 'ok' || v === 'configured') ? 'ok' : 'degraded';
|
||||
return c.json({ status, version: VERSION, checks });
|
||||
});
|
||||
|
||||
// MCP endpoint
|
||||
app.all('/mcp', async (c) => {
|
||||
// Auth check
|
||||
const auth = await authenticateToken(c.req.header('Authorization') || null);
|
||||
if (!auth.valid) {
|
||||
return new Response(auth.error, {
|
||||
status: auth.status || 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const eng = await getEngine();
|
||||
const server = createMcpServer(eng);
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
// Stateless mode — no sessions needed for single-user personal brain
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
try {
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
|
||||
// Log the request (await to ensure it completes before isolate dies)
|
||||
const latency = Date.now() - startTime;
|
||||
await logRequest(auth.name || 'unknown', 'mcp_request', latency, 'success');
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
const latency = Date.now() - startTime;
|
||||
await logRequest(auth.name || 'unknown', 'mcp_request', latency, 'error');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore: Deno.serve
|
||||
Deno.serve(app.fetch);
|
||||
@@ -524,7 +524,7 @@ describeE2E('E2E: Setup Journey', () => {
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(stdout).toContain('Brain ready');
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('gbrain import imports fixtures via CLI', () => {
|
||||
const result = Bun.spawnSync({
|
||||
@@ -536,7 +536,7 @@ describeE2E('E2E: Setup Journey', () => {
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(stdout).toContain('imported');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('gbrain search returns results via CLI', () => {
|
||||
const result = Bun.spawnSync({
|
||||
@@ -548,7 +548,7 @@ describeE2E('E2E: Setup Journey', () => {
|
||||
const stdout = new TextDecoder().decode(result.stdout);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('gbrain stats shows page count via CLI', () => {
|
||||
const result = Bun.spawnSync({
|
||||
@@ -558,7 +558,7 @@ describeE2E('E2E: Setup Journey', () => {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('gbrain health runs via CLI', () => {
|
||||
const result = Bun.spawnSync({
|
||||
@@ -568,7 +568,7 @@ describeE2E('E2E: Setup Journey', () => {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -787,7 +787,7 @@ describeE2E('E2E: Doctor Command', () => {
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('gbrain doctor --json produces valid JSON', () => {
|
||||
const result = Bun.spawnSync({
|
||||
@@ -806,7 +806,7 @@ describeE2E('E2E: Doctor Command', () => {
|
||||
expect(typeof check.name).toBe('string');
|
||||
expect(typeof check.message).toBe('string');
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -851,7 +851,7 @@ describeE2E('E2E: Parallel Import', () => {
|
||||
|
||||
expect(seqPageCount).toBeGreaterThan(0);
|
||||
expect(seqChunkCount).toBeGreaterThan(0);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('parallel import with --workers 2 matches sequential page count', async () => {
|
||||
await setupDB();
|
||||
@@ -866,7 +866,7 @@ describeE2E('E2E: Parallel Import', () => {
|
||||
|
||||
const stats = await callOp('get_stats') as any;
|
||||
expect(stats.page_count).toBe(seqPageCount);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('parallel import has same chunk count (no duplicates)', async () => {
|
||||
const stats = await callOp('get_stats') as any;
|
||||
@@ -912,7 +912,7 @@ describeE2E('E2E: Parallel Import', () => {
|
||||
const stats = await callOp('get_stats') as any;
|
||||
expect(stats.page_count).toBe(seqPageCount);
|
||||
expect(stats.chunk_count).toBe(seqChunkCount);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('re-import with workers is idempotent', async () => {
|
||||
// Import again on top of existing data
|
||||
@@ -927,7 +927,7 @@ describeE2E('E2E: Parallel Import', () => {
|
||||
const stats = await callOp('get_stats') as any;
|
||||
expect(stats.page_count).toBe(seqPageCount);
|
||||
expect(stats.chunk_count).toBe(seqChunkCount);
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('pathToSlug', () => {
|
||||
expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi');
|
||||
});
|
||||
|
||||
test('lowercases uppercase paths', () => {
|
||||
test('normalizes to lowercase', () => {
|
||||
expect(pathToSlug('People/Pedro-Franceschi.md')).toBe('people/pedro-franceschi');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user