diff --git a/README.md b/README.md index d3f443b68..255876e24 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ packages. For the CLI, see the [CLI guide](docs/cli/README.md) and the published documentation at . The [documentation index](docs/README.md) covers both products and shared contributor guidance. +The independent [Docs MCP Worker](apps/docs-mcp/README.md) provides documentation +search and AI-generated answers backed by `docs.sentry.io`. + ## Sentry MCP Sentry's MCP service is primarily designed for human-in-the-loop coding agents. Our tool selection and priorities are focused on developer workflows and debugging use cases, rather than providing a general-purpose MCP server for all Sentry functionality. diff --git a/apps/docs-mcp/.env.example b/apps/docs-mcp/.env.example new file mode 100644 index 000000000..5f4687fc2 --- /dev/null +++ b/apps/docs-mcp/.env.example @@ -0,0 +1,3 @@ +# Required — routes both the primary and fallback docs agent models. +# Get one at https://openrouter.ai/keys. +OPENROUTER_API_KEY= diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md new file mode 100644 index 000000000..25e59de84 --- /dev/null +++ b/apps/docs-mcp/README.md @@ -0,0 +1,106 @@ +# Docs MCP + +An independent Cloudflare Worker for Sentry documentation search, cited answers, +and tailored SDK integration guides. It reads the Markdown exports and doctree +from `docs.sentry.io`; AI-backed operations use OpenRouter through Mastra. + +Imported from [getsentry/cli-init-api at f3b32b49](https://github.com/getsentry/cli-init-api/tree/f3b32b49ebaa5eb13378e1e5ab9f05be2f39ba5d/apps/docs-mcp). +The Worker has no build or runtime dependency on Init Server. Its Cloudflare name +remains `docs-mcp`. + +## Interfaces + +Production access is through Cloudflare service bindings. `workers.dev` and +preview URLs are disabled. Both docs endpoints accept the `docs-mcp.internal` +origin used by the binding, or localhost during development. + +| Endpoint | Contract | +| --------------------- | ----------------------------------------------------------- | +| `GET /health` | Readiness response `{ "status": "ok" }`. | +| `/mcp` | Streamable HTTP MCP with the four tools below. | +| `POST /internal/docs` | JSON API with `operation: "search-docs"` or `"query-docs"`. | + +| MCP tool | Input | Output inside text content | +| ------------------------ | ------------------------------------------- | ---------------------------------------------------------------- | +| `search-docs` | `query`, optional `limit` | JSON `{ results: [{ title, url, description? }] }`; no LLM call. | +| `query-docs` | `query`, optional safe project `context` | JSON `{ answer, sources }` with validated citations. | +| `get-available-features` | `libs` | JSON `{ features }` with canonical feature slugs. | +| `get-docs` | `libs`, `features`, optional `stackSummary` | Tailored Markdown integration guide. | + +The internal API uses the same search and query implementations. Query context is +limited to `frameworks`, `languages`, and `sentryConfigured`. Model failures and +unverifiable answers retain the safe `DOCS_MODEL_UNAVAILABLE` and +`DOCS_UNGROUNDED` error codes. + +The existing `sentry docs` CLI calls Init Server's authenticated `/api/docs/*` +facade, which forwards requests through its `DOCS_MCP` binding. Moving this app +does not change that contract. Retiring Init Server would require a separate +decision about the CLI's entry point; it would not stop this Worker. Toolkit's +main MCP server has its own `search_docs` and `get_doc` tools and does not consume +this Worker. + +## Development + +From the Toolkit root: + +```sh +pnpm install +cp apps/docs-mcp/.env.example apps/docs-mcp/.dev.vars +# Set OPENROUTER_API_KEY in the ignored .dev.vars file for AI-backed calls. +pnpm --filter docs-mcp dev +``` + +The server listens on `http://localhost:8788`. Search and readiness checks do not +require an OpenRouter key: + +```sh +curl http://localhost:8788/health +curl http://localhost:8788/internal/docs \ + -H 'Content-Type: application/json' \ + -d '{"operation":"search-docs","query":"tracing","limit":3}' +``` + +Validation runs without model credentials: + +```sh +pnpm --filter docs-mcp test +pnpm --filter docs-mcp typecheck +pnpm --filter docs-mcp build +``` + +`build` only bundles with `wrangler deploy --dry-run`; it does not deploy. +Toolkit's workspace CI discovers the app's build, typecheck, and `test:ci` +scripts automatically. Keep the app's AI SDK 5 and Sentry 10 dependencies +separate from Toolkit's newer catalogs until a dedicated upgrade is validated. +Wrangler follows the shared workspace catalog used by MCP and CLI docs. +The `execa` alias excludes Mastra's unsupported local-process execution path from +the Worker bundle. + +## Deployment ownership + +Importing this app does not change Cloudflare or enable a new deployment workflow. +The existing Worker is connected to the source repository through Workers Builds. +Toolkit's MCP deployment workflow targets `sentry-mcp`, not this app. + +When a production cutover is scheduled: + +1. Record the existing Worker's account, build settings, runtime secrets, and + active version. Keep the name `docs-mcp`, compatibility settings, private + access configuration, and `OPENROUTER_API_KEY` runtime secret. +2. Disconnect the old repository under the existing Worker's **Settings → + Builds**. Ensure no old build remains in flight before enabling a new deploy. +3. Connect that same Worker to `getsentry/toolkit`, with the repository root as + its root directory. Set build variables `NODE_VERSION=22.23.4`, + `PNPM_VERSION=11.8.0`, and `SKIP_DEPENDENCY_INSTALL=1`. Use + `pnpm install --frozen-lockfile && pnpm --filter docs-mcp run build` as the + build command and `pnpm --filter docs-mcp run deploy` as the deploy command. + Watch paths must cover this app, the root `package.json`, lockfile/workspace + configuration, and patches. Review preview settings so the private service + is not exposed publicly. +4. Deploy the tested revision and verify its active version and service-binding + calls before removing the source repository's copy. + +The caller's `service: "docs-mcp"` binding still identifies the same Worker in +the same account. No consumer URL change is required. Runtime secrets belong to +the Worker and are separate from build secrets; never copy secret values into +this repository. See [Cloudflare's repository-switch procedure](https://developers.cloudflare.com/workers/ci-cd/builds/#disconnecting-builds). diff --git a/apps/docs-mcp/package.json b/apps/docs-mcp/package.json new file mode 100644 index 000000000..5f619ef1e --- /dev/null +++ b/apps/docs-mcp/package.json @@ -0,0 +1,30 @@ +{ + "name": "docs-mcp", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --port 8788", + "build": "wrangler deploy --dry-run", + "deploy": "wrangler deploy --minify", + "generate:cf-types": "wrangler types --env-interface CloudflareBindings", + "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/mcp": "^0.2.3", + "@mastra/core": "^1.63.2", + "@modelcontextprotocol/sdk": "^1.31.0", + "@openrouter/ai-sdk-provider": "^1.5.4", + "@sentry/cloudflare": "^10.62.0", + "ai": "^5.0.0", + "hono": "^4.13.11", + "zod": "^4.5.4" + }, + "devDependencies": { + "@types/node": "^22", + "typescript": "^5.9.3", + "wrangler": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/docs-mcp/src/__tests__/access.test.ts b/apps/docs-mcp/src/__tests__/access.test.ts new file mode 100644 index 000000000..880a01ce2 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/access.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { isDocsMcpServiceRequest } from "../access"; + +describe("isDocsMcpServiceRequest", () => { + test.each([ + "https://docs-mcp.internal/mcp", + "http://localhost:8788/mcp", + "http://127.0.0.1:8788/mcp", + ])("allows the service-binding or local development origin: %s", (url) => { + expect(isDocsMcpServiceRequest(new Request(url))).toBe(true); + }); + + test.each([ + "https://docs-mcp.getsentry.workers.dev/mcp", + "https://preview-docs-mcp.getsentry.workers.dev/mcp", + ])("rejects public worker origins: %s", (url) => { + expect(isDocsMcpServiceRequest(new Request(url))).toBe(false); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/doctree.test.ts b/apps/docs-mcp/src/__tests__/doctree.test.ts new file mode 100644 index 000000000..a0835c1b6 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/doctree.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; +import { MAX_SEARCH_TERMS, searchDocTree } from "../sentry-docs/doctree"; + +describe("searchDocTree", () => { + test("bounds token scoring for long natural-language questions", () => { + expect(MAX_SEARCH_TERMS).toBeLessThanOrEqual(24); + }); + + test("ranks matching visible pages and returns canonical docs URLs", () => { + const results = searchDocTree( + { + children: [ + { + frontmatter: { + description: "Set up Sentry for Next.js applications.", + title: "Next.js", + }, + path: "/platforms/javascript/guides/nextjs/", + }, + { + frontmatter: { + description: "Configure Next.js tracing.", + title: "Tracing", + }, + path: "/platforms/javascript/guides/nextjs/tracing/", + }, + { + frontmatter: { sidebar_hidden: true, title: "Hidden" }, + path: "/internal/nextjs/", + }, + ], + path: "/", + }, + "nextjs", + 1, + ); + + expect(results).toEqual([ + { + description: "Set up Sentry for Next.js applications.", + title: "Next.js", + url: "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + }, + ]); + }); + + test("breaks equal-score ties by path", () => { + const results = searchDocTree( + { + children: [ + { frontmatter: { title: "Tracing" }, path: "/z/tracing/" }, + { frontmatter: { title: "Tracing" }, path: "/a/tracing/" }, + ], + path: "/", + }, + "tracing", + 2, + ); + + expect(results.map((result) => result.url)).toEqual([ + "https://docs.sentry.io/a/tracing/", + "https://docs.sentry.io/z/tracing/", + ]); + }); + + test("finds relevant pages for a natural-language docs question", () => { + const results = searchDocTree( + { + children: [ + { + frontmatter: { + description: "Set up Sentry for Next.js applications.", + title: "Next.js", + }, + path: "/platforms/javascript/guides/nextjs/", + }, + { + frontmatter: { + description: "Configure tracing in your Next.js app.", + title: "Tracing", + }, + path: "/platforms/javascript/guides/nextjs/tracing/", + }, + ], + path: "/", + }, + "How do I configure Next.js tracing?", + 1, + ); + + expect(results).toEqual([ + { + description: "Configure tracing in your Next.js app.", + title: "Tracing", + url: "https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/", + }, + ]); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/fetcher.test.ts b/apps/docs-mcp/src/__tests__/fetcher.test.ts new file mode 100644 index 000000000..5af00c9b5 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/fetcher.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; +import { normalizeDocPath } from "../sentry-docs/fetcher"; + +describe("normalizeDocPath", () => { + test.each([ + ["/platforms/javascript/", "/platforms/javascript"], + ["/platforms/javascript", "/platforms/javascript"], + ["platforms/javascript", "/platforms/javascript"], + ["https://docs.sentry.io/platforms/javascript/", "/platforms/javascript"], + ["/platforms/javascript/.md", "/platforms/javascript"], + ["https://docs.sentry.io", "/"], + ["https://docs.sentry.io/", "/"], + ])("normalizes supported docs input %s", (input, expected) => { + expect(normalizeDocPath(input)).toBe(expected); + }); + + test.each([ + "https://docs.sentry.io.example.com/platforms/javascript", + "https://docs.sentry.io@example.com/platforms/javascript", + ])("does not strip the docs prefix from a different origin: %s", (input) => { + expect(normalizeDocPath(input)).toBe(`/${input}`); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/internal-api.test.ts b/apps/docs-mcp/src/__tests__/internal-api.test.ts new file mode 100644 index 000000000..cfbb1ae86 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/internal-api.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { executeInternalDocsRequest } = vi.hoisted(() => ({ + executeInternalDocsRequest: vi.fn(), +})); + +vi.mock("../internal-api", async (importOriginal) => ({ + ...(await importOriginal()), + executeInternalDocsRequest, +})); + +import { app } from "../index"; + +describe("private docs API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("rejects requests outside the service binding", async () => { + const response = await app.request( + "https://docs-mcp.getsentry.workers.dev/internal/docs", + { + body: JSON.stringify({ operation: "search-docs", query: "tracing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + ); + + expect(response.status).toBe(403); + }); + + test("executes a validated request through the service binding", async () => { + executeInternalDocsRequest.mockResolvedValue({ + results: [{ title: "Tracing", url: "https://docs.sentry.io/tracing/" }], + }); + + const response = await app.request( + "https://docs-mcp.internal/internal/docs", + { + body: JSON.stringify({ operation: "search-docs", query: "tracing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + { + CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + results: [{ title: "Tracing", url: "https://docs.sentry.io/tracing/" }], + }); + expect(executeInternalDocsRequest).toHaveBeenCalledWith( + undefined, + { operation: "search-docs", query: "tracing" }, + "test", + ); + }); + + test("returns a safe code when a docs answer cannot be citation-verified", async () => { + executeInternalDocsRequest.mockRejectedValue( + new Error("Docs citation was not fetched: https://docs.sentry.io/"), + ); + + const response = await app.request( + "https://docs-mcp.internal/internal/docs", + { + body: JSON.stringify({ operation: "query-docs", query: "tracing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + { + CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, + }, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + code: "DOCS_UNGROUNDED", + error: "Docs API request failed", + }); + }); + + test("returns a safe code when no configured model is available in the region", async () => { + executeInternalDocsRequest.mockRejectedValue( + new Error("This model is not available in your region."), + ); + + const response = await app.request( + "https://docs-mcp.internal/internal/docs", + { + body: JSON.stringify({ operation: "query-docs", query: "tracing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + { + CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, + }, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + code: "DOCS_MODEL_UNAVAILABLE", + error: "Docs API request failed", + }); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/mcp.test.ts b/apps/docs-mcp/src/__tests__/mcp.test.ts new file mode 100644 index 000000000..c9fde62d2 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/mcp.test.ts @@ -0,0 +1,50 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { describe, expect, test } from "vitest"; +import { app } from "../index"; + +describe("private MCP transport", () => { + test("initializes and lists the docs tools through the service binding", async () => { + const client = new Client({ name: "docs-mcp-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL("https://docs-mcp.internal/mcp"), + { + fetch: async (input, init) => + await app.request(new Request(input, init), undefined, { + CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, + }), + }, + ); + + try { + await client.connect(transport); + const { tools } = await client.listTools(); + + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "get-available-features", + "get-docs", + "query-docs", + "search-docs", + ]); + expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe( + true, + ); + } finally { + await client.close(); + } + }); + + test("rejects MCP requests outside the service binding", async () => { + const response = await app.request( + "https://docs-mcp.getsentry.workers.dev/mcp", + { + method: "POST", + }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Docs MCP is only available through its service binding", + }); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/query-agent.test.ts b/apps/docs-mcp/src/__tests__/query-agent.test.ts new file mode 100644 index 000000000..c2ac6f719 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/query-agent.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, test, vi } from "vitest"; + +const { fetchDocPage, searchPages } = vi.hoisted(() => ({ + fetchDocPage: vi.fn(), + searchPages: vi.fn(), +})); + +vi.mock("../sentry-docs/doctree", async (importOriginal) => ({ + ...(await importOriginal()), + searchPages, +})); + +vi.mock("../sentry-docs/fetcher", async (importOriginal) => ({ + ...(await importOriginal()), + fetchDocPage, +})); + +import { buildDocsQueryCacheKey } from "../agent/cache"; +import { + finalizeCitedAttempts, + finalizeDocsAnswer, + QUERY_MODEL_SETTINGS, + QUERY_SYSTEM_PROMPT, + recoverQueryFallbackSources, +} from "../agent/query-agent"; + +describe("finalizeDocsAnswer", () => { + test("keeps fetched inline citations and appends a deterministic sources section", () => { + const result = finalizeDocsAnswer( + "Configure tracing as described in [the guide](https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/).", + [ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + ], + ); + + expect(result.sources).toEqual([ + "https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/", + "https://docs.sentry.io/platforms/javascript/guides/nextjs/", + ]); + expect(result.answer).toContain( + "[the guide](https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/)", + ); + expect( + result.answer.endsWith( + "## Sources\n\n- \n- ", + ), + ).toBe(true); + }); + + test("does not strip an answer heading that merely starts with Sources", () => { + const url = "https://docs.sentry.io/platforms/javascript/"; + const answer = `## Sources of Error\n\nRead [the guide](${url}).`; + + expect(finalizeDocsAnswer(answer, [url]).answer).toContain( + "## Sources of Error", + ); + }); + + test("replaces an exact model-provided Sources section", () => { + const url = "https://docs.sentry.io/platforms/javascript/"; + + expect( + finalizeDocsAnswer( + `Read [the guide](${url}).\n\n## Sources\n\n- `, + [url], + ).answer, + ).toBe(`Read [the guide](${url}).\n\n## Sources\n\n- <${url}>`); + }); + + test("accepts bare fetched URLs followed by sentence punctuation", () => { + const url = + "https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/"; + + expect( + finalizeDocsAnswer( + `Read [Configure tracing](${url}) for setup details. The canonical URL is ${url}, then follow the guide.`, + [url], + ), + ).toEqual({ + answer: `Read [Configure tracing](${url}) for setup details. The canonical URL is ${url}, then follow the guide.\n\n## Sources\n\n- <${url}>`, + sources: [url], + }); + }); + + test("rejects a docs citation that was not fetched", () => { + expect(() => + finalizeDocsAnswer( + "See [the guide](https://docs.sentry.io/platforms/python/).", + ["https://docs.sentry.io/platforms/javascript/"], + ), + ).toThrow("was not fetched"); + }); + + test("rejects an external inline citation", () => { + expect(() => + finalizeDocsAnswer("See [an external page](https://example.com/).", [ + "https://docs.sentry.io/platforms/javascript/", + ]), + ).toThrow("not an allowed Sentry docs URL"); + }); + + test("rejects an unfetched URL even when it is not Markdown link syntax", () => { + expect(() => + finalizeDocsAnswer( + "See [the guide](https://docs.sentry.io/platforms/javascript/) and https://example.com/.", + ["https://docs.sentry.io/platforms/javascript/"], + ), + ).toThrow("not an allowed Sentry docs URL"); + }); + + test("ignores URL-like values in quoted code strings", () => { + const docsUrl = "https://docs.sentry.io/platforms/javascript/"; + const answer = `Set \`dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'\`, then follow [the guide](${docsUrl}).`; + + expect(finalizeDocsAnswer(answer, [docsUrl])).toEqual({ + answer: `${answer}\n\n## Sources\n\n- <${docsUrl}>`, + sources: [docsUrl], + }); + }); + + test("rejects an answer without an inline fetched citation", () => { + expect(() => + finalizeDocsAnswer("Configure the SDK.", [ + "https://docs.sentry.io/platforms/javascript/", + ]), + ).toThrow("inline citation"); + }); +}); + +describe("buildDocsQueryCacheKey", () => { + test("normalizes a question and safe context without using raw project data", () => { + expect( + buildDocsQueryCacheKey(" How do I trace requests? ", { + frameworks: ["Next.js", "react"], + languages: ["TypeScript"], + sentryConfigured: true, + }), + ).toBe( + buildDocsQueryCacheKey("how do\nI trace requests?", { + frameworks: ["react", "next.js"], + languages: ["typescript"], + sentryConfigured: true, + }), + ); + }); + + test("separates cached answers by the deployed docs version", () => { + const context = { frameworks: ["nextjs"] }; + + expect( + buildDocsQueryCacheKey("How do I trace requests?", context, "version-a"), + ).not.toBe( + buildDocsQueryCacheKey("How do I trace requests?", context, "version-b"), + ); + }); +}); + +describe("query answer defaults", () => { + test("instruct the model to produce a short answer within a bounded output budget", () => { + expect(QUERY_MODEL_SETTINGS.maxOutputTokens).toBeLessThanOrEqual(700); + expect(QUERY_SYSTEM_PROMPT).toContain("at most 6 short bullet points"); + }); +}); + +describe("finalizeCitedAttempts", () => { + test("requires a recovery attempt to cite only pages it fetched itself", async () => { + const firstUrl = "https://docs.sentry.io/platforms/javascript/"; + const retryUrl = + "https://docs.sentry.io/platforms/javascript/guides/nextjs/"; + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ + fetchedUrls: [firstUrl], + text: `See [the guide](${retryUrl}).`, + }) + .mockResolvedValueOnce({ + fetchedUrls: [retryUrl], + text: `See [the guide](${retryUrl}).`, + }); + + await expect(finalizeCitedAttempts(generateAttempt)).resolves.toEqual({ + cacheable: true, + citationFallback: false, + citationFallbackReason: null, + citationRetries: 1, + finalized: { + answer: `See [the guide](${retryUrl}).\n\n## Sources\n\n- <${retryUrl}>`, + sources: [retryUrl], + }, + }); + expect(generateAttempt).toHaveBeenCalledTimes(2); + }); + + test("retries after an empty first draft", async () => { + const retryUrl = + "https://docs.sentry.io/platforms/javascript/guides/nextjs/"; + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ fetchedUrls: [], text: " " }) + .mockResolvedValueOnce({ + fetchedUrls: [retryUrl], + text: `See [the guide](${retryUrl}).`, + }); + + await expect(finalizeCitedAttempts(generateAttempt)).resolves.toEqual({ + cacheable: true, + citationFallback: false, + citationFallbackReason: null, + citationRetries: 1, + finalized: { + answer: `See [the guide](${retryUrl}).\n\n## Sources\n\n- <${retryUrl}>`, + sources: [retryUrl], + }, + }); + }); + + test("falls back to retry-fetched docs after an empty retry draft", async () => { + const firstUrl = + "https://docs.sentry.io/platforms/javascript/guides/nextjs/"; + const retryUrl = + "https://docs.sentry.io/platforms/javascript/guides/astro/"; + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ + fetchedUrls: [firstUrl], + text: `See [the guide](${retryUrl}).`, + }) + .mockResolvedValueOnce({ fetchedUrls: [retryUrl], text: " " }); + + await expect(finalizeCitedAttempts(generateAttempt)).resolves.toEqual({ + cacheable: false, + citationFallback: true, + citationFallbackReason: "validation_failed", + citationRetries: 1, + finalized: { + answer: + "I couldn't produce a fully cited answer for that question. Please refer to [the Sentry documentation](https://docs.sentry.io/platforms/javascript/guides/astro/).\n\n## Sources\n\n- ", + sources: [retryUrl], + }, + }); + }); + + test("recovers a grounded fallback when the retry fetched no docs", async () => { + const recoveryUrl = + "https://docs.sentry.io/platforms/javascript/guides/hono/"; + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ fetchedUrls: [], text: " " }) + .mockResolvedValueOnce({ fetchedUrls: [], text: " " }); + const resolveFallbackSources = vi.fn().mockResolvedValue([recoveryUrl]); + + await expect( + finalizeCitedAttempts(generateAttempt, resolveFallbackSources), + ).resolves.toEqual({ + cacheable: false, + citationFallback: true, + citationFallbackReason: "no_fetched_sources", + citationRetries: 1, + finalized: { + answer: + "I couldn't produce a fully cited answer for that question. Please refer to [the Sentry documentation](https://docs.sentry.io/platforms/javascript/guides/hono/).\n\n## Sources\n\n- ", + sources: [recoveryUrl], + }, + }); + expect(resolveFallbackSources).toHaveBeenCalledOnce(); + }); + + test("preserves the verified failure when recovery cannot fetch a source", async () => { + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ fetchedUrls: [], text: " " }) + .mockResolvedValueOnce({ fetchedUrls: [], text: " " }); + const resolveFallbackSources = vi.fn().mockResolvedValue([]); + + await expect( + finalizeCitedAttempts(generateAttempt, resolveFallbackSources), + ).rejects.toThrow("Docs query returned no fetched sources to cite."); + expect(resolveFallbackSources).toHaveBeenCalledOnce(); + }); + + test("returns a grounded fallback when both attempts cite an external signup URL", async () => { + const firstUrl = + "https://docs.sentry.io/platforms/javascript/guides/nextjs/"; + const astroUrl = + "https://docs.sentry.io/platforms/javascript/guides/astro/"; + const generateAttempt = vi + .fn() + .mockResolvedValueOnce({ + fetchedUrls: [firstUrl], + text: "Create an account at [Sentry signup](https://sentry.io/signup/).", + }) + .mockResolvedValueOnce({ + fetchedUrls: [astroUrl], + text: "Start at [Sentry signup](https://sentry.io/signup/).", + }); + + await expect(finalizeCitedAttempts(generateAttempt)).resolves.toEqual({ + cacheable: false, + citationFallback: true, + citationFallbackReason: "external_host", + citationRetries: 1, + finalized: { + answer: + "I couldn't produce a fully cited answer for that question. Please refer to [the Sentry documentation](https://docs.sentry.io/platforms/javascript/guides/astro/).\n\n## Sources\n\n- ", + sources: [astroUrl], + }, + }); + }); +}); + +describe("recoverQueryFallbackSources", () => { + test("returns a source only after the matching docs page is fetched", async () => { + const url = "https://docs.sentry.io/platforms/javascript/guides/hono/"; + searchPages.mockResolvedValue([{ title: "Hono", url }]); + fetchDocPage.mockResolvedValue("# Hono"); + + await expect( + recoverQueryFallbackSources("How do I configure Hono tracing?"), + ).resolves.toEqual([url]); + + expect(searchPages).toHaveBeenCalledWith( + "How do I configure Hono tracing?", + 1, + ); + expect(fetchDocPage).toHaveBeenCalledWith( + "/platforms/javascript/guides/hono", + ); + }); + + test("does not return an unfetched search result", async () => { + searchPages.mockResolvedValue([ + { + title: "Hono", + url: "https://docs.sentry.io/platforms/javascript/guides/hono/", + }, + ]); + fetchDocPage.mockRejectedValue(new Error("origin unavailable")); + + await expect( + recoverQueryFallbackSources("How do I configure Hono tracing?"), + ).resolves.toEqual([]); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/tailor-agent.test.ts b/apps/docs-mcp/src/__tests__/tailor-agent.test.ts new file mode 100644 index 000000000..e22f24700 --- /dev/null +++ b/apps/docs-mcp/src/__tests__/tailor-agent.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; +import { setSynthesizedMarkdownAttributes } from "../agent/tailor-agent"; + +describe("setSynthesizedMarkdownAttributes", () => { + test("stores the complete markdown without truncating it", () => { + const attributes = new Map(); + const markdown = `# Tailored guide\n\n${"full markdown ".repeat(5000)}`; + + setSynthesizedMarkdownAttributes( + { + setAttribute(name, value) { + attributes.set(name, value); + }, + }, + markdown, + ); + + expect(attributes.get("docs.synthesized_markdown")).toBe(markdown); + expect(attributes.get("docs.synthesized_markdown_chars")).toBe( + markdown.length, + ); + }); +}); diff --git a/apps/docs-mcp/src/__tests__/worker-config.test.ts b/apps/docs-mcp/src/__tests__/worker-config.test.ts new file mode 100644 index 000000000..3429ff4ff --- /dev/null +++ b/apps/docs-mcp/src/__tests__/worker-config.test.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { unstable_readConfig } from "wrangler"; + +describe("docs-mcp deployment configuration", () => { + test("preserves the existing private Worker", () => { + const config = unstable_readConfig({ + config: fileURLToPath(new URL("../../wrangler.jsonc", import.meta.url)), + }); + + expect(config.name).toBe("docs-mcp"); + expect(config.workers_dev).toBe(false); + expect(config.preview_urls).toBe(false); + }); +}); diff --git a/apps/docs-mcp/src/access.ts b/apps/docs-mcp/src/access.ts new file mode 100644 index 000000000..f9da748ca --- /dev/null +++ b/apps/docs-mcp/src/access.ts @@ -0,0 +1,13 @@ +const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost"]); + +/** + * docs-mcp is intentionally callable only through the DOCS_MCP service binding. + * Cloudflare preserves the request URL passed to Fetcher.fetch(), so the caller's + * private `docs-mcp.internal` origin is available here without a shared secret. + * Local development also accepts localhost origins. + */ +export function isDocsMcpServiceRequest(request: Request): boolean { + const { url } = request; + const { hostname } = new URL(url); + return hostname === "docs-mcp.internal" || LOCAL_HOSTNAMES.has(hostname); +} diff --git a/apps/docs-mcp/src/agent/cache.ts b/apps/docs-mcp/src/agent/cache.ts new file mode 100644 index 000000000..52541f527 --- /dev/null +++ b/apps/docs-mcp/src/agent/cache.ts @@ -0,0 +1,207 @@ +import * as Sentry from "@sentry/cloudflare"; +import { trackAgentCacheLookup } from "../metrics"; + +/** + * CF Cache TTL for agent results. + * + * 1 hour matches the doc page TTL — we don't want feature listings to + * lag behind the docs that back them. Repeat wizard runs for the same + * platform combo within an hour reuse the same cached answer. + */ +const FEATURES_TTL_SECONDS = 3600; + +/** Cache scope label used in the cache key URL and metric attributes. */ +const FEATURES_SCOPE = "features"; +const QUERY_SCOPE = "query"; + +export interface DocsQueryContext { + frameworks?: string[]; + languages?: string[]; + sentryConfigured?: boolean; +} + +export interface CachedDocsQuery { + answer: string; + sources: string[]; +} + +/** + * Resolve the Cloudflare Cache API instance, or `undefined` if it isn't + * available in the current runtime (e.g. local non-CF dev). + */ +function getCache(): Cache | undefined { + try { + return (caches as CacheStorage & { default?: Cache })?.default; + } catch { + // Cache API is unavailable outside the Worker runtime. + } +} + +/** + * Build a stable cache URL key for a sorted set of libs. + * + * The CF Cache API requires URL-shaped keys, so we synthesize a fake + * `https://docs-mcp.local/cache/features?libs=…` URL. Lib names are + * lower-cased, trimmed, and sorted before joining so `[react, nextjs]` + * and `[nextjs, React]` collide on the same cache entry. + */ +function featuresKey(libs: string[]): string { + const sorted = [...libs] + .map((v) => v.toLowerCase().trim()) + .sort((a, b) => a.localeCompare(b)); + const search = new URLSearchParams({ libs: sorted.join(",") }); + return `https://docs-mcp.local/cache/${FEATURES_SCOPE}?${search.toString()}`; +} + +function normalizeValues(values: readonly string[] | undefined): string[] { + return [...new Set((values ?? []).map((value) => value.toLowerCase().trim()))] + .filter(Boolean) + .sort(); +} + +function normalizeQuery(query: string): string { + return query.trim().toLowerCase().replace(/\s+/g, " "); +} + +function normalizeDocsVersion(version: string | undefined): string { + return version?.trim() || "unknown"; +} + +/** Build a stable query cache key from the question and approved metadata only. */ +export function buildDocsQueryCacheKey( + query: string, + context: DocsQueryContext | undefined, + docsVersion?: string, +): string { + const normalizedContext = { + cacheSchemaVersion: 2, + docsVersion: normalizeDocsVersion(docsVersion), + frameworks: normalizeValues(context?.frameworks), + languages: normalizeValues(context?.languages), + sentryConfigured: Boolean(context?.sentryConfigured), + }; + const search = new URLSearchParams({ + context: JSON.stringify(normalizedContext), + query: normalizeQuery(query), + }); + return `https://docs-mcp.local/cache/${QUERY_SCOPE}?${search.toString()}`; +} + +/** + * Look up a cached feature listing for a sorted set of libs. + * + * Returns null on cache miss, when the Cache API is unavailable, or on + * any runtime error (corrupt entry, transient `cache.match` / `text()` + * failure). All paths emit a `docs_mcp.agent.cache` metric so we can + * see hit-rates separately from outright failures. + */ +export async function cacheGetFeatures( + libs: string[], +): Promise { + const cache = getCache(); + if (!cache) { + trackAgentCacheLookup(FEATURES_SCOPE, false); + return null; + } + + try { + const cached = await cache.match(new Request(featuresKey(libs))); + if (!cached) { + trackAgentCacheLookup(FEATURES_SCOPE, false); + return null; + } + const { features } = JSON.parse(await cached.text()) as { + features: string[]; + }; + trackAgentCacheLookup(FEATURES_SCOPE, true); + return features; + } catch (err) { + // Anything in the read path failing — corrupt entry, runtime hiccup + // in cache.match / cached.text — should degrade to a cache miss + // rather than fail the agent. + Sentry.addBreadcrumb({ + category: "docs.cache", + level: "warning", + message: `Failed to read cached entry for ${FEATURES_SCOPE}: ${err instanceof Error ? err.message : String(err)}`, + }); + trackAgentCacheLookup(FEATURES_SCOPE, false); + return null; + } +} + +/** + * Store a feature listing for a sorted set of libs. 1h TTL. + * + * Best-effort — failures are silent (cache writes are non-critical). + */ +export async function cachePutFeatures( + libs: string[], + features: string[], +): Promise { + const cache = getCache(); + if (!cache) { + return; + } + const response = new Response(JSON.stringify({ features }), { + headers: { + "Cache-Control": `public, max-age=${FEATURES_TTL_SECONDS}`, + "Content-Type": "application/json; charset=utf-8", + }, + }); + await cache.put(new Request(featuresKey(libs)), response); +} + +/** Read a cached, already citation-validated docs query result. */ +export async function cacheGetDocsQuery( + query: string, + context: DocsQueryContext | undefined, + docsVersion?: string, +): Promise { + const cache = getCache(); + if (!cache) { + trackAgentCacheLookup(QUERY_SCOPE, false); + return null; + } + + try { + const cached = await cache.match( + new Request(buildDocsQueryCacheKey(query, context, docsVersion)), + ); + if (!cached) { + trackAgentCacheLookup(QUERY_SCOPE, false); + return null; + } + const result = JSON.parse(await cached.text()) as CachedDocsQuery; + if (!(result.answer && Array.isArray(result.sources))) { + throw new Error("Invalid cached docs query result"); + } + trackAgentCacheLookup(QUERY_SCOPE, true); + return result; + } catch { + trackAgentCacheLookup(QUERY_SCOPE, false); + return null; + } +} + +/** Best-effort one-hour cache write for a citation-validated query result. */ +export async function cachePutDocsQuery( + query: string, + context: DocsQueryContext | undefined, + result: CachedDocsQuery, + docsVersion?: string, +): Promise { + const cache = getCache(); + if (!cache) { + return; + } + const response = new Response(JSON.stringify(result), { + headers: { + "Cache-Control": `public, max-age=${FEATURES_TTL_SECONDS}`, + "Content-Type": "application/json; charset=utf-8", + }, + }); + await cache.put( + new Request(buildDocsQueryCacheKey(query, context, docsVersion)), + response, + ); +} diff --git a/apps/docs-mcp/src/agent/features-agent.ts b/apps/docs-mcp/src/agent/features-agent.ts new file mode 100644 index 000000000..f5cc6194e --- /dev/null +++ b/apps/docs-mcp/src/agent/features-agent.ts @@ -0,0 +1,249 @@ +import { Agent } from "@mastra/core/agent"; +import * as Sentry from "@sentry/cloudflare"; +import { z } from "zod"; +import { trackAgentCall, trackAgentSteps, trackAgentTokens } from "../metrics"; +import { libToPlatformPath } from "../sentry-docs/doctree"; +import { createNativeModelFallbacks, PRIMARY_MODEL } from "./model-fallback"; +import { createFetchDocPageTool, createSearchDocsTool } from "./tools"; + +/** + * Discovery agent runs are short — typically one search + one or two + * fetches before the structured output is produced. The extra step + * provides headroom for unmapped libs that need a search round-trip + * before fetching the canonical getting-started page. + */ +const MAX_STEPS = 5; + +/** + * Match the temperature used by all server-side workflow agents + * (getsentry/cli-init-api/apps/server/src/mastra/agents/default-options.ts). Determinism makes + * results stable for the CF Cache key by sorted-libs hash. + */ +const TEMPERATURE = 0; + +/** + * Canonical Sentry feature slugs. Anything outside this list is rejected + * before being returned to the caller — this prevents the agent from + * hallucinating new feature names that would later fail downstream. + * + * Stays in sync with the FEATURE_TO_SUBPATH map in + * `src/sentry-docs/doctree.ts` and the SLUG_TO_FEATURE map in + * `getsentry/cli-init-api/apps/server/src/mastra/lib/docs-mcp-client.ts`. + */ +const KNOWN_FEATURE_SLUGS = [ + "error-monitoring", + "tracing", + "session-replay", + "profiling", + "logs", + "source-maps", + "crons", + "metrics", + "ai-monitoring", + "mcp-observability", + "user-feedback", + "react-features", +] as const; + +/** + * Hardcoded fallback when the agent fails entirely. + * + * Error monitoring is universally available across every Sentry SDK, + * and tracing is supported on every platform that has a getting-started + * guide on docs.sentry.io. These are safe defaults that won't crash the + * wizard's downstream feature picker even if our docs lookup is broken. + */ +const FALLBACK_FEATURES = ["error-monitoring", "tracing"]; +const AGENT_DEFAULT_OPTIONS = { + modelSettings: { + temperature: TEMPERATURE, + // Disable p-retry's inner retries so the outer fallback loop is the + // sole retry/fallback mechanism. See default-options.ts in getsentry/cli-init-api/apps/server. + maxRetries: 0, + }, +}; + +function getUsedModelId(result: { + steps?: Array<{ response?: { modelId?: string } }>; + response?: { modelId?: string }; +}): string { + return ( + result.steps?.[result.steps.length - 1]?.response?.modelId ?? + result.response?.modelId ?? + PRIMARY_MODEL + ); +} + +const SYSTEM_PROMPT = `You are a Sentry SDK feature-discovery specialist. + +Given a list of user libraries, identify which Sentry features are documented for that platform on docs.sentry.io. Use search_docs and fetch_doc_page as needed to confirm support. + +Return ONLY canonical slugs from this allow-list: +${KNOWN_FEATURE_SLUGS.map((s) => `- ${s}`).join("\n")} + +Always include "error-monitoring" — it is supported by every Sentry SDK. + +WHERE TO LOOK +The fastest and most authoritative way to determine supported features is to fetch_doc_page on the platform's getting-started page (e.g. /platforms/javascript/guides//) and look for the "Choose the features you want to configure" toggle list near the top of the page. This is the canonical, Sentry-maintained list of supported features per platform — it is far more reliable than guessing from the presence of side-bar topic links (which exist for many platforms regardless of actual support). + +Map the displayed feature names from that toggle list to canonical slugs as follows: +- "Error Monitoring" / "Issues" → error-monitoring +- "Tracing" / "Performance" → tracing +- "Session Replay" → session-replay +- "Profiling" → profiling +- "Logs" → logs +- "User Feedback" → user-feedback + +If the getting-started page also documents source map upload (look for a "Source Maps" step or section, or a "sentryVitePlugin"/"sentryWebpackPlugin"/"@sentry/wizard -i sourcemaps" code block), include "source-maps". + +If the getting-started page documents AI Agent Monitoring or AI agent instrumentation in the toggle list or as a primary feature, include "ai-monitoring". + +If the docs show MCP Observability or Sentry MCP monitoring setup for MCP servers, include "mcp-observability". + +Be conservative: include a feature only if the docs clearly show it for the requested platform. If the platform's docs can't be determined, return ["error-monitoring"] as a safe minimum. Never invent feature slugs. + +OUTPUT +Use the structured output schema. Return { features: string[] } where each entry is one of the allow-listed slugs above.`; + +const featuresOutputSchema = z.object({ + features: z + .array(z.string()) + .describe("Canonical Sentry feature slugs available for the given libs"), +}); + +/** + * Filter agent output to only canonical slugs and ensure error-monitoring is present. + * + * Defends against the model returning slugs outside the allow-list, even + * with the system prompt's restriction. Always returns at least + * `error-monitoring` so callers never get an empty list. + */ +function sanitizeFeatures(raw: string[]): string[] { + const allowed = new Set(KNOWN_FEATURE_SLUGS); + const out = new Set(); + out.add("error-monitoring"); + for (const slug of raw) { + const normalized = slug.trim().toLowerCase(); + if (allowed.has(normalized)) { + out.add(normalized); + } + } + return [...out]; +} + +function createFeaturesAgent( + apiKey: string | undefined, + fetchedUrls: string[], +) { + return new Agent({ + id: "docs-features-agent", + name: "Docs Features Agent", + instructions: SYSTEM_PROMPT, + defaultOptions: AGENT_DEFAULT_OPTIONS, + model: createNativeModelFallbacks(PRIMARY_MODEL, apiKey), + tools: { + search_docs: createSearchDocsTool(), + fetch_doc_page: createFetchDocPageTool(fetchedUrls), + }, + }); +} + +/** + * Resolve lib slugs to canonical Sentry feature slugs. + * + * Caches results in the CF Cache API by sorted-libs key (1h TTL) so + * repeat wizard runs for the same platform combo skip the LLM call. + * + * Falls back to FALLBACK_FEATURES on any error so the wizard never + * sees an empty list. + */ +export async function runFeaturesAgent( + apiKey: string | undefined, + libs: string[], +): Promise { + return await Sentry.startSpan( + { + op: "docs.agent.features", + name: "featuresAgent", + attributes: { + "agent.libs": libs.join(","), + "agent.model": PRIMARY_MODEL, + "agent.max_steps": MAX_STEPS, + }, + }, + async (span) => { + const fetchedUrls: string[] = []; + + const setUrlAttrs = () => { + const urls = [...new Set(fetchedUrls)]; + span.setAttribute("docs.urls_accessed_count", urls.length); + if (urls.length > 0) { + span.setAttribute( + "docs.urls_accessed", + urls.join(",").slice(0, 2000), + ); + } + }; + + const seedPaths = libs + .map((lib) => libToPlatformPath(lib)) + .filter((p): p is string => p !== null); + + span.setAttribute("agent.seed_paths_count", seedPaths.length); + + const seedSection = + seedPaths.length > 0 + ? `Platform doc paths to investigate:\n${seedPaths.map((p) => `- ${p}`).join("\n")}` + : "No platform paths matched the libs. Use search_docs to find what platform docs exist."; + + const userPrompt = `Libraries: ${libs.join(", ")} + +${seedSection} + +Return the canonical feature slugs available for this platform.`; + + try { + const agent = createFeaturesAgent(apiKey, fetchedUrls); + const result = await agent.generate(userPrompt, { + maxSteps: MAX_STEPS, + structuredOutput: { schema: featuresOutputSchema }, + }); + + const inputTokens = result.totalUsage?.inputTokens ?? 0; + const outputTokens = result.totalUsage?.outputTokens ?? 0; + const stepCount = result.steps?.length ?? 0; + const usedModelId = getUsedModelId(result); + + span.setAttribute("agent.model", usedModelId); + span.setAttribute("agent.tokens.input", inputTokens); + span.setAttribute("agent.tokens.output", outputTokens); + span.setAttribute("agent.steps", stepCount); + + trackAgentTokens(inputTokens, outputTokens); + trackAgentSteps(stepCount); + trackAgentCall(true); + + const rawFeatures = result.object?.features ?? []; + const features = sanitizeFeatures(rawFeatures); + + span.setAttribute("agent.feature_count", features.length); + setUrlAttrs(); + + return features; + } catch (err) { + span.setStatus({ code: 2, message: "error" }); + Sentry.captureException(err); + Sentry.addBreadcrumb({ + category: "docs.agent.features", + message: `Features agent failed for libs=${libs.join(",")}: ${err instanceof Error ? err.message : String(err)}`, + level: "warning", + }); + trackAgentCall(false); + setUrlAttrs(); + // Never throw — wizard's select-features step has its own fallback, + // but we want to give it *something* useful. + return [...FALLBACK_FEATURES]; + } + }, + ); +} diff --git a/apps/docs-mcp/src/agent/model-fallback.test.ts b/apps/docs-mcp/src/agent/model-fallback.test.ts new file mode 100644 index 000000000..754dd1868 --- /dev/null +++ b/apps/docs-mcp/src/agent/model-fallback.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { createNativeModelFallbacks, PRIMARY_MODEL } from "./model-fallback"; + +describe("createNativeModelFallbacks", () => { + test("tries Kimi after the primary and standard fallback models", () => { + const result = createNativeModelFallbacks(PRIMARY_MODEL, "sk-or-test"); + + expect( + result.map((entry) => ({ id: entry.id, model: entry.model.modelId })), + ).toEqual([ + { id: "openrouter-primary", model: "anthropic/claude-sonnet-4.6" }, + { id: "openrouter-fallback", model: "openai/gpt-5.4" }, + { id: "openrouter-kimi-fallback", model: "moonshotai/kimi-k2.5" }, + ]); + }); +}); diff --git a/apps/docs-mcp/src/agent/model-fallback.ts b/apps/docs-mcp/src/agent/model-fallback.ts new file mode 100644 index 000000000..4feae34b8 --- /dev/null +++ b/apps/docs-mcp/src/agent/model-fallback.ts @@ -0,0 +1,68 @@ +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import * as Sentry from "@sentry/cloudflare"; + +// Default OpenRouter model slugs. Change these to swap models across all +// agents in one place. PRIMARY_MODEL is used by lighter synthesis agents, +// PRIMARY_MODEL_HEAVY by reasoning-heavy agents, and FALLBACK_MODEL is the +// shared fallback model. +export const PRIMARY_MODEL = "anthropic/claude-sonnet-4.6"; +export const PRIMARY_MODEL_HEAVY = "anthropic/claude-opus-4.6"; +export const FALLBACK_MODEL = "openai/gpt-5.4"; +export const KIMI_FALLBACK_MODEL = "moonshotai/kimi-k2.5"; + +interface NativeModelFallbackOptions { + fallbackModelId?: string; + parallelToolCalls?: boolean; +} + +export function createNativeModelFallbacks( + primaryModelId: string, + apiKey?: string, + fallbackOrOptions: string | NativeModelFallbackOptions = {}, +) { + if (!apiKey) { + console.warn( + "[model-fallback] OPENROUTER_API_KEY missing — models disabled", + ); + Sentry.addBreadcrumb({ + category: "model-fallback", + level: "warning", + message: "OPENROUTER_API_KEY missing — models disabled", + }); + } + + const openrouter = createOpenRouter(apiKey ? { apiKey } : {}); + const options = + typeof fallbackOrOptions === "string" + ? { fallbackModelId: fallbackOrOptions } + : fallbackOrOptions; + const chatModel = (modelId: string) => + options.parallelToolCalls === undefined + ? openrouter(modelId) + : openrouter.chat(modelId, { + parallelToolCalls: options.parallelToolCalls, + }); + + // maxRetries controls the outer fallback loop in Mastra's + // executeStreamWithFallbackModels. p-retry inside Mastra's execute() + // already provides inner retries (default 2 = 3 total attempts). + // Setting outer maxRetries to 0 moves to the next model after those + // inner retries instead of retrying the same model chain again. + return [ + { + id: "openrouter-primary", + maxRetries: 0, + model: chatModel(primaryModelId), + }, + { + id: "openrouter-fallback", + maxRetries: 0, + model: chatModel(options.fallbackModelId ?? FALLBACK_MODEL), + }, + { + id: "openrouter-kimi-fallback", + maxRetries: 0, + model: chatModel(KIMI_FALLBACK_MODEL), + }, + ]; +} diff --git a/apps/docs-mcp/src/agent/query-agent.ts b/apps/docs-mcp/src/agent/query-agent.ts new file mode 100644 index 000000000..809e6908f --- /dev/null +++ b/apps/docs-mcp/src/agent/query-agent.ts @@ -0,0 +1,346 @@ +import { Agent } from "@mastra/core/agent"; +import * as Sentry from "@sentry/cloudflare"; +import { searchPages } from "../sentry-docs/doctree"; +import { + buildDocsUrl, + fetchDocPage, + normalizeDocPath, +} from "../sentry-docs/fetcher"; +import { + cacheGetDocsQuery, + cachePutDocsQuery, + type DocsQueryContext, +} from "./cache"; +import { createNativeModelFallbacks, PRIMARY_MODEL } from "./model-fallback"; +import { createFetchDocPageTool, createSearchDocsTool } from "./tools"; + +export interface DocsQueryResult { + answer: string; + sources: string[]; +} + +export type { DocsQueryContext } from "./cache"; + +const QUERY_MAX_OUTPUT_TOKENS = 700; +export const QUERY_MODEL_SETTINGS = { + maxOutputTokens: QUERY_MAX_OUTPUT_TOKENS, + maxRetries: 0, + temperature: 0, +}; + +const MARKDOWN_LINK_URL_RE = /\[[^\]]+\]\((https?:\/\/[^\s)]+)\)/g; +// Ignore URL-like values in quoted code strings; only prose URLs need citation +// validation. Markdown link destinations are separately validated above. +const URL_RE = /(?()[\]"']+/g; +const TRAILING_MD_RE = /\.md$/; +const TRAILING_URL_PUNCTUATION_RE = /[.,;:!?]+$/; +const TRAILING_SLASHES_RE = /\/+$/; +const SOURCES_SECTION_RE = /\n{0,2}## Sources[ \t]*\r?\n[\s\S]*$/i; +const MAX_QUERY_STEPS = 6; + +export const QUERY_SYSTEM_PROMPT = `You answer Sentry documentation questions using only current docs.sentry.io pages. + +First use search_docs to find relevant pages, then fetch_doc_page for every page you rely on. A successful fetch response includes the canonical URL that you may cite. Give a concise Markdown answer that directly addresses the question: use at most 6 short bullet points and include at most one essential code snippet. Every factual claim must have an inline Markdown citation to a successful fetch response URL, such as [Configure tracing](https://docs.sentry.io/platforms/javascript/guides/nextjs/tracing/). + +Do not cite URLs you did not fetch. Do not cite non-docs.sentry.io URLs. Do not include a Sources section; the server adds it after validating the fetched URLs.`; + +function canonicalizeDocsUrl(value: string): string { + const url = new URL(value.replace(TRAILING_URL_PUNCTUATION_RE, "")); + if (url.protocol !== "https:" || url.hostname !== "docs.sentry.io") { + throw new Error(`Citation URL is not an allowed Sentry docs URL: ${value}`); + } + + url.search = ""; + url.hash = ""; + url.pathname = url.pathname + .replace(TRAILING_MD_RE, "") + .replace(TRAILING_SLASHES_RE, "") + .concat("/"); + return url.toString(); +} + +function uniqueCanonicalDocsUrls(urls: readonly string[]): string[] { + return [...new Set(urls.map(canonicalizeDocsUrl))]; +} + +/** + * Enforce the public query contract after an LLM has synthesized an answer. + * + * The model may only cite docs pages that its fetch tool actually read. The + * Sources section is rendered here rather than trusting a model-produced list + * so human and JSON output always agree on the provenance of the answer. + */ +export function finalizeDocsAnswer( + answer: string, + fetchedUrls: readonly string[], +): DocsQueryResult { + const sources = uniqueCanonicalDocsUrls(fetchedUrls); + if (sources.length === 0) { + throw new Error("Docs query returned no fetched sources to cite."); + } + + const body = answer.replace(SOURCES_SECTION_RE, "").trim(); + const citations = uniqueCanonicalDocsUrls( + [...body.matchAll(MARKDOWN_LINK_URL_RE)].map((match) => match[1]), + ); + if (citations.length === 0) { + throw new Error("Docs query answer must include an inline citation."); + } + + const emittedUrls = uniqueCanonicalDocsUrls( + [...body.matchAll(URL_RE)].map((match) => match[0]), + ); + const fetched = new Set(sources); + for (const citation of emittedUrls) { + if (!fetched.has(citation)) { + throw new Error(`Docs citation was not fetched: ${citation}`); + } + } + + return { + answer: `${body}\n\n## Sources\n\n${sources.map((url) => `- <${url}>`).join("\n")}`, + sources, + }; +} + +function createQueryAgent( + apiKey: string | undefined, + fetchedUrls: string[], +): Agent { + return new Agent({ + defaultOptions: { + modelSettings: QUERY_MODEL_SETTINGS, + }, + id: "docs-query-agent", + instructions: QUERY_SYSTEM_PROMPT, + model: createNativeModelFallbacks(PRIMARY_MODEL, apiKey), + name: "Docs Query Agent", + tools: { + fetch_doc_page: createFetchDocPageTool(fetchedUrls), + search_docs: createSearchDocsTool(), + }, + }); +} + +function contextPrompt(context: DocsQueryContext | undefined): string { + const frameworks = context?.frameworks?.join(", ") || "none detected"; + const languages = context?.languages?.join(", ") || "none detected"; + const sentryConfigured = context?.sentryConfigured ? "yes" : "no"; + return `\n\nSafe local project context (metadata only):\n- Frameworks: ${frameworks}\n- Languages: ${languages}\n- Sentry configuration detected: ${sentryConfigured}`; +} + +function citationRecoveryPrompt(): string { + return "\n\nCitation validation rejected the previous draft. Start over: search the docs and fetch every page you cite in this new answer. Do not rely on URLs from the rejected draft."; +} + +interface GeneratedDocsAnswer { + cacheable: boolean; + citationFallback: boolean; + citationFallbackReason: + | "external_host" + | "no_fetched_sources" + | "validation_failed" + | null; + citationRetries: number; + finalized: DocsQueryResult; +} + +export interface DocsAnswerAttempt { + fetchedUrls: readonly string[]; + text: string; +} + +type FallbackSourceResolver = () => Promise; + +function noFallbackSources(): Promise { + return Promise.resolve([]); +} + +/** + * Retrieve one current docs page as a last-resort citation source. + * + * The source is returned only after a successful fetch, so the fallback + * remains grounded even when both agent attempts skip or fail their tools. + */ +export async function recoverQueryFallbackSources( + query: string, +): Promise { + try { + const [candidate] = await searchPages(query, 1); + if (!candidate) { + return []; + } + + const path = normalizeDocPath(candidate.url); + await fetchDocPage(path); + return [buildDocsUrl(path)]; + } catch (error) { + Sentry.addBreadcrumb({ + category: "docs.citation_recovery", + level: "warning", + message: `Failed to retrieve a docs fallback source: ${error instanceof Error ? error.name : "unknown"}`, + }); + return []; + } +} + +function fallbackToFetchedDocs( + fetchedUrls: readonly string[], +): DocsQueryResult { + const [source] = uniqueCanonicalDocsUrls(fetchedUrls); + if (!source) { + throw new Error("Docs query returned no fetched sources to cite."); + } + + return finalizeDocsAnswer( + "I couldn't produce a fully cited answer for that question. Please refer to " + + `[the Sentry documentation](${source}).`, + [source], + ); +} + +function fallbackReason( + error: unknown, +): Exclude { + if (!(error instanceof Error)) { + return "validation_failed"; + } + if ( + error.message.startsWith("Citation URL is not an allowed Sentry docs URL") + ) { + return "external_host"; + } + return error.message === "Docs query returned no fetched sources to cite." + ? "no_fetched_sources" + : "validation_failed"; +} + +export async function finalizeCitedAttempts( + generateAttempt: (retry: boolean) => Promise, + resolveFallbackSources: FallbackSourceResolver = noFallbackSources, +): Promise { + const firstAttempt = await generateAttempt(false); + try { + return { + cacheable: true, + citationFallback: false, + citationFallbackReason: null, + citationRetries: 0, + finalized: finalizeDocsAnswer( + firstAttempt.text, + firstAttempt.fetchedUrls, + ), + }; + } catch { + const retryAttempt = await generateAttempt(true); + try { + return { + cacheable: true, + citationFallback: false, + citationFallbackReason: null, + citationRetries: 1, + finalized: finalizeDocsAnswer( + retryAttempt.text, + retryAttempt.fetchedUrls, + ), + }; + } catch (retryError) { + const fallbackSources = + retryAttempt.fetchedUrls.length > 0 + ? retryAttempt.fetchedUrls + : await resolveFallbackSources(); + return { + cacheable: false, + citationFallback: true, + citationFallbackReason: fallbackReason(retryError), + citationRetries: 1, + finalized: fallbackToFetchedDocs(fallbackSources), + }; + } + } +} + +function generateCitedAnswer( + apiKey: string | undefined, + query: string, + context: DocsQueryContext | undefined, +): Promise { + return finalizeCitedAttempts( + async (retry) => { + const fetchedUrls: string[] = []; + const result = await createQueryAgent(apiKey, fetchedUrls).generate( + `Question: ${query.trim()}${contextPrompt(context)}${retry ? citationRecoveryPrompt() : ""}`, + { + maxSteps: MAX_QUERY_STEPS, + prepareStep: ({ stepNumber }) => + stepNumber === MAX_QUERY_STEPS - 1 ? { toolChoice: "none" } : {}, + }, + ); + return { fetchedUrls, text: result.text }; + }, + () => recoverQueryFallbackSources(query), + ); +} + +/** Run the free-form docs agent and return an answer grounded in fetched pages. */ +export async function runQueryAgent( + apiKey: string | undefined, + query: string, + context?: DocsQueryContext, + docsVersion?: string, +): Promise { + const cached = await cacheGetDocsQuery(query, context, docsVersion); + if (cached) { + return cached; + } + + return await Sentry.startSpan( + { + attributes: { + "agent.framework_count": context?.frameworks?.length ?? 0, + "agent.language_count": context?.languages?.length ?? 0, + "agent.max_steps": MAX_QUERY_STEPS, + "agent.model": PRIMARY_MODEL, + }, + name: "queryAgent", + op: "docs.agent.query", + }, + async (span) => { + try { + const { + cacheable, + citationFallback, + citationFallbackReason, + citationRetries, + finalized, + } = await generateCitedAnswer(apiKey, query, context); + span.setAttribute("docs.citation_fallback", citationFallback); + if (citationFallbackReason) { + span.setAttribute( + "docs.citation_fallback_reason", + citationFallbackReason, + ); + } + span.setAttribute("docs.citation_retries", citationRetries); + span.setAttribute("docs.source_count", finalized.sources.length); + span.setAttribute("agent.output_chars", finalized.answer.length); + if (cacheable) { + try { + await cachePutDocsQuery(query, context, finalized, docsVersion); + } catch (error) { + Sentry.addBreadcrumb({ + category: "docs.cache", + level: "warning", + message: `Failed to cache docs query: ${error instanceof Error ? error.name : "unknown"}`, + }); + } + } + return finalized; + } catch (error) { + span.setStatus({ code: 2, message: "error" }); + Sentry.captureException(error); + throw error; + } + }, + ); +} diff --git a/apps/docs-mcp/src/agent/tailor-agent.ts b/apps/docs-mcp/src/agent/tailor-agent.ts new file mode 100644 index 000000000..3550dd0cb --- /dev/null +++ b/apps/docs-mcp/src/agent/tailor-agent.ts @@ -0,0 +1,296 @@ +import { Agent } from "@mastra/core/agent"; +import * as Sentry from "@sentry/cloudflare"; +import { + trackAgentCall, + trackAgentFallbackSynthesis, + trackAgentSteps, + trackAgentTokens, +} from "../metrics"; +import { findPagesForLibsFeatures } from "../sentry-docs/doctree"; +import { createNativeModelFallbacks, PRIMARY_MODEL } from "./model-fallback"; +import { createFetchDocPageTool, createSearchDocsTool } from "./tools"; + +/** + * Maximum tool-call steps the agent can take before forced stop. + * + * 10 leaves room for ~8-9 tool steps (seed fetches + additional discovery) + * + 1 forced synthesis step. The last step is gated by `prepareStep` to + * disallow tools so the model is guaranteed to produce a final text response. + * + * Wall-clock: ~10 × 15–20s = ~150–200s for complex stacks (iOS + many + * features). The CF Workers 30s limit is CPU time — async LLM calls don't + * count against it, so wall-clock can be much longer. The real constraint + * is the MCP client timeout set by the caller (getsentry/cli-init-api/apps/server/src/mastra/lib/ + * docs-mcp-client.ts). + */ +const MAX_STEPS = 10; + +/** + * Match the temperature used by all server-side workflow agents + * (getsentry/cli-init-api/apps/server/src/mastra/agents/default-options.ts). Determinism + * makes synthesis output stable across repeat runs and easier to debug. + */ +const TEMPERATURE = 0; +const AGENT_DEFAULT_OPTIONS = { + modelSettings: { + temperature: TEMPERATURE, + // Disable p-retry's inner retries so the outer fallback loop is the + // sole retry/fallback mechanism. See default-options.ts in getsentry/cli-init-api/apps/server. + maxRetries: 0, + }, +}; + +function getUsedModelId(result: { + steps?: Array<{ response?: { modelId?: string } }>; + response?: { modelId?: string }; +}): string { + return ( + result.steps?.[result.steps.length - 1]?.response?.modelId ?? + result.response?.modelId ?? + PRIMARY_MODEL + ); +} + +const SYSTEM_PROMPT = `You are a Sentry integration documentation specialist. + +Your job is to produce a single, coherent Markdown guide that walks a developer through integrating Sentry into THEIR specific stack with THEIR selected features. The output is consumed by another agent that generates code modifications, so it must be precise, complete, and reflect the current SDK API exactly. + +You have two tools at your disposal: +- search_docs(query): finds relevant pages on docs.sentry.io. Use it when you need to discover a page (e.g. "celery integration", "session replay privacy"). +- fetch_doc_page(path): fetches the full Markdown of a docs.sentry.io page. Use it whenever you need the actual content. + +WORKFLOW +1. The user prompt will list the libraries (libs) and selected features. A SEED SET of likely doc paths will be pre-loaded for you — START by fetching those in parallel. The seed set includes install/ and manual-setup/ sub-pages for each lib (the two patterns Sentry platforms use for package installation instructions). In that same parallel step, also call search_docs(" install") for each lib — this catches platforms that embed install instructions in their root index rather than a dedicated sub-page (e.g. Python, Ruby, Go). +2. When you need multiple pages, call fetch_doc_page in PARALLEL within one step rather than sequentially. Claude supports parallel tool calls and step budget is limited. +3. If the seed set is incomplete (e.g. an unmapped lib or feature), use search_docs to discover the right pages, then fetch_doc_page on the best matches. +3b. If a "Project stack" is provided in the user prompt, use search_docs to find integration pages for any notable 3rd-party libraries mentioned that aren't already in the seed set (e.g. if the stack mentions "@supabase/supabase-js", call search_docs("supabase") and fetch the most relevant integration page). Do this in the same parallel step as the seed fetches. +4. Read carefully and synthesize — DO NOT just concatenate raw doc content. Merge getting-started + per-feature config into a single coherent setup that initializes the SDK once and configures all selected features together. +5. Preserve EXACT API signatures, import paths, file names, configuration keys, AND package install commands from the docs. Do not invent any of these from training data — including package manager commands. If you reach the ## Installation section without a confirmed install page, call search_docs(" install") or search_docs(" setup") before writing it. + +OUTPUT FORMAT +Return Markdown with this structure: + +\`\`\` +# Sentry Integration: + +## Overview +<1-2 sentence summary of what's being set up> + +## Installation + + +## Configuration + + +## Feature Setup +### + + +(repeat per feature) + +## Notes + +\`\`\` + +CRITICAL RULES +- Only include features that are explicitly in the selected features list. Don't enable extras "for completeness". +- Use the file paths and code patterns from the docs verbatim. If the docs say to put SDK init in \`instrumentation.ts\`, do not call it \`sentry.config.ts\`. +- Keep code blocks copy-pasteable. Prefer one well-commented init block over multiple snippets that have to be merged. +- Cite source URLs at the bottom under "## Sources" so the downstream agent (and the developer) can verify. + +PLACEHOLDERS AND MARKERS (preserve verbatim from the docs) +The Sentry docs occasionally embed placeholders that the downstream codemod-planner agent replaces with real values. If you encounter any of these in the source docs, leave them EXACTLY as-is in your output: +- ___PUBLIC_DSN___ (replaced with the project's DSN) +- ___ORG_SLUG___ (replaced with the Sentry org slug) +- ___PROJECT_SLUG___ (replaced with the Sentry project slug) +- ___PRODUCT_OPTION_START___ ... ___PRODUCT_OPTION_END___ + (feature-gated config blocks; only emit the inner code if is in the selected features list) + +The known feature names inside ___PRODUCT_OPTION_*___ markers are: performance, session-replay, user-feedback, logs. Map them to the selected feature slugs as follows: performance→tracing, session-replay→session-replay, user-feedback→user-feedback, logs→logs. Never include the marker comment lines themselves in your output.`; + +/** Final-step instruction sent during the fallback synthesis pass. */ +const FALLBACK_SYNTHESIS_INSTRUCTION = + "You have gathered enough context. Now produce the final tailored guide as Markdown, following the OUTPUT FORMAT in the system prompt. Do not call any tools."; + +interface SpanAttributeWriter { + setAttribute: (name: string, value: number | string) => unknown; +} + +export function setSynthesizedMarkdownAttributes( + span: SpanAttributeWriter, + markdown: string, +): void { + // Keep the complete synthesized guide in Sentry so a wizard run can + // be audited without relying on the transient MCP response body. + // Intentionally do not truncate this attribute. + span.setAttribute("docs.synthesized_markdown", markdown); + span.setAttribute("docs.synthesized_markdown_chars", markdown.length); +} + +function createTailorAgent(apiKey: string | undefined, fetchedUrls: string[]) { + return new Agent({ + id: "docs-tailor-agent", + name: "Docs Tailor Agent", + instructions: SYSTEM_PROMPT, + defaultOptions: AGENT_DEFAULT_OPTIONS, + model: createNativeModelFallbacks(PRIMARY_MODEL, apiKey), + tools: { + search_docs: createSearchDocsTool(), + fetch_doc_page: createFetchDocPageTool(fetchedUrls), + }, + }); +} + +// ── Agent entry point ─────────────────────────────────────────────── + +/** + * Run the docs tailoring agent for a (libs, features) combo. + * + * Returns a single Markdown string the codemod-planner can consume. + * + * Step-budget protection: + * - `prepareStep` forces `toolChoice: 'none'` on the last step so the + * model is guaranteed to produce a text response even if it would + * otherwise have called another tool. + * - If `result.text` still comes back empty (rare — e.g. model refused), + * a fallback `generateText` call without tools is run with the prior + * message history to coerce a final synthesis from the accumulated + * tool-call context. + */ +export async function runTailorAgent( + apiKey: string | undefined, + libs: string[], + features: string[], + stackSummary?: string, +): Promise { + return await Sentry.startSpan( + { + op: "docs.agent.tailor", + name: "tailorAgent", + attributes: { + "agent.libs": libs.join(","), + "agent.features": features.join(","), + "agent.model": PRIMARY_MODEL, + "agent.max_steps": MAX_STEPS, + }, + }, + async (span) => { + const fetchedUrls: string[] = []; + const seedPaths = findPagesForLibsFeatures(libs, features); + span.setAttribute("agent.seed_paths_count", seedPaths.length); + + const setUrlAttrs = () => { + const urls = [...new Set(fetchedUrls)]; + span.setAttribute("docs.urls_accessed_count", urls.length); + if (urls.length > 0) { + span.setAttribute( + "docs.urls_accessed", + urls.join(",").slice(0, 2000), + ); + } + }; + + const seedSection = + seedPaths.length > 0 + ? `Seed paths (start by fetching these — call fetch_doc_page for all of them in parallel within a single step):\n${seedPaths.map((p) => `- ${p}`).join("\n")}` + : "No seed paths matched. Use search_docs to find the right pages."; + + const stackLine = stackSummary ? `\nProject stack: ${stackSummary}` : ""; + + const userPrompt = `Libraries: ${libs.join(", ")} +Selected features: ${features.join(", ") || "(none — produce the base getting-started guide only)"}${stackLine} + +${seedSection} + +Produce the tailored integration guide.`; + + try { + const agent = createTailorAgent(apiKey, fetchedUrls); + const result = await agent.generate(userPrompt, { + maxSteps: MAX_STEPS, + // Force a tool-free synthesis on the final step so the loop + // never terminates with tool-calls and no text. + prepareStep: ({ stepNumber }) => { + if (stepNumber === MAX_STEPS - 1) { + return { toolChoice: "none" }; + } + return {}; + }, + }); + + const inputTokens = result.totalUsage?.inputTokens ?? 0; + const outputTokens = result.totalUsage?.outputTokens ?? 0; + const stepCount = result.steps?.length ?? 0; + const usedModelId = getUsedModelId(result); + + span.setAttribute("agent.model", usedModelId); + span.setAttribute("agent.tokens.input", inputTokens); + span.setAttribute("agent.tokens.output", outputTokens); + span.setAttribute("agent.steps", stepCount); + span.setAttribute("agent.output_chars", result.text.length); + + trackAgentTokens(inputTokens, outputTokens); + trackAgentSteps(stepCount); + + // Primary path produced text — short-circuit and return. + if (result.text && result.text.trim().length > 0) { + trackAgentCall(true); + setSynthesizedMarkdownAttributes(span, result.text); + setUrlAttrs(); + return result.text; + } + + // Fallback synthesis pass: the primary call exited without text + // (e.g. step limit hit despite prepareStep, or model refusal). + // Replay the accumulated tool-call history with no tools and a + // direct instruction to produce the final guide. + Sentry.addBreadcrumb({ + category: "docs.agent.tailor", + message: + "Primary call produced empty text — running fallback synthesis pass", + level: "warning", + }); + trackAgentFallbackSynthesis(); + span.setAttribute("agent.fallback_synthesis", true); + + const fallback = await agent.generate( + [ + ...(result.response.messages ?? []), + { role: "user", content: FALLBACK_SYNTHESIS_INSTRUCTION }, + ], + { + maxSteps: 1, + toolChoice: "none", + }, + ); + + const fbInputTokens = fallback.totalUsage?.inputTokens ?? 0; + const fbOutputTokens = fallback.totalUsage?.outputTokens ?? 0; + const fallbackModelId = getUsedModelId(fallback); + + span.setAttribute("agent.model", fallbackModelId); + span.setAttribute("agent.fallback.tokens.input", fbInputTokens); + span.setAttribute("agent.fallback.tokens.output", fbOutputTokens); + trackAgentTokens(fbInputTokens, fbOutputTokens); + + if (!fallback.text || fallback.text.trim().length === 0) { + trackAgentCall(false); + throw new Error( + "Agent returned empty output even after fallback synthesis pass.", + ); + } + + trackAgentCall(true); + setSynthesizedMarkdownAttributes(span, fallback.text); + setUrlAttrs(); + return fallback.text; + } catch (err) { + span.setStatus({ code: 2, message: "error" }); + Sentry.captureException(err); + trackAgentCall(false); + setUrlAttrs(); + throw err; + } + }, + ); +} diff --git a/apps/docs-mcp/src/agent/tools.ts b/apps/docs-mcp/src/agent/tools.ts new file mode 100644 index 000000000..93863bc5e --- /dev/null +++ b/apps/docs-mcp/src/agent/tools.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { searchPages } from "../sentry-docs/doctree"; +import { + buildDocsUrl, + fetchDocPage, + normalizeDocPath, +} from "../sentry-docs/fetcher"; + +// Keep the concrete Zod schemas: Mastra vendors AI SDK schema types with +// distinct private brands, so widening these objects through tool() breaks typing. + +/** + * Truncate a fetched doc page to keep the agent's context window bounded. + * + * The head of the doc always contains the canonical setup steps; the + * tail is usually changelog/reference and not needed for synthesis. + */ +const MAX_PAGE_CHARS = 25_000; + +/** + * `search_docs` — discovery tool the agents use to find relevant docs.sentry.io pages. + * Returns up to 8 hits with `url`, `title`, `description`. + */ +export function createSearchDocsTool() { + return { + description: + "Search docs.sentry.io for pages relevant to a query. Returns up to 8 matching pages with their URL, title, and description. Use this to discover the right doc page when you don't already have its path.", + execute: async ({ query }: { query: string }) => { + const hits = await searchPages(query, 8); + return { hits }; + }, + inputSchema: z.object({ + query: z + .string() + .min(1) + .describe( + "Free-text search, e.g. 'celery integration', 'session replay privacy', 'sourcemaps webpack'", + ), + }), + }; +} + +/** + * `fetch_doc_page` — page reader. Returns the pre-rendered Markdown for a path. + * The body is truncated to MAX_PAGE_CHARS to keep the agent's context bounded. + */ +export function createFetchDocPageTool(fetchedUrls?: string[]) { + return { + description: + "Fetch the full pre-rendered Markdown of a docs.sentry.io page. Pass the path you got from search_docs (or a known path like '/platforms/javascript/guides/nextjs/'). A successful response includes its canonical url; only cite urls returned by successful fetches.", + execute: async ({ path }: { path: string }) => { + const normalizedPath = normalizeDocPath(path); + const url = buildDocsUrl(normalizedPath); + try { + const content = await fetchDocPage(normalizedPath); + fetchedUrls?.push(url); + const truncated = + content.length > MAX_PAGE_CHARS + ? `${content.slice(0, MAX_PAGE_CHARS)}\n\n[... truncated, ${content.length - MAX_PAGE_CHARS} more chars ...]` + : content; + return { content: truncated, path: normalizedPath, url }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { error: message, path }; + } + }, + inputSchema: z.object({ + path: z + .string() + .min(1) + .describe( + "Docs path or full docs.sentry.io URL. The .md suffix is added automatically.", + ), + }), + }; +} diff --git a/apps/docs-mcp/src/index.ts b/apps/docs-mcp/src/index.ts new file mode 100644 index 000000000..ec2ffd94e --- /dev/null +++ b/apps/docs-mcp/src/index.ts @@ -0,0 +1,98 @@ +/** + * Owns the docs Worker HTTP boundary. Health is public for readiness checks; + * docs routes accept only service-bound or local-development requests. + */ +import { StreamableHTTPTransport } from "@hono/mcp"; +import * as Sentry from "@sentry/cloudflare"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { isDocsMcpServiceRequest } from "./access"; +import { + executeInternalDocsRequest, + internalDocsRequestSchema, + isModelUnavailableError, + isUngroundedDocsError, +} from "./internal-api"; +import { createMcpServer } from "./mcp"; +import { trackToolCall, trackToolDuration } from "./metrics"; +import type { Env } from "./types"; + +export const app = new Hono<{ Bindings: Env }>().use(cors()); + +app.get("/health", (c) => c.json({ status: "ok" })); + +app.all("/mcp", async (c) => { + if (!isDocsMcpServiceRequest(c.req.raw)) { + return c.json( + { error: "Docs MCP is only available through its service binding" }, + 403, + ); + } + + const transport = new StreamableHTTPTransport(); + const mcp = createMcpServer( + c.env.OPENROUTER_API_KEY, + c.env.CF_VERSION_METADATA.id, + ); + + await mcp.connect(transport); + + return await transport.handleRequest(c); +}); + +app.post("/internal/docs", async (c) => { + if (!isDocsMcpServiceRequest(c.req.raw)) { + return c.json( + { error: "Docs API is only available through its service binding" }, + 403, + ); + } + + const body = await c.req.json().catch(() => undefined); + const parsed = internalDocsRequestSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: "Invalid docs API request" }, 400); + } + + const start = Date.now(); + const { operation } = parsed.data; + Sentry.getActiveSpan()?.setAttribute("docs.operation", operation); + + try { + const result = await executeInternalDocsRequest( + c.env.OPENROUTER_API_KEY, + parsed.data, + c.env.CF_VERSION_METADATA.id, + ); + trackToolCall(operation, true); + trackToolDuration(operation, Date.now() - start); + return c.json(result); + } catch (error) { + Sentry.getActiveSpan()?.setStatus({ code: 2, message: "error" }); + Sentry.captureException(error); + trackToolCall(operation, false); + trackToolDuration(operation, Date.now() - start); + let errorCode: "DOCS_MODEL_UNAVAILABLE" | "DOCS_UNGROUNDED" | undefined; + if (operation === "query-docs" && isUngroundedDocsError(error)) { + errorCode = "DOCS_UNGROUNDED"; + } else if (operation === "query-docs" && isModelUnavailableError(error)) { + errorCode = "DOCS_MODEL_UNAVAILABLE"; + } + return c.json( + { + ...(errorCode ? { code: errorCode } : {}), + error: "Docs API request failed", + }, + 500, + ); + } +}); + +export default Sentry.withSentry( + (_env: Env) => ({ + dsn: "https://05c618aef6e0489b8c9d07e0d65664ce@o1.ingest.us.sentry.io/4511189954199552", + sendDefaultPii: true, + tracesSampleRate: 1.0, + }), + app, +); diff --git a/apps/docs-mcp/src/internal-api.ts b/apps/docs-mcp/src/internal-api.ts new file mode 100644 index 000000000..b6d99c293 --- /dev/null +++ b/apps/docs-mcp/src/internal-api.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; +import { runQueryAgent } from "./agent/query-agent"; +import { getDoctree, searchPages } from "./sentry-docs/doctree"; + +const UNGROUNDED_DOCS_ERROR_RE = + /Citation URL is not an allowed Sentry docs URL|Docs citation .*not fetched|Docs query returned no fetched sources|Docs query answer must include an inline citation/; +const MODEL_UNAVAILABLE_ERROR_RE = /model is not available in your region/i; + +const projectContextSchema = z.object({ + frameworks: z.array(z.string().trim().min(1).max(100)).max(20).optional(), + languages: z.array(z.string().trim().min(1).max(100)).max(20).optional(), + sentryConfigured: z.boolean().optional(), +}); + +export const queryDocsInputSchema = z.object({ + context: projectContextSchema + .optional() + .describe("Optional safe project metadata used only to tailor the answer"), + query: z.string().min(1).max(2000).describe("Natural-language docs question"), +}); + +export const searchDocsInputSchema = z.object({ + limit: z + .number() + .int() + .min(1) + .max(20) + .optional() + .describe("Maximum number of matching pages to return (default 8)"), + query: z.string().min(1).describe("Keywords to search for"), +}); + +export type QueryDocsInput = z.infer; +export type SearchDocsInput = z.infer; +export type InternalDocsRequest = + | (QueryDocsInput & { operation: "query-docs" }) + | (SearchDocsInput & { operation: "search-docs" }); + +export const internalDocsRequestSchema: z.ZodType = + z.discriminatedUnion("operation", [ + queryDocsInputSchema.extend({ operation: z.literal("query-docs") }), + searchDocsInputSchema.extend({ operation: z.literal("search-docs") }), + ]); + +export function isUngroundedDocsError(error: unknown): boolean { + return error instanceof Error && UNGROUNDED_DOCS_ERROR_RE.test(error.message); +} + +/** Identify upstream model failures that can be safely explained to callers. */ +export function isModelUnavailableError(error: unknown): boolean { + return ( + error instanceof Error && MODEL_UNAVAILABLE_ERROR_RE.test(error.message) + ); +} + +export function executeDocsQuery( + apiKey: string | undefined, + input: QueryDocsInput, + docsVersion?: string, +) { + if (!apiKey) { + throw new Error("Set OPENROUTER_API_KEY on the docs-mcp worker"); + } + + // Start loading the index before the agent needs its search tool. Failures + // are retried by the agent, so they should not block the request here. + getDoctree().catch(() => { + // swallow + }); + + return runQueryAgent(apiKey, input.query, input.context, docsVersion); +} + +export async function executeDocsSearch(input: SearchDocsInput) { + return { results: await searchPages(input.query, input.limit ?? 8) }; +} + +export function executeInternalDocsRequest( + apiKey: string | undefined, + request: InternalDocsRequest, + docsVersion?: string, +) { + switch (request.operation) { + case "query-docs": + return executeDocsQuery(apiKey, request, docsVersion); + case "search-docs": + return executeDocsSearch(request); + default: + throw new Error("Unsupported docs operation"); + } +} diff --git a/apps/docs-mcp/src/mcp.ts b/apps/docs-mcp/src/mcp.ts new file mode 100644 index 000000000..39d4ad744 --- /dev/null +++ b/apps/docs-mcp/src/mcp.ts @@ -0,0 +1,270 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as Sentry from "@sentry/cloudflare"; +import { type ZodRawShape, z } from "zod"; +import { runFeaturesAgent } from "./agent/features-agent"; +import { runTailorAgent } from "./agent/tailor-agent"; +import { + executeDocsQuery, + executeDocsSearch, + type QueryDocsInput, + queryDocsInputSchema, + searchDocsInputSchema, +} from "./internal-api"; +import { trackToolCall, trackToolDuration } from "./metrics"; +import { getDoctree } from "./sentry-docs/doctree"; + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Build a JSON error envelope in the MCP `text` content shape. */ +function errorEnvelope(message: string) { + return { + content: [ + { + text: JSON.stringify({ error: message }), + type: "text" as const, + }, + ], + }; +} + +/** Wrap an error into a human-readable single-line message. */ +function errorMessage(prefix: string, err: unknown): string { + const detail = err instanceof Error ? err.message : String(err); + return `${prefix}: ${detail}`; +} + +/** Span attribute values are restricted to primitives. */ +type SpanAttrs = Record; + +interface AgentToolConfig { + /** Long-form description shown to MCP clients. */ + description: string; + /** Prefix used in user-facing error messages, e.g. "Failed to list features". */ + errorPrefix: string; + /** Zod input shape passed straight to MCP. */ + inputSchema: ZodRawShape; + /** MCP tool name, also used as the metric label. */ + name: string; + /** + * Run the agent. Receives the validated input and the worker's OpenRouter + * API key. Returns whatever native shape the agent produces (string, array, etc.). + */ + run: (input: TInput, apiKey: string | undefined) => Promise; + /** + * Convert the agent's native result into the MCP text payload. + * Public MCP input/output recording is handled by Sentry's MCP wrapper. + */ + serializeOutput: (result: TAgentResult) => string; + /** Non-overlapping custom span attributes not captured by MCP input/output recording. */ + spanAttributes?: (result: TAgentResult) => SpanAttrs; + /** Human-readable title shown to MCP clients. */ + title: string; +} + +/** + * Register an MCP tool that delegates to a Mastra-backed agent. + * + * Relies on `wrapMcpServerWithSentry` for MCP input/output spans, then + * handles shared app concerns: wall-clock duration metrics, exception + * capture, and the standard MCP `{ content: [{ type: "text", text }] }` + * shape on success or a JSON error envelope on failure. + * + * The closure captures the OpenRouter API key so each tool registration + * can short-circuit with a clear error message when it is missing instead + * of letting the agent fail downstream. + */ +function registerAgentTool( + mcp: McpServer, + apiKey: string | undefined, + config: AgentToolConfig, +): void { + mcp.registerTool( + config.name, + { + description: config.description, + inputSchema: config.inputSchema, + title: config.title, + }, + async (input: unknown) => { + const typedInput = input as TInput; + const start = Date.now(); + + // Eagerly warm the doctree cache so it's likely ready when the agent + // calls search_docs. Errors are swallowed — the agent will retry. + getDoctree().catch(() => { + // swallow + }); + + try { + if (!apiKey) { + throw new Error("Set OPENROUTER_API_KEY on the docs-mcp worker"); + } + + const result = await config.run(typedInput, apiKey); + const text = config.serializeOutput(result); + const durationMs = Date.now() - start; + const span = Sentry.getActiveSpan(); + + for (const [k, v] of Object.entries( + config.spanAttributes?.(result) ?? {}, + )) { + span?.setAttribute(k, v); + } + trackToolCall(config.name, true); + trackToolDuration(config.name, durationMs); + + return { + content: [ + { + text, + type: "text" as const, + }, + ], + }; + } catch (err) { + Sentry.getActiveSpan()?.setStatus({ code: 2, message: "error" }); + Sentry.captureException(err); + const durationMs = Date.now() - start; + const message = errorMessage(config.errorPrefix, err); + trackToolCall(config.name, false); + trackToolDuration(config.name, durationMs); + return errorEnvelope(message); + } + }, + ); +} + +// ── MCP Server ────────────────────────────────────────────────────── + +/** + * Build the MCP server. + * + * An OpenRouter API key is required at call time — each tool invocation + * throws a clear error if it is missing. The Mastra-backed tools route both + * their primary and fallback models through OpenRouter. + */ +export function createMcpServer( + apiKey: string | undefined, + docsVersion?: string, +) { + const mcp = Sentry.wrapMcpServerWithSentry( + new McpServer({ + description: + "Generates tailored Sentry integration docs by reading docs.sentry.io.", + name: "sentry-docs", + version: "1.0.0", + }), + { + recordInputs: true, + recordOutputs: true, + }, + ); + + // ── get-available-features ─────────────────────────────────────── + + registerAgentTool<{ libs: string[] }, string[]>(mcp, apiKey, { + description: + "Given the libraries/frameworks in the user's codebase, returns the canonical Sentry feature slugs available for that platform. " + + "Pass library slugs like 'nextjs', 'django', 'go', 'react', 'ruby', 'flutter', etc. " + + "Backed by an LLM agent that inspects docs.sentry.io; results are cached for 1h by libs-hash.", + errorPrefix: "Failed to list features", + inputSchema: { + libs: z + .array(z.string()) + .describe("Library/framework slugs from the user's codebase"), + }, + name: "get-available-features", + run: ({ libs }, key) => runFeaturesAgent(key, libs), + serializeOutput: (features) => JSON.stringify({ features }), + spanAttributes: (features) => ({ "agent.feature_count": features.length }), + title: "Get Available Features", + }); + + // ── search-docs ───────────────────────────────────────────────── + + mcp.registerTool( + "search-docs", + { + description: + "Search the public Sentry docs index. Returns deterministic ranked page titles, descriptions, and canonical URLs without invoking an LLM.", + inputSchema: searchDocsInputSchema.shape, + title: "Search Sentry Docs", + }, + async ({ limit, query }) => { + const start = Date.now(); + try { + const result = await executeDocsSearch({ limit, query }); + trackToolCall("search-docs", true); + trackToolDuration("search-docs", Date.now() - start); + return { + content: [ + { + text: JSON.stringify(result), + type: "text" as const, + }, + ], + }; + } catch (error) { + Sentry.captureException(error); + trackToolCall("search-docs", false); + trackToolDuration("search-docs", Date.now() - start); + return errorEnvelope(errorMessage("Failed to search docs", error)); + } + }, + ); + + // ── query-docs ────────────────────────────────────────────────── + + registerAgentTool( + mcp, + apiKey, + { + description: + "Answer a free-form Sentry documentation question using fetched docs.sentry.io pages. The result contains a cited Markdown answer and its validated source URLs.", + errorPrefix: "Failed to answer docs question", + inputSchema: queryDocsInputSchema.shape, + name: "query-docs", + run: (input, key) => executeDocsQuery(key, input, docsVersion), + serializeOutput: (result) => JSON.stringify(result), + spanAttributes: (result) => ({ + "docs.source_count": result.sources.length, + }), + title: "Query Sentry Docs", + }, + ); + + // ── get-docs ───────────────────────────────────────────────────── + + registerAgentTool< + { features: string[]; libs: string[]; stackSummary?: string }, + string + >(mcp, apiKey, { + description: + "Returns a tailored Sentry integration guide synthesized by an LLM agent " + + "from the live docs.sentry.io content. The output is a single coherent " + + "Markdown guide that integrates the user's selected features into one setup.", + errorPrefix: "Failed to tailor docs", + inputSchema: { + features: z + .array(z.string()) + .describe("Feature slugs to include (from get-available-features)"), + libs: z + .array(z.string()) + .describe("Library/framework slugs from the user's codebase"), + stackSummary: z + .string() + .optional() + .describe( + "Natural-language description of the project's tech stack for this context. Used to discover relevant integration docs beyond the base platform guide.", + ), + }, + name: "get-docs", + run: ({ features, libs, stackSummary }, key) => + runTailorAgent(key, libs, features, stackSummary), + serializeOutput: (docs) => docs, + spanAttributes: (docs) => ({ "docs.output_chars": docs.length }), + title: "Get Sentry Integration Docs", + }); + + return mcp; +} diff --git a/apps/docs-mcp/src/metrics.ts b/apps/docs-mcp/src/metrics.ts new file mode 100644 index 000000000..1051e945b --- /dev/null +++ b/apps/docs-mcp/src/metrics.ts @@ -0,0 +1,84 @@ +/** + * Centralized Sentry metrics helpers for the docs-mcp server. + * + * Follows the same pattern as getsentry/cli-init-api/apps/server/src/mastra/lib/metrics.ts. + * Uses the Sentry Metrics API (count, distribution) from @sentry/cloudflare. + */ +import * as Sentry from "@sentry/cloudflare"; + +// ── Tool Metrics ──────────────────────────────────────────────────── + +/** Track an MCP tool call (count). */ +export function trackToolCall(tool: string, success: boolean): void { + Sentry.metrics.count("docs_mcp.tool.call", 1, { + attributes: { tool, success }, + }); +} + +/** Track MCP tool call duration (distribution). */ +export function trackToolDuration(tool: string, durationMs: number): void { + Sentry.metrics.distribution("docs_mcp.tool.duration", durationMs, { + unit: "millisecond", + attributes: { tool }, + }); +} + +// ── Fetch Metrics ─────────────────────────────────────────────────── + +/** Track a docs.sentry.io fetch duration with cache hit/miss info. */ +export function trackFetchDuration( + url: string, + cacheHit: boolean, + durationMs: number, +): void { + Sentry.metrics.distribution("docs_mcp.fetch.duration", durationMs, { + unit: "millisecond", + attributes: { url, cache_hit: cacheHit }, + }); +} + +// ── Agent Metrics ─────────────────────────────────────────────────── + +/** Track LLM token usage for an agent call. */ +export function trackAgentTokens( + promptTokens: number, + completionTokens: number, +): void { + Sentry.metrics.distribution("docs_mcp.agent.tokens.prompt", promptTokens, { + attributes: { kind: "prompt" }, + }); + Sentry.metrics.distribution( + "docs_mcp.agent.tokens.completion", + completionTokens, + { attributes: { kind: "completion" } }, + ); +} + +/** Track how many tool-call steps the agent took before finishing. */ +export function trackAgentSteps(stepCount: number): void { + Sentry.metrics.distribution("docs_mcp.agent.steps", stepCount); +} + +/** Track an agent invocation (success/failure count). */ +export function trackAgentCall(success: boolean): void { + Sentry.metrics.count("docs_mcp.agent.call", 1, { + attributes: { success }, + }); +} + +/** + * Track when the tailor agent's primary call returned empty text and + * we had to run a fallback synthesis pass. + */ +export function trackAgentFallbackSynthesis(): void { + Sentry.metrics.count("docs_mcp.agent.fallback_synthesis", 1); +} + +// ── Cache Metrics ─────────────────────────────────────────────────── + +/** Track an agent-result cache lookup (hit/miss). */ +export function trackAgentCacheLookup(scope: string, hit: boolean): void { + Sentry.metrics.count("docs_mcp.agent.cache", 1, { + attributes: { scope, hit }, + }); +} diff --git a/apps/docs-mcp/src/sentry-docs/doctree.ts b/apps/docs-mcp/src/sentry-docs/doctree.ts new file mode 100644 index 000000000..15b882e65 --- /dev/null +++ b/apps/docs-mcp/src/sentry-docs/doctree.ts @@ -0,0 +1,454 @@ +import * as Sentry from "@sentry/cloudflare"; +import type { DocPageHit, DocTreeNode } from "../types"; +import { buildDocsUrl, fetchDocTree, normalizeDocPath } from "./fetcher"; + +/** Trailing slash, hoisted per lint/performance/useTopLevelRegex. */ +const TRAILING_SLASH_RE = /\/$/; + +/** + * In-isolate cache for the parsed doctree. + * + * The CF Cache API holds the raw JSON for 12h (see fetcher.ts), but + * parsing 6MB of JSON on every request is wasteful, so we keep the + * parsed root in memory for the isolate's lifetime. Isolates are + * short-lived and can be evicted at any time, so this is safe to + * cache without invalidation. + */ +let doctreeCache: DocTreeNode | null = null; + +/** + * In-flight promise for deduplicating concurrent doctree fetches. + * + * When multiple tool calls fire in parallel (e.g. 3 search_docs calls), + * only the first one should actually fetch the 6MB doctree.json. The + * others await the same promise, avoiding redundant network requests + * and the "Large HTTP payload" performance issue (CLI-DOCS-MCP-4). + */ +let doctreeInflight: Promise | null = null; + +/** + * Parse and return the docs.sentry.io doctree, with in-isolate caching. + * + * Exported so callers can eagerly warm the cache at request start, + * ensuring the doctree is ready before the agent needs it. + */ +export function getDoctree(): Promise { + if (doctreeCache) { + return Promise.resolve(doctreeCache); + } + + // Piggyback on in-flight fetch if one exists. + if (doctreeInflight) { + return doctreeInflight; + } + + // Start fetch; concurrent callers will await this same promise. + doctreeInflight = (async () => { + try { + const json = await fetchDocTree(); + doctreeCache = JSON.parse(json) as DocTreeNode; + return doctreeCache; + } finally { + // Clear in-flight marker so next call retries on failure. + doctreeInflight = null; + } + })(); + + return doctreeInflight; +} + +/** + * Lib slug → canonical Sentry docs platform path. + * + * When a caller passes recognized library slugs, this seeds the agent with + * the right docs.sentry.io paths instead of relying on full doctree search. + * + * For framework-flavored guides (nextjs, django, rails, etc.) the path + * is `/platforms//guides//`; for the bare runtime/SDK + * it's just `/platforms//`. + */ +const LIB_TO_PLATFORM_PATH: Record = { + ".net": "/platforms/dotnet/", + android: "/platforms/android/", + angular: "/platforms/javascript/guides/angular/", + astro: "/platforms/javascript/guides/astro/", + "aws-lambda": "/platforms/javascript/guides/aws-lambda/", + "azure-functions": "/platforms/javascript/guides/azure-functions/", + browser: "/platforms/javascript/", + bun: "/platforms/javascript/guides/bun/", + capacitor: "/platforms/javascript/guides/capacitor/", + celery: "/platforms/python/integrations/celery/", + cloudflare: "/platforms/javascript/guides/cloudflare/", + cocoa: "/platforms/apple/", + dart: "/platforms/dart/", + deno: "/platforms/javascript/guides/deno/", + django: "/platforms/python/integrations/django/", + dotnet: "/platforms/dotnet/", + electron: "/platforms/javascript/guides/electron/", + elixir: "/platforms/elixir/", + ember: "/platforms/javascript/guides/ember/", + express: "/platforms/javascript/guides/express/", + fastapi: "/platforms/python/integrations/fastapi/", + fastify: "/platforms/javascript/guides/fastify/", + firebase: "/platforms/javascript/guides/firebase/", + flask: "/platforms/python/integrations/flask/", + flutter: "/platforms/dart/guides/flutter/", + gatsby: "/platforms/javascript/guides/gatsby/", + "gcp-functions": "/platforms/javascript/guides/gcp-functions/", + // Other ecosystems + go: "/platforms/go/", + hapi: "/platforms/javascript/guides/hapi/", + hono: "/platforms/javascript/guides/hono/", + ios: "/platforms/apple/guides/ios/", + java: "/platforms/java/", + javascript: "/platforms/javascript/", + koa: "/platforms/javascript/guides/koa/", + kotlin: "/platforms/android/", + laravel: "/platforms/php/guides/laravel/", + nestjs: "/platforms/javascript/guides/nestjs/", + next: "/platforms/javascript/guides/nextjs/", + "next.js": "/platforms/javascript/guides/nextjs/", + // JavaScript ecosystem + nextjs: "/platforms/javascript/guides/nextjs/", + node: "/platforms/javascript/guides/node/", + "node.js": "/platforms/javascript/guides/node/", + nuxt: "/platforms/javascript/guides/nuxt/", + php: "/platforms/php/", + // Python ecosystem + python: "/platforms/python/", + rails: "/platforms/ruby/guides/rails/", + react: "/platforms/javascript/guides/react/", + "react native": "/platforms/react-native/", + "react-native": "/platforms/react-native/", + "react-router": "/platforms/javascript/guides/react-router/", + remix: "/platforms/javascript/guides/remix/", + ruby: "/platforms/ruby/", + rust: "/platforms/rust/", + // Other JS frameworks / runtimes + solid: "/platforms/javascript/guides/solid/", + solidstart: "/platforms/javascript/guides/solidstart/", + svelte: "/platforms/javascript/guides/svelte/", + sveltekit: "/platforms/javascript/guides/sveltekit/", + "tanstack-start": "/platforms/javascript/guides/tanstackstart-react/", + "tanstack-start-react": "/platforms/javascript/guides/tanstackstart-react/", + // TanStack Start (cover the common slug spellings the wizard might emit) + "tanstackstart-react": "/platforms/javascript/guides/tanstackstart-react/", + vue: "/platforms/javascript/guides/vue/", +}; + +/** + * Feature slug → docs sub-path under a platform guide. + * + * Most Sentry platform guides follow the same convention: + * /platforms/// (e.g. /platforms/javascript/guides/nextjs/tracing/) + * + * The feature slugs match the ones returned by skills.sentry.dev / + * the existing get-available-features tool. + */ +const FEATURE_TO_SUBPATH: Record = { + "ai-monitoring": "tracing/instrumentation/ai-agents-module/", + crons: "crons/", + "error-monitoring": "", // base getting-started page covers errors + logging: "logs/", + logs: "logs/", + "mcp-observability": "", + metrics: "metrics/", + performance: "tracing/", + profiling: "profiling/", + "session-replay": "session-replay/", + "source-maps": "sourcemaps/", + sourcemaps: "sourcemaps/", + tracing: "tracing/", + "user-feedback": "user-feedback/", +}; + +const FEATURE_EXTRA_PATHS: Record = { + "mcp-observability": ["/ai/monitoring/mcp/getting-started/"], +}; + +// ── Page discovery ────────────────────────────────────────────────── + +/** + * Resolve a lib slug to its canonical docs.sentry.io platform path. + * Returns null if the lib isn't recognized. + */ +export function libToPlatformPath(lib: string): string | null { + const normalized = lib.toLowerCase().trim(); + return LIB_TO_PLATFORM_PATH[normalized] ?? null; +} + +/** + * Build a candidate doc path for a (lib, feature) combo using the + * hardcoded maps. Returns null if either lib or feature is unknown. + */ +export function libFeaturePath(lib: string, feature: string): string | null { + const platformPath = libToPlatformPath(lib); + if (!platformPath) { + return null; + } + const subpath = FEATURE_TO_SUBPATH[feature.toLowerCase().trim()]; + if (subpath === undefined) { + return null; + } + // Concatenate, ensuring we don't double-slash + return `${platformPath.replace(TRAILING_SLASH_RE, "")}/${subpath}`.replace( + TRAILING_SLASH_RE, + "", + ); +} + +/** + * Default cap on seed paths returned to the tailor agent. + * + * Seeds are hints — a head start so the agent doesn't spend steps + * discovering pages it could have been told about upfront. The real + * guardrail is the MCP client timeout set by the caller, not this cap. + * + * 20 comfortably covers any single-lib stack (max ~11 paths) and most + * multi-lib stacks. The agent fetches all seeds in parallel in one step, + * so a larger seed set does not cost extra steps. + */ +const DEFAULT_SEED_LIMIT = 20; + +/** + * Find candidate doc pages for a (libs[], features[]) combo. + * + * Strategy: + * 1. For each lib, add the platform's getting-started page (always preferred). + * 2. For each (lib, feature) combo, add the feature's sub-page if mapped. + * 3. Add global feature docs such as MCP Observability's setup guide, + * in feature-list order, until `limit` is reached. + * 4. Returns deduplicated, normalized paths. + * + * This is the *seed* set the agent starts from. The agent is free to + * call search_docs / fetch_doc_page to expand beyond these. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ordered seed selection keeps platform/feature precedence explicit +export function findPagesForLibsFeatures( + libs: string[], + features: string[], + limit: number = DEFAULT_SEED_LIMIT, +): string[] { + const paths = new Set(); + + // Always seed the platform path(s) first — the getting-started guide + // is the most important page for any synthesis. + // + // Also seed the two sub-page patterns that Sentry platforms use for + // package installation instructions: + // install/ — iOS, Node.js, most standalone JS frameworks + // manual-setup/ — Next.js, SvelteKit, Android, Flutter, React Native + // + // Both are fetched in parallel with the rest of the seeds so there is no + // extra step cost. Pages that don't exist return empty and are silently + // ignored by the agent. Platforms that embed install instructions in their + // root index.mdx (Python, Ruby, Go, etc.) are already covered by the + // platform root seed above. + for (const lib of libs) { + if (paths.size >= limit) { + break; + } + const platformPath = libToPlatformPath(lib); + if (platformPath) { + const base = platformPath.replace(TRAILING_SLASH_RE, ""); + paths.add(normalizeDocPath(platformPath)); + paths.add(normalizeDocPath(`${base}/install/`)); + paths.add(normalizeDocPath(`${base}/manual-setup/`)); + } + } + + // Then add per-feature paths up to the limit. Iterating features in + // user-provided order means the user's first-listed features get the + // remaining seed slots. + outer: for (const feature of features) { + for (const lib of libs) { + if (paths.size >= limit) { + break outer; + } + const path = libFeaturePath(lib, feature); + if (path) { + paths.add(normalizeDocPath(path)); + } + } + const extraPaths = FEATURE_EXTRA_PATHS[feature.toLowerCase().trim()] ?? []; + for (const extraPath of extraPaths) { + if (paths.size >= limit) { + break outer; + } + paths.add(normalizeDocPath(extraPath)); + } + } + + return [...paths]; +} + +// ── Doctree search ────────────────────────────────────────────────── + +/** + * Walk the doctree, yielding every node depth-first. + * + * Skips nodes flagged `sidebar_hidden` since those aren't user-facing. + */ +function* walkDoctree(node: DocTreeNode): Generator { + if (node.frontmatter?.sidebar_hidden) { + return; + } + yield node; + if (node.children) { + for (const child of node.children) { + yield* walkDoctree(child); + } + } +} + +/** + * Path segments that are semantically equivalent to "install" / "setup". + * Used to boost pages like /manual-setup/ when the query is install-related, + * so they rank alongside /install/ pages rather than scoring zero. + */ +const INSTALL_SYNONYMS_RE = + /\/(install|manual-setup|manual-install|quick-start|getting-started)\//; + +/** Terms which carry little meaning in a natural-language docs question. */ +const SEARCH_STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "at", + "by", + "do", + "for", + "how", + "i", + "in", + "is", + "of", + "on", + "the", + "to", + "what", + "with", + "you", + "your", +]); + +/** Bound per-node token checks for the full doctree traversal. */ +export const MAX_SEARCH_TERMS = 24; + +function searchTerms(query: string): string[] { + const terms = query.toLowerCase().match(/[a-z0-9][a-z0-9+.#-]*/g) ?? []; + return [...new Set(terms.filter((term) => term.length >= 3))] + .filter((term) => !SEARCH_STOP_WORDS.has(term)) + .slice(0, MAX_SEARCH_TERMS); +} + +/** Score a node for relevance against a normalized query. */ +function scoreNode( + node: DocTreeNode, + queryLower: string, + terms: readonly string[], + isInstallQuery: boolean, +): number { + const title = node.frontmatter?.title?.toLowerCase() ?? ""; + const description = node.frontmatter?.description?.toLowerCase() ?? ""; + const path = node.path.toLowerCase(); + + let score = 0; + if (title === queryLower) { + score += 100; + } + if (title.includes(queryLower)) { + score += 25; + } + if (path.includes(queryLower)) { + score += 15; + } + if (description.includes(queryLower)) { + score += 5; + } + + // Full questions rarely appear verbatim in a doctree. Score their meaningful + // terms independently so a question such as "How do I configure Next.js + // tracing?" can still surface the tracing guide. + for (const term of terms) { + if (title === term) { + score += 30; + } + if (title.includes(term)) { + score += 12; + } + if (path.includes(term)) { + score += 8; + } + if (description.includes(term)) { + score += 4; + } + } + + // When the query is install/setup-related, treat synonymous path segments + // as near-matches. Score 12 — just below the 15 for exact path match — + // so /manual-setup/ pages surface for "install" queries on platforms like + // Next.js, Android, Flutter, and React Native that don't use /install/. + if (isInstallQuery && INSTALL_SYNONYMS_RE.test(path)) { + score += 12; + } + + return score; +} + +/** + * Term-aware search over the doctree's titles, descriptions, and paths. + * + * Not a vector search — scoring uses exact phrases plus meaningful query terms, + * which is fast and deterministic for agent terms and user questions alike. + * + * @param query - Free-text search string (case-insensitive). + * @param limit - Max results to return (default 8). + */ +export function searchDocTree( + root: DocTreeNode, + query: string, + limit = 8, +): DocPageHit[] { + const queryLower = query.toLowerCase().trim(); + + if (!queryLower) { + return []; + } + const terms = searchTerms(queryLower); + const isInstallQuery = ["install", "setup", "manual"].some((keyword) => + queryLower.includes(keyword), + ); + + const hits: Array<{ node: DocTreeNode; score: number }> = []; + for (const node of walkDoctree(root)) { + const score = scoreNode(node, queryLower, terms, isInstallQuery); + if (score > 0) { + hits.push({ node, score }); + } + } + + hits.sort( + (a, b) => b.score - a.score || a.node.path.localeCompare(b.node.path), + ); + return hits.slice(0, limit).map(({ node }) => ({ + description: node.frontmatter?.description, + title: node.frontmatter?.title ?? node.slug ?? node.path, + url: buildDocsUrl(node.path), + })); +} + +export function searchPages(query: string, limit = 8): Promise { + return Sentry.startSpan( + { name: "searchDoctree", op: "docs.search" }, + async (span) => { + span.setAttribute("docs.query_term_count", searchTerms(query).length); + + const root = await getDoctree(); + const results = searchDocTree(root, query, limit); + span.setAttribute("docs.hit_count", results.length); + return results; + }, + ); +} diff --git a/apps/docs-mcp/src/sentry-docs/fetcher.ts b/apps/docs-mcp/src/sentry-docs/fetcher.ts new file mode 100644 index 000000000..ddb72a527 --- /dev/null +++ b/apps/docs-mcp/src/sentry-docs/fetcher.ts @@ -0,0 +1,151 @@ +import * as Sentry from "@sentry/cloudflare"; +import { trackFetchDuration } from "../metrics"; + +const BASE_URL = "https://docs.sentry.io"; +const FETCH_TIMEOUT_MS = 15_000; // 15s — .md endpoint is CDN-served, fail fast if slow + +// Regex literals are hoisted to module scope per the lint rule +// (lint/performance/useTopLevelRegex) — avoids re-compilation on every call. +const TRAILING_MD_RE = /\.md$/; +const TRAILING_SLASHES_RE = /\/+$/; + +/** + * Cache TTLs. + * + * - `doctree.json` is the global hierarchical sitemap (~6MB) and changes + * only when new docs are published. 12h keeps it fresh enough. + * - Individual `.md` pages get a 1h TTL. + */ +const DOCTREE_TTL = 43_200; // 12 hours +const PAGE_TTL = 3600; // 1 hour + +/** + * Fetch a URL with Cloudflare Cache API caching. + * + * Cache hits return immediately. Misses go to docs.sentry.io with a + * 15s timeout and store the body in the per-zone CF Cache for the + * requested TTL. The Cache API is unavailable outside of CF Workers, + * so the cache layer no-ops in non-CF runtimes. + */ +function cachedFetch(url: string, ttlSeconds: number): Promise { + return Sentry.startSpan( + { op: "http.client", name: `fetch ${url}` }, + async (span) => { + const start = Date.now(); + + let cache: Cache | undefined; + try { + cache = (caches as CacheStorage & { default?: Cache })?.default; + } catch { + // Cache API not available (local dev / non-CF runtime) + } + + const request = new Request(url); + + if (cache) { + const cached = await cache.match(request); + if (cached) { + span.setAttribute("cache.hit", true); + span.setAttribute("http.url", url); + const body = await cached.text(); + const durationMs = Date.now() - start; + trackFetchDuration(url, true, durationMs); + return body; + } + } + + span.setAttribute("cache.hit", false); + const response = await fetch(request, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + span.setAttribute("http.url", url); + span.setAttribute("http.response.status_code", response.status); + + if (!response.ok) { + throw new Error( + `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + ); + } + + const body = await response.text(); + span.setAttribute("http.response_content_length", body.length); + + // Store in CF Cache with the requested TTL + if (cache) { + const cacheResponse = new Response(body, { + headers: { + "Cache-Control": `public, max-age=${ttlSeconds}`, + "Content-Type": url.endsWith(".json") + ? "application/json; charset=utf-8" + : "text/markdown; charset=utf-8", + }, + }); + await cache.put(request, cacheResponse); + } + + const durationMs = Date.now() - start; + trackFetchDuration(url, false, durationMs); + return body; + }, + ); +} + +/** + * Normalize a docs.sentry.io path so it works with the .md API. + * + * Accepts: + * - "/platforms/javascript/guides/nextjs/" + * - "/platforms/javascript/guides/nextjs" + * - "platforms/javascript/guides/nextjs" + * - "https://docs.sentry.io/platforms/javascript/guides/nextjs/" + * - "/platforms/javascript/guides/nextjs/.md" (no-op) + * + * Returns a path with a single leading slash and no trailing slash. + */ +export function normalizeDocPath(path: string): string { + let p = path.trim(); + // Strip absolute URL prefix + if (p === BASE_URL || p.startsWith(`${BASE_URL}/`)) { + p = p.slice(BASE_URL.length); + } + // Strip trailing .md if a caller mistakenly included it + p = p.replace(TRAILING_MD_RE, ""); + // Collapse trailing slashes + p = p.replace(TRAILING_SLASHES_RE, ""); + // Ensure single leading slash + if (!p.startsWith("/")) { + p = `/${p}`; + } + return p; +} + +/** + * Fetch the pre-rendered Markdown for a single docs.sentry.io page. + * + * Hits `https://docs.sentry.io/.md`. The .md endpoint returns + * an LLM-friendly Markdown export with YAML frontmatter, no MDX components. + * + * @param path - Docs path (with or without leading slash, .md suffix, etc.) + * Examples: "/platforms/javascript/guides/nextjs/", + * "platforms/javascript/guides/nextjs" + */ +export function fetchDocPage(path: string): Promise { + const normalized = normalizeDocPath(path); + return cachedFetch(`${BASE_URL}${normalized}.md`, PAGE_TTL); +} + +/** + * Fetch the global doctree.json sitemap from docs.sentry.io. + * + * Returns the raw JSON string — caller is responsible for parsing. + * Cached for 12h since it's ~6MB and changes infrequently. + */ +export function fetchDocTree(): Promise { + return cachedFetch(`${BASE_URL}/doctree.json`, DOCTREE_TTL); +} + +/** Build the public docs.sentry.io URL for a given path (no .md suffix). */ +export function buildDocsUrl(path: string): string { + return `${BASE_URL}${normalizeDocPath(path)}/`; +} diff --git a/apps/docs-mcp/src/stubs/execa.ts b/apps/docs-mcp/src/stubs/execa.ts new file mode 100644 index 000000000..fc91a88fe --- /dev/null +++ b/apps/docs-mcp/src/stubs/execa.ts @@ -0,0 +1,7 @@ +/** + * Mastra's local workspace can launch processes through execa, which is not + * available in Workers. Keep that unsupported path out of the Worker bundle. + */ +export function execa(): never { + throw new Error("Local process execution is unavailable in this Worker"); +} diff --git a/apps/docs-mcp/src/types.ts b/apps/docs-mcp/src/types.ts new file mode 100644 index 000000000..ae1eda374 --- /dev/null +++ b/apps/docs-mcp/src/types.ts @@ -0,0 +1,43 @@ +// ── Cloudflare Worker Env ─────────────────────────────────────────── + +/** Runtime shape of Cloudflare's version_metadata binding. */ +export interface WorkerVersionMetadata { + id: string; + tag: string; + timestamp: string; +} + +export interface Env { + CF_VERSION_METADATA: WorkerVersionMetadata; + /** OpenRouter API key — routes both the primary and fallback docs agent models. */ + OPENROUTER_API_KEY?: string; +} + +// ── Sentry Docs Types ─────────────────────────────────────────────── + +/** A single node in the docs.sentry.io doctree.json. */ +export interface DocTreeNode { + /** Child nodes nested under this path. */ + children?: DocTreeNode[]; + /** Frontmatter fields parsed from the page's MDX. */ + frontmatter?: { + description?: string; + sidebar_hidden?: boolean; + sidebar_order?: number; + title?: string; + }; + /** Path relative to docs.sentry.io root, e.g. "/platforms/javascript/guides/nextjs/". */ + path: string; + /** Slug fragment, e.g. "nextjs". */ + slug?: string; +} + +/** A search hit returned by searchPages(). */ +export interface DocPageHit { + /** Optional one-line summary from frontmatter. */ + description?: string; + /** Display title from frontmatter. */ + title: string; + /** Absolute docs.sentry.io URL of the page (without the .md suffix). */ + url: string; +} diff --git a/apps/docs-mcp/tsconfig.json b/apps/docs-mcp/tsconfig.json new file mode 100644 index 000000000..d349c64f7 --- /dev/null +++ b/apps/docs-mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ESNext", "WebWorker"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", ".wrangler"] +} diff --git a/apps/docs-mcp/wrangler.jsonc b/apps/docs-mcp/wrangler.jsonc new file mode 100644 index 000000000..7d4a22019 --- /dev/null +++ b/apps/docs-mcp/wrangler.jsonc @@ -0,0 +1,24 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "docs-mcp", + "workers_dev": false, + "preview_urls": false, + "main": "src/index.ts", + "compatibility_date": "2026-02-26", + "compatibility_flags": ["nodejs_compat"], + "alias": { + "execa": "./src/stubs/execa.ts", + }, + "upload_source_maps": true, + "version_metadata": { + "binding": "CF_VERSION_METADATA", + }, + "observability": { + "logs": { + "enabled": true, + }, + }, + "dev": { + "inspector_port": 9230, + }, +} diff --git a/docs/README.md b/docs/README.md index 9c9efe503..c74d1a52c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,8 @@ you are working on: and the published CLI documentation site. - [Sentry MCP](mcp/README.md) — tools, transports, Cloudflare, operations, and MCP-specific tests and specs. +- [Docs MCP](../apps/docs-mcp/README.md) — the independent private documentation + Worker, local development, and deployment ownership. The published CLI site lives in `apps/cli-docs/` because CLI releases build and package it from that workspace. The contributor index here links to its diff --git a/packages/mcp-cloudflare/vite.config.ts b/packages/mcp-cloudflare/vite.config.ts index 0f96c1528..93ffe5006 100644 --- a/packages/mcp-cloudflare/vite.config.ts +++ b/packages/mcp-cloudflare/vite.config.ts @@ -5,8 +5,13 @@ import { cloudflare } from "@cloudflare/vite-plugin"; import { sentryCloudflareVitePlugin } from "@sentry/cloudflare/vite"; import tailwindcss from "@tailwindcss/vite"; import path from "node:path"; +import { createRequire } from "node:module"; import { generateHomepageFallbackHtml } from "./src/homepage-content"; +const sentrySdkPackage = createRequire(import.meta.url).resolve( + "@sentry/cloudflare/package.json", +); + const STATIC_HOME_RE = /[\s\S]*?/; @@ -45,9 +50,21 @@ export default defineConfig({ }), ], resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, + alias: [ + { find: "@", replacement: path.resolve(__dirname, "./src") }, + { + // Instrumented dependencies must use this Worker's SDK, even when + // another workspace hoists a different Sentry major into node_modules. + find: /^@sentry\/(cloudflare|server-utils)(\/.*)?$/, + replacement: "$&", + customResolver(source, _importer, options) { + return this.resolve(source, sentrySdkPackage, { + ...options, + skipSelf: true, + }); + }, + }, + ], }, build: { sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd9ce6293..885adef6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ catalogs: version: 4.4.3 overrides: + ajv@8.20.0>fast-uri: 3.1.8 + express-rate-limit@8>ip-address: 10.7.4 + js-yaml@3.15.2>argparse: 2.0.1 '@modelcontextprotocol/sdk': 1.32.0 fast-xml-parser: 5.7.2 picomatch@2.3.1: 2.3.2 @@ -189,6 +192,8 @@ overrides: sharp: ^0.35.4 undici: ^7.24.4 +packageExtensionsChecksum: sha256-PIoL2jn0sFpFHgcMKW97dNeqBXVNxH6qFIzsWIBFOMY= + patchedDependencies: '@ai-sdk/provider-utils@2.2.8': 4649a74595006c4a262d453549e091db331ade06b4f4320957af1b2c659b8ee3 '@cloudflare/workers-oauth-provider@0.10.3': 34910b67929c1888b1492384c514d6baeedc4a439f66e21375dee5af404834f8 @@ -281,6 +286,46 @@ importers: specifier: 'catalog:' version: 4.151.3(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) + apps/docs-mcp: + dependencies: + '@hono/mcp': + specifier: ^0.2.3 + version: 0.2.5(@modelcontextprotocol/sdk@1.32.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(hono-rate-limiter@0.4.2(hono@4.13.13))(hono@4.13.13)(zod@4.5.4) + '@mastra/core': + specifier: ^1.63.2 + version: 1.63.2(ai@5.0.216(zod@4.5.4))(express@5.3.0)(zod@4.5.4) + '@modelcontextprotocol/sdk': + specifier: 1.32.0 + version: 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.5.4) + '@openrouter/ai-sdk-provider': + specifier: ^1.5.4 + version: 1.5.4(ai@5.0.216(zod@4.5.4))(zod@4.5.4) + '@sentry/cloudflare': + specifier: ^10.62.0 + version: 10.67.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) + ai: + specifier: ^5.0.0 + version: 5.0.216(zod@4.5.4) + hono: + specifier: ^4.13.11 + version: 4.13.13 + zod: + specifier: ^4.5.4 + version: 4.5.4 + devDependencies: + '@types/node': + specifier: ^22 + version: 22.20.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.1)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + wrangler: + specifier: 'catalog:' + version: 4.151.3(@types/node@22.20.1) + apps/local: dependencies: '@radix-ui/react-dialog': @@ -993,6 +1038,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@2.0.115': + resolution: {integrity: sha512-BLN5eLjzg6mKkbxAf6oP8Z7mEyRihrDzBJr7QlNPZTFvgZJ15peoflUJuskTCZFaisqelj1P4Se1pHY8QBBgBA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.153': resolution: {integrity: sha512-R5r567od2Olqze/lL2dVS4wVOVyVqG6sJ3cEUI/kW12XQJYKf8fetyxWPYS0ZFe87QPcsh3Z3sDnrMAtAIiS3Q==} engines: {node: '>=18'} @@ -1023,6 +1074,12 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/provider-utils@3.0.30': + resolution: {integrity: sha512-NCJ9JKow5ENAgEZxzvEvF20thwDiH+hutvzmrUDbloRX0azpJHNst8+7pZIVryYhLM9wgpT5/ShTSjPTFhkxEQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.11': resolution: {integrity: sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ==} engines: {node: '>=18'} @@ -1088,13 +1145,24 @@ packages: resolution: {integrity: sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==} engines: {node: '>=18.0.0'} + '@apm-js-collab/code-transformer-bundler-plugins@0.7.1': + resolution: {integrity: sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA==} + engines: {node: '>=18.0.0'} + '@apm-js-collab/code-transformer@0.15.0': resolution: {integrity: sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==} hasBin: true + '@apm-js-collab/code-transformer@0.18.0': + resolution: {integrity: sha512-aN3Oq8r1J3gPJtCwErP664gM0+HhM1I1lujPr9TMTCcEl/joQQbpGpeMdts9B1+W2wHMsvioDMv5F4PvMWE6gw==} + hasBin: true + '@apm-js-collab/tracing-hooks@0.10.1': resolution: {integrity: sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==} + '@apm-js-collab/tracing-hooks@0.13.0': + resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2927,6 +2995,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@mastra/core@1.63.2': + resolution: {integrity: sha512-BHVDF4GtQnIqRND/lPVVoTnmoNzZ7+voz+EpI4YSY0W7In6c/EOrmMnshuNQSDJ9NpxQNrwip0WGYpRYwzFirQ==} + engines: {node: '>=22.13.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@mastra/core@1.67.0': resolution: {integrity: sha512-Z+kVa5SXpZ7c1xrycGOZ8tGFTDAeXYR+6rC8gAKY/m6Ac9dOPDaPCfQRmFHFxAgKIuxDj+bsauFPTnpkl9S4lw==} engines: {node: '>=22.13.0'} @@ -2939,6 +3013,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@mastra/schema-compat@1.3.7': + resolution: {integrity: sha512-06WfY+j9rulYnDp8CM7pL7JIyrntkMolqmyyb1VJNEzlYTQCIcJcY0SwQiubZv2tztmoYGikszDiRVyXp0E9gA==} + engines: {node: '>=22.13.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -3014,6 +3094,16 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openrouter/ai-sdk-provider@1.5.4': + resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} + engines: {node: '>=18'} + peerDependencies: + ai: ^5.0.0 + zod: ^3.24.1 || ^v4 + + '@openrouter/sdk@0.1.27': + resolution: {integrity: sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -3032,6 +3122,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/instrumentation@0.220.0': resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3044,6 +3140,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.11.0': resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3051,12 +3153,25 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.9.0': + resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} + engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace@2.11.0': resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -3528,9 +3643,15 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} + '@posthog/core@1.50.1': + resolution: {integrity: sha512-REG9hSmWdQhPvXJfZuH0cwvveq5lS3wAluNSav7mqiaZszALRL4+4qS5FFO2gpgvV5D7OIhkJFB4ZKFbzqbviA==} + '@posthog/core@1.54.3': resolution: {integrity: sha512-Xttyf9OSViNw89aMW1Cdpl9dCY0t00FqzDc1JLJsxhqEwBqUP9aCzNToPt7NVu+I0M9yp7608abNYdO8hal7nQ==} + '@posthog/types@1.407.1': + resolution: {integrity: sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==} + '@posthog/types@1.412.1': resolution: {integrity: sha512-FxXsb9YOOME8bJI5K09qKeSvLjnZQ2dPV7wZpI7a8sERQtXkAuhBcPB/balCnVE7TYwhgtHj5NQss9BgQ5bAfQ==} @@ -4337,6 +4458,15 @@ packages: engines: {node: '>= 10'} hasBin: true + '@sentry/cloudflare@10.67.0': + resolution: {integrity: sha512-QNaHgNKBevqJ8JgaBDwNm6kkQQICuxCvLEAvzF3uC3MFlHrx2X1pMa8SwHU65rPCLumGRyWJxTrQgpmHwGbgaQ==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x || ^5.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@sentry/cloudflare@11.6.0': resolution: {integrity: sha512-FOwluoZmgE60JLxQBKUw0smiz3FaVsmtuvslkgLb+9pUbxGsjnLf1RX8F99vPSCB724Q916asImCHTlWRynUSQ==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} @@ -4373,6 +4503,10 @@ packages: resolution: {integrity: sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==} engines: {node: '>=18'} + '@sentry/core@10.67.0': + resolution: {integrity: sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==} + engines: {node: '>=18'} + '@sentry/core@10.75.0': resolution: {integrity: sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==} engines: {node: '>=18'} @@ -4435,6 +4569,27 @@ packages: '@opentelemetry/sdk-trace-base': optional: true + '@sentry/node-core@10.67.0': + resolution: {integrity: sha512-dBHHRwZyan1pOnFJ+sNBvR8TkXbZAfZU/jpxmALS3JZ2/8AGR7cQKL+b7SleKuJ7iUDZyklN3Nqi0i5JkcA+HA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@sentry/node-core@10.75.0': resolution: {integrity: sha512-+E3KSX1oMqhpWQ23hj6NblIVUzLy3T2oceU7DfMX1s1ar+npaQZxR5K0/cMGcQgS1LLm8Jqwqo/lrZlKTCDm5A==} engines: {node: '>=18'} @@ -4460,6 +4615,10 @@ packages: resolution: {integrity: sha512-t35dcdyksysVch/m/XdLgGJqGKJhr9eMD30Ctn3TeQ8yMB0wNXySfjPR5Yg93fpjmfaHtzc6iYIXRAvgNVfrvA==} engines: {node: '>=18'} + '@sentry/node@10.67.0': + resolution: {integrity: sha512-SFKpZGqOCEFSmP93NdDP6ikZp4NS7A/JR8+2ofK3jF6Y9Vyox7pX0pxdOnPLpFcPtMybMahfWqSAWJvsFs4RmA==} + engines: {node: '>=18'} + '@sentry/node@10.75.0': resolution: {integrity: sha512-XdYW+SEiscQnuugh1hMldfnHurmleKSTSDqgfro6Y1yd7s0r2csnZ5/SEYxIeL4lSl1QXg1lIA1pBmAXjcdnKA==} engines: {node: '>=18'} @@ -4484,6 +4643,14 @@ packages: '@opentelemetry/core': ^1.30.1 || ^2.1.0 '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@sentry/opentelemetry@10.67.0': + resolution: {integrity: sha512-oLTOrAK1rOqmYRktOJZwz37B1seXPx1W2FTMVtzTVNjMFA/LZwGzePeZzhUOgzZgfLHixMd/ceWtGqoxAndcjQ==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@sentry/opentelemetry@10.75.0': resolution: {integrity: sha512-reJoMtuHMuaiztoDVoaeitc22eHlTAvz2qpFhibWyzukJlF8oXB+o8EvDN9mmDDMedU89c//cXRcnPBlJvUxcw==} engines: {node: '>=18'} @@ -4532,6 +4699,10 @@ packages: resolution: {integrity: sha512-80toEFD6s+0Le7jrYB6pHWLF703WSg0WyavAWqrBGWG8JkREHgedAxzFYgoY5GlMI756qk6Ea7UzhJTHd2zAXA==} engines: {node: '>=18'} + '@sentry/server-utils@10.67.0': + resolution: {integrity: sha512-GQ9t+RSTx5s3b/aZrLFuL4nrwPLMah5NiZk5cjxJmgmOSgm3nMdO8gdqCedDch5B13F3YsxtaQYjSJnzLz8M5A==} + engines: {node: '>=18'} + '@sentry/server-utils@10.75.0': resolution: {integrity: sha512-7fIa9vFGNzB13hmxl8Sip3xImSkqpc4wETzuQ7CLzVOwPvwI6y/gVEm0pxRhtJSq8SzfOp26eJWl80u8v78Osg==} engines: {node: '>=18'} @@ -4874,6 +5045,9 @@ packages: '@types/node@22.16.0': resolution: {integrity: sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@22.20.3': resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} @@ -5154,6 +5328,12 @@ packages: vite: optional: true + ai@5.0.216: + resolution: {integrity: sha512-W1QgTQS/2itHasAjqx0CVWfRojmnu1V4tJbqaHJf5hXckAZa9IrPDS9VNWBSgZjBhBE9//5M+D4FKdvAGcQBEw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ai@6.0.230: resolution: {integrity: sha512-TQKOUPL7GLirkGvA2/sxA3bNzPeTY2w30mOyzwfQyiB8WvTQTr08mGgBBETPHwKIefgYF8HbLeVMgEahRVsX/Q==} engines: {node: '>=18'} @@ -5230,9 +5410,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -5400,6 +5577,10 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5496,6 +5677,18 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chat@4.34.0: + resolution: {integrity: sha512-g3c9ANavtCX7BwHcB5c3lWKIUm+8Oo7qlbgQ7/ni1rmVLLeCQa7FiMfeIyEyTAd/4HlRQu5jcS4EPdb0VyhUDQ==} + engines: {node: '>=20'} + peerDependencies: + ai: ^6.0.182 || ^7.0.0 + zod: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + ai: + optional: true + zod: + optional: true + chat@4.40.0: resolution: {integrity: sha512-slu3VDxItlelEZ8A5vqzlmtT2WErqj2YCGuhhcNx+Ev0+wHeBUqUDUA7hXkca+BfFtW1ZX7ECcySCTG2q2gefg==} engines: {node: '>=20'} @@ -5533,6 +5726,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cjs-module-lexer@2.2.1: resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} @@ -5655,10 +5851,18 @@ packages: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -5702,6 +5906,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -5971,6 +6179,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -6134,10 +6345,20 @@ packages: peerDependencies: express: '>= 4.11' + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + expressive-code@0.44.2: resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} @@ -6177,8 +6398,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -6186,6 +6407,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -6225,6 +6449,10 @@ packages: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-proc@0.2.0: resolution: {integrity: sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6508,6 +6736,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -6543,9 +6775,17 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + ignore@7.0.9: resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} @@ -6554,6 +6794,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.3.1: + resolution: {integrity: sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==} + engines: {node: '>=18'} + import-in-the-middle@3.5.1: resolution: {integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==} engines: {node: '>=18'} @@ -6594,8 +6838,8 @@ packages: iobuffer@6.0.1: resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -6739,6 +6983,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} @@ -7201,6 +7448,10 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -7344,6 +7595,10 @@ packages: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimetext@3.0.28: resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==} @@ -7636,6 +7891,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.5: + resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} + engines: {node: '>=18'} + p-map@7.0.8: resolution: {integrity: sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==} engines: {node: '>=18'} @@ -7824,6 +8083,15 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + posthog-node@5.51.6: + resolution: {integrity: sha512-r+Ge3p0OnOcOWeTnvKZmAtDwECeIoyNTyxBxuZc8wM6RHCQ9j2Xrhogrf39HSq1Y2gIOpWLqIDRmWDcu7xmqcA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + posthog-node@5.52.4: resolution: {integrity: sha512-P3p4OouGQfw/ouv+Px4gcVWLNwYsFB4JPyksYmxv7QRX4niwp1/C9XjQSUyl0nt+KoQnK14zFDfONHWVthwKDA==} engines: {node: ^20.20.0 || >=22.22.0} @@ -7874,6 +8142,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -7902,6 +8174,10 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} @@ -7918,10 +8194,18 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + raw-body@3.0.1: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -8104,6 +8388,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + remend@1.3.1: resolution: {integrity: sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==} @@ -8256,6 +8543,10 @@ packages: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + sentry@0.45.0: resolution: {integrity: sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA==} engines: {node: '>=20.0'} @@ -8275,6 +8566,10 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -8313,6 +8608,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -8325,6 +8624,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -8407,9 +8710,6 @@ packages: split@0.2.10: resolution: {integrity: sha512-e0pKq+UUH2Xq/sXbYpZBZc3BawsfDZ7dgv+JtRTUPNcvF5CMR4Y9cvJqkMY0MoxWzTHvZuz1beg6pNEKlszPiQ==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -8640,6 +8940,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tokenx@1.3.0: + resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} + tokenx@1.6.0: resolution: {integrity: sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==} @@ -8721,6 +9024,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} @@ -9277,6 +9584,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -9388,6 +9707,11 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -9397,6 +9721,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -9411,6 +9738,12 @@ snapshots: optionalDependencies: express: 5.2.1 + '@a2a-js/sdk@0.3.14(express@5.3.0)': + dependencies: + uuid: 11.1.1 + optionalDependencies: + express: 5.3.0 + '@a2a-js/sdk@1.0.1(express@5.2.1)': dependencies: jose: 6.2.8 @@ -9418,6 +9751,13 @@ snapshots: optionalDependencies: express: 5.2.1 + '@a2a-js/sdk@1.0.1(express@5.3.0)': + dependencies: + jose: 6.2.12 + uuid: 11.1.1 + optionalDependencies: + express: 5.3.0 + '@acemir/cssom@0.9.32': {} '@ai-sdk/anthropic@3.0.33(zod@4.4.3)': @@ -9426,6 +9766,13 @@ snapshots: '@ai-sdk/provider-utils': 4.0.11(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/gateway@2.0.115(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.30(zod@4.5.4) + '@vercel/oidc': 3.1.0 + zod: 4.5.4 + '@ai-sdk/gateway@3.0.153(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.14 @@ -9468,6 +9815,13 @@ snapshots: secure-json-parse: 2.7.0 zod: 4.6.5 + '@ai-sdk/provider-utils@3.0.30(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.1 + zod: 4.5.4 + '@ai-sdk/provider-utils@4.0.11(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.6 @@ -9482,6 +9836,13 @@ snapshots: eventsource-parser: 3.1.1 zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.40(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 3.0.14 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.1 + zod: 4.5.4 + '@ai-sdk/provider-utils@4.0.40(zod@4.6.5)': dependencies: '@ai-sdk/provider': 3.0.14 @@ -9489,6 +9850,14 @@ snapshots: eventsource-parser: 3.1.1 zod: 4.6.5 + '@ai-sdk/provider-utils@5.0.13(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 4.0.4 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + zod: 4.5.4 + '@ai-sdk/provider-utils@5.0.13(zod@4.6.5)': dependencies: '@ai-sdk/provider': 4.0.4 @@ -9563,6 +9932,13 @@ snapshots: magic-string: 0.30.21 module-details-from-path: 1.0.4 + '@apm-js-collab/code-transformer-bundler-plugins@0.7.1': + dependencies: + '@apm-js-collab/code-transformer': 0.18.0 + es-module-lexer: 2.3.1 + magic-string: 0.30.21 + module-details-from-path: 1.0.4 + '@apm-js-collab/code-transformer@0.15.0': dependencies: '@types/estree': 1.0.9 @@ -9572,6 +9948,15 @@ snapshots: semifies: 1.0.0 source-map: 0.6.1 + '@apm-js-collab/code-transformer@0.18.0': + dependencies: + '@types/estree': 1.0.9 + astring: 1.9.0 + esquery: 1.7.0 + meriyah: 6.1.4 + semifies: 1.0.0 + source-map: 0.6.1 + '@apm-js-collab/tracing-hooks@0.10.1': dependencies: '@apm-js-collab/code-transformer': 0.15.0 @@ -9580,6 +9965,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@apm-js-collab/tracing-hooks@0.13.0': + dependencies: + '@apm-js-collab/code-transformer': 0.18.0 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -10817,6 +11210,14 @@ snapshots: '@fontsource/ibm-plex-mono@5.3.0': {} + '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.32.0(@cfworker/json-schema@4.1.1)(zod@4.5.4))(hono-rate-limiter@0.4.2(hono@4.13.13))(hono@4.13.13)(zod@4.5.4)': + dependencies: + '@modelcontextprotocol/sdk': 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.5.4) + hono: 4.13.13 + hono-rate-limiter: 0.4.2(hono@4.13.13) + pkce-challenge: 5.0.1 + zod: 4.5.4 + '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(hono-rate-limiter@0.4.2(hono@4.13.13))(hono@4.13.8)(zod@4.6.5)': dependencies: '@modelcontextprotocol/sdk': 1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) @@ -10957,6 +11358,14 @@ snapshots: '@types/node': 22.16.0 optional: true + '@inquirer/confirm@5.1.21(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/confirm@5.1.21(@types/node@22.20.3)': dependencies: '@inquirer/core': 10.3.2(@types/node@22.20.3) @@ -10986,6 +11395,20 @@ snapshots: '@types/node': 22.16.0 optional: true + '@inquirer/core@10.3.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/core@10.3.2(@types/node@22.20.3)': dependencies: '@inquirer/ansi': 1.0.2 @@ -11020,6 +11443,11 @@ snapshots: '@types/node': 22.16.0 optional: true + '@inquirer/type@3.0.10(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/type@3.0.10(@types/node@22.20.3)': optionalDependencies: '@types/node': 22.20.3 @@ -11104,6 +11532,51 @@ snapshots: - utf-8-validate - workflow + '@mastra/core@1.63.2(ai@5.0.216(zod@4.5.4))(express@5.3.0)(zod@4.5.4)': + dependencies: + '@a2a-js/sdk-v0_3': '@a2a-js/sdk@0.3.14(express@5.3.0)' + '@a2a-js/sdk-v1': '@a2a-js/sdk@1.0.1(express@5.3.0)' + '@ai-sdk/provider-utils-v5': '@ai-sdk/provider-utils@3.0.30(zod@4.5.4)' + '@ai-sdk/provider-utils-v6': '@ai-sdk/provider-utils@4.0.40(zod@4.5.4)' + '@ai-sdk/provider-utils-v7': '@ai-sdk/provider-utils@5.0.13(zod@4.5.4)' + '@ai-sdk/provider-v5': '@ai-sdk/provider@2.0.3' + '@ai-sdk/provider-v6': '@ai-sdk/provider@3.0.14' + '@ai-sdk/provider-v7': '@ai-sdk/provider@4.0.4' + '@isaacs/ttlcache': 2.1.5 + '@lukeed/uuid': 2.0.1 + '@mastra/schema-compat': 1.3.7(zod@4.5.4) + '@modelcontextprotocol/server': 2.0.0 + '@sindresorhus/slugify': 2.2.1 + '@standard-schema/spec': 1.1.0 + ajv: 8.20.0 + chat: 4.34.0(ai@5.0.216(zod@4.5.4))(zod@4.5.4) + croner: 10.0.1 + dotenv: 17.4.2 + execa: 9.6.1 + fastq: 1.20.1 + gray-matter: 4.0.3 + ignore: 7.0.6 + jpeg-js: 0.4.4 + json-schema: 0.4.0 + lru-cache: 11.5.2 + p-map: 7.0.5 + p-retry: 7.1.1 + picomatch: 4.0.5 + posthog-node: 5.51.6 + tokenx: 1.3.0 + ws: 8.21.1 + xxhash-wasm: 1.1.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@bufbuild/protobuf' + - '@grpc/grpc-js' + - ai + - bufferutil + - express + - rxjs + - supports-color + - utf-8-validate + '@mastra/core@1.67.0(ai@6.0.230(zod@4.6.5))(express@5.2.1)(zod@4.6.5)': dependencies: '@a2a-js/sdk-v0_3': '@a2a-js/sdk@0.3.14(express@5.2.1)' @@ -11155,6 +11628,12 @@ snapshots: zod: 4.6.5 zod-from-json-schema: 0.5.6 + '@mastra/schema-compat@1.3.7(zod@4.5.4)': + dependencies: + json-schema-to-zod: 2.8.1 + zod: 4.5.4 + zod-from-json-schema: 0.5.6 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -11227,6 +11706,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.32.0(@cfworker/json-schema@4.1.1)(zod@4.5.4)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.32.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)': dependencies: '@hono/node-server': 2.1.1(hono@4.13.13) @@ -11315,6 +11818,17 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.216(zod@4.5.4))(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 2.0.3 + '@openrouter/sdk': 0.1.27 + ai: 5.0.216(zod@4.5.4) + zod: 4.5.4 + + '@openrouter/sdk@0.1.27': + dependencies: + zod: 4.5.4 + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -11328,6 +11842,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11343,6 +11862,12 @@ snapshots: '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11351,6 +11876,14 @@ snapshots: '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11358,6 +11891,13 @@ snapshots: '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oslojs/encoding@1.1.0': {} @@ -11601,10 +12141,16 @@ snapshots: '@poppinss/exception@1.2.2': {} + '@posthog/core@1.50.1': + dependencies: + '@posthog/types': 1.407.1 + '@posthog/core@1.54.3': dependencies: '@posthog/types': 1.412.1 + '@posthog/types@1.407.1': {} + '@posthog/types@1.412.1': {} '@quansync/fs@0.1.3': @@ -12271,6 +12817,17 @@ snapshots: - encoding - supports-color + '@sentry/cloudflare@10.67.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/core': 10.67.0 + '@sentry/node': 10.67.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.67.0 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + '@sentry/cloudflare@11.6.0(@cloudflare/workers-types@4.20260405.1)(wrangler@4.151.3(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': dependencies: '@opentelemetry/api': 1.9.1 @@ -12297,6 +12854,10 @@ snapshots: dependencies: '@sentry/conventions': 0.15.1 + '@sentry/core@10.67.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core@10.75.0': dependencies: '@sentry/conventions': 0.16.0 @@ -12339,6 +12900,18 @@ snapshots: '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@sentry/node-core@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.67.0 + '@sentry/opentelemetry': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.3.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/node-core@10.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))': dependencies: '@sentry/conventions': 0.16.0 @@ -12367,6 +12940,22 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-http' - supports-color + '@sentry/node@10.67.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.67.0 + '@sentry/node-core': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.67.0 + import-in-the-middle: 3.3.1 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + '@sentry/node@10.75.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -12413,6 +13002,14 @@ snapshots: '@sentry/conventions': 0.15.1 '@sentry/core': 10.65.0 + '@sentry/opentelemetry@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.67.0 + '@sentry/opentelemetry@10.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -12472,6 +13069,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry/server-utils@10.67.0': + dependencies: + '@apm-js-collab/code-transformer-bundler-plugins': 0.7.1 + '@apm-js-collab/tracing-hooks': 0.13.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.67.0 + transitivePeerDependencies: + - supports-color + '@sentry/server-utils@10.75.0': dependencies: '@sentry/conventions': 0.16.0 @@ -12881,6 +13487,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@22.20.3': dependencies: undici-types: 6.21.0 @@ -13016,6 +13626,15 @@ snapshots: msw: 2.10.2(@types/node@22.16.0)(typescript@5.9.3) vite: 8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@22.20.1)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.10.2(@types/node@22.20.1)(typescript@5.9.3) + vite: 8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -13152,6 +13771,14 @@ snapshots: - '@cloudflare/workers-types' - rolldown + ai@5.0.216(zod@4.5.4): + dependencies: + '@ai-sdk/gateway': 2.0.115(zod@4.5.4) + '@ai-sdk/provider': 2.0.3 + '@ai-sdk/provider-utils': 3.0.30(zod@4.5.4) + '@opentelemetry/api': 1.9.0 + zod: 4.5.4 + ai@6.0.230(zod@4.4.3): dependencies: '@ai-sdk/gateway': 3.0.153(zod@4.4.3) @@ -13184,7 +13811,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -13227,10 +13854,6 @@ snapshots: arg@5.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -13467,6 +14090,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} brace-expansion@2.0.2: @@ -13546,6 +14183,21 @@ snapshots: character-reference-invalid@2.0.1: {} + chat@4.34.0(ai@5.0.216(zod@4.5.4))(zod@4.5.4): + dependencies: + '@workflow/serde': 4.1.0-beta.2 + mdast-util-to-string: 4.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + remend: 1.3.0 + unified: 11.0.5 + optionalDependencies: + ai: 5.0.216(zod@4.5.4) + zod: 4.5.4 + transitivePeerDependencies: + - supports-color + chat@4.40.0(ai@6.0.230(zod@4.6.5))(zod@4.6.5): dependencies: '@workflow/serde': 4.1.0-beta.2 @@ -13603,6 +14255,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.0: {} + cjs-module-lexer@2.2.1: {} class-variance-authority@0.7.1: @@ -13716,8 +14370,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@2.0.1: {} + content-type@1.0.5: {} + content-type@2.1.0: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -13745,6 +14403,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -13960,6 +14623,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} es-module-lexer@3.0.2: {} @@ -14250,7 +14915,15 @@ snapshots: express-rate-limit@8.2.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.7.4 + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color express@5.2.1: dependencies: @@ -14285,6 +14958,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + expressive-code@0.44.2: dependencies: '@expressive-code/core': 0.44.2 @@ -14331,7 +15037,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.8: {} fast-wrap-ansi@0.2.2: dependencies: @@ -14341,6 +15047,10 @@ snapshots: dependencies: reusify: 1.1.0 + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -14381,6 +15091,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-proc@0.2.0: {} find-root@1.1.0: {} @@ -14804,6 +15525,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -14841,8 +15570,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@7.0.6: {} + ignore@7.0.9: {} import-fresh@3.3.1: @@ -14850,6 +15585,12 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.3.1: + dependencies: + cjs-module-lexer: 2.2.0 + es-module-lexer: 2.3.1 + module-details-from-path: 1.0.4 + import-in-the-middle@3.5.1: dependencies: cjs-module-lexer: 2.2.1 @@ -14907,7 +15648,7 @@ snapshots: iobuffer@6.0.1: {} - ip-address@10.0.1: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -15011,6 +15752,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + jose@6.2.8: {} jpeg-js@0.4.4: {} @@ -15023,7 +15766,7 @@ snapshots: js-yaml@3.15.2: dependencies: - argparse: 1.0.10 + argparse: 2.0.1 esprima: 4.0.1 js-yaml@4.3.2: @@ -15556,6 +16299,8 @@ snapshots: media-typer@1.1.0: {} + media-typer@1.1.1: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -15855,6 +16600,10 @@ snapshots: dependencies: mime-db: 1.54.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimetext@3.0.28: dependencies: '@babel/runtime': 7.28.4 @@ -15902,6 +16651,19 @@ snapshots: - bufferutil - utf-8-validate + miniflare@5.20261019.1-beta(@types/node@22.20.1): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@22.20.1) + undici: 7.24.4 + workerd: 1.20261019.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + miniflare@5.20261019.1-beta(@types/node@24.13.5): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -15989,6 +16751,32 @@ snapshots: - '@types/node' optional: true + msw@2.10.2(@types/node@22.20.1)(typescript@5.9.3): + dependencies: + '@bundled-es-modules/cookie': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 5.1.21(@types/node@22.20.1) + '@mswjs/interceptors': 0.39.8 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.6 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + strict-event-emitter: 0.5.1 + type-fest: 4.41.0 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 @@ -16292,6 +17080,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.5: {} + p-map@7.0.8: {} p-queue@9.3.3: @@ -16463,6 +17253,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-node@5.51.6: + dependencies: + '@posthog/core': 1.50.1 + posthog-node@5.52.4: dependencies: '@posthog/core': 1.54.3 @@ -16515,6 +17309,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} psl@1.15.0: @@ -16538,6 +17337,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.10: {} querystringify@2.2.0: {} @@ -16548,6 +17352,8 @@ snapshots: range-parser@1.2.1: {} + range-parser@1.3.0: {} + raw-body@3.0.1: dependencies: bytes: 3.1.2 @@ -16555,6 +17361,13 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -16821,6 +17634,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remend@1.3.0: {} + remend@1.3.1: {} require-directory@2.1.1: {} @@ -17089,6 +17904,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + sentry@0.45.0: {} seroval-plugins@1.3.3(seroval@1.5.6): @@ -17106,10 +17937,52 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} + sharp@0.35.4(@types/node@22.20.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.20.1 + sharp@0.35.4(@types/node@24.13.5): dependencies: '@img/colour': 1.1.0 @@ -17178,6 +18051,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -17201,6 +18079,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -17273,8 +18159,6 @@ snapshots: dependencies: through: 2.3.8 - sprintf-js@1.0.3: {} - stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -17499,6 +18383,8 @@ snapshots: toidentifier@1.0.1: {} + tokenx@1.3.0: {} + tokenx@1.6.0: {} tough-cookie@4.1.4: @@ -17605,6 +18491,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@5.7.3: {} typescript@5.8.3: {} @@ -17867,6 +18759,21 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 + vite@8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.13 + yaml: 2.9.0 + vite@8.3.3(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -17996,6 +18903,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.1)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.20.1)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.3.3(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.3(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 @@ -18261,6 +19198,23 @@ snapshots: - bufferutil - utf-8-validate + wrangler@4.151.3(@types/node@22.20.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261019.1-beta(@types/node@22.20.1) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261019.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -18316,6 +19270,8 @@ snapshots: ws@8.21.0: {} + ws@8.21.1: {} + ws@8.21.3: {} xml-name-validator@5.0.0: {} @@ -18412,12 +19368,18 @@ snapshots: dependencies: zod: 4.6.5 + zod-to-json-schema@3.25.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + zod@3.22.3: {} zod@3.25.76: {} zod@4.4.3: {} + zod@4.5.4: {} + zod@4.6.5: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c5d531e4..6984d6fe8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,11 @@ patchedDependencies: "@sentry/node-core@10.63.0": packages/cli/patches/@sentry%2Fnode-core@10.63.0.patch "@stricli/core@1.2.8": packages/cli/patches/@stricli%2Fcore@1.2.8.patch +packageExtensions: + "@openrouter/ai-sdk-provider@1.5.4": + dependencies: + "@ai-sdk/provider": 2.0.3 + allowBuilds: "@ast-grep/cli": true "@sentry/cli": false @@ -24,6 +29,9 @@ allowBuilds: workerd: true overrides: + "ajv@8.20.0>fast-uri": 3.1.8 + "express-rate-limit@8>ip-address": 10.7.4 + "js-yaml@3.15.2>argparse": 2.0.1 "@modelcontextprotocol/sdk": 1.32.0 fast-xml-parser: 5.7.2 "picomatch@2.3.1": 2.3.2