diff --git a/.env.example b/.env.example index 631dfdedc..c0bbdb9ea 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,27 @@ TYPESENSE_PRIVATE_API_KEY=typesense_private_api_key_required_for_indexing_only DIRECTUS_URL=https://marketing-directus-url.com GOOGLE_TAG_MANAGER_ID=GTM-PTLT3GH POSTHOG_API_HOST=https://directus.com/ingest +POSTHOG_AI_HOST=https://us.i.posthog.com POSTHOG_API_KEY=phc_secret_key_here NUXT_PUBLIC_SITE_URL=https://directus.com NUXT_PUBLIC_OG_BASE_URL=https://og.directus.com # Required outside local OG worker dev. -# NUXT_OG_SIGNING_SECRET=shared_secret_from_website_og_worker # OG_SIGNING_SECRET=shared_secret_from_website_og_worker # Optional. Fine-grained PAT, public repos read-only. Required for code search and raises GitHub raw rate limits. # GITHUB_TOKEN=github_pat_... +# Optional. Enables the in-site assistant chat. Get a key at https://openrouter.ai/keys. +# OPENROUTER_API_KEY=sk-or-v1-... +# Optional. Override the default model. +# AI_MODEL=google/gemini-3.1-flash-lite +# ASSISTANT_ENABLED=true +# Required when assistant is enabled. Long random string for daily fingerprint salting. +# ASSISTANT_FP_SECRET=change-me +# Dev only. Allows local reset endpoint to clear assistant limits. +# ASSISTANT_RESET_TOKEN=change-me +# Dev only. Shorten burst window for curl testing. +# RATE_LIMIT_WINDOW_MS=60000 +# Optional. PostHog survey id used for thumbs up/down feedback on assistant responses. +# ASSISTANT_FEEDBACK_SURVEY_ID=019e081e-2c3b-0000-04c3-564ad5dff4ed +# Optional. Upstash Redis for cross-instance assistant daily rate limits. Falls back to per-process memory if unset. +# UPSTASH_REDIS_REST_URL=https://your-db.upstash.io +# UPSTASH_REDIS_REST_TOKEN=your_upstash_token diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..823b7fa24 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,55 @@ +# Directus Docs — Domain Language + +Shared vocabulary for this codebase. Seeded with rate-limiting terms; extend as other areas are sharpened. + +## Language + +### Rate limiting + +**Burst limit**: +A short-window cap (seconds) on requests per identity, the cheap first gate against rapid-fire abuse. +_Avoid_: throttle, flood limit + +**Daily limit**: +A per-UTC-day cap with multiple keyed dimensions (exact IP, IP prefix, fingerprint, IP+fingerprint combo) and degraded modes. The deep backstop; distinct from a burst limit. +_Avoid_: quota, cap + +**Policy**: +What a caller declares to a limiter: `max`, `windowSeconds`, and `onStoreError`. The caller picks a policy; it never reimplements the algorithm. +_Avoid_: config, rule, options + +**Store**: +The counting backend behind a limiter — `incr(key, ttlSeconds) -> count`. `MemoryStore` for local dev, `UpstashStore` for production (atomic, holds across serverless instances). +_Avoid_: backend, cache, KV (KV is one specific store) + +**Verdict**: +The result of a limit check: `{ ok, retryAfter? }`. What the caller acts on. +_Avoid_: result, decision, response + +**Fail closed / fail open**: +When the store errors, a `deny` policy fails closed (refuse the request); an `allow` policy fails open (let it through). Expensive/abusable endpoints deny; cheap public reads allow. + +### Assistant + +**Conversation history**: +The deep, framework-agnostic owner of persisted conversations (`useAssistantHistory`): localStorage sync, sorting, title derivation, compaction, and the `activeId`. Enforces the invariant that `activeId` points to an existing conversation — mutate it only through `setActive`/`clearActive`/`startNew`/`remove`, never by assigning the returned ref. +_Avoid_: conversation store, chat state + +**Message transition**: +A change to the chat's message list — reset, open a saved conversation, delete the active one, or the easter egg. All transitions go through the single `setMessages(next)` writer in `useAssistant`, so the persistence watcher reasons about one mutation idiom. `useAssistant` is the Vue-reactive surface; it is not a separate session module. +_Avoid_: chat singleton, assistant session, set chat messages + +## Relationships + +- A caller passes a **policy** to a limiter and acts on the returned **verdict**. +- A limiter counts against a **store**; the store knows nothing about limits. +- **Burst limit** and **daily limit** are separate limiters. Burst is the cheap first gate; daily is the deep backstop. + +## Example dialogue + +> **Dev:** "If Upstash is down, does the burst limit block the assistant?" +> **Maintainer:** "Yes — the assistant burst **policy** is `onStoreError: 'deny'`, so it fails closed. The docs API **policy** is `'allow'`, so a store blip never takes down public docs search." + +## Flagged ambiguities + +- "rate limit" was used for both the burst and daily limiters. Resolved: they are distinct concepts — **burst limit** (short window, single key, process- or store-counted) vs **daily limit** (per-day, multi-key, degraded modes). Only the burst-style limiters share the `checkRateLimit` core. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f2e38fb0d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,228 @@ +# Contributing to Directus Docs + +This guide covers local development, docs authoring, search indexing, deployment, and the optional AI Assistant. + +## Running the Docs + +### Requirements + +- Node.js 22.18 or later +- pnpm + +### Install Dependencies + +```bash +pnpm install +``` + +### Setup Environment + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Update `.env` with the service keys you need for the feature you are working on. Most docs-only changes do not require every optional secret. + +### Run Development Server + +Start the development server on `http://localhost:3000`: + +```bash +pnpm dev +``` + +### Building Locally + +```bash +pnpm build +``` + +## Authoring Content + +Pages live as Markdown files under `content/`. Frontmatter fields are validated by the schema in `content.config.ts`. + +### Framework Guides + +Framework guides live under `content/frameworks//`. The numeric prefix on filenames (`01.`, `02.`, ...) controls sidebar sort order only. It has no semantic meaning; renumber freely. + +The `section` frontmatter field controls grouping on the `/frameworks/` hub page: + +- `section: start-here` appears in the "Start Here" block at the top. +- `section: guides` or unset appears in the "Guides" block below. + +Minimal frontmatter for a new framework guide: + +```yaml +--- +title: Fetch Data from Directus with Foo +description: Learn how to integrate Directus in your Foo app. +section: start-here +technologies: + - foo +navigation: + title: Data Fetching +--- +``` + +## Repository Tooling + +The repository includes scripts that keep docs routes stable when files move and that index the docs into Typesense. + +```bash +pnpm stable-ids:ensure # Add missing stableId frontmatter +pnpm stable-ids:check # Validate stableId frontmatter +pnpm redirects:sync # Update redirects.json for moved pages +pnpm redirects:check # Check redirect coverage without writing files +pnpm index:docs # Build the search index in Typesense +pnpm typesense:cleanup-preview # Delete stale Typesense preview indexes +pnpm typecheck:scripts # Type check repository scripts +``` + +Stable IDs give each public docs page a permanent identity. Nuxt Content derives its unique page IDs from file paths, so moving a page changes its built-in ID. Redirect sync compares the current branch to `origin/main`, so moved pages keep their old URLs working. + +CI runs `pnpm stable-ids:check` and `pnpm redirects:check` for docs changes. + +- New docs page: run `pnpm stable-ids:ensure`, then commit the new `stableId`. +- Moved docs page: keep the existing `stableId`, run `pnpm redirects:sync`, then commit `redirects.json`. +- Deleted, split, or merged docs page: run `pnpm redirects:sync`, review `.docs/redirect-decisions-needed.md`, choose target redirects, then re-run `pnpm redirects:check`. +- Before opening a PR: run `pnpm stable-ids:check` and `pnpm redirects:check`. + +Redirect scripts compare against `origin/main` by default. To check a release branch or another target, fetch it first, then pass `--base` directly to the script: + +```bash +git fetch origin release/v13 +node scripts/redirects-sync.ts --base origin/release/v13 --no-write --fail-on-unresolved +node scripts/redirects-sync.ts --base origin/release/v13 --write-deterministic --fail-on-unresolved +``` + +## AI Assistant + +The in-site AI Assistant is optional. It is disabled unless `OPENROUTER_API_KEY` is present and `ASSISTANT_ENABLED` is not set to `false`. + +### Required for Vercel + +```bash +OPENROUTER_API_KEY=sk-or-v1-... +ASSISTANT_FP_SECRET=long-random-string +``` + +`ASSISTANT_FP_SECRET` salts daily fingerprint identifiers. Use a long random value and keep it secret. + +### Recommended for Vercel + +Use one supported Redis env pair for cross-instance burst and daily limits: + +```bash +UPSTASH_REDIS_REST_URL=https://your-db.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_upstash_token +``` + +or: + +```bash +KV_REST_API_URL=https://your-db.upstash.io +KV_REST_API_TOKEN=your_upstash_token +``` + +Without Redis env vars, limits fall back to per-process memory. That is fine for local development, but not enough for production or preview deployments. + +### Optional + +```bash +AI_MODEL=google/gemini-3.1-flash-lite +ASSISTANT_ENABLED=true +ASSISTANT_FEEDBACK_SURVEY_ID=019e081e-2c3b-0000-04c3-564ad5dff4ed +GITHUB_TOKEN=github_pat_... +POSTHOG_AI_HOST=https://us.i.posthog.com +``` + +- `ASSISTANT_ENABLED=false` is the kill switch. Set it before build/deploy to hide the assistant and skip the server route; the server also rejects requests when the flag is false. +- `GITHUB_TOKEN` is required for the assistant's source-code search tool and raises GitHub raw-file rate limits. +- `ASSISTANT_FEEDBACK_SURVEY_ID` enables thumbs up/down feedback on assistant responses. +- `POSTHOG_AI_HOST` only needs to be set when AI telemetry should use a different PostHog host than `POSTHOG_API_HOST`. + +### Local Testing + +Useful assistant tests: + +```bash +pnpm exec vitest run modules/assistant/index.test.ts modules/assistant/runtime/composables/useAssistant.test.ts modules/assistant/runtime/server/utils/admit.test.ts modules/assistant/runtime/server/utils/abuse-gate.test.ts modules/assistant/runtime/server/utils/bind-tools.test.ts modules/assistant/runtime/server/utils/rate-limit.test.ts modules/assistant/runtime/server/utils/request-context.test.ts server/utils/rate-limit.test.ts server/utils/docs-api-limit.test.ts +``` + +Dev-only helpers: + +```bash +ASSISTANT_RESET_TOKEN=change-me +RATE_LIMIT_WINDOW_MS=60000 +POSTHOG_AI_DEBUG=true +POSTHOG_AI_SELF_TEST=true +``` + +The reset/status endpoints are only registered in dev and only respond from a local development context. + +## Search + +Search is powered by [Typesense](https://typesense.org). The browser palette (`UCommandPalette`-based) lives at `app/components/DocsSearchPalette.vue` and queries Typesense directly via `app/services/typesenseService.ts`. The official `typesense` npm client is used by the indexer only. + +### Indexing + +The indexer at `scripts/index-docs.ts` walks `/content`, chunks each Markdown page, attaches synonyms, and pushes everything to Typesense. OpenAPI indexing is deferred to a later branch. Run it locally with: + +```bash +pnpm index:docs +``` + +CI runs the same command on every push to `main` (production index) and on every PR commit (per-branch preview index). See `.github/workflows/search-index.yml`. + +### Collection Naming + +Indexes use a blue/green slot pattern with a stable alias: + +- `main` -> alias `directus-docs`, slots `directus-docs-a` / `directus-docs-b` +- Branch `bry/foo` -> alias `directus-docs-preview-bry-foo`, slots `...-a` / `...-b` +- Local branch runs use the same branch-derived alias as CI + +Each indexer run writes to whichever slot the alias is not currently pointing at, swaps the alias, then deletes the previous slot. + +For one-off writes, override the index target with `TYPESENSE_INDEX_TARGET=...`. + +The browser reads from `TYPESENSE_COLLECTION` when set. Otherwise it derives the same branch alias as the indexer. The app reads the alias, never the `-a` / `-b` slot name. + +### Preview Cleanup + +PR preview indexes are deleted when same-repo PRs close. The cleanup job deletes the branch alias and both fixed slots: + +```bash +pnpm typesense:cleanup-preview --branch bry/foo +``` + +For one-time cleanup of accumulated preview indexes, run a dry run first: + +```bash +pnpm typesense:cleanup-preview --stale --dry-run +pnpm typesense:cleanup-preview --stale +``` + +Stale cleanup keeps preview aliases for currently open PR branches and deletes the rest. It requires `TYPESENSE_URL`, `TYPESENSE_PRIVATE_API_KEY`, and authenticated `gh`. + +### Ranking + +Section boosts and personalization live in `buildPersonalizedSortBy` in `app/composables/useDocsSearch.ts`. The same `sectionPriority` array drives both the Typesense `_eval` boost order and the chip-bar render order in the palette. + +### Synonyms + +Search synonyms live in `server/data/synonyms.ts` and are pushed to Typesense on every indexer run. Two formats: `multiway` (equivalent terms) and `oneway` (directional shorthand -> canonical, e.g. `db -> database`). Header comment in the file explains both. + +### Search-Friendly Content + +Write H2s and first paragraphs so they work as standalone search results. + +## Deploying the Docs + +The documentation automatically deploys to Vercel when changes are merged into the main branch. + +1. Open a pull request. +2. Review the deploy preview. +3. Once the PR is approved and merged to `main`, Vercel builds and deploys the updated documentation. diff --git a/README.md b/README.md index c14e87db2..d47507f17 100644 --- a/README.md +++ b/README.md @@ -4,125 +4,26 @@ ## 🐰 Introduction -Welcome! This is the repo for [Directus' documentation](https://directus.com/docs). +Welcome. This is the repo for [Directus documentation](https://directus.com/docs). **[Learn more about Directus](https://directus.com)** +## 🚀 Contributing -## 🖥️ Running the Docs - -### Requirements - -- Node.js 22.18 or later -- pnpm - -### Install Dependencies - -```bash -pnpm install -``` - -### Setup Environment - -Copy the example environment file: - -```bash -cp .env.example .env -``` - -Update the environment variables in the `.env` file with proper secret keys for the different -services. - -### Run Development Server - -Start the development server on `http://localhost:3000`: - -```bash -pnpm dev -``` - -### Building Locally - -```bash -pnpm build -``` - -### Repository Tooling - -The repository includes scripts that keep docs routes stable when files move and that index the docs into Typesense. - -```bash -pnpm stable-ids:ensure # Add missing stableId frontmatter -pnpm stable-ids:check # Validate stableId frontmatter -pnpm redirects:sync # Update redirects.json for moved pages -pnpm redirects:check # Check redirect coverage without writing files -pnpm index:docs # Build the search index in Typesense -pnpm typesense:cleanup-preview # Delete stale Typesense preview indexes -pnpm typecheck:scripts # Type check repository scripts -``` - -Stable IDs give each public docs page a permanent identity. Nuxt Content derives -its unique page IDs from file paths, so moving a page changes its built-in ID. -Redirect sync compares the current branch to `origin/main`, so moved pages keep -their old URLs working. - -CI runs `pnpm stable-ids:check` and `pnpm redirects:check` for docs changes. - -- New docs page: run `pnpm stable-ids:ensure`, then commit the new `stableId`. -- Moved docs page: keep the existing `stableId`, run `pnpm redirects:sync`, then commit `redirects.json`. -- Deleted, split, or merged docs page: run `pnpm redirects:sync`, review `.docs/redirect-decisions-needed.md`, choose target redirects, then re-run `pnpm redirects:check`. -- Before opening a PR: run `pnpm stable-ids:check` and `pnpm redirects:check`. - -Redirect scripts compare against `origin/main` by default. To check a release branch -or another target, fetch it first, then pass `--base` directly to the script: - -```bash -git fetch origin release/v13 -node scripts/redirects-sync.ts --base origin/release/v13 --no-write --fail-on-unresolved -node scripts/redirects-sync.ts --base origin/release/v13 --write-deterministic --fail-on-unresolved -``` - -## ✍️ Authoring Content - -Pages live as Markdown files under `content/`. Frontmatter fields are validated by the schema in `content.config.ts`. - -### Framework Guides - -Framework guides live under `content/frameworks//`. The numeric prefix on filenames (`01.`, `02.`, …) controls sidebar sort order only — it has no semantic meaning, renumber freely. - -The `section` frontmatter field controls grouping on the `/frameworks/` hub page: - -- `section: start-here` — appears in the "Start Here" block at the top. -- `section: guides` (or unset) — appears in the "Guides" block below. - -Minimal frontmatter for a new framework guide: - -```yaml ---- -title: Fetch Data from Directus with Foo -description: Learn how to integrate Directus in your Foo app. -section: start-here -technologies: - - foo -navigation: - title: Data Fetching ---- -``` - -## ☁️ Deploying the Docs +Looking to report a docs issue or improve the documentation? -The documentation automatically deploys to Vercel when changes are merged into the main branch. Simply: +- [Open an issue](https://github.com/directus/docs/issues) +- [Read the contribution guide](CONTRIBUTING.md) +- [Code of Conduct](https://directus.com/docs/community/overview/conduct) +- [Documentation authoring guidelines](https://directus.com/docs/community/contribution/documentation) -1. Open a Pull Request with your changes -2. This should trigger a deploy preview as well. -3. Once PR is approved and merged to main, Vercel will automatically build and deploy the updated documentation +## 🖥️ Running Locally -## 🚀 Contributing +Contributor setup, local development, search indexing, and deployment notes live in [CONTRIBUTING.md](CONTRIBUTING.md). -- [Code of Conduct](https://directus.com/docs/community/overview/conduct) -- [Contributing and authoring guidelines](https://directus.com/docs/community/contribution/documentation) +## ✨ AI Assistant -
+The docs site includes an optional AI Assistant for deploy previews and production. Setup, environment variables, rate limits, and telemetry notes live in [CONTRIBUTING.md#ai-assistant](CONTRIBUTING.md#ai-assistant). ## 🤔 Community Help @@ -130,63 +31,6 @@ The documentation automatically deploys to Vercel when changes are merged into t - [GitHub Issues](https://github.com/directus/docs/issues) (Report Bugs) - [Roadmap](https://roadmap.directus.io) (Roadmap & Feature Requests) -## 🔍 Search - -Search is powered by [Typesense](https://typesense.org). The browser palette (`UCommandPalette`-based) lives at `app/components/DocsSearchPalette.vue` and queries Typesense directly via `app/services/typesenseService.ts`. The official `typesense` npm client is used by the indexer only. - -### Indexing - -The indexer at `scripts/index-docs.ts` walks `/content`, chunks each Markdown page, attaches synonyms, and pushes everything to Typesense. OpenAPI indexing is deferred to a later branch. Run it locally with: - -```bash -pnpm index:docs -``` - -CI runs the same command on every push to `main` (production index) and on every PR commit (per-branch preview index). See `.github/workflows/search-index.yml`. - -### Collection naming - -Indexes use a blue/green slot pattern with a stable alias: - -- `main` -> alias `directus-docs`, slots `directus-docs-a` / `directus-docs-b` -- Branch `bry/foo` -> alias `directus-docs-preview-bry-foo`, slots `...-a` / `...-b` -- Local branch runs use the same branch-derived alias as CI - -Each indexer run writes to whichever slot the alias is not currently pointing at, swaps the alias, then deletes the previous slot. - -For one-off writes, override the index target with `TYPESENSE_INDEX_TARGET=...`. - -The browser reads from `TYPESENSE_COLLECTION` when set. Otherwise it derives the same branch alias as the indexer. The app reads the alias, never the `-a` / `-b` slot name. - -### Preview Cleanup - -PR preview indexes are deleted when same-repo PRs close. The cleanup job deletes the branch alias and both fixed slots: - -```bash -pnpm typesense:cleanup-preview --branch bry/foo -``` - -For one-time cleanup of accumulated preview indexes, run a dry run first: - -```bash -pnpm typesense:cleanup-preview --stale --dry-run -pnpm typesense:cleanup-preview --stale -``` - -Stale cleanup keeps preview aliases for currently open PR branches and deletes the rest. It requires `TYPESENSE_URL`, `TYPESENSE_PRIVATE_API_KEY`, and authenticated `gh`. - -### Ranking - -Section boosts and personalization live in `buildPersonalizedSortBy` in `app/composables/useDocsSearch.ts`. The same `sectionPriority` array drives both the Typesense `_eval` boost order and the chip-bar render order in the palette. - -### Synonyms - -Search synonyms live in `server/data/synonyms.ts` and are pushed to Typesense on every indexer run. Two formats: `multiway` (equivalent terms) and `oneway` (directional shorthand -> canonical, e.g. `db -> database`). Header comment in the file explains both. - -### Search-friendly content - -Write H2s and first paragraphs so they work as standalone search results. -
© 2004-2024, Monospace, Inc. diff --git a/app/app.config.ts b/app/app.config.ts index cab43c92c..954ff0f58 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -3,6 +3,33 @@ export default defineAppConfig({ backend: 'typesense', }, + assistant: { + faqQuestions: [ + { + category: 'Getting Started', + items: [ + 'What is Directus?', + 'How do I install Directus?', + 'How do I create a collection?', + ], + }, + { + category: 'Frameworks', + items: [ + 'How do I fetch data from Directus in Nuxt?', + 'How do I use the Directus SDK in Next.js?', + ], + }, + { + category: 'Source code', + items: [ + 'Where is the items service defined in directus/directus?', + 'Show me an example of a custom Directus extension.', + ], + }, + ], + }, + ui: { colors: { primary: 'purple', @@ -16,6 +43,12 @@ export default defineAppConfig({ }, }, + // Container-query overrides for Nuxt UI components, keyed off the + // `docs-pane` parent set on . These translate "narrow pane" + // into "mobile mode" so the AI split panel can collapse the layout + // based on pane width rather than viewport. Tailwind 4 only sees + // literal class strings, so these stay inline (no template-string + // composition). container: { base: '@max-[40rem]/docs-pane:px-4! @min-[40rem]/docs-pane:px-6! @min-[64rem]/docs-pane:px-8!', }, @@ -34,6 +67,7 @@ export default defineAppConfig({ bottom: '@max-[64rem]/docs-pane:hidden! @min-[64rem]/docs-pane:flex!', }, }, + content: { callout: { // Fix background color of pre > code blocks @@ -47,6 +81,9 @@ export default defineAppConfig({ }, pageHeader: { slots: { + // Counter the default `lg:flex-row lg:items-center lg:justify-between` + // from Nuxt UI: at narrow pane widths the row should collapse to a + // stacked column even when the viewport is wide (AI panel open). wrapper: '@max-[40rem]/docs-pane:flex-col! @max-[40rem]/docs-pane:items-stretch! @max-[40rem]/docs-pane:justify-start! @min-[40rem]/docs-pane:flex-row! @min-[40rem]/docs-pane:items-center! @min-[40rem]/docs-pane:justify-between!', title: 'text-3xl sm:text-4xl text-pretty font-display font-medium text-highlighted', }, @@ -220,7 +257,7 @@ export default defineAppConfig({ hsForm: 'd57a69e4-6f43-4768-a600-5f7d30306260', }, - // Has "edit page" dynamically added in the first position in DocsTocAuthors.vue + // Has "edit page" dynamically added in the first position in DocsToc.vue links: [ { icon: 'i-lucide-star', diff --git a/app/app.vue b/app/app.vue index 3251d5d5e..fffae3223 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,21 +1,88 @@ diff --git a/app/components/DocsHeader.vue b/app/components/DocsHeader.vue index f1ec41b62..559491d78 100644 --- a/app/components/DocsHeader.vue +++ b/app/components/DocsHeader.vue @@ -1,5 +1,6 @@ + + diff --git a/modules/assistant/runtime/components/AssistantChatBody.vue b/modules/assistant/runtime/components/AssistantChatBody.vue new file mode 100644 index 000000000..94f00ad05 --- /dev/null +++ b/modules/assistant/runtime/components/AssistantChatBody.vue @@ -0,0 +1,395 @@ + + + diff --git a/modules/assistant/runtime/components/AssistantFloatingInput.vue b/modules/assistant/runtime/components/AssistantFloatingInput.vue new file mode 100644 index 000000000..5a8e0d2e1 --- /dev/null +++ b/modules/assistant/runtime/components/AssistantFloatingInput.vue @@ -0,0 +1,85 @@ + + + diff --git a/modules/assistant/runtime/components/AssistantLoading.vue b/modules/assistant/runtime/components/AssistantLoading.vue new file mode 100644 index 000000000..a877e0363 --- /dev/null +++ b/modules/assistant/runtime/components/AssistantLoading.vue @@ -0,0 +1,188 @@ + + + diff --git a/modules/assistant/runtime/components/AssistantMessageFeedback.vue b/modules/assistant/runtime/components/AssistantMessageFeedback.vue new file mode 100644 index 000000000..b089209ba --- /dev/null +++ b/modules/assistant/runtime/components/AssistantMessageFeedback.vue @@ -0,0 +1,147 @@ + + + diff --git a/modules/assistant/runtime/components/AssistantPreStream.vue b/modules/assistant/runtime/components/AssistantPreStream.vue new file mode 100644 index 000000000..dcf6d4ee7 --- /dev/null +++ b/modules/assistant/runtime/components/AssistantPreStream.vue @@ -0,0 +1,46 @@ + + + diff --git a/modules/assistant/runtime/components/AssistantSlashes.vue b/modules/assistant/runtime/components/AssistantSlashes.vue new file mode 100644 index 000000000..00e205bff --- /dev/null +++ b/modules/assistant/runtime/components/AssistantSlashes.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/modules/assistant/runtime/composables/useAssistant.test.ts b/modules/assistant/runtime/composables/useAssistant.test.ts new file mode 100644 index 000000000..e8f5e651e --- /dev/null +++ b/modules/assistant/runtime/composables/useAssistant.test.ts @@ -0,0 +1,213 @@ +import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computed, defineComponent, h, nextTick, reactive } from 'vue'; + +const mockState = vi.hoisted(() => ({ + chats: [] as any[], + transports: [] as any[], + toastAdd: vi.fn(), + messageId: 0, + route: undefined as { path: string } | undefined, +})); + +mockNuxtImport('useAppConfig', () => () => ({ assistant: { faqQuestions: [] } })); +mockNuxtImport('useRuntimeConfig', () => () => ({ + app: { baseURL: '/docs' }, + public: { assistant: { enabled: true, apiPath: '/__ai__/chat' } }, +})); +mockNuxtImport('useRoute', () => () => mockState.route); +mockNuxtImport('useToast', () => () => ({ add: mockState.toastAdd })); + +vi.mock('ai', () => ({ + DefaultChatTransport: class DefaultChatTransport { + init: unknown; + + constructor(init: unknown) { + this.init = init; + mockState.transports.push(this); + } + }, +})); + +vi.mock('@ai-sdk/vue', async () => { + const { ref } = await import('vue'); + + return { + Chat: class Chat { + init: any; + messagesRef: any; + statusRef: any; + sendMessage = vi.fn((message: { text: string }) => { + this.messages = [...this.messages, { + id: `user-${++mockState.messageId}`, + role: 'user', + parts: [{ type: 'text', text: message.text }], + }]; + }); + stop = vi.fn(() => { + this.status = 'ready'; + }); + regenerate = vi.fn(); + + constructor(init: any) { + this.init = init; + this.messagesRef = ref(init.messages ?? []); + this.statusRef = ref('ready'); + mockState.chats.push(this); + } + + get messages() { + return this.messagesRef.value; + } + + set messages(value) { + this.messagesRef.value = value; + } + + get status() { + return this.statusRef.value; + } + + set status(value) { + this.statusRef.value = value; + } + + get lastMessage() { + return this.messages.at(-1); + } + }, + }; +}); + +async function flush() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function texts(messages: any[]) { + return messages.flatMap(message => + message.parts?.flatMap((part: any) => part.type === 'text' ? [part.text] : []) ?? [], + ); +} + +async function mountAssistant(initialPath = '/docs/guides/ai') { + mockState.route = reactive({ path: initialPath }); + const { useAssistant } = await import('./useAssistant'); + let assistant: ReturnType | undefined; + + await mountSuspended(defineComponent({ + setup() { + assistant = useAssistant(); + return () => h('div'); + }, + })); + + if (!assistant) throw new Error('assistant did not mount'); + return assistant; +} + +describe('useAssistant', () => { + beforeEach(async () => { + vi.useRealTimers(); + vi.resetModules(); + localStorage.clear(); + mockState.chats.length = 0; + mockState.transports.length = 0; + mockState.toastAdd.mockReset(); + mockState.messageId = 0; + mockState.route = undefined; + const { clearNuxtState } = await import('#imports'); + clearNuxtState(); + }); + + it('sends a pending initial message under a new conversation', async () => { + const assistant = await mountAssistant(); + const chat = mockState.chats[0]; + + assistant.open('How do I install Directus?', true); + await flush(); + + expect(assistant.isOpen.value).toBe(true); + expect(chat.sendMessage).toHaveBeenCalledWith({ text: 'How do I install Directus?' }); + expect(assistant.activeConversationId.value).toBeTruthy(); + expect(texts(chat.messages)).toEqual(['How do I install Directus?']); + }); + + it('resets without allowing a stale easter egg timer to append', async () => { + vi.useFakeTimers(); + const assistant = await mountAssistant(); + const chat = mockState.chats[0]; + + assistant.submit('what\'s up doc'); + await flush(); + expect(chat.messages).toHaveLength(1); + expect(assistant.easterEggLoadingId.value).toBeTruthy(); + + assistant.resetChat(); + vi.advanceTimersByTime(2_000); + await flush(); + + expect(chat.stop).toHaveBeenCalled(); + expect(chat.messages).toHaveLength(0); + expect(assistant.easterEggLoadingId.value).toBeNull(); + }); + + it('stops the stream when deleting the active conversation', async () => { + const assistant = await mountAssistant(); + const chat = mockState.chats[0]; + + assistant.submit('Tell me about collections'); + await flush(); + const activeId = assistant.activeConversationId.value; + expect(activeId).toBeTruthy(); + + chat.status = 'streaming'; + assistant.deleteConversation(activeId!); + await flush(); + + expect(chat.stop).toHaveBeenCalled(); + expect(chat.messages).toHaveLength(0); + expect(assistant.activeConversationId.value).toBeNull(); + expect(assistant.conversations.value.some(conv => conv.id === activeId)).toBe(false); + }); + + it('stops the current stream before opening another conversation', async () => { + const assistant = await mountAssistant(); + const chat = mockState.chats[0]; + + assistant.submit('First question'); + await flush(); + const firstId = assistant.activeConversationId.value!; + assistant.resetChat(); + await flush(); + + assistant.submit('Second question'); + await flush(); + expect(assistant.activeConversationId.value).not.toBe(firstId); + + chat.stop.mockClear(); + assistant.openConversation(firstId); + await flush(); + + expect(chat.stop).toHaveBeenCalled(); + expect(assistant.activeConversationId.value).toBe(firstId); + expect(texts(chat.messages)).toEqual(['First question']); + }); + + it('reenables page context after route changes', async () => { + const assistant = await mountAssistant('/docs/guides/ai/assistant'); + + expect(assistant.currentPagePath.value).toBe('/guides/ai/assistant'); + expect(assistant.pageContextActive.value).toBe(true); + + assistant.dismissPageContext(); + expect(assistant.pageContextActive.value).toBe(false); + + mockState.route!.path = '/docs/guides/ai/mcp'; + await flush(); + + expect(assistant.currentPagePath.value).toBe('/guides/ai/mcp'); + expect(assistant.pageContextActive.value).toBe(true); + }); +}); diff --git a/modules/assistant/runtime/composables/useAssistant.ts b/modules/assistant/runtime/composables/useAssistant.ts new file mode 100644 index 000000000..572c04356 --- /dev/null +++ b/modules/assistant/runtime/composables/useAssistant.ts @@ -0,0 +1,256 @@ +import { Chat } from '@ai-sdk/vue'; +import { DefaultChatTransport, type ChatStatus, type UIMessage } from 'ai'; +import { useAppConfig, useRoute, useRuntimeConfig, useState, useToast } from '#imports'; +import { computed, watch, type Ref } from 'vue'; +import type { FaqCategory } from '../types'; +import { buildEasterEggMessages, EASTER_EGG_RESPONSE_DELAY_MS, isEasterEggPrompt } from '../utils/easter-egg'; +import { compactMessagesForRequest } from '../utils/messages'; +import { useAssistantHistory } from './useAssistantHistory'; + +let chat: Chat | null = null; +let initialized = false; +let lifecycleVersion = 0; +let easterEggTimer: ReturnType | undefined; + +function parseErrorMessage(error: Error): string { + try { + const parsed = JSON.parse(error.message); + return parsed?.message || error.message; + } + catch { + return error.message; + } +} + +function cancelEasterEgg(easterEggLoadingId: Ref) { + if (easterEggTimer) { + clearTimeout(easterEggTimer); + easterEggTimer = undefined; + } + easterEggLoadingId.value = null; +} + +export function useAssistant() { + const config = useRuntimeConfig(); + const appConfig = useAppConfig(); + const assistantConfig = appConfig.assistant as { faqQuestions?: FaqCategory[] } | undefined; + const isEnabled = computed(() => Boolean(config.public.assistant?.enabled)); + const isOpen = useState('assistant-open', () => false); + const pendingMessage = useState('assistant-pending', () => undefined); + const easterEggLoadingId = useState('assistant-easter-egg-id', () => null); + const pageContextDismissed = useState('assistant-page-context-dismissed', () => false); + const history = useAssistantHistory(); + + const route = useRoute(); + const baseURL = (config.app?.baseURL || '/').replace(/\/$/, ''); + const currentPagePath = computed(() => { + const path = route?.path; + if (!path || !path.startsWith('/')) return null; + const stripped = baseURL && path.startsWith(baseURL) ? path.slice(baseURL.length) : path; + if (!stripped || stripped === '/') return null; + return stripped; + }); + const pageContextActive = computed(() => !pageContextDismissed.value && currentPagePath.value !== null); + + const faqQuestions = computed(() => assistantConfig?.faqQuestions ?? []); + + function ensureConversationId() { + return history.activeId.value ?? history.startNew(chat?.messages ?? []).id; + } + + function bumpLifecycle() { + lifecycleVersion++; + cancelEasterEgg(easterEggLoadingId); + } + + // The single writer of chat.messages. Every conversation transition + // (reset, open, delete, easter egg) goes through here so the persistence + // watcher reasons about one mutation idiom, not three. + function setMessages(next: UIMessage[]) { + if (!chat) return; + chat.messages.splice(0, chat.messages.length, ...next); + } + + if (import.meta.client && !chat) { + const toast = useToast(); + chat = new Chat({ + messages: history.active.value?.messages ? [...history.active.value.messages] : [], + transport: new DefaultChatTransport({ + api: `${baseURL}${config.public.assistant.apiPath}`, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await fetch(input, init); + if (localStorage.getItem('assistantDebug') === '1' && response.headers.get('x-assistant-mode') === 'degraded') { + toast.add({ + description: 'Assistant running in reduced-quality mode.', + icon: 'i-lucide-info', + color: 'warning', + }); + } + return response; + }, + headers: (): Record => { + const headers: Record = { + 'x-assistant-session-id': ensureConversationId(), + }; + if (pageContextActive.value && currentPagePath.value) headers['x-page-path'] = currentPagePath.value; + return headers; + }, + prepareSendMessagesRequest: ({ body, id, messageId, messages, trigger }) => ({ + body: { + ...body, + id, + messageId, + trigger, + messages: compactMessagesForRequest(messages), + }, + }), + }), + onError: (error: Error) => { + toast.add({ + description: parseErrorMessage(error), + icon: 'i-lucide-alert-circle', + color: 'error', + duration: 0, + }); + }, + }); + } + + const messages = computed(() => chat?.messages ?? []); + const status = computed(() => chat?.status ?? 'ready'); + const lastMessage = computed(() => messages.value.at(-1)); + const showThinking = computed(() => { + const last = lastMessage.value; + if (!last || last.role !== 'assistant') return false; + if (easterEggLoadingId.value && last.id === easterEggLoadingId.value) return true; + return status.value === 'streaming' && !last.parts?.some(p => p.type === 'text'); + }); + + if (import.meta.client && !initialized) { + initialized = true; + + watch(() => chat!.messages, (current) => { + if (current.length === 0) return; + const id = history.activeId.value ?? history.startNew(current).id; + history.update(id, current); + }, { deep: true }); + + watch(pendingMessage, (message) => { + if (!message || !chat) return; + chat.sendMessage({ text: message }); + pendingMessage.value = undefined; + }, { immediate: true }); + + watch(() => route?.path, () => { + pageContextDismissed.value = false; + }); + + if (chat && chat.lastMessage?.role === 'user' && !pendingMessage.value) { + chat.regenerate(); + } + } + + function open(initialMessage?: string, clearPrevious = false) { + if (clearPrevious && chat) resetChat(); + if (initialMessage) pendingMessage.value = initialMessage; + isOpen.value = true; + } + + function close() { + isOpen.value = false; + } + + function toggle() { + isOpen.value = !isOpen.value; + } + + function dismissPageContext() { + pageContextDismissed.value = true; + } + + function submit(text: string) { + const trimmed = text.trim(); + if (!trimmed || !chat) return; + if (isEasterEggPrompt(trimmed)) { + triggerEasterEgg(trimmed); + return; + } + chat.sendMessage({ text: trimmed }); + } + + function triggerEasterEgg(userText: string) { + if (!chat) return; + cancelEasterEgg(easterEggLoadingId); + const version = lifecycleVersion; + const { user, assistant, response } = buildEasterEggMessages(userText); + easterEggLoadingId.value = assistant.id; + setMessages([...chat.messages, user]); + easterEggTimer = setTimeout(() => { + if (!chat || version !== lifecycleVersion || easterEggLoadingId.value !== assistant.id) return; + setMessages([...chat.messages, { ...assistant, parts: response }]); + easterEggLoadingId.value = null; + easterEggTimer = undefined; + }, EASTER_EGG_RESPONSE_DELAY_MS); + } + + function stop() { + chat?.stop(); + } + + function regenerate() { + chat?.regenerate(); + } + + function resetChat() { + if (!chat) return; + bumpLifecycle(); + chat.stop(); + setMessages([]); + history.clearActive(); + } + + function openConversation(id: string) { + const conv = history.conversations.value.find(c => c.id === id); + if (!conv || !chat) return; + bumpLifecycle(); + chat.stop(); + history.setActive(id); + setMessages(conv.messages); + isOpen.value = true; + } + + function deleteConversation(id: string) { + const wasActive = history.activeId.value === id; + if (wasActive && chat) { + bumpLifecycle(); + chat.stop(); + setMessages([]); + } + history.remove(id); + } + + return { + isEnabled, + isOpen, + messages, + status, + lastMessage, + showThinking, + easterEggLoadingId, + faqQuestions, + currentPagePath, + pageContextActive, + conversations: history.conversations, + activeConversationId: history.activeId, + open, + close, + toggle, + submit, + stop, + regenerate, + resetChat, + openConversation, + deleteConversation, + dismissPageContext, + }; +} diff --git a/modules/assistant/runtime/composables/useAssistantHistory.ts b/modules/assistant/runtime/composables/useAssistantHistory.ts new file mode 100644 index 000000000..1e1d71008 --- /dev/null +++ b/modules/assistant/runtime/composables/useAssistantHistory.ts @@ -0,0 +1,107 @@ +import type { UIMessage } from 'ai'; +import { useStorage } from '@vueuse/core'; +import { computed } from 'vue'; + +export interface AssistantConversation { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messages: UIMessage[]; +} + +const STORAGE_KEY = 'directus-docs-assistant-conversations'; +const ACTIVE_KEY = 'directus-docs-assistant-active'; +const MAX_CONVERSATIONS = 50; +const STORED_MESSAGE_LIMIT = 40; + +function newId() { + return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function deriveTitle(messages: UIMessage[]): string { + const firstUser = messages.find(m => m.role === 'user'); + if (!firstUser) return 'New chat'; + const text = firstUser.parts + ?.flatMap(p => (p.type === 'text' ? [p.text] : [])) + .join(' ') + .trim() ?? ''; + return text.length > 60 ? `${text.slice(0, 57)}…` : text || 'New chat'; +} + +function compactForStorage(messages: UIMessage[]): UIMessage[] { + return messages.slice(-STORED_MESSAGE_LIMIT).map(message => ({ + ...message, + parts: message.parts?.filter(part => part.type === 'text' || part.type === 'data-tool-calls') ?? [], + })); +} + +export function useAssistantHistory() { + const conversations = useStorage(STORAGE_KEY, []); + const activeId = useStorage(ACTIVE_KEY, null); + + const sorted = computed(() => + [...conversations.value].sort((a, b) => b.updatedAt - a.updatedAt), + ); + + const active = computed(() => + conversations.value.find(c => c.id === activeId.value) ?? null, + ); + + function startNew(initial?: UIMessage[]): AssistantConversation { + const now = Date.now(); + const messages = compactForStorage(initial ?? []); + const conv: AssistantConversation = { + id: newId(), + title: deriveTitle(messages), + createdAt: now, + updatedAt: now, + messages, + }; + conversations.value = [conv, ...conversations.value].slice(0, MAX_CONVERSATIONS); + activeId.value = conv.id; + return conv; + } + + function setActive(id: string) { + if (conversations.value.some(c => c.id === id)) { + activeId.value = id; + } + } + + function clearActive() { + activeId.value = null; + } + + function update(id: string, messages: UIMessage[]) { + const idx = conversations.value.findIndex(c => c.id === id); + if (idx === -1) return; + const existing = conversations.value[idx]!; + const compacted = compactForStorage(messages); + const next: AssistantConversation = { + ...existing, + messages: compacted, + title: existing.title === 'New chat' ? deriveTitle(compacted) : existing.title, + updatedAt: Date.now(), + }; + const copy = [...conversations.value]; + copy[idx] = next; + conversations.value = copy; + } + + function remove(id: string) { + conversations.value = conversations.value.filter(c => c.id !== id); + if (activeId.value === id) activeId.value = null; + } + + return { + conversations: sorted, + activeId, + active, + startNew, + setActive, + clearActive, + update, + remove, + }; +} diff --git a/modules/assistant/runtime/composables/useHighlighter.ts b/modules/assistant/runtime/composables/useHighlighter.ts new file mode 100644 index 000000000..7e22ce4fa --- /dev/null +++ b/modules/assistant/runtime/composables/useHighlighter.ts @@ -0,0 +1,36 @@ +import { createHighlighterCore } from '@shikijs/core'; +import type { HighlighterCore } from '@shikijs/core'; +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'; +import directusLight from '~~/app/assets/shiki/directus-light.json'; +import directusDark from '~~/app/assets/shiki/directus-dark.json'; + +let highlighter: HighlighterCore | null = null; +let promise: Promise | null = null; + +export const useHighlighter = async () => { + if (!promise) { + promise = createHighlighterCore({ + langs: [ + import('@shikijs/langs/vue'), + import('@shikijs/langs/javascript'), + import('@shikijs/langs/typescript'), + import('@shikijs/langs/css'), + import('@shikijs/langs/html'), + import('@shikijs/langs/json'), + import('@shikijs/langs/yaml'), + import('@shikijs/langs/markdown'), + import('@shikijs/langs/bash'), + ], + themes: [ + directusLight as never, + directusDark as never, + ], + engine: createJavaScriptRegexEngine(), + }); + } + if (!highlighter) { + highlighter = await promise; + } + + return highlighter; +}; diff --git a/modules/assistant/runtime/server/api/__test__/assistant-status.get.ts b/modules/assistant/runtime/server/api/__test__/assistant-status.get.ts new file mode 100644 index 000000000..75a49ea7c --- /dev/null +++ b/modules/assistant/runtime/server/api/__test__/assistant-status.get.ts @@ -0,0 +1,26 @@ +import { defineEventHandler, getRequestIP, createError } from 'h3'; +import { fingerprintFromEvent } from '../../utils/fingerprint'; +import { getAssistantLimitStatus, ipPrefix } from '../../utils/rate-limit'; +import { isDevContext } from '../../utils/is-dev-context'; + +export default defineEventHandler(async (event) => { + if (!import.meta.dev || process.env.NODE_ENV === 'production' || !isDevContext(event)) { + throw createError({ statusCode: 404, message: 'Not found' }); + } + + const fp = fingerprintFromEvent(event); + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; + const status = await getAssistantLimitStatus({ + ip, + fingerprint: fp.fingerprint, + fingerprintEntropy: fp.entropy, + ipPrefix: ipPrefix(ip), + }); + + return { + ip, + fingerprint: fp.fingerprint, + fingerprintEntropy: fp.entropy, + ...status, + }; +}); diff --git a/modules/assistant/runtime/server/api/__test__/reset-limits.post.ts b/modules/assistant/runtime/server/api/__test__/reset-limits.post.ts new file mode 100644 index 000000000..786ae0206 --- /dev/null +++ b/modules/assistant/runtime/server/api/__test__/reset-limits.post.ts @@ -0,0 +1,20 @@ +import { defineEventHandler, createError, getHeader } from 'h3'; +import { resetAssistantLimits } from '../../utils/rate-limit'; +import { isDevContext } from '../../utils/is-dev-context'; + +export default defineEventHandler(async (event) => { + if (!import.meta.dev || process.env.NODE_ENV === 'production' || !isDevContext(event)) { + throw createError({ statusCode: 404, message: 'Not found' }); + } + + const expected = process.env.ASSISTANT_RESET_TOKEN; + if (expected) { + const provided = getHeader(event, 'x-reset-token'); + if (provided !== expected) { + throw createError({ statusCode: 403, message: 'Forbidden' }); + } + } + + const cleared = await resetAssistantLimits(); + return { ok: true, cleared }; +}); diff --git a/modules/assistant/runtime/server/api/chat.post.ts b/modules/assistant/runtime/server/api/chat.post.ts new file mode 100644 index 000000000..7915ecb94 --- /dev/null +++ b/modules/assistant/runtime/server/api/chat.post.ts @@ -0,0 +1,69 @@ +import { + createUIMessageStreamResponse, + type ToolSet, +} from 'ai'; +import getDirectusFile from '~~/server/mcp/tools/get-directus-file'; +import getDirectusPage from '~~/server/mcp/tools/get-directus-page'; +import getDoc from '~~/server/mcp/tools/get-doc'; +import listDocs from '~~/server/mcp/tools/list-docs'; +import searchDirectusCode from '~~/server/mcp/tools/search-directus-code'; +import searchDocs from '~~/server/mcp/tools/search-docs'; +import { admitChat } from '../utils/admit'; +import { bindMcpToolsForAI } from '../utils/bind-tools'; +import { PROFILES } from '../utils/profiles'; +import { buildRequestContext } from '../utils/request-context'; +import { createAssistantStream } from '../utils/stream'; +import { systemPrompt } from '../../../prompts/system-prompt'; + +export default defineEventHandler(async (event) => { + const admission = await admitChat(event); + if (!admission.ok) return admission.body; + const { ctx: limited, messages: admittedMessages } = admission; + + const config = useRuntimeConfig(event); + const apiKey = config.assistant?.openrouterApiKey; + if (!apiKey) { + setResponseStatus(event, 503); + return { code: 'NOT_CONFIGURED', message: 'AI assistant not configured.', requestId: limited.requestId }; + } + + const profile = PROFILES[limited.mode]; + const request = await buildRequestContext({ + event, + baseURL: config.app?.baseURL || '/', + basePrompt: systemPrompt, + requestId: limited.requestId, + messages: admittedMessages, + profile, + }); + + const stream = createAssistantStream({ + event, + apiKey, + model: config.assistant.model, + system: request.systemPrompt, + messages: request.messages, + profile, + admit: limited, + sessionId: request.sessionId, + pagePath: request.pagePath, + framework: request.framework, + createTools: onActivity => bindMcpToolsForAI({ + 'list-docs': listDocs, + 'get-doc': getDoc, + 'search-docs': searchDocs, + 'search-directus-code': searchDirectusCode, + 'get-directus-file': getDirectusFile, + 'get-directus-page': getDirectusPage, + }, { maxCalls: 15, maxResultBytes: 50 * 1024, onActivity }) as ToolSet, + }); + + return createUIMessageStreamResponse({ + stream, + headers: { + 'X-Request-ID': limited.requestId, + 'X-Assistant-Mode': limited.mode, + 'X-RateLimit-Remaining-Day': String(limited.remainingDay ?? ''), + }, + }); +}); diff --git a/modules/assistant/runtime/server/plugins/posthog-otel.ts b/modules/assistant/runtime/server/plugins/posthog-otel.ts new file mode 100644 index 000000000..153d55165 --- /dev/null +++ b/modules/assistant/runtime/server/plugins/posthog-otel.ts @@ -0,0 +1,107 @@ +import type { Context } from '@opentelemetry/api'; +import type { NodeSDK } from '@opentelemetry/sdk-node'; +import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { isAssistantEnabled } from '../../utils/is-assistant-enabled'; +import { assistantRateLimitStore } from '../utils/rate-limit'; +import { redactValue } from '../utils/sanitize'; + +declare global { + // eslint-disable-next-line no-var + var __directusAssistantOtel: NodeSDK | undefined; + // eslint-disable-next-line no-var + var __directusAssistantOtelProcessor: SpanProcessor | undefined; +} + +function isAiSpan(span: ReadableSpan): boolean { + if (/^(gen_ai\.|llm\.|ai\.|traceloop\.)/.test(span.name)) return true; + return Object.keys(span.attributes).some(key => /^(gen_ai\.|llm\.|ai\.|traceloop\.)/.test(key)); +} + +class RedactingSpanProcessor implements SpanProcessor { + constructor(private inner: SpanProcessor) {} + + onStart(span: Span, parentContext: Context): void { + this.inner.onStart(span, parentContext); + } + + onEnd(span: ReadableSpan): void { + if (process.env.POSTHOG_AI_DEBUG === 'true') { + console.log('[assistant:otel] span', { + name: span.name, + ai: isAiSpan(span), + attributes: Object.keys(span.attributes).filter(key => /^(gen_ai\.|llm\.|ai\.|traceloop\.)/.test(key)), + }); + } + + const mutable = span as unknown as { attributes?: Record; events?: Array<{ attributes?: Record }> }; + if (mutable.attributes) mutable.attributes = redactValue(mutable.attributes) as Record; + if (mutable.events) { + mutable.events = mutable.events.map(event => ({ + ...event, + attributes: event.attributes ? redactValue(event.attributes) as Record : event.attributes, + })); + } + this.inner.onEnd(span); + } + + shutdown(): Promise { + return this.inner.shutdown(); + } + + forceFlush(): Promise { + return this.inner.forceFlush(); + } +} + +export default defineNitroPlugin(async () => { + const config = useRuntimeConfig(); + const assistantEnabled = isAssistantEnabled(config.assistant?.openrouterApiKey); + const posthogAiHost = process.env.POSTHOG_AI_HOST || config.public.posthog?.host; + const posthogEnabled = !config.public.posthog?.disabled && Boolean(config.public.posthog?.publicKey && posthogAiHost); + + const redisReachable = Boolean((process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL) && (process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN)); + console.log(`[assistant] enabled=${assistantEnabled} model=${config.assistant?.model || 'unset'} store=${assistantRateLimitStore()} redis_reachable=${redisReachable} posthog=${posthogEnabled ? 'ok' : 'disabled'} posthog_ai_host=${posthogAiHost || 'unset'}`); + + if (!assistantEnabled || !posthogEnabled || globalThis.__directusAssistantOtel) return; + + try { + const [{ NodeSDK }, { resourceFromAttributes }, { PostHogSpanProcessor }, { trace }] = await Promise.all([ + import('@opentelemetry/sdk-node'), + import('@opentelemetry/resources'), + import('@posthog/ai/otel'), + import('@opentelemetry/api'), + ]); + + const processor = new RedactingSpanProcessor(new PostHogSpanProcessor({ + apiKey: config.public.posthog.publicKey, + host: posthogAiHost, + })); + globalThis.__directusAssistantOtelProcessor = processor; + + globalThis.__directusAssistantOtel = new NodeSDK({ + resource: resourceFromAttributes({ + 'service.name': 'directus-docs-assistant', + }), + spanProcessors: [processor], + }); + globalThis.__directusAssistantOtel.start(); + + if (process.env.POSTHOG_AI_SELF_TEST === 'true') { + const span = trace.getTracer('directus-docs-assistant').startSpan('gen_ai.self_test', { + attributes: { + 'gen_ai.system': 'directus-docs', + 'gen_ai.request.model': 'self-test', + 'ai.telemetry.functionId': 'posthog-ai-self-test', + }, + }); + span.end(); + await processor.forceFlush(); + console.log('[assistant:otel] self-test flushed'); + } + } + catch (error) { + globalThis.__directusAssistantOtel = undefined; + globalThis.__directusAssistantOtelProcessor = undefined; + console.warn('[assistant] failed to start PostHog OTel', error); + } +}); diff --git a/modules/assistant/runtime/server/utils/abuse-gate.test.ts b/modules/assistant/runtime/server/utils/abuse-gate.test.ts new file mode 100644 index 000000000..499b76060 --- /dev/null +++ b/modules/assistant/runtime/server/utils/abuse-gate.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { abuseGate } from './abuse-gate'; + +const trusted = { + origin: 'http://localhost:3000', + referer: 'http://localhost:3000/docs', + secFetchSite: 'same-origin', + secFetchMode: 'cors', + secFetchDest: 'empty', + userAgent: 'Mozilla/5.0 Chrome/120 Safari/537.36', + acceptLanguage: 'en-US', + host: 'localhost:3000', + nodeEnv: 'development', +}; + +describe('abuseGate', () => { + it('trusts browser requests from localhost in dev', () => { + expect(abuseGate(trusted).verdict).toBe('trusted'); + }); + + it('blocks missing origin and referer', () => { + expect(abuseGate({ ...trusted, origin: undefined, referer: undefined }).verdict).toBe('blocked'); + }); + + it('degrades valid origin with missing fetch headers', () => { + expect(abuseGate({ ...trusted, secFetchSite: undefined }).verdict).toBe('suspicious'); + }); + + it('does not allow arbitrary vercel apps in production', () => { + expect(abuseGate({ + ...trusted, + origin: 'https://evil.vercel.app', + referer: 'https://evil.vercel.app/docs', + vercelEnv: 'production', + nodeEnv: 'production', + }).verdict).toBe('blocked'); + }); + + it('allows the Directus docs production origin', () => { + expect(abuseGate({ + ...trusted, + origin: 'https://directus.com', + referer: 'https://directus.com/docs', + host: 'directus.com', + vercelEnv: 'production', + nodeEnv: 'production', + }).verdict).toBe('trusted'); + }); + + it('allows exact Vercel preview URL in preview', () => { + expect(abuseGate({ + ...trusted, + origin: 'https://directus-docs-git-branch.vercel.app', + referer: 'https://directus-docs-git-branch.vercel.app/docs', + vercelEnv: 'preview', + vercelUrl: 'directus-docs-git-branch.vercel.app', + nodeEnv: 'production', + }).verdict).toBe('trusted'); + }); + + it('allows Directus Vercel branch aliases in preview', () => { + expect(abuseGate({ + ...trusted, + origin: 'https://docs-git-bry-dockem-8-ai-assistant-directus.vercel.app', + referer: 'https://docs-git-bry-dockem-8-ai-assistant-directus.vercel.app/docs', + host: 'docs-git-bry-dockem-8-ai-assistant-directus.vercel.app', + vercelEnv: 'preview', + vercelUrl: 'docs-abc123-directus.vercel.app', + nodeEnv: 'production', + }).verdict).toBe('trusted'); + }); + + it('does not allow arbitrary Vercel branch aliases in preview', () => { + expect(abuseGate({ + ...trusted, + origin: 'https://evil-git-branch-someone.vercel.app', + referer: 'https://evil-git-branch-someone.vercel.app/docs', + host: 'evil-git-branch-someone.vercel.app', + vercelEnv: 'preview', + vercelUrl: 'docs-abc123-directus.vercel.app', + nodeEnv: 'production', + }).verdict).toBe('blocked'); + }); +}); diff --git a/modules/assistant/runtime/server/utils/abuse-gate.ts b/modules/assistant/runtime/server/utils/abuse-gate.ts new file mode 100644 index 000000000..509eca737 --- /dev/null +++ b/modules/assistant/runtime/server/utils/abuse-gate.ts @@ -0,0 +1,101 @@ +import { getHeader, type H3Event } from 'h3'; + +export type GateVerdict = 'trusted' | 'suspicious' | 'blocked'; + +export type AbuseGateInput = { + origin?: string; + referer?: string; + secFetchSite?: string; + secFetchMode?: string; + secFetchDest?: string; + userAgent?: string; + acceptLanguage?: string; + host?: string; + vercelEnv?: string; + vercelUrl?: string; + nodeEnv?: string; +}; + +export type AbuseGateResult = { + verdict: GateVerdict; + reason?: string; +}; + +function originOf(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(value).origin; + } + catch { + return null; + } +} + +function isDev(input: AbuseGateInput): boolean { + if (input.vercelEnv === 'production' || input.vercelEnv === 'preview') return false; + if (input.nodeEnv !== 'production') return true; + return Boolean(input.host?.startsWith('localhost:') || input.host?.startsWith('127.0.0.1:')); +} + +function allowedOrigins(input: AbuseGateInput): Set { + const origins = new Set(['https://directus.com', 'https://www.directus.com', 'https://directus.io', 'https://www.directus.io']); + if (input.vercelEnv === 'preview' && input.vercelUrl) origins.add(`https://${input.vercelUrl}`); + if (input.vercelEnv === 'preview' && input.host?.endsWith('-directus.vercel.app')) origins.add(`https://${input.host}`); + if (isDev(input)) { + origins.add('http://localhost:3000'); + origins.add('http://localhost:3001'); + origins.add('http://127.0.0.1:3000'); + origins.add('http://127.0.0.1:3001'); + } + return origins; +} + +function isAllowed(origin: string | null, allowed: Set, dev: boolean): boolean { + if (!origin) return false; + if (allowed.has(origin)) return true; + if (!dev) return false; + try { + const url = new URL(origin); + return url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1'); + } + catch { + return false; + } +} + +export function abuseGate(input: AbuseGateInput): AbuseGateResult { + const allowed = allowedOrigins(input); + const dev = isDev(input); + const origin = originOf(input.origin); + const referer = originOf(input.referer); + const originOk = isAllowed(origin, allowed, dev); + const refererOk = isAllowed(referer, allowed, dev); + + if (!originOk && !refererOk) return { verdict: 'blocked', reason: 'bad_origin_referer' }; + + const ua = input.userAgent || ''; + const uaOk = ua.length >= 20 && ua.length <= 500 && /(Mozilla|Safari|Chrome|Firefox|Edge)/i.test(ua); + const fetchOk = input.secFetchSite === 'same-origin' + && input.secFetchMode === 'cors' + && input.secFetchDest === 'empty'; + const langOk = Boolean(input.acceptLanguage); + + if (originOk && refererOk && uaOk && fetchOk && langOk) return { verdict: 'trusted' }; + return { verdict: 'suspicious', reason: [fetchOk ? '' : 'fetch_headers', uaOk ? '' : 'ua', langOk ? '' : 'language'].filter(Boolean).join(',') || 'partial_headers' }; +} + +export function abuseGateFromEvent(event: H3Event): AbuseGateResult { + return abuseGate({ + origin: getHeader(event, 'origin'), + referer: getHeader(event, 'referer'), + secFetchSite: getHeader(event, 'sec-fetch-site'), + secFetchMode: getHeader(event, 'sec-fetch-mode'), + secFetchDest: getHeader(event, 'sec-fetch-dest'), + userAgent: getHeader(event, 'user-agent'), + acceptLanguage: getHeader(event, 'accept-language'), + host: getHeader(event, 'host'), + vercelEnv: process.env.VERCEL_ENV, + vercelUrl: process.env.VERCEL_URL, + nodeEnv: process.env.NODE_ENV, + }); +} diff --git a/modules/assistant/runtime/server/utils/admit.test.ts b/modules/assistant/runtime/server/utils/admit.test.ts new file mode 100644 index 000000000..bb8a8d961 --- /dev/null +++ b/modules/assistant/runtime/server/utils/admit.test.ts @@ -0,0 +1,153 @@ +import { Readable } from 'node:stream'; +import { createEvent, type H3Event } from 'h3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { admitChat } from './admit'; +import { checkAssistantDailyLimits } from './rate-limit'; +import { checkRateLimit } from '~~/server/utils/rate-limit'; + +vi.mock('~~/modules/posthog/runtime/server/capture', () => ({ + captureServerPostHog: () => {}, +})); + +vi.mock('~~/server/utils/rate-limit', () => ({ + checkRateLimit: vi.fn(async () => ({ ok: true })), +})); + +// Replace only the daily limiter; keep ipPrefix and the rest of the real module. +vi.mock('./rate-limit', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, checkAssistantDailyLimits: vi.fn() }; +}); + +const mockedBurst = vi.mocked(checkRateLimit); +const mockedDaily = vi.mocked(checkAssistantDailyLimits); + +const ADMITTED_DAILY = { + ok: true as const, + mode: 'normal' as const, + resetAt: '2026-06-01T00:00:00.000Z', + counts: {}, + remainingExactIp: 29, +}; + +// A request that passes the abuse gate as `trusted` from localhost dev. +const TRUSTED_HEADERS: Record = { + 'origin': 'http://localhost:3000', + 'referer': 'http://localhost:3000/docs', + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', + 'user-agent': 'Mozilla/5.0 Chrome/120 Safari/537.36', + 'accept-language': 'en-US', + 'host': 'localhost:3000', + 'sec-ch-ua': '"Chromium";v="120"', +}; + +function buildEvent(body: string, headers: Record = {}): H3Event { + const merged = { ...TRUSTED_HEADERS, ...headers }; + const req = Readable.from([Buffer.from(body)]) as unknown as import('node:http').IncomingMessage; + req.method = 'POST'; + req.url = '/api/chat'; + req.headers = { 'content-length': String(Buffer.byteLength(body)), ...merged }; + (req as { socket: unknown }).socket = { remoteAddress: '127.0.0.1' }; + + const res = { + statusCode: 200, + setHeader() {}, + getHeader() {}, + headersSent: false, + } as unknown as import('node:http').ServerResponse; + + return createEvent(req, res); +} + +function validBody(text = 'hello') { + return JSON.stringify({ messages: [{ id: 'm1', role: 'user', parts: [{ type: 'text', text }] }] }); +} + +let savedEnv: NodeJS.ProcessEnv; + +beforeEach(() => { + savedEnv = { ...process.env }; + mockedBurst.mockReset(); + mockedBurst.mockResolvedValue({ ok: true }); + mockedDaily.mockReset(); + mockedDaily.mockResolvedValue(ADMITTED_DAILY); + process.env.NODE_ENV = 'development'; + delete process.env.ASSISTANT_ENABLED; +}); + +afterEach(() => { + process.env = savedEnv; +}); + +describe('admitChat sequence', () => { + it('admits a trusted, well-formed request with validated messages', async () => { + const result = await admitChat(buildEvent(validBody())); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.messages).toHaveLength(1); + expect(result.ctx.gateVerdict).toBe('trusted'); + } + }); + + it('blocks before touching the burst limit when the gate rejects', async () => { + const result = await admitChat(buildEvent(validBody(), { origin: '', referer: '' })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.body.code).toBe('BLOCKED'); + expect(mockedBurst).not.toHaveBeenCalled(); + }); + + it('rejects burst before parsing the body', async () => { + mockedBurst.mockResolvedValue({ ok: false, retryAfter: 42 }); + // Body is invalid JSON; if parse ran first this would be BAD_JSON. + const result = await admitChat(buildEvent('{ not json', {})); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.body.code).toBe('RATE_LIMIT_BURST'); + expect((result.body as { retryAfter?: number }).retryAfter).toBe(42); + } + }); + + it('rejects malformed JSON before spending the daily quota', async () => { + const result = await admitChat(buildEvent('{ not json')); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.body.code).toBe('BAD_JSON'); + expect(mockedDaily).not.toHaveBeenCalled(); + }); + + it('rejects invalid message shapes before the daily quota', async () => { + const result = await admitChat(buildEvent(JSON.stringify({ messages: 'nope' }))); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.body.code).toBe('INVALID_MESSAGES'); + expect(mockedDaily).not.toHaveBeenCalled(); + }); + + it('enforces the daily limit only after gate, burst, and parse pass', async () => { + mockedDaily.mockResolvedValue({ + ok: false, + mode: 'degraded', + reason: 'exact_ip', + retryAfter: 3600, + resetAt: '2026-06-01T00:00:00.000Z', + counts: {}, + remainingExactIp: 0, + }); + const result = await admitChat(buildEvent(validBody())); + expect(mockedDaily).toHaveBeenCalledOnce(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.body.code).toBe('RATE_LIMIT_DAILY'); + expect((result.body as { retryAfter?: number }).retryAfter).toBe(3600); + } + }); + + it('honors the kill switch before any other check', async () => { + process.env.ASSISTANT_ENABLED = 'false'; + const result = await admitChat(buildEvent(validBody(), { origin: '', referer: '' })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.body.code).toBe('KILL_SWITCH'); + expect(mockedBurst).not.toHaveBeenCalled(); + expect(mockedDaily).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/assistant/runtime/server/utils/admit.ts b/modules/assistant/runtime/server/utils/admit.ts new file mode 100644 index 000000000..e14970c5c --- /dev/null +++ b/modules/assistant/runtime/server/utils/admit.ts @@ -0,0 +1,182 @@ +import { getHeader, getRequestIP, readRawBody, setResponseHeader, setResponseStatus, type H3Event } from 'h3'; +import { safeValidateUIMessages, type UIMessage } from 'ai'; +import { abuseGateFromEvent, type GateVerdict } from './abuse-gate'; +import { fingerprintFromEvent } from './fingerprint'; +import { checkAssistantDailyLimits, ipPrefix } from './rate-limit'; +import type { AssistantMode } from './profiles'; +import { boundRawMessages, redactValue } from './sanitize'; +import { checkRateLimit } from '~~/server/utils/rate-limit'; +import { captureServerPostHog } from '~~/modules/posthog/runtime/server/capture'; + +const MAX_BODY_BYTES = 512 * 1024; + +export type AssistantAdmitContext = { + requestId: string; + ip: string; + ipPrefix: string; + fingerprint: string; + fingerprintEntropy: 'high' | 'low'; + posthogDistinctId: string; + gateVerdict: GateVerdict; + mode: AssistantMode; + rateLimitHit?: string; + resetAt?: string; + remainingDay?: number; +}; + +// Result of the admission sequence. `ok: true` carries the validated request; +// `ok: false` carries the JSON body already written to the response via rejectJson. +export type AdmittedRequest = { ok: true; ctx: AssistantAdmitContext; messages: UIMessage[] }; +export type Denied = { ok: false; body: ReturnType }; + +function requestId(): string { + return `req_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`; +} + +export type AssistantErrorCode = 'BLOCKED' | 'RATE_LIMIT_BURST' | 'RATE_LIMIT_DAILY' | 'PAYLOAD_TOO_LARGE' | 'BAD_JSON' | 'INVALID_MESSAGES' | 'KILL_SWITCH' | 'UPSTREAM_QUOTA' | 'NOT_CONFIGURED'; + +function errorBody(code: AssistantErrorCode, message: string, requestIdValue: string, extra: Record = {}) { + return { code, message, requestId: requestIdValue, ...extra }; +} + +function rejectJson(event: H3Event, statusCode: number, code: AssistantErrorCode, message: string, ctx: Partial, extra: Record = {}) { + setResponseStatus(event, statusCode); + setResponseHeader(event, 'Content-Type', 'application/json'); + if (ctx.requestId) setResponseHeader(event, 'X-Request-ID', ctx.requestId); + if (typeof extra.retryAfter === 'number') setResponseHeader(event, 'Retry-After', extra.retryAfter); + + captureServerPostHog(event, 'assistant_rejected', ctx.posthogDistinctId || ctx.fingerprint || 'unknown', redactValue({ + reason: code, + request_id: ctx.requestId, + ip_prefix: ctx.ipPrefix, + fingerprint: ctx.fingerprint, + fingerprint_entropy: ctx.fingerprintEntropy, + mode: ctx.mode, + gate_verdict: ctx.gateVerdict, + rate_limit_hit: ctx.rateLimitHit, + }) as Record); + + console.warn('[assistant] rejected', { code, requestId: ctx.requestId, ipPrefix: ctx.ipPrefix, gate: ctx.gateVerdict, rateLimitHit: ctx.rateLimitHit }); + return errorBody(code, message, ctx.requestId || 'unknown', extra); +} + +function deny(event: H3Event, statusCode: number, code: AssistantErrorCode, message: string, ctx: Partial, extra: Record = {}): Denied { + return { ok: false, body: rejectJson(event, statusCode, code, message, ctx, extra) }; +} + +// Gate + burst limit. Cheap identity/abuse checks before we spend a daily quota. +async function checkGateAndBurst(event: H3Event): Promise { + const id = requestId(); + setResponseHeader(event, 'X-Request-ID', id); + + if (process.env.ASSISTANT_ENABLED === 'false') { + return deny(event, 503, 'KILL_SWITCH', 'Assistant is temporarily unavailable.', { requestId: id }); + } + + const fp = fingerprintFromEvent(event); + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; + const prefix = ipPrefix(ip); + const ctx: AssistantAdmitContext = { + requestId: id, + ip, + ipPrefix: prefix, + fingerprint: fp.fingerprint, + fingerprintEntropy: fp.entropy, + posthogDistinctId: fp.posthogDistinctId, + gateVerdict: 'blocked', + mode: 'normal', + }; + + const gate = abuseGateFromEvent(event); + ctx.gateVerdict = gate.verdict; + if (gate.verdict === 'blocked') { + return deny(event, 403, 'BLOCKED', 'Chat unavailable from this browser context. Try refreshing the page.', ctx); + } + + const burstWindow = Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000); + const burst = await checkRateLimit(`assistant:burst:${ip}`, { max: 10, windowSeconds: Math.ceil(burstWindow / 1000), onStoreError: 'deny' }); + if (!burst.ok) { + ctx.rateLimitHit = 'burst'; + return deny(event, 429, 'RATE_LIMIT_BURST', `Slow down — too many messages. Try again in ${burst.retryAfter}s.`, ctx, { retryAfter: burst.retryAfter }); + } + + ctx.mode = gate.verdict === 'suspicious' || fp.entropy === 'low' ? 'degraded' : 'normal'; + setResponseHeader(event, 'X-Assistant-Mode', ctx.mode); + return ctx; +} + +// Body size / JSON / message bounding / UIMessage validation. Runs before the +// daily limit so a malformed request never consumes a user's daily quota. +async function parseBody(event: H3Event, ctx: AssistantAdmitContext): Promise { + const contentLength = Number(getHeader(event, 'content-length') || 0); + if (contentLength > MAX_BODY_BYTES) { + return deny(event, 413, 'PAYLOAD_TOO_LARGE', 'Message is too large. Shorten your request and try again.', ctx); + } + + const raw = await readRawBody(event); + if (!raw || Buffer.byteLength(raw, 'utf8') > MAX_BODY_BYTES) { + return deny(event, 413, 'PAYLOAD_TOO_LARGE', 'Message is too large. Shorten your request and try again.', ctx); + } + + let body: unknown; + try { + body = JSON.parse(raw); + } + catch { + return deny(event, 400, 'BAD_JSON', 'Invalid request body.', ctx); + } + + const bounded = boundRawMessages((body as { messages?: unknown })?.messages); + if (bounded.error) { + return deny(event, 400, 'INVALID_MESSAGES', bounded.error, ctx); + } + + const validated = await safeValidateUIMessages({ messages: bounded.messages }); + if (!validated.success) { + return deny(event, 400, 'INVALID_MESSAGES', validated.error?.message || 'Invalid messages.', ctx); + } + + return validated.data; +} + +// Per-UTC-day limit. Last gate, because it is the scarcest quota. +async function enforceDailyLimits(event: H3Event, ctx: AssistantAdmitContext): Promise { + const daily = await checkAssistantDailyLimits({ + ip: ctx.ip, + fingerprint: ctx.fingerprint, + fingerprintEntropy: ctx.fingerprintEntropy, + ipPrefix: ctx.ipPrefix, + }); + ctx.resetAt = daily.resetAt; + ctx.remainingDay = daily.remainingExactIp; + if (!daily.ok) { + ctx.rateLimitHit = daily.reason; + return deny(event, 429, 'RATE_LIMIT_DAILY', 'Daily limit reached. Resets at midnight UTC. If you are a real user hitting this often, email docs@directus.io.', ctx, { + retryAfter: daily.retryAfter, + resetAt: daily.resetAt, + }); + } + + if (daily.mode === 'degraded') ctx.mode = 'degraded'; + setResponseHeader(event, 'X-Assistant-Mode', ctx.mode); + setResponseHeader(event, 'X-RateLimit-Remaining-Day', String(ctx.remainingDay)); + return ctx; +} + +// The whole admission sequence in one place. Order is load-bearing: +// gate → burst → parse → daily +// Cheap abuse/identity checks first; body validation before the daily quota so +// garbage requests never burn a user's daily allowance. This ordering is the +// real bug surface — see admit.test.ts. +export async function admitChat(event: H3Event): Promise { + const gated = await checkGateAndBurst(event); + if ('ok' in gated) return gated; + + const parsed = await parseBody(event, gated); + if (!Array.isArray(parsed)) return parsed; + + const limited = await enforceDailyLimits(event, gated); + if ('ok' in limited) return limited; + + return { ok: true, ctx: limited, messages: parsed }; +} diff --git a/modules/assistant/runtime/server/utils/bind-tools.test.ts b/modules/assistant/runtime/server/utils/bind-tools.test.ts new file mode 100644 index 000000000..0f8127c37 --- /dev/null +++ b/modules/assistant/runtime/server/utils/bind-tools.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { bindMcpToolsForAI, serializeResult, truncateResult } from './bind-tools'; + +type ExecutableTool = { execute: (args: unknown, extra?: unknown) => Promise }; + +function exec(tool: unknown): ExecutableTool['execute'] { + return (tool as ExecutableTool).execute; +} + +// A tool whose schema accepts a single string `value`, used to drive execute(). +function stringTool(name: string, handler: (args: { value: string }) => unknown) { + return { + name, + description: name, + inputSchema: { value: z.string() }, + handler, + }; +} + +describe('truncateResult', () => { + it('returns the value unchanged when within the byte budget', () => { + expect(truncateResult('hello', 100)).toBe('hello'); + }); + + it('truncates and appends the continuation hint when over budget', () => { + const out = truncateResult('x'.repeat(200), 50); + const body = out.split('\n\n[')[0] ?? ''; + expect(out.length).toBeLessThan(200); + expect(out).toContain('truncated at 50 bytes'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(50); + }); + + it('never splits a multi-byte UTF-8 character', () => { + // '€' is 3 bytes; a naive char-count slice would corrupt it. + const out = truncateResult('€'.repeat(50), 40); + const body = out.split('\n\n[')[0] ?? ''; + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(40); + expect(body.endsWith('€')).toBe(true); + }); + + it('never leaves a lone surrogate when truncating emoji', () => { + const out = truncateResult('abc😀def', 5); + const body = out.split('\n\n[')[0] ?? ''; + expect(body).toBe('abc'); + expect(body).not.toContain('\uFFFD'); + }); +}); + +describe('serializeResult', () => { + it('passes strings through untouched', () => { + expect(serializeResult('plain')).toBe('plain'); + }); + + it('JSON-encodes non-strings', () => { + expect(serializeResult({ a: 1 })).toBe('{"a":1}'); + }); + + it('redacts secrets inside JSON-encoded objects', () => { + const out = serializeResult({ content: 'token ghp_' + 'a'.repeat(36) }); + expect(out).toContain('[REDACTED_GITHUB_TOKEN]'); + expect(out).not.toContain('ghp_aaaa'); + }); +}); + +describe('bindMcpToolsForAI', () => { + it('binds tools under their name and exposes an execute fn', () => { + const bound = bindMcpToolsForAI({ echo: stringTool('echo', a => a.value) }); + expect(bound.echo).toBeDefined(); + expect(typeof exec(bound.echo)).toBe('function'); + }); + + it('enforces a cross-tool call budget shared by all tools', async () => { + const bound = bindMcpToolsForAI( + { a: stringTool('a', () => 'ok'), b: stringTool('b', () => 'ok') }, + { maxCalls: 2 }, + ); + await expect(exec(bound.a)({ value: 'x' })).resolves.toBe('ok'); + await expect(exec(bound.b)({ value: 'x' })).resolves.toBe('ok'); + // Third call across either tool exceeds the shared budget of 2. + await expect(exec(bound.a)({ value: 'x' })).rejects.toThrow(/call limit exceeded \(2\)/); + }); + + it('rejects arguments that fail the tool schema', async () => { + const handler = vi.fn(() => 'ok'); + const bound = bindMcpToolsForAI({ a: stringTool('a', handler) }); + await expect(exec(bound.a)({ value: 123 })).rejects.toThrow(/Invalid arguments for a/); + expect(handler).not.toHaveBeenCalled(); + }); + + it('rejects unknown keys via the strict schema', async () => { + const bound = bindMcpToolsForAI({ a: stringTool('a', () => 'ok') }); + await expect(exec(bound.a)({ value: 'x', extra: 'nope' })).rejects.toThrow(/Invalid arguments/); + }); + + it('redacts secrets in a tool result before returning to the model', async () => { + const leak = 'ghp_' + 'b'.repeat(36); + const bound = bindMcpToolsForAI({ a: stringTool('a', () => ({ body: `leaked ${leak}` })) }); + const out = await exec(bound.a)({ value: 'x' }); + expect(out).toContain('[REDACTED_GITHUB_TOKEN]'); + expect(out).not.toContain(leak); + }); + + it('truncates oversized results', async () => { + const bound = bindMcpToolsForAI( + { a: stringTool('a', () => 'y'.repeat(500)) }, + { maxResultBytes: 50 }, + ); + const out = await exec(bound.a)({ value: 'x' }) as string; + expect(out).toContain('truncated at 50 bytes'); + }); + + it('calls onActivity for each invocation', async () => { + const onActivity = vi.fn(); + const bound = bindMcpToolsForAI({ a: stringTool('a', () => 'ok') }, { onActivity }); + await exec(bound.a)({ value: 'x' }); + // once before the handler, once after a successful handler. + expect(onActivity).toHaveBeenCalledTimes(2); + }); + + it('propagates handler errors', async () => { + const bound = bindMcpToolsForAI({ + a: stringTool('a', () => { throw new Error('boom'); }), + }); + await expect(exec(bound.a)({ value: 'x' })).rejects.toThrow('boom'); + }); +}); diff --git a/modules/assistant/runtime/server/utils/bind-tools.ts b/modules/assistant/runtime/server/utils/bind-tools.ts new file mode 100644 index 000000000..3baeb8420 --- /dev/null +++ b/modules/assistant/runtime/server/utils/bind-tools.ts @@ -0,0 +1,75 @@ +import { tool, type Tool } from 'ai'; +import { z, type ZodRawShape } from 'zod'; +import { redactValue } from './sanitize'; +import { sliceUtf8 } from '~~/server/utils/sliceUtf8'; + +interface ToolLike { + name?: string; + description?: string; + inputSchema?: ZodRawShape; + handler: (args: never, extra: never) => unknown; +} + +export type ToolBindOptions = { + maxCalls?: number; + maxResultBytes?: number; + onActivity?: () => void; +}; + +const DEFAULT_MAX_CALLS = 15; +const DEFAULT_MAX_RESULT_BYTES = 50 * 1024; + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +// Serialize a tool's return value to the string the model sees, redacting +// secrets along the way. Strings pass through; everything else is JSON. +export function serializeResult(result: unknown): string { + return typeof result === 'string' ? result : JSON.stringify(redactValue(result)); +} + +export function truncateResult(value: string, maxBytes: number): string { + if (byteLength(value) <= maxBytes) return value; + const { content } = sliceUtf8(value, 0, maxBytes); + return `${content}\n\n[tool result truncated at ${maxBytes} bytes. If this is from get-directus-file, call it again with a higher offset to read the next chunk.]`; +} + +export function bindMcpToolsForAI(tools: Record, options: ToolBindOptions = {}) { + const bound: Record = {}; + let callCount = 0; + const maxCalls = options.maxCalls ?? DEFAULT_MAX_CALLS; + const maxResultBytes = options.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; + + for (const [key, t] of Object.entries(tools)) { + const name = t.name ?? key; + const schema = t.inputSchema ? z.object(t.inputSchema).strict() : z.object({}).strict(); + bound[name] = tool({ + description: t.description ?? '', + inputSchema: schema, + execute: async (args: unknown) => { + options.onActivity?.(); + callCount++; + if (callCount > maxCalls) throw new Error(`Tool call limit exceeded (${maxCalls})`); + + const parsed = schema.safeParse(args); + if (!parsed.success) { + console.warn('[assistant] invalid tool args', { tool: name, issues: parsed.error.issues }); + throw new Error(`Invalid arguments for ${name}`); + } + + try { + const handler = t.handler as (args: unknown, extra: unknown) => unknown; + const result = await handler(parsed.data, {}); + options.onActivity?.(); + return truncateResult(serializeResult(result), maxResultBytes); + } + catch (error) { + console.warn('[assistant] tool failed', { tool: name, error }); + throw error; + } + }, + }); + } + return bound; +} diff --git a/modules/assistant/runtime/server/utils/fingerprint.ts b/modules/assistant/runtime/server/utils/fingerprint.ts new file mode 100644 index 000000000..06fa727c8 --- /dev/null +++ b/modules/assistant/runtime/server/utils/fingerprint.ts @@ -0,0 +1,37 @@ +import { createHash, createHmac } from 'node:crypto'; +import { getHeader, type H3Event } from 'h3'; + +export type FingerprintResult = { + fingerprint: string; + entropy: 'high' | 'low'; + posthogDistinctId: string; +}; + +function hashHex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function fingerprintFromEvent(event: H3Event): FingerprintResult { + const ua = getHeader(event, 'user-agent') || ''; + const language = getHeader(event, 'accept-language') || ''; + const encoding = getHeader(event, 'accept-encoding') || ''; + const chUa = getHeader(event, 'sec-ch-ua') || ''; + const platform = getHeader(event, 'sec-ch-ua-platform') || ''; + const mobile = getHeader(event, 'sec-ch-ua-mobile') || ''; + const raw = [ua, language, encoding, chUa, platform, mobile].join('\n'); + const fingerprint = hashHex(raw).slice(0, 16); + const entropy = chUa ? 'high' : 'low'; + + return { + fingerprint, + entropy, + posthogDistinctId: posthogDistinctId(fingerprint), + }; +} + +export function posthogDistinctId(fingerprint: string, now = new Date()): string { + const secret = process.env.ASSISTANT_FP_SECRET || 'missing-assistant-fp-secret'; + const day = now.toISOString().slice(0, 10); + const salt = createHmac('sha256', secret).update(day).digest('hex'); + return hashHex(`${salt}:${fingerprint}`).slice(0, 32); +} diff --git a/modules/assistant/runtime/server/utils/is-dev-context.ts b/modules/assistant/runtime/server/utils/is-dev-context.ts new file mode 100644 index 000000000..b1abc441c --- /dev/null +++ b/modules/assistant/runtime/server/utils/is-dev-context.ts @@ -0,0 +1,18 @@ +import { getHeader, type H3Event } from 'h3'; + +export type AssistantRuntimeContext = 'production' | 'preview' | 'development'; + +export function getAssistantRuntimeContext(event?: H3Event): AssistantRuntimeContext { + if (process.env.VERCEL_ENV === 'production') return 'production'; + if (process.env.VERCEL_ENV === 'preview') return 'preview'; + + const host = event ? getHeader(event, 'host') || '' : ''; + if (host.startsWith('localhost:') || host.startsWith('127.0.0.1:')) return 'development'; + if (process.env.NODE_ENV !== 'production') return 'development'; + + return 'preview'; +} + +export function isDevContext(event?: H3Event): boolean { + return getAssistantRuntimeContext(event) === 'development'; +} diff --git a/modules/assistant/runtime/server/utils/profiles.ts b/modules/assistant/runtime/server/utils/profiles.ts new file mode 100644 index 000000000..bdc784d69 --- /dev/null +++ b/modules/assistant/runtime/server/utils/profiles.ts @@ -0,0 +1,20 @@ +export type AssistantMode = 'normal' | 'degraded'; + +export type LimitProfile = { + maxOutputTokens: number; + maxSteps: number; + messageLimit: number; +}; + +export const PROFILES: Record = { + normal: { + maxOutputTokens: 4000, + maxSteps: 12, + messageLimit: 12, + }, + degraded: { + maxOutputTokens: 1500, + maxSteps: 3, + messageLimit: 4, + }, +}; diff --git a/modules/assistant/runtime/server/utils/rate-limit.test.ts b/modules/assistant/runtime/server/utils/rate-limit.test.ts new file mode 100644 index 000000000..9d6479b06 --- /dev/null +++ b/modules/assistant/runtime/server/utils/rate-limit.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkAssistantDailyLimits, + getAssistantLimitStatus, + ipPrefix, + resetAssistantLimits, + setAssistantOverride, +} from './rate-limit'; + +const redisClient = vi.hoisted(() => ({ + del: vi.fn(), + expire: vi.fn(), + get: vi.fn(), + incr: vi.fn(), + scan: vi.fn(), + set: vi.fn(), +})); + +const Redis = vi.hoisted(() => vi.fn(() => redisClient)); + +vi.mock('@upstash/redis', () => ({ Redis })); + +type DailyInputOptions = { + ip?: string; + fingerprint?: string; + fingerprintEntropy?: 'high' | 'low'; +}; + +function dailyInput(options: DailyInputOptions = {}) { + const ip = options.ip ?? '203.0.113.10'; + return { + ip, + fingerprint: options.fingerprint ?? 'fp_test', + fingerprintEntropy: options.fingerprintEntropy ?? 'high', + ipPrefix: ipPrefix(ip), + }; +} + +let savedEnv: NodeJS.ProcessEnv; + +beforeEach(async () => { + savedEnv = { ...process.env }; + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + delete process.env.KV_REST_API_URL; + delete process.env.KV_REST_API_TOKEN; + redisClient.del.mockResolvedValue(0); + redisClient.expire.mockResolvedValue(undefined); + redisClient.get.mockResolvedValue(null); + redisClient.incr.mockResolvedValue(1); + redisClient.scan.mockResolvedValue([0, []]); + redisClient.set.mockResolvedValue(undefined); + Redis.mockClear(); + await resetAssistantLimits(); +}); + +afterEach(async () => { + process.env = savedEnv; + await resetAssistantLimits(); + vi.clearAllMocks(); +}); + +describe('checkAssistantDailyLimits', () => { + it('normalizes compressed IPv6 addresses to a stable /64 prefix', () => { + expect(ipPrefix('2001:db8::1')).toBe('2001:db8:0:0::/64'); + expect(ipPrefix('2001:db8:0:0:abcd::1')).toBe('2001:db8:0:0::/64'); + expect(ipPrefix('2001:db8:abcd:1234:1::1')).toBe('2001:db8:abcd:1234::/64'); + }); + + it('fails closed when the exact IP limit is exceeded', async () => { + let result = await checkAssistantDailyLimits(dailyInput()); + for (let i = 1; i < 31; i++) result = await checkAssistantDailyLimits(dailyInput()); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('exact_ip'); + expect(result.counts.exactIp).toBe(31); + expect(result.remainingExactIp).toBe(0); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it('degrades but allows requests when an IP prefix crosses the shared limit', async () => { + let result = await checkAssistantDailyLimits(dailyInput()); + for (let i = 0; i < 501; i++) { + result = await checkAssistantDailyLimits(dailyInput({ + ip: `2001:db8:abcd:1234:${i.toString(16)}::1`, + fingerprint: `fp_prefix_${i}`, + })); + } + + expect(result.ok).toBe(true); + expect(result.mode).toBe('degraded'); + expect(result.reason).toBe('shared_key'); + expect(result.counts.prefix).toBe(501); + }); + + it('degrades but allows requests when a high-entropy fingerprint crosses the shared limit', async () => { + let result = await checkAssistantDailyLimits(dailyInput()); + for (let i = 0; i < 201; i++) { + result = await checkAssistantDailyLimits(dailyInput({ + ip: `2001:db8:${i.toString(16)}:abcd::1`, + fingerprint: 'fp_shared', + })); + } + + expect(result.ok).toBe(true); + expect(result.mode).toBe('degraded'); + expect(result.reason).toBe('shared_key'); + expect(result.counts.fingerprint).toBe(201); + }); + + it('does not count low-entropy fingerprints against the fingerprint key', async () => { + let result = await checkAssistantDailyLimits(dailyInput()); + for (let i = 0; i < 201; i++) { + result = await checkAssistantDailyLimits(dailyInput({ + ip: `2001:db8:${i.toString(16)}:abcd::1`, + fingerprint: 'fp_low_entropy', + fingerprintEntropy: 'low', + })); + } + + expect(result.ok).toBe(true); + expect(result.mode).toBe('normal'); + expect(result.counts.fingerprint).toBe(0); + }); + + it('honors fingerprint overrides without incrementing daily counters', async () => { + await setAssistantOverride('fp_override'); + + const result = await checkAssistantDailyLimits(dailyInput({ fingerprint: 'fp_override' })); + const status = await getAssistantLimitStatus(dailyInput({ fingerprint: 'fp_override' })); + + expect(result.ok).toBe(true); + expect(result.counts).toEqual({}); + expect(status.exactIp).toBe(0); + expect(status.fingerprint).toBe(0); + }); + + it('fails closed when the KV store errors', async () => { + process.env.UPSTASH_REDIS_REST_URL = 'https://example-upstash.test'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; + redisClient.incr.mockRejectedValue(new Error('kv down')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await checkAssistantDailyLimits(dailyInput()); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('kv_error'); + expect(result.retryAfter).toBe(60); + expect(result.remainingExactIp).toBe(0); + errorSpy.mockRestore(); + }); +}); diff --git a/modules/assistant/runtime/server/utils/rate-limit.ts b/modules/assistant/runtime/server/utils/rate-limit.ts new file mode 100644 index 000000000..8b87c4db7 --- /dev/null +++ b/modules/assistant/runtime/server/utils/rate-limit.ts @@ -0,0 +1,247 @@ +// Daily limits use Vercel KV when configured, with process-memory fallback for +// local dev. Burst limits live in the shared server/utils/rate-limit module. + +interface Bucket { + count: number; + resetAt: number; +} + +const dailyMemory = new Map(); + +export type DailyLimitInput = { + ip: string; + fingerprint: string; + fingerprintEntropy: 'high' | 'low'; + ipPrefix: string; +}; + +export type DailyLimitResult = { + ok: boolean; + mode: 'normal' | 'degraded'; + reason?: string; + retryAfter?: number; + resetAt: string; + counts: Record; + remainingExactIp: number; +}; + +const LIMITS = { + exactIp: 30, + ipPrefix: 500, + fingerprint: 200, + combo: 50, +}; + +export function secondsUntilUtcMidnight(now = new Date()): number { + const reset = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1) / 1000; + return Math.max(1, Math.ceil(reset - now.getTime() / 1000)); +} + +export function utcResetAt(now = new Date()): string { + return new Date(now.getTime() + secondsUntilUtcMidnight(now) * 1000).toISOString(); +} + +function ipv6Prefix64(ip: string): string | null { + const address = ip.toLowerCase().split('%')[0] ?? ip.toLowerCase(); + if (!address.includes(':')) return null; + + const parts = address.split('::'); + if (parts.length > 2) return null; + + const head = parts[0] ? parts[0].split(':') : []; + const tail = parts[1] ? parts[1].split(':') : []; + if ([...head, ...tail].some(part => !/^[0-9a-f]{1,4}$/.test(part))) return null; + + const missing = 8 - head.length - tail.length; + if (missing < 0 || (parts.length === 1 && missing !== 0)) return null; + + const full = [...head, ...Array.from({ length: missing }, () => '0'), ...tail]; + if (full.length !== 8) return null; + + return `${full.slice(0, 4).map(part => Number.parseInt(part, 16).toString(16)).join(':')}::/64`; +} + +export function ipPrefix(ip: string): string { + if (!ip || ip === 'unknown') return 'unknown'; + const first = ip.split(',')[0]?.trim() || ip; + if (/^\d+\.\d+\.\d+\.\d+$/.test(first)) return first.split('.').slice(0, 3).join('.') + '.0/24'; + const ipv6 = ipv6Prefix64(first); + if (ipv6) return ipv6; + return first; +} + +function dailyKey(kind: string, value: string, now = new Date()): string { + return `assistant:daily:${now.toISOString().slice(0, 10)}:${kind}:${value}`; +} + +function upstashUrl(): string | undefined { + return process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL; +} + +function upstashToken(): string | undefined { + return process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN; +} + +function hasKvEnv(): boolean { + return Boolean(upstashUrl() && upstashToken()); +} + +async function kvClient() { + const { Redis } = await import('@upstash/redis'); + return new Redis({ url: upstashUrl()!, token: upstashToken()! }); +} + +async function kvIncr(key: string, ttl: number): Promise { + const kv = await kvClient(); + const count = await kv.incr(key); + if (count === 1) await kv.expire(key, ttl); + return count; +} + +async function kvGetNumber(key: string): Promise { + const kv = await kvClient(); + return Number(await kv.get(key) || 0); +} + +async function kvSet(key: string, value: string, ttl: number): Promise { + const kv = await kvClient(); + await kv.set(key, value, { ex: ttl }); +} + +async function kvDel(pattern: string): Promise { + const kv = await kvClient(); + let cursor = 0; + let deleted = 0; + do { + const [next, keys] = await kv.scan(cursor, { match: pattern, count: 100 }); + cursor = Number(next); + if (keys.length > 0) deleted += await kv.del(...keys); + } while (cursor !== 0); + return deleted; +} + +function memoryIncr(key: string, ttl: number): number { + const now = Date.now(); + const resetAt = now + ttl * 1000; + const bucket = dailyMemory.get(key); + if (!bucket || bucket.resetAt < now) { + dailyMemory.set(key, { count: 1, resetAt }); + return 1; + } + bucket.count++; + return bucket.count; +} + +function memoryGet(key: string): number { + const bucket = dailyMemory.get(key); + if (!bucket || bucket.resetAt < Date.now()) return 0; + return bucket.count; +} + +async function getOverride(fingerprint: string): Promise { + const key = `assistant:override:${fingerprint}`; + if (hasKvEnv()) { + const kv = await kvClient(); + return Boolean(await kv.get(key)); + } + return Boolean(memoryGet(key)); +} + +export async function setAssistantOverride(fingerprint: string, ttlSeconds = 86400): Promise { + const key = `assistant:override:${fingerprint}`; + if (hasKvEnv()) await kvSet(key, '1', ttlSeconds); + else dailyMemory.set(key, { count: 1, resetAt: Date.now() + ttlSeconds * 1000 }); +} + +export async function checkAssistantDailyLimits(input: DailyLimitInput): Promise { + const ttl = secondsUntilUtcMidnight(); + const resetAt = utcResetAt(); + const keys = { + exactIp: dailyKey('ip', input.ip), + ipPrefix: dailyKey('prefix', input.ipPrefix), + fingerprint: dailyKey('fp', input.fingerprint), + combo: dailyKey('combo', `${input.ip}:${input.fingerprint}`), + }; + + try { + if (await getOverride(input.fingerprint)) { + return { ok: true, mode: 'normal', resetAt, counts: {}, remainingExactIp: LIMITS.exactIp }; + } + + const counts = hasKvEnv() + ? await Promise.all([ + kvIncr(keys.exactIp, ttl), + kvIncr(keys.ipPrefix, ttl), + input.fingerprintEntropy === 'high' ? kvIncr(keys.fingerprint, ttl) : Promise.resolve(0), + kvIncr(keys.combo, ttl), + ]) + : [ + memoryIncr(keys.exactIp, ttl), + memoryIncr(keys.ipPrefix, ttl), + input.fingerprintEntropy === 'high' ? memoryIncr(keys.fingerprint, ttl) : 0, + memoryIncr(keys.combo, ttl), + ]; + + const exactIp = counts[0] ?? 0; + const prefix = counts[1] ?? 0; + const fingerprint = counts[2] ?? 0; + const combo = counts[3] ?? 0; + const hitExact = exactIp > LIMITS.exactIp; + const hitCombo = combo > LIMITS.combo; + const sharedHit = prefix > LIMITS.ipPrefix || fingerprint > LIMITS.fingerprint; + + if (hitExact || hitCombo) { + return { + ok: false, + mode: 'degraded', + reason: hitExact ? 'exact_ip' : 'combo', + retryAfter: ttl, + resetAt, + counts: { exactIp, prefix, fingerprint, combo }, + remainingExactIp: Math.max(0, LIMITS.exactIp - exactIp), + }; + } + + return { + ok: true, + mode: sharedHit ? 'degraded' : 'normal', + reason: sharedHit ? 'shared_key' : undefined, + resetAt, + counts: { exactIp, prefix, fingerprint, combo }, + remainingExactIp: Math.max(0, LIMITS.exactIp - exactIp), + }; + } + catch (error) { + console.error('[assistant] daily rate limit failed closed', error); + return { ok: false, mode: 'degraded', reason: 'kv_error', retryAfter: 60, resetAt, counts: {}, remainingExactIp: 0 }; + } +} + +export async function getAssistantLimitStatus(input: DailyLimitInput): Promise> { + const keys = { + exactIp: dailyKey('ip', input.ip), + ipPrefix: dailyKey('prefix', input.ipPrefix), + fingerprint: dailyKey('fp', input.fingerprint), + combo: dailyKey('combo', `${input.ip}:${input.fingerprint}`), + }; + const read = hasKvEnv() ? kvGetNumber : async (key: string) => memoryGet(key); + return { + store: hasKvEnv() ? 'vercel-kv' : 'memory', + resetAt: utcResetAt(), + exactIp: await read(keys.exactIp), + ipPrefix: await read(keys.ipPrefix), + fingerprint: await read(keys.fingerprint), + combo: await read(keys.combo), + }; +} + +export async function resetAssistantLimits(): Promise { + if (hasKvEnv()) return kvDel('assistant:daily:*'); + const count = dailyMemory.size; + dailyMemory.clear(); + return count; +} + +export function assistantRateLimitStore(): string { + return hasKvEnv() ? 'vercel-kv' : 'memory'; +} diff --git a/modules/assistant/runtime/server/utils/request-context.test.ts b/modules/assistant/runtime/server/utils/request-context.test.ts new file mode 100644 index 000000000..81f7bf515 --- /dev/null +++ b/modules/assistant/runtime/server/utils/request-context.test.ts @@ -0,0 +1,140 @@ +import { Readable } from 'node:stream'; +import { createEvent, type H3Event } from 'h3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { UIMessage } from 'ai'; +import { buildRequestContext } from './request-context'; +import { PROFILES } from './profiles'; + +// queryCollection is the only I/O dependency: it verifies a page path exists. +// Default to "page exists"; individual tests override to simulate unknown pages. +const mockFirst = vi.fn(async () => ({ path: '/docs/getting-started' })); +vi.mock('@nuxt/content/server', () => ({ + queryCollection: () => ({ + where: () => ({ first: mockFirst }), + }), +})); + +function buildEvent(headers: Record = {}): H3Event { + const req = Readable.from([]) as unknown as import('node:http').IncomingMessage; + req.method = 'POST'; + req.url = '/api/chat'; + req.headers = headers; + (req as { socket: unknown }).socket = { remoteAddress: '127.0.0.1' }; + + const res = { + statusCode: 200, + setHeader() {}, + getHeader() {}, + headersSent: false, + } as unknown as import('node:http').ServerResponse; + + return createEvent(req, res); +} + +function textMessage(id: string, text: string): UIMessage { + return { id, role: 'user', parts: [{ type: 'text', text }] } as UIMessage; +} + +function prefsCookie(prefs: Record): Record { + return { cookie: `directus-docs-prefs=${encodeURIComponent(JSON.stringify(prefs))}` }; +} + +const BASE = { + baseURL: '/docs', + basePrompt: 'SYSTEM', + requestId: 'req_test', + profile: PROFILES.normal, +}; + +beforeEach(() => { + mockFirst.mockReset(); + mockFirst.mockResolvedValue({ path: '/getting-started' }); +}); + +describe('buildRequestContext', () => { + it('returns null page path and bare prompt when nothing is supplied', async () => { + const ctx = await buildRequestContext({ ...BASE, event: buildEvent(), messages: [] }); + expect(ctx.pagePath).toBeNull(); + expect(ctx.prefs).toBeNull(); + expect(ctx.sessionId).toBeUndefined(); + expect(ctx.framework).toBe(''); + expect(ctx.systemPrompt).toBe('SYSTEM'); + }); + + it('resolves a known page path and prepends it to the prompt', async () => { + const ctx = await buildRequestContext({ + ...BASE, + event: buildEvent({ 'x-page-path': '/docs/getting-started' }), + messages: [], + }); + expect(ctx.pagePath).toBe('/getting-started'); + expect(ctx.systemPrompt).toBe('Current page: /getting-started\n\nSYSTEM'); + }); + + it('drops a page path the content collection does not know', async () => { + mockFirst.mockResolvedValue(null); + const ctx = await buildRequestContext({ + ...BASE, + event: buildEvent({ 'x-page-path': '/docs/nope' }), + messages: [], + }); + expect(ctx.pagePath).toBeNull(); + expect(ctx.systemPrompt).toBe('SYSTEM'); + }); + + it('sanitizes prefs, exposes framework, and renders them as user context', async () => { + const ctx = await buildRequestContext({ + ...BASE, + event: buildEvent(prefsCookie({ framework: 'Vue', role: 'Developer', junk: 'xy!@#$' })), + messages: [], + }); + expect(ctx.framework).toBe('Vue'); + expect(ctx.prefs).toMatchObject({ framework: 'Vue', role: 'Developer' }); + expect(ctx.systemPrompt).toContain('User context:'); + expect(ctx.systemPrompt).toContain('- Preferred framework: Vue'); + expect(ctx.systemPrompt).toContain('- Role: Developer'); + }); + + it('ignores a malformed prefs cookie', async () => { + const ctx = await buildRequestContext({ + ...BASE, + event: buildEvent({ cookie: 'directus-docs-prefs=not-json' }), + messages: [], + }); + expect(ctx.prefs).toBeNull(); + expect(ctx.framework).toBe(''); + }); + + it('accepts a valid session id and rejects an invalid one', async () => { + const ok = await buildRequestContext({ + ...BASE, + event: buildEvent({ 'x-assistant-session-id': 'sess_ABC-123' }), + messages: [], + }); + expect(ok.sessionId).toBe('sess_ABC-123'); + + const bad = await buildRequestContext({ + ...BASE, + event: buildEvent({ 'x-assistant-session-id': 'has spaces!' }), + messages: [], + }); + expect(bad.sessionId).toBeUndefined(); + }); + + it('compacts to the profile message limit and strips non-text parts', async () => { + const messages: UIMessage[] = [ + ...Array.from({ length: 15 }, (_, i) => textMessage(`m${i}`, `msg ${i}`)), + { id: 'tool', role: 'assistant', parts: [{ type: 'tool-foo' } as never] } as UIMessage, + ]; + const ctx = await buildRequestContext({ + ...BASE, + profile: PROFILES.normal, + event: buildEvent(), + messages, + }); + // normal.messageLimit is 12; the tool-only message has no text parts and drops out. + expect(ctx.messages.length).toBeLessThanOrEqual(PROFILES.normal.messageLimit); + expect(ctx.messages.every(m => m.parts.length > 0)).toBe(true); + expect(ctx.messages.every(m => m.parts.every(p => p.type === 'text'))).toBe(true); + }); +}); diff --git a/modules/assistant/runtime/server/utils/request-context.ts b/modules/assistant/runtime/server/utils/request-context.ts new file mode 100644 index 000000000..a0089f683 --- /dev/null +++ b/modules/assistant/runtime/server/utils/request-context.ts @@ -0,0 +1,113 @@ +import { queryCollection } from '@nuxt/content/server'; +import { getCookie, getHeader, type H3Event } from 'h3'; +import type { UIMessage } from 'ai'; +import type { LimitProfile } from './profiles'; +import { sanitizePagePath, sanitizePrefs } from './sanitize'; + +const PREFS_COOKIE = 'directus-docs-prefs'; + +const PREF_KEYS: Array = ['framework', 'useCase', 'deployment', 'role', 'experience']; +const PREF_LABELS: Record = { + framework: 'Preferred framework', + useCase: 'Primary use case', + deployment: 'Deployment target', + role: 'Role', + experience: 'Directus experience level', +}; + +export type UserPreferences = { + framework: string | null; + useCase: string | null; + deployment: string | null; + role: string | null; + experience: string | null; +}; + +// Everything an assistant request says about *what* it is asking, derived from +// headers, cookies and the message body. The admission context covers *who* is +// asking; this covers the prompt-shaping side. +export type RequestContext = { + pagePath: string | null; + prefs: Partial | null; + sessionId: string | undefined; + framework: string; + systemPrompt: string; + messages: UIMessage[]; +}; + +function readUserPrefs(event: H3Event): Partial | null { + const cookie = getCookie(event, PREFS_COOKIE); + if (!cookie) return null; + try { + const parsed = JSON.parse(decodeURIComponent(cookie)); + return sanitizePrefs(parsed); + } + catch { + return null; + } +} + +async function readPagePath(event: H3Event, baseURL: string, requestId: string): Promise { + const pagePath = sanitizePagePath(getHeader(event, 'x-page-path'), baseURL); + if (!pagePath) return null; + const page = await queryCollection(event, 'content') + .where('path', '=', pagePath) + .first(); + if (!page) { + console.warn('[assistant] rejected unknown page path', { requestId, pagePath }); + return null; + } + return pagePath; +} + +function readSessionId(event: H3Event): string | undefined { + const value = getHeader(event, 'x-assistant-session-id'); + if (!value) return undefined; + return /^[a-zA-Z0-9_-]{1,80}$/.test(value) ? value : undefined; +} + +function buildSystemPrompt(basePrompt: string, pagePath: string | null, prefs: Partial | null): string { + const parts: string[] = []; + if (pagePath) parts.push(`Current page: ${pagePath}`); + if (prefs) { + const lines = PREF_KEYS + .filter(key => typeof prefs[key] === 'string' && prefs[key]) + .map(key => `- ${PREF_LABELS[key]}: ${prefs[key]}`); + if (lines.length > 0) parts.push(`User context:\n${lines.join('\n')}`); + } + parts.push(basePrompt); + return parts.join('\n\n'); +} + +function compactMessagesForModel(messages: UIMessage[], messageLimit: number): UIMessage[] { + return messages + .slice(-messageLimit) + .map((message) => { + return { + ...message, + parts: message.parts?.filter((part: { type: string }) => part.type === 'text') ?? [], + } as UIMessage; + }) + .filter(message => message.parts.length > 0); +} + +export async function buildRequestContext(options: { + event: H3Event; + baseURL: string; + basePrompt: string; + requestId: string; + messages: UIMessage[]; + profile: LimitProfile; +}): Promise { + const { event, baseURL, basePrompt, requestId, messages, profile } = options; + const pagePath = await readPagePath(event, baseURL, requestId); + const prefs = readUserPrefs(event); + return { + pagePath, + prefs, + sessionId: readSessionId(event), + framework: prefs?.framework || '', + systemPrompt: buildSystemPrompt(basePrompt, pagePath, prefs), + messages: compactMessagesForModel(messages, profile.messageLimit), + }; +} diff --git a/modules/assistant/runtime/server/utils/sanitize.ts b/modules/assistant/runtime/server/utils/sanitize.ts new file mode 100644 index 000000000..71e7a1e5f --- /dev/null +++ b/modules/assistant/runtime/server/utils/sanitize.ts @@ -0,0 +1,96 @@ +const PAGE_PATH_PATTERN = /^\/?[a-z0-9/_-]{0,127}$/; +const PREF_PATTERN = /^[a-zA-Z0-9 _.-]{1,64}$/; + +function removeControlChars(value: string): string { + return [...value].filter((char) => { + const code = char.charCodeAt(0); + return code > 31 && code !== 127; + }).join(''); +} + +export const MAX_MESSAGE_CHARS = 4000; +export const MAX_TOTAL_INPUT_CHARS = 30_000; +export const MAX_REQUEST_MESSAGES = 100; + +const REDACTIONS: Array<[RegExp, string]> = [ + [/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_API_KEY]'], + [/sk-or-[A-Za-z0-9_-]+/g, '[REDACTED_API_KEY]'], + [/gh[pousr]_[A-Za-z0-9]{36}/g, '[REDACTED_GITHUB_TOKEN]'], + [/AKIA[0-9A-Z]{16}/g, '[REDACTED_AWS_KEY]'], + [/Bearer\s+[A-Za-z0-9._-]{20,}/gi, 'Bearer [REDACTED]'], + [/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED_JWT]'], + [/[A-Z_]*(SECRET|PASSWORD|API_KEY|TOKEN)[A-Z_]*\s*[=:]\s*\S+/gi, '[REDACTED_ENV_SECRET]'], + [/([?&](token|access_token|api_key)=)[^&\s]+/gi, '$1[REDACTED]'], + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[REDACTED_EMAIL]'], + [/\b(?:[a-z0-9-]+\.)*(?:internal|local)\b/gi, '[REDACTED_HOST]'], + [/\b10(?:\.\d{1,3}){3}\b/g, '[REDACTED_IP]'], + [/\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, '[REDACTED_IP]'], + [/\b192\.168(?:\.\d{1,3}){2}\b/g, '[REDACTED_IP]'], +]; + +export function redactText(value: string): string { + let out = value; + for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement); + return out; +} + +export function redactValue(value: unknown): unknown { + if (typeof value === 'string') return redactText(value); + if (Array.isArray(value)) return value.map(redactValue); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactValue(child)])); + } + return value; +} + +export function sanitizePagePath(raw: string | null | undefined, baseURL = '/'): string | null { + if (!raw) return null; + const cleaned = removeControlChars(raw.trim()).toLowerCase(); + const base = baseURL.replace(/\/$/, ''); + const stripped = base && cleaned.startsWith(base) ? cleaned.slice(base.length) || '/' : cleaned; + if (stripped === '/' || stripped === '') return null; + if (stripped.length > 128) return null; + if (!PAGE_PATH_PATTERN.test(stripped)) return null; + return stripped.startsWith('/') ? stripped : `/${stripped}`; +} + +export function sanitizePrefs>(prefs: T | null): Partial | null { + if (!prefs) return null; + const out: Partial = {}; + for (const [key, value] of Object.entries(prefs)) { + if (typeof value === 'string') { + const cleaned = removeControlChars(value.trim()); + if (PREF_PATTERN.test(cleaned)) (out as Record)[key] = cleaned; + } + } + return Object.keys(out).length > 0 ? out : null; +} + +export function boundRawMessages(messages: unknown): { messages?: unknown; error?: string } { + if (!Array.isArray(messages)) return { error: 'Invalid messages' }; + if (messages.length > MAX_REQUEST_MESSAGES) return { error: 'Start a new chat to continue.' }; + + let totalChars = 0; + const bounded = messages.map((message) => { + if (!message || typeof message !== 'object') return message; + const clone = { ...(message as Record) }; + const parts = Array.isArray(clone.parts) + ? clone.parts.map((part) => { + if (!part || typeof part !== 'object') return part; + const next = { ...(part as Record) }; + if (next.type === 'text' && typeof next.text === 'string') { + const text = next.text.length > MAX_MESSAGE_CHARS ? next.text.slice(0, MAX_MESSAGE_CHARS) : next.text; + next.text = text; + totalChars += text.length; + } + return next; + }) + : clone.parts; + clone.parts = parts; + return clone; + }); + + if (totalChars > MAX_TOTAL_INPUT_CHARS) return { error: 'Message is too long. Start a new chat or shorten your request.' }; + + return { messages: bounded }; +} diff --git a/modules/assistant/runtime/server/utils/stream.ts b/modules/assistant/runtime/server/utils/stream.ts new file mode 100644 index 000000000..4e393688f --- /dev/null +++ b/modules/assistant/runtime/server/utils/stream.ts @@ -0,0 +1,146 @@ +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { + convertToModelMessages, + createUIMessageStream, + streamText, + type ToolCallPart, + type ToolSet, + type UIMessage, + type UIMessageStreamWriter, +} from 'ai'; +import type { H3Event } from 'h3'; +import type { AssistantAdmitContext } from './admit'; +import type { LimitProfile } from './profiles'; + +type StepContent = { content?: Array<{ type: string; toolName?: string }> }; + +interface AssistantStreamOptions { + event: H3Event; + apiKey: string; + model: string; + system: string; + messages: UIMessage[]; + profile: LimitProfile; + admit: AssistantAdmitContext; + sessionId?: string; + pagePath: string | null; + framework?: string; + createTools: (onActivity: () => void) => ToolSet; +} + +function stopWhenResponseComplete(maxSteps: number) { + return ({ steps }: { steps: Array<{ text?: string; toolCalls?: unknown[] }> }): boolean => { + const lastStep = steps.at(-1); + if (!lastStep) return false; + const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0); + const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0; + if (hasText && hasNoToolCalls) return true; + return steps.length >= maxSteps; + }; +} + +function forceSummaryStep(maxSteps: number) { + return ({ steps, stepNumber }: { steps: StepContent[]; stepNumber: number }) => { + if (stepNumber >= maxSteps - 1) return { toolChoice: 'none' as const }; + + const lastTwo = steps.slice(-2); + if (lastTwo.length === 2) { + const errors = lastTwo.map(s => s.content?.find(p => p.type === 'tool-error')?.toolName); + if (errors[0] && errors[0] === errors[1]) return { toolChoice: 'none' as const }; + } + + return {}; + }; +} + +function flushPostHogAiTelemetry(requestId: string): void { + const processor = (globalThis as typeof globalThis & { __directusAssistantOtelProcessor?: { forceFlush: () => Promise } }).__directusAssistantOtelProcessor; + if (!processor) return; + void processor.forceFlush().catch(error => console.warn('[assistant:otel] force flush failed', { requestId, error })); +} + +function toolCallArgs(toolCall: ToolCallPart): unknown { + if ('args' in toolCall) return (toolCall as unknown as { args: unknown }).args; + if ('input' in toolCall) return (toolCall as unknown as { input: unknown }).input; + return {}; +} + +export function createAssistantStream(options: AssistantStreamOptions) { + const abortController = new AbortController(); + let idleTimer: NodeJS.Timeout | undefined; + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => abortController.abort(), 60_000); + }; + resetIdleTimer(); + options.event.node.req.on('close', () => abortController.abort()); + + const openrouter = createOpenRouter({ apiKey: options.apiKey }); + const tools = options.createTools(resetIdleTimer); + + return createUIMessageStream({ + onError: (error) => { + console.error('[assistant] stream error', { requestId: options.admit.requestId, error }); + return 'Assistant is temporarily unavailable. Try again in a few hours.'; + }, + execute: async ({ writer }: { writer: UIMessageStreamWriter }) => { + writer.write({ + id: `trace-${options.admit.requestId}`, + type: 'data-trace-id', + data: { traceId: options.admit.requestId }, + }); + const modelMessages = await convertToModelMessages(options.messages); + const result = streamText({ + model: openrouter(options.model), + system: options.system, + messages: modelMessages, + tools, + stopWhen: stopWhenResponseComplete(options.profile.maxSteps), + prepareStep: forceSummaryStep(options.profile.maxSteps), + maxOutputTokens: options.profile.maxOutputTokens, + maxRetries: 0, + abortSignal: abortController.signal, + experimental_telemetry: { + isEnabled: true, + recordInputs: true, + recordOutputs: true, + functionId: 'docs-assistant-chat', + metadata: { + posthog_distinct_id: options.admit.posthogDistinctId, + $ai_session_id: options.sessionId || options.admit.fingerprint, + $ai_trace_id: options.admit.requestId, + request_id: options.admit.requestId, + ip_prefix: options.admit.ipPrefix, + fingerprint: options.admit.fingerprint, + fingerprint_entropy: options.admit.fingerprintEntropy, + page_path: options.pagePath || '', + mode: options.admit.mode, + gate_verdict: options.admit.gateVerdict, + framework: options.framework || '', + }, + }, + onChunk: resetIdleTimer, + onFinish: () => { + if (idleTimer) clearTimeout(idleTimer); + flushPostHogAiTelemetry(options.admit.requestId); + }, + onStepFinish: ({ toolCalls }: { toolCalls: ToolCallPart[] }) => { + resetIdleTimer(); + if (toolCalls.length === 0) return; + writer.write({ + id: toolCalls[0]?.toolCallId, + type: 'data-tool-calls', + data: { + tools: toolCalls.map(toolCall => ({ + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + args: toolCallArgs(toolCall), + })), + }, + }); + }, + }); + writer.merge(result.toUIMessageStream()); + }, + }); +} diff --git a/modules/assistant/runtime/strings.ts b/modules/assistant/runtime/strings.ts new file mode 100644 index 000000000..fcf3f899b --- /dev/null +++ b/modules/assistant/runtime/strings.ts @@ -0,0 +1,17 @@ +export const strings = { + tooltip: 'Ask AI', + title: 'Ask AI', + placeholder: 'Ask anything about Directus...', + close: 'Close', + askMeAnything: 'Ask me anything', + askMeAnythingDescription: 'I can search the Directus docs and source code to ground answers.', + lineBreak: 'Shift + Enter for new line', + assistantFooterNotice: 'AI responses may be inaccurate.', + loadingFinished: 'Sources used', + toolListPages: 'Listed documentation pages', + toolReadPage: 'Read', + toolSearchDocs: 'Searched docs for', + toolSearchCode: 'Searched code for', + toolReadFile: 'Read source file', + toolReadWebPage: 'Read', +}; diff --git a/modules/assistant/runtime/types.ts b/modules/assistant/runtime/types.ts new file mode 100644 index 000000000..5e33ec81e --- /dev/null +++ b/modules/assistant/runtime/types.ts @@ -0,0 +1,4 @@ +export interface FaqCategory { + category: string; + items: string[]; +} diff --git a/modules/assistant/runtime/utils/easter-egg.ts b/modules/assistant/runtime/utils/easter-egg.ts new file mode 100644 index 000000000..81db642b0 --- /dev/null +++ b/modules/assistant/runtime/utils/easter-egg.ts @@ -0,0 +1,26 @@ +import type { UIMessage } from 'ai'; + +const VIDEO_ID = 'pwLn_His9Yw'; +export const EASTER_EGG_RESPONSE_DELAY_MS = 1200; + +export function isEasterEggPrompt(text: string): boolean { + const normalized = text.toLowerCase().replace(/['’]/g, '').replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); + return /\bwh?ats? up docs?\b/.test(normalized); +} + +export function buildEasterEggMessages(userText: string): { user: UIMessage; assistant: UIMessage; response: UIMessage['parts'] } { + const now = Date.now(); + return { + user: { + id: `easter-user-${now}`, + role: 'user', + parts: [{ type: 'text', text: userText }], + } as unknown as UIMessage, + assistant: { + id: `easter-assistant-${now}`, + role: 'assistant', + parts: [], + } as unknown as UIMessage, + response: [{ type: 'text', text: `Eh, what's up, doc?\n\n:video-embed{youtube-id="${VIDEO_ID}"}` }] as UIMessage['parts'], + }; +} diff --git a/modules/assistant/runtime/utils/is-assistant-enabled.ts b/modules/assistant/runtime/utils/is-assistant-enabled.ts new file mode 100644 index 000000000..ab410669e --- /dev/null +++ b/modules/assistant/runtime/utils/is-assistant-enabled.ts @@ -0,0 +1,3 @@ +export function isAssistantEnabled(apiKey: string | undefined): boolean { + return process.env.ASSISTANT_ENABLED !== 'false' && Boolean(apiKey); +} diff --git a/modules/assistant/runtime/utils/messages.ts b/modules/assistant/runtime/utils/messages.ts new file mode 100644 index 000000000..bc24f093e --- /dev/null +++ b/modules/assistant/runtime/utils/messages.ts @@ -0,0 +1,22 @@ +import type { UIMessage } from 'ai'; + +export type ToolCallSummary = { toolCallId: string; toolName: string; args: Record }; + +export const REQUEST_MESSAGE_LIMIT = 12; + +export function compactMessagesForRequest(messages: UIMessage[]): UIMessage[] { + return messages.slice(-REQUEST_MESSAGE_LIMIT).map(message => ({ + ...message, + parts: message.parts?.filter(part => part.type === 'text') ?? [], + })); +} + +export function getMessageToolCalls(message: UIMessage | undefined): ToolCallSummary[] { + if (!message?.parts) return []; + return message.parts + .filter((p): p is Extract => p.type === 'data-tool-calls') + .flatMap((p) => { + const data = (p as { data?: { tools?: ToolCallSummary[] } }).data; + return data?.tools ?? []; + }); +} diff --git a/modules/posthog/runtime/plugins/posthog.server.ts b/modules/posthog/runtime/plugins/posthog.server.ts index 89f7044c3..8acfac568 100644 --- a/modules/posthog/runtime/plugins/posthog.server.ts +++ b/modules/posthog/runtime/plugins/posthog.server.ts @@ -1,5 +1,4 @@ -import { defineNuxtPlugin, useRuntimeConfig, useState, useRequestEvent } from '#app'; -import { getCookie } from 'h3'; +import { defineNuxtPlugin, useCookie, useRuntimeConfig, useState } from '#app'; import { PostHog } from 'posthog-node'; import type { JsonType } from 'posthog-js'; @@ -9,12 +8,7 @@ export default defineNuxtPlugin({ setup: async () => { const config = useRuntimeConfig().public.posthog; - const event = useRequestEvent(); - const cookie = event ? getCookie(event, `ph_${config.publicKey}_posthog`) : undefined; - const identity = JSON.parse(cookie ?? '{}'); - const distinctId = identity?.distinct_id ?? undefined; - - if (config.disabled) { + if (config.disabled || !config.publicKey || !config.host) { return { provide: { serverPosthog: null as PostHog | null, @@ -22,15 +16,27 @@ export default defineNuxtPlugin({ }; } - if (config.publicKey.length === 0) { - // PostHog public key is not defined. Skipping PostHog setup. - // User has already been warned on dev startup - return {}; - } + const identity = useCookie<{ distinct_id?: string } | null>(`ph_${config.publicKey}_posthog`, { + default: () => null, + readonly: true, + watch: false, + }); + const distinctId = identity.value && typeof identity.value === 'object' ? identity.value.distinct_id : undefined; const posthog = new PostHog(config.publicKey, { host: config.host }); - - const { featureFlags, featureFlagPayloads } = await posthog.getAllFlagsAndPayloads(distinctId); + let featureFlags: Record = {}; + let featureFlagPayloads: Record = {}; + + if (distinctId) { + try { + const flags = await posthog.getAllFlagsAndPayloads(distinctId); + featureFlags = flags.featureFlags ?? {}; + featureFlagPayloads = flags.featureFlagPayloads ?? {}; + } + catch (error) { + console.warn('[posthog] failed to load feature flags', error); + } + } useState | undefined>('ph-feature-flags', () => featureFlags); useState | undefined>('ph-feature-flag-payloads', () => featureFlagPayloads); diff --git a/modules/posthog/runtime/server/capture.ts b/modules/posthog/runtime/server/capture.ts new file mode 100644 index 000000000..4a16d6a60 --- /dev/null +++ b/modules/posthog/runtime/server/capture.ts @@ -0,0 +1,27 @@ +import type { H3Event } from 'h3'; +import { PostHog } from 'posthog-node'; + +let client: PostHog | null | undefined; +let clientKey = ''; + +export function getServerPostHog(event?: H3Event): PostHog | null { + const config = useRuntimeConfig(event).public.posthog; + if (config?.disabled || !config?.publicKey || !config?.host) return null; + + const key = `${config.publicKey}:${config.host}`; + if (client !== undefined && clientKey === key) return client; + + clientKey = key; + client = new PostHog(config.publicKey, { + host: config.host, + flushAt: 1, + flushInterval: 0, + }); + return client; +} + +export function captureServerPostHog(event: H3Event | undefined, name: string, distinctId: string, properties: Record): void { + const posthog = getServerPostHog(event); + if (!posthog) return; + posthog.capture({ distinctId, event: name, properties }); +} diff --git a/nuxt.config.ts b/nuxt.config.ts index d9f85b2d4..93d0f199a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -62,6 +62,7 @@ export default defineNuxtConfig({ '@vueuse/nuxt', '@nuxtjs/mcp-toolkit', '~~/modules/content-markdown', + '~~/modules/assistant', ], devtools: { @@ -72,7 +73,7 @@ export default defineNuxtConfig({ baseURL: BASE_URL, }, - css: ['~/assets/css/main.css'], + css: ['~/assets/css/main.css', '@directus/vue-split-panel/index.css'], site: { name: 'Directus Docs', @@ -223,7 +224,7 @@ export default defineNuxtConfig({ icon: { serverBundle: { - collections: ['lucide', 'material-symbols', 'simple-icons'], + collections: ['lucide', 'material-symbols', 'ph', 'simple-icons'], }, customCollections: [ { diff --git a/package.json b/package.json index 949a01d8a..af7488139 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,13 @@ "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/scripts/cleanup-typesense-preview.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { + "@ai-sdk/vue": "3.0.185", "@directus/openapi": "0.3.0", "@directus/sdk": "^21.2.2", + "@directus/vue-split-panel": "^0.8.9", "@iconify-json/lucide": "1.2.111", "@iconify-json/material-symbols": "1.2.68", + "@iconify-json/ph": "^1.2.2", "@iconify-json/simple-icons": "1.2.79", "@nuxt/content": "3.13.0", "@nuxt/fonts": "0.14.0", @@ -31,8 +34,16 @@ "@nuxtjs/mcp-toolkit": "0.17.2", "@nuxtjs/robots": "6.0.8", "@nuxtjs/sitemap": "8.0.13", + "@openrouter/ai-sdk-provider": "2.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-node": "^0.218.0", + "@opentelemetry/sdk-trace-base": "^2.7.1", + "@posthog/ai": "^7.19.1", + "@upstash/redis": "^1.34.3", "@vueuse/core": "14.2.1", "@vueuse/nuxt": "14.2.1", + "ai": "6.0.185", "h3": "1.15.11", "nuxt": "4.4.2", "nuxt-llms": "0.2.0", @@ -41,6 +52,7 @@ "posthog-node": "5.29.7", "sharp": "^0.34.5", "shiki": "4.0.2", + "shiki-stream": "^0.1.4", "tailwindcss": "^4.2.4", "typesense": "^3.0.6", "ufo": "1.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ef40880..0b52d0005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,18 +12,27 @@ importers: .: dependencies: + '@ai-sdk/vue': + specifier: 3.0.185 + version: 3.0.185(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) '@directus/openapi': specifier: 0.3.0 version: 0.3.0 '@directus/sdk': specifier: ^21.2.2 version: 21.2.2 + '@directus/vue-split-panel': + specifier: ^0.8.9 + version: 0.8.13(vue@3.5.33(typescript@6.0.3)) '@iconify-json/lucide': specifier: 1.2.111 version: 1.2.111 '@iconify-json/material-symbols': specifier: 1.2.68 version: 1.2.68 + '@iconify-json/ph': + specifier: ^1.2.2 + version: 1.2.2 '@iconify-json/simple-icons': specifier: 1.2.79 version: 1.2.79 @@ -32,34 +41,58 @@ importers: version: 3.13.0(better-sqlite3@11.10.0)(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.3)) '@nuxt/fonts': specifier: 0.14.0 - version: 0.14.0(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) + version: 0.14.0(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/ui': specifier: ^4.6.1 - version: 4.6.1(@netlify/blobs@9.1.2)(@nuxt/content@3.13.0(better-sqlite3@11.10.0)(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.3)))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.1)(change-case@5.4.4)(db0@0.3.4(better-sqlite3@11.10.0))(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.4)(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3) + version: 4.6.1(@netlify/blobs@9.1.2)(@nuxt/content@3.13.0(better-sqlite3@11.10.0)(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.3)))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(@upstash/redis@1.38.0)(axios@1.16.1)(change-case@5.4.4)(db0@0.3.4(better-sqlite3@11.10.0))(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.4)(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3) '@nuxtjs/color-mode': specifier: 4.0.0 version: 4.0.0(magicast@0.5.2) '@nuxtjs/mcp-toolkit': specifier: 0.17.2 - version: 0.17.2(@vue/compiler-sfc@3.5.33)(h3@1.15.11)(magicast@0.5.2)(rollup@4.60.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) + version: 0.17.2(@cfworker/json-schema@4.1.1)(@vue/compiler-sfc@3.5.33)(h3@1.15.11)(magicast@0.5.2)(rollup@4.60.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) '@nuxtjs/robots': specifier: 6.0.8 - version: 6.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) + version: 6.0.8(2300d570e85eb120cdddd958a503880c) '@nuxtjs/sitemap': specifier: 8.0.13 - version: 8.0.13(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) + version: 8.0.13(2300d570e85eb120cdddd958a503880c) + '@openrouter/ai-sdk-provider': + specifier: 2.9.0 + version: 2.9.0(ai@6.0.185(zod@4.4.3))(zod@4.4.3) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.218.0 + version: 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.7.1 + version: 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: ^0.218.0 + version: 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.7.1 + version: 2.7.1(@opentelemetry/api@1.9.1) + '@posthog/ai': + specifier: ^7.19.1 + version: 7.20.5(@ai-sdk/provider@3.0.10)(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(posthog-node@5.29.7)(vue@3.5.33(typescript@6.0.3))(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.3)) + '@upstash/redis': + specifier: ^1.34.3 + version: 1.38.0 '@vueuse/core': specifier: 14.2.1 version: 14.2.1(vue@3.5.33(typescript@6.0.3)) '@vueuse/nuxt': specifier: 14.2.1 - version: 14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) + version: 14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) + ai: + specifier: 6.0.185 + version: 6.0.185(zod@4.4.3) h3: specifier: 1.15.11 version: 1.15.11 nuxt: specifier: 4.4.2 - version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) + version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) nuxt-llms: specifier: 0.2.0 version: 0.2.0(magicast@0.5.2) @@ -78,6 +111,9 @@ importers: shiki: specifier: 4.0.2 version: 4.0.2 + shiki-stream: + specifier: ^0.1.4 + version: 0.1.4(vue@3.5.33(typescript@6.0.3)) tailwindcss: specifier: ^4.2.4 version: 4.2.4 @@ -99,7 +135,7 @@ importers: version: 1.15.2(@typescript-eslint/utils@8.59.0(eslint@9.28.0(jiti@2.6.1))(typescript@6.0.3))(@vue/compiler-sfc@3.5.33)(eslint@9.28.0(jiti@2.6.1))(magicast@0.5.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/scripts': specifier: 1.0.2 - version: 1.0.2(@netlify/blobs@9.1.2)(@types/google.maps@3.58.1)(@unhead/vue@2.1.13(vue@3.5.33(typescript@6.0.3)))(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(posthog-js@1.371.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) + version: 1.0.2(@netlify/blobs@9.1.2)(@types/google.maps@3.58.1)(@unhead/vue@2.1.13(vue@3.5.33(typescript@6.0.3)))(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(posthog-js@1.371.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) '@nuxt/test-utils': specifier: ^4.0.3 version: 4.0.3(@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)))(crossws@0.4.5(srvx@0.11.15))(happy-dom@20.9.0)(magicast@0.5.2)(playwright-core@1.58.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(happy-dom@20.9.0)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))) @@ -151,6 +187,28 @@ importers: packages: + '@ai-sdk/gateway@3.0.116': + resolution: {integrity: sha512-k8P17w7Eho5Y4l3tZrYxqQdffkI4xwtl8GCxkZs+JdMWZhyrLLlozqWkKLaWrCSlEYQOeIhEnQLhqQgYYU86Rw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.27': + resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + + '@ai-sdk/vue@3.0.185': + resolution: {integrity: sha512-HKd/aoG4YHl9G01W7616nnEMjYOB7ewwfFGvHVeMx9F3xOP/eBkGkJIGh+6OoowFUYKXzZ2aXVL+GvRyQfs/EQ==} + engines: {node: '>=18'} + peerDependencies: + vue: ^3.3.4 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -158,6 +216,15 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/sdk@0.78.0': + resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apidevtools/json-schema-ref-parser@14.2.1': resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} engines: {node: '>= 20'} @@ -304,6 +371,9 @@ packages: resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} @@ -325,6 +395,11 @@ packages: resolution: {integrity: sha512-T8UhDG+GY534AqvLpRmycLYZmIYIaUYzu25X3WeUjWuxCvqAiKqXbYUrTRb0A5Mw93vUeVuyqabzvLbBY9AXxQ==} engines: {node: '>=22'} + '@directus/vue-split-panel@0.8.13': + resolution: {integrity: sha512-bYsbBwtTkkUmuwJoHP5AeJB+fyW9ybk2NrKfSCy+Jjtd3kbgy7uLA+vVE+j68aZtGdgC7nlhOMXzsit0pwmRzQ==} + peerDependencies: + vue: ^3.5.0 + '@dxup/nuxt@0.4.1': resolution: {integrity: sha512-gtYffW6OfWNvoLW+XD3Mx/K8uUq08PMGLYJoDxc92EzZAWqR0FhcR5iaLm5r/OxyGTKz+P5f5Y7Aoir9+SjYaw==} peerDependencies: @@ -758,6 +833,24 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -790,6 +883,9 @@ packages: '@iconify-json/material-symbols@1.2.68': resolution: {integrity: sha512-MGo7A6j+evFoks/kIZAdAKMSKl24ARa19bUvXMw/RVFKuMo2tIc27HZitTuXna858pvhjzMaFq8UrXaKqbQGjA==} + '@iconify-json/ph@1.2.2': + resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} + '@iconify-json/simple-icons@1.2.79': resolution: {integrity: sha512-aNyO7Fd1qej9oQfIyohYFRv0lhQLaZ+6UkK1c1qwax0MDPUOZOdq65MlU500kow97pD/W+b2u1And3e25eE24Q==} @@ -996,12 +1092,57 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@langchain/core@1.1.48': + resolution: {integrity: sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==} + engines: {node: '>=20'} + + '@langchain/langgraph-checkpoint@1.0.4': + resolution: {integrity: sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.44 + + '@langchain/langgraph-sdk@1.9.11': + resolution: {integrity: sha512-mhadkZy4LQ97NJwvATiVIkSxVfOnauXNhrVHFgGnzyqr5zzPLS0VIKJW9xKT+pM8yLqW8Qj6+nPPNhwGUaxoRw==} + peerDependencies: + '@langchain/core': ^1.1.44 + react: ^18 || ^19 + react-dom: ^18 || ^19 + svelte: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + + '@langchain/langgraph@1.3.3': + resolution: {integrity: sha512-8xbpGUQNBcWua7ivT5vUvDnQ+6Qbt0JO8RisgXZ8guPXNqh8plGVvrODW68S4AlJbOYY2yi0ROKtrL/1yN3MBQ==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.44 + zod: ^3.25.32 || ^4.2.0 + zod-to-json-schema: ^3.x + peerDependenciesMeta: + zod-to-json-schema: + optional: true + + '@langchain/protocol@0.0.16': + resolution: {integrity: sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -1363,52 +1504,177 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@openrouter/ai-sdk-provider@2.9.0': + resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==} + engines: {node: '>=18'} + peerDependencies: + ai: ^6.0.0 + zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api-logs@0.208.0': resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.218.0': + resolution: {integrity: sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/configuration@0.218.0': + resolution: {integrity: sha512-W8wIz7H2R1pufR5jfjb3gU2XkMpm2x/7b1RJcsuzvd70Il/rWWE+g5/Od7hQKrxRTSrTrOWlru101PWXz5I1EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.7.1': + resolution: {integrity: sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.2.0': resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.7.0': - resolution: {integrity: sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==} + '@opentelemetry/core@2.7.1': + resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-grpc@0.218.0': + resolution: {integrity: sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.208.0': resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.218.0': + resolution: {integrity: sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.218.0': + resolution: {integrity: sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.218.0': + resolution: {integrity: sha512-YapQ9vNMX0NSZF6LK5pWAFfjpJleV2O9uYWfYGeb/5F1Kb9rPGK8tZDMJFa/sOksgdFuflDvYuA0B4qjDB4fjQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.218.0': + resolution: {integrity: sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.218.0': + resolution: {integrity: sha512-ubLddKjWULhla9YZRCj/rTBeppjJYE4e9w0icx5mTu3eFhWjQzbV75NYjXuIlEG+NJsBl6d+sTFw5Qu+oej4oQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.218.0': + resolution: {integrity: sha512-RT5oEyu1kddZJ1vt7/BUo5wV+P7hpNAESsR3dUd3+8deHuX7gWNoCOZn+SfDT+hJHlIJ5h/AxiCLXIrutswDJg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.218.0': + resolution: {integrity: sha512-3fXxVQEj9TNAFaCi79JeFKfeLd0sDtInaR3gaZDVlzNSPHtz8PZuCV34JKWjD4XXzT20IdMe8IpX6mRVNDA4Tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.218.0': + resolution: {integrity: sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.218.0': + resolution: {integrity: sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.7.1': + resolution: {integrity: sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation@0.218.0': + resolution: {integrity: sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.208.0': resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.218.0': + resolution: {integrity: sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.218.0': + resolution: {integrity: sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.208.0': resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.218.0': + resolution: {integrity: sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.7.1': + resolution: {integrity: sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.7.1': + resolution: {integrity: sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/resources@2.2.0': resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.7.0': - resolution: {integrity: sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==} + '@opentelemetry/resources@2.7.1': + resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -1419,18 +1685,48 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.218.0': + resolution: {integrity: sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.2.0': resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.7.1': + resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.218.0': + resolution: {integrity: sha512-tPMjHrLV5gsfNdYqoRHjeGbCAZBXXD9c1Qo/2ut7VwnUABDNh76xNxrT0SEhkIIJuCN45bbN1vZnYL1gY0IkOg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.2.0': resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.7.1': + resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.7.1': + resolution: {integrity: sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -2069,12 +2365,40 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/ai@7.20.5': + resolution: {integrity: sha512-yqZ69uk2LlVUuZXnE3MlYZ3O0vfa2bOZSFtzPd7OQ4bORhvx3Qk7XhI3Lfy7YBkSsqQ4CSactR4vMXWGNN/MEg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + '@ai-sdk/provider': ^2.0.0 || ^3.0.0 + '@openai/agents': ^0.8.0 + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.200.0 <1.0.0' + '@opentelemetry/sdk-trace-base': ^2.0.0 + posthog-node: ^5.0.0 + peerDependenciesMeta: + '@ai-sdk/provider': + optional: true + '@openai/agents': + optional: true + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@posthog/core@1.27.1': resolution: {integrity: sha512-130F5zAmGoY0KAqdT2FYfU79mk54z0wTWfCMkyzXmkKz2eg23694O2ofaAf4NuIdI8e6LFNHyaZ7aWbatUeW5A==} + '@posthog/core@1.30.2': + resolution: {integrity: sha512-d7RTpfi+/q5+SZ+4f1WhanfEtNBz9onMmUxn3BO0GDT8N5ZT4DEP3LqFisqeP+xkJTaFPWCOVA/nGyKmUX9y9g==} + '@posthog/types@1.371.2': resolution: {integrity: sha512-Ak3kuGMPBTjuoK6Ki0kQbq9eROk7LRJ3GBkbQINCZBT9FPlHvlXRwQr0/OVsi4xYPSMSGxWjRDMPFsR9Px66Cg==} + '@posthog/types@1.378.1': + resolution: {integrity: sha512-bKOXVWySe5oKFjV6X9VW9jngIm14d4BvnT7l/Eb7e6DrT5uD+XclvbRdhC5f1/l5KwoIU+qswBobHRPlix2D1w==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2408,6 +2732,9 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + '@shikijs/core@4.0.2': resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} @@ -2436,6 +2763,9 @@ packages: resolution: {integrity: sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==} engines: {node: '>=20'} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/types@4.0.2': resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} engines: {node: '>=20'} @@ -2871,6 +3201,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3062,11 +3395,18 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.38.0': + resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==} + '@vercel/nft@1.5.0': resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} engines: {node: '>=20'} hasBin: true + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-vue-jsx@5.1.5': resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3215,6 +3555,11 @@ packages: peerDependencies: vue: ^3.5.0 + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/integrations@14.2.1': resolution: {integrity: sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==} peerDependencies: @@ -3263,6 +3608,9 @@ packages: '@vueuse/metadata@14.2.1': resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + '@vueuse/nuxt@14.2.1': resolution: {integrity: sha512-DHgFMUpyH98M1YM9pbnRjFXMAMKEsHntJeOp8rOXs8QN2cvJBzEZ+TTWIBSPESNFOEwM02RA6BDsaTL35OK4Mw==} peerDependencies: @@ -3277,6 +3625,11 @@ packages: peerDependencies: vue: ^3.5.0 + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -3344,6 +3697,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@6.0.185: + resolution: {integrity: sha512-oGsqscREaTlo75KHZLtwZxRyI+ZBwHV2wRX9B8smHjgOs13WwoCvUyr5aPUWpIBRz406wmIKy1RzoUEq0/WKJw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3515,6 +3874,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -3557,6 +3919,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3691,6 +4056,9 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-git-ref@2.0.1: resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} @@ -3698,6 +4066,10 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -4053,6 +4425,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@1.0.7: resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} engines: {node: '>=14'} @@ -4372,6 +4747,12 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -4609,6 +4990,14 @@ packages: fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4696,6 +5085,14 @@ packages: resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} engines: {node: '>=20'} + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4872,6 +5269,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.0.1: + resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==} + engines: {node: '>=18'} + impound@1.1.5: resolution: {integrity: sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==} @@ -4973,6 +5374,10 @@ packages: is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -5054,6 +5459,9 @@ packages: resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} engines: {node: '>=20'} + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5077,9 +5485,16 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-to-typescript-lite@15.0.0: resolution: {integrity: sha512-5mMORSQm9oTLyjM4mWnyNBi2T042Fhg1/0gCIB6X8U/LVpM2A+Nmj2yEyArqVouDmFThDxpEXcnTgSrjkGJRFA==} @@ -5092,6 +5507,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5100,6 +5518,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -5122,6 +5546,32 @@ packages: knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + langchain@1.4.4: + resolution: {integrity: sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.1.48 + + langsmith@0.7.3: + resolution: {integrity: sha512-Gg+IeRGoF1/aStu80aEnIXJCu+N6+4NoV4tAVFS51ZPRBsRa2KG0LkS7K7/ryZ/yES7O9xdqah5QuuWMIeMjQw==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + launch-editor@2.13.2: resolution: {integrity: sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==} @@ -5246,6 +5696,9 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5533,6 +5986,9 @@ packages: mocked-exports@0.1.1: resolution: {integrity: sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.38.0: resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} @@ -5555,6 +6011,10 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5765,6 +6225,17 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + openai@6.41.0: + resolution: {integrity: sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==} + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} @@ -5796,6 +6267,10 @@ packages: peerDependencies: oxc-parser: '>=0.98.0' + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5812,10 +6287,34 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-timeout@6.1.4: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + p-wait-for@5.0.2: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} @@ -6371,10 +6870,18 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -6395,6 +6902,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6565,6 +7076,20 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki-stream@0.1.4: + resolution: {integrity: sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==} + peerDependencies: + react: ^19.0.0 + solid-js: ^1.9.0 + vue: ^3.2.0 + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + vue: + optional: true + shiki@4.0.2: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} @@ -6776,6 +7301,11 @@ packages: engines: {node: '>=16'} hasBin: true + swrv@1.2.0: + resolution: {integrity: sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==} + peerDependencies: + vue: '>=3.2.26 < 4' + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6890,6 +7420,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -7168,6 +7701,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + valibot@1.3.1: resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} peerDependencies: @@ -7567,10 +8104,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yargs@18.0.0: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -7613,6 +8158,33 @@ packages: snapshots: + '@ai-sdk/gateway@3.0.116(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/vue@3.0.185(vue@3.5.33(typescript@6.0.3))(zod@4.4.3)': + dependencies: + '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) + ai: 6.0.185(zod@4.4.3) + swrv: 1.2.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + transitivePeerDependencies: + - zod + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -7620,6 +8192,12 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.1 + '@anthropic-ai/sdk@0.78.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': dependencies: '@types/json-schema': 7.0.15 @@ -7803,6 +8381,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@cfworker/json-schema@4.1.1': {} + '@clack/core@1.2.0': dependencies: fast-wrap-ansi: 0.1.6 @@ -7823,6 +8403,11 @@ snapshots: '@directus/sdk@21.2.2': {} + '@directus/vue-split-panel@0.8.13(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + '@dxup/nuxt@0.4.1(magicast@0.5.2)(typescript@6.0.3)': dependencies: '@dxup/unimport': 0.1.2 @@ -8134,6 +8719,31 @@ snapshots: - '@vue/composition-api' - vue + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.5 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.5 + yargs: 17.7.2 + '@hono/node-server@1.19.14(hono@4.12.21)': dependencies: hono: 4.12.21 @@ -8162,6 +8772,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/ph@1.2.2': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.79': dependencies: '@iconify/types': 2.0.0 @@ -8326,13 +8940,66 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kwsites/file-exists@1.1.1': + '@js-sdsl/ordered-map@4.4.2': {} + + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + + '@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + js-tiktoken: 1.0.21 + langsmith: 0.7.3(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + mustache: 4.2.0 + p-queue: 6.6.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-checkpoint@1.0.4(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))': + dependencies: + '@langchain/core': 1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + uuid: 14.0.0 + + '@langchain/langgraph-sdk@1.9.11(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@langchain/core': 1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + '@langchain/protocol': 0.0.16 + '@types/json-schema': 7.0.15 + p-queue: 9.3.0 + p-retry: 7.1.1 + uuid: 14.0.0 + optionalDependencies: + vue: 3.5.33(typescript@6.0.3) + + '@langchain/langgraph@1.3.3(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(vue@3.5.33(typescript@6.0.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)': dependencies: - debug: 4.4.1 + '@langchain/core': 1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0)) + '@langchain/langgraph-sdk': 1.9.11(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(vue@3.5.33(typescript@6.0.3)) + '@langchain/protocol': 0.0.16 + '@standard-schema/spec': 1.1.0 + uuid: 14.0.0 + zod: 4.4.3 + optionalDependencies: + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - - supports-color + - react + - react-dom + - svelte + - vue - '@kwsites/promise-deferred@1.1.1': {} + '@langchain/protocol@0.0.16': {} '@mapbox/node-pre-gyp@2.0.3': dependencies: @@ -8347,7 +9014,7 @@ snapshots: - encoding - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.21) ajv: 8.20.0 @@ -8366,6 +9033,8 @@ snapshots: raw-body: 3.0.2 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: - supports-color @@ -8675,13 +9344,13 @@ snapshots: - utf-8-validate - vite - '@nuxt/fonts@0.14.0(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))': + '@nuxt/fonts@0.14.0(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.2(magicast@0.5.2) consola: 3.4.2 defu: 6.1.7 - fontless: 0.2.1(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) + fontless: 0.2.1(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) h3: 1.15.11 magic-regexp: 0.10.0 ofetch: 1.5.1 @@ -8691,7 +9360,7 @@ snapshots: ufo: 1.6.3 unifont: 0.7.4 unplugin: 3.0.0 - unstorage: 1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) + unstorage: 1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8837,7 +9506,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.2(d8bfb31b08d32093c0f73363b4b5ece1)': + '@nuxt/nitro-server@4.4.2(5ac035037443e02dcb8736d8c69574e7)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@nuxt/devalue': 2.0.2 @@ -8855,8 +9524,8 @@ snapshots: impound: 1.1.5 klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.3(@netlify/blobs@9.1.2)(better-sqlite3@11.10.0)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(srvx@0.11.15) - nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) + nitropack: 2.13.3(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(better-sqlite3@11.10.0)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(srvx@0.11.15) + nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -8865,7 +9534,7 @@ snapshots: std-env: 4.1.0 ufo: 1.6.3 unctx: 2.5.0 - unstorage: 1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) + unstorage: 1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) vue: 3.5.33(typescript@6.0.3) vue-bundle-renderer: 2.2.0 vue-devtools-stub: 0.1.0 @@ -8915,7 +9584,7 @@ snapshots: pkg-types: 2.3.0 std-env: 4.1.0 - '@nuxt/scripts@1.0.2(@netlify/blobs@9.1.2)(@types/google.maps@3.58.1)(@unhead/vue@2.1.13(vue@3.5.33(typescript@6.0.3)))(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(posthog-js@1.371.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))': + '@nuxt/scripts@1.0.2(@netlify/blobs@9.1.2)(@types/google.maps@3.58.1)(@unhead/vue@2.1.13(vue@3.5.33(typescript@6.0.3)))(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(posthog-js@1.371.2)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))': dependencies: '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/kit': 4.4.2(magicast@0.5.2) @@ -8937,7 +9606,7 @@ snapshots: ufo: 1.6.3 ultrahtml: 1.6.0 unplugin: 3.0.0 - unstorage: 1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) + unstorage: 1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) valibot: 1.3.1(typescript@6.0.3) optionalDependencies: '@types/google.maps': 3.58.1 @@ -9018,13 +9687,13 @@ snapshots: - typescript - vite - '@nuxt/ui@4.6.1(@netlify/blobs@9.1.2)(@nuxt/content@3.13.0(better-sqlite3@11.10.0)(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.3)))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.1)(change-case@5.4.4)(db0@0.3.4(better-sqlite3@11.10.0))(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.4)(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)': + '@nuxt/ui@4.6.1(@netlify/blobs@9.1.2)(@nuxt/content@3.13.0(better-sqlite3@11.10.0)(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.3)))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(@upstash/redis@1.38.0)(axios@1.16.1)(change-case@5.4.4)(db0@0.3.4(better-sqlite3@11.10.0))(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.4)(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)': dependencies: '@floating-ui/dom': 1.7.6 '@iconify/vue': 5.0.0(vue@3.5.33(typescript@6.0.3)) '@internationalized/date': 3.12.1 '@internationalized/number': 3.6.6 - '@nuxt/fonts': 0.14.0(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) + '@nuxt/fonts': 0.14.0(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) '@nuxt/icon': 2.2.1(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) '@nuxt/kit': 4.4.2(magicast@0.5.2) '@nuxt/schema': 4.4.2 @@ -9133,7 +9802,7 @@ snapshots: - vue - yjs - '@nuxt/vite-builder@4.4.2(7c2c1dd7c498c9a3b536bcb4959772f5)': + '@nuxt/vite-builder@4.4.2(87bcd92ed3206498f8fa20d471379d87)': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) @@ -9151,7 +9820,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) + nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) nypm: 0.6.5 pathe: 2.0.3 pkg-types: 2.3.0 @@ -9215,9 +9884,9 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/mcp-toolkit@0.17.2(@vue/compiler-sfc@3.5.33)(h3@1.15.11)(magicast@0.5.2)(rollup@4.60.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3)': + '@nuxtjs/mcp-toolkit@0.17.2(@cfworker/json-schema@4.1.1)(@vue/compiler-sfc@3.5.33)(h3@1.15.11)(magicast@0.5.2)(rollup@4.60.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@nuxt/kit': 4.4.6(magicast@0.5.2) '@vitejs/plugin-vue': 6.0.7(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) h3: 1.15.11 @@ -9284,15 +9953,15 @@ snapshots: - magicast - supports-color - '@nuxtjs/robots@6.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3)': + '@nuxtjs/robots@6.0.8(2300d570e85eb120cdddd958a503880c)': dependencies: '@fingerprintjs/botd': 2.0.0 '@nuxt/kit': 4.4.6(magicast@0.5.2) consola: 3.4.2 defu: 6.1.7 h3: 1.15.11 - nuxt-site-config: 4.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) - nuxtseo-shared: 5.1.3(0ce84322e3b1ccdfacbefd4e4cf7b8f3) + nuxt-site-config: 4.0.8(2300d570e85eb120cdddd958a503880c) + nuxtseo-shared: 5.1.3(309a7050206cf66b1764b19dd66322f5) pathe: 2.0.3 pkg-types: 2.3.1 ufo: 1.6.3 @@ -9305,14 +9974,14 @@ snapshots: - vite - vue - '@nuxtjs/sitemap@8.0.13(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3)': + '@nuxtjs/sitemap@8.0.13(2300d570e85eb120cdddd958a503880c)': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.2) consola: 3.4.2 defu: 6.1.7 fast-xml-parser: 5.7.1 - nuxt-site-config: 4.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) - nuxtseo-shared: 5.1.3(0ce84322e3b1ccdfacbefd4e4cf7b8f3) + nuxt-site-config: 4.0.8(2300d570e85eb120cdddd958a503880c) + nuxtseo-shared: 5.1.3(309a7050206cf66b1764b19dd66322f5) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -9330,22 +9999,51 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@openrouter/ai-sdk-provider@2.9.0(ai@6.0.185(zod@4.4.3))(zod@4.4.3)': + dependencies: + ai: 6.0.185(zod@4.4.3) + zod: 4.4.3 + '@opentelemetry/api-logs@0.208.0': dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.218.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} + '@opentelemetry/configuration@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -9355,12 +10053,131 @@ snapshots: '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -9372,16 +10189,36 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1) protobufjs: 7.5.5 + '@opentelemetry/otlp-transformer@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-b3@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.1)': + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)': @@ -9391,12 +10228,57 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.218.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/configuration': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -9404,6 +10286,20 @@ snapshots: '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions@1.40.0': {} '@oxc-minify/binding-android-arm-eabi@0.117.0': @@ -9757,12 +10653,47 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/ai@7.20.5(@ai-sdk/provider@3.0.10)(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(posthog-node@5.29.7)(vue@3.5.33(typescript@6.0.3))(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.3))': + dependencies: + '@anthropic-ai/sdk': 0.78.0(zod@4.4.3) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)) + '@langchain/core': 1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + '@posthog/core': 1.30.2 + langchain: 1.4.4(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(vue@3.5.33(typescript@6.0.3))(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.3)) + openai: 6.41.0(ws@8.20.0)(zod@4.4.3) + posthog-node: 5.29.7 + uuid: 11.1.0 + zod: 4.4.3 + optionalDependencies: + '@ai-sdk/provider': 3.0.10 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@opentelemetry/exporter-trace-otlp-proto' + - bufferutil + - react + - react-dom + - supports-color + - svelte + - utf-8-validate + - vue + - ws + - zod-to-json-schema + '@posthog/core@1.27.1': dependencies: '@posthog/types': 1.371.2 + '@posthog/core@1.30.2': + dependencies: + '@posthog/types': 1.378.1 + '@posthog/types@1.371.2': {} + '@posthog/types@1.378.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -9976,6 +10907,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 @@ -10014,6 +10952,11 @@ snapshots: '@shikijs/core': 4.0.2 '@shikijs/types': 4.0.2 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 @@ -10424,6 +11367,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.0': {} + '@types/trusted-types@2.0.7': optional: true @@ -10599,6 +11544,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/redis@1.38.0': + dependencies: + uncrypto: 0.1.3 + '@vercel/nft@1.5.0(rollup@4.60.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 @@ -10618,6 +11567,8 @@ snapshots: - rollup - supports-color + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-vue-jsx@5.1.5(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))': dependencies: '@babel/core': 7.29.0 @@ -10843,6 +11794,13 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.33(typescript@6.0.3)) vue: 3.5.33(typescript@6.0.3) + '@vueuse/core@14.3.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + '@vueuse/integrations@14.2.1(axios@1.16.1)(change-case@5.4.4)(fuse.js@7.3.0)(jwt-decode@4.0.0)(vue@3.5.33(typescript@6.0.3))': dependencies: '@vueuse/core': 14.2.1(vue@3.5.33(typescript@6.0.3)) @@ -10858,13 +11816,15 @@ snapshots: '@vueuse/metadata@14.2.1': {} - '@vueuse/nuxt@14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))': + '@vueuse/metadata@14.3.0': {} + + '@vueuse/nuxt@14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) '@vueuse/core': 14.2.1(vue@3.5.33(typescript@6.0.3)) '@vueuse/metadata': 14.2.1 local-pkg: 1.1.2 - nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) + nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) vue: 3.5.33(typescript@6.0.3) transitivePeerDependencies: - magicast @@ -10880,6 +11840,10 @@ snapshots: dependencies: vue: 3.5.33(typescript@6.0.3) + '@vueuse/shared@14.3.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + '@webcontainer/env@1.1.1': {} '@whatwg-node/disposablestack@0.0.6': @@ -10948,6 +11912,14 @@ snapshots: agent-base@7.1.4: {} + ai@6.0.185(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 3.0.116(zod@4.4.3) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3) + '@opentelemetry/api': 1.9.1 + zod: 4.4.3 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -11114,6 +12086,8 @@ snapshots: prebuild-install: 7.1.3 optional: true + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -11172,6 +12146,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -11311,12 +12287,20 @@ snapshots: citty@0.2.2: {} + cjs-module-lexer@2.2.0: {} + clean-git-ref@2.0.1: {} clean-regexp@1.0.0: dependencies: escape-string-regexp: 1.0.5 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -11502,8 +12486,7 @@ snapshots: csstype@3.2.3: {} - data-uri-to-buffer@4.0.1: - optional: true + data-uri-to-buffer@4.0.1: {} db0@0.3.4(better-sqlite3@11.10.0): optionalDependencies: @@ -11623,6 +12606,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@1.0.7: dependencies: '@one-ini/wasm': 0.1.1 @@ -12023,6 +13010,10 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -12159,7 +13150,6 @@ snapshots: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - optional: true fflate@0.4.8: {} @@ -12228,7 +13218,7 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - fontless@0.2.1(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)): + fontless@0.2.1(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)): dependencies: consola: 3.4.2 css-tree: 3.2.1 @@ -12242,7 +13232,7 @@ snapshots: pathe: 2.0.3 ufo: 1.6.3 unifont: 0.7.4 - unstorage: 1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) + unstorage: 1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) optionalDependencies: vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: @@ -12286,7 +13276,6 @@ snapshots: formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 - optional: true forwarded@0.2.0: {} @@ -12312,6 +13301,22 @@ snapshots: fzf@0.5.2: {} + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -12411,6 +13416,19 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.4.0 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -12678,6 +13696,13 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.0.1: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + impound@1.1.5: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -12764,6 +13789,8 @@ snapshots: is-module@1.0.0: {} + is-network-error@1.3.2: {} + is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -12838,6 +13865,10 @@ snapshots: js-cookie@3.0.7: {} + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -12855,8 +13886,17 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-to-typescript-lite@15.0.0: dependencies: '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) @@ -12868,10 +13908,23 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + jwt-decode@4.0.0: optional: true @@ -12887,6 +13940,35 @@ snapshots: knitwork@1.3.0: {} + langchain@1.4.4(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(vue@3.5.33(typescript@6.0.3))(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.3)): + dependencies: + '@langchain/core': 1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + '@langchain/langgraph': 1.3.3(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(vue@3.5.33(typescript@6.0.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) + '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.1.48(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0)) + langsmith: 0.7.3(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0) + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - svelte + - vue + - ws + - zod-to-json-schema + + langsmith@0.7.3(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.41.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + openai: 6.41.0(ws@8.20.0)(zod@4.4.3) + ws: 8.20.0 + launch-editor@2.13.2: dependencies: picocolors: 1.1.1 @@ -13004,6 +14086,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: optional: true @@ -13455,6 +14539,8 @@ snapshots: mocked-exports@0.1.1: {} + module-details-from-path@1.0.4: {} + motion-dom@12.38.0: dependencies: motion-utils: 12.36.0 @@ -13480,6 +14566,8 @@ snapshots: muggle-string@0.4.1: {} + mustache@4.2.0: {} + nanoid@3.3.11: {} nanotar@0.3.0: {} @@ -13503,7 +14591,7 @@ snapshots: qs: 6.15.1 optional: true - nitropack@2.13.3(@netlify/blobs@9.1.2)(better-sqlite3@11.10.0)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(srvx@0.11.15): + nitropack@2.13.3(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(better-sqlite3@11.10.0)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(srvx@0.11.15): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) @@ -13570,7 +14658,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.1.1 unplugin-utils: 0.3.1 - unstorage: 1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) + unstorage: 1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -13614,8 +14702,7 @@ snapshots: node-addon-api@7.1.1: {} - node-domexception@1.0.0: - optional: true + node-domexception@1.0.0: {} node-emoji@2.2.0: dependencies: @@ -13635,7 +14722,6 @@ snapshots: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - optional: true node-forge@1.4.0: {} @@ -13697,12 +14783,12 @@ snapshots: - magicast - vue - nuxt-site-config@4.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3): + nuxt-site-config@4.0.8(2300d570e85eb120cdddd958a503880c): dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.2) h3: 1.15.11 nuxt-site-config-kit: 4.0.8(magicast@0.5.2)(vue@3.5.33(typescript@6.0.3)) - nuxtseo-shared: 5.1.3(0ce84322e3b1ccdfacbefd4e4cf7b8f3) + nuxtseo-shared: 5.1.3(309a7050206cf66b1764b19dd66322f5) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.0.8(vue@3.5.33(typescript@6.0.3)) @@ -13715,16 +14801,16 @@ snapshots: - vue - zod - nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0): + nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.2)(typescript@6.0.3) '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2) '@nuxt/devtools': 3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.2(d8bfb31b08d32093c0f73363b4b5ece1) + '@nuxt/nitro-server': 4.4.2(5ac035037443e02dcb8736d8c69574e7) '@nuxt/schema': 4.4.2 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.2(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.2(7c2c1dd7c498c9a3b536bcb4959772f5) + '@nuxt/vite-builder': 4.4.2(87bcd92ed3206498f8fa20d471379d87) '@unhead/vue': 2.1.13(vue@3.5.33(typescript@6.0.3)) '@vue/shared': 3.5.33 c12: 3.3.4(magicast@0.5.2) @@ -13848,7 +14934,7 @@ snapshots: - xml2js - yaml - nuxtseo-shared@5.1.3(0ce84322e3b1ccdfacbefd4e4cf7b8f3): + nuxtseo-shared@5.1.3(309a7050206cf66b1764b19dd66322f5): dependencies: '@clack/prompts': 1.2.0 '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0)) @@ -13857,7 +14943,7 @@ snapshots: birpc: 4.0.0 consola: 3.4.2 defu: 6.1.7 - nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) + nuxt: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@upstash/redis@1.38.0)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) ofetch: 1.5.1 pathe: 2.0.3 pkg-types: 2.3.1 @@ -13867,7 +14953,7 @@ snapshots: ufo: 1.6.3 vue: 3.5.33(typescript@6.0.3) optionalDependencies: - nuxt-site-config: 4.0.8(@nuxt/schema@4.4.2)(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3))(zod@4.4.3) + nuxt-site-config: 4.0.8(2300d570e85eb120cdddd958a503880c) zod: 4.4.3 transitivePeerDependencies: - magicast @@ -13941,6 +15027,11 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + openai@6.41.0(ws@8.20.0)(zod@4.4.3): + optionalDependencies: + ws: 8.20.0 + zod: 4.4.3 + openapi3-ts@4.5.0: dependencies: yaml: 2.8.2 @@ -14071,6 +15162,8 @@ snapshots: magic-regexp: 0.10.0 oxc-parser: 0.127.0 + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -14087,9 +15180,34 @@ snapshots: dependencies: p-limit: 4.0.0 + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.2 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-timeout@6.1.4: optional: true + p-timeout@7.0.1: {} + p-wait-for@5.0.2: dependencies: p-timeout: 6.1.4 @@ -14382,7 +15500,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) '@posthog/core': 1.27.1 '@posthog/types': 1.371.2 @@ -14775,8 +15893,17 @@ snapshots: transitivePeerDependencies: - supports-color + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} @@ -14792,6 +15919,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + reusify@1.1.0: {} rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0): @@ -15014,6 +16143,12 @@ snapshots: shell-quote@1.8.3: {} + shiki-stream@0.1.4(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@shikijs/core': 3.23.0 + optionalDependencies: + vue: 3.5.33(typescript@6.0.3) + shiki@4.0.2: dependencies: '@shikijs/core': 4.0.2 @@ -15245,6 +16380,10 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + swrv@1.2.0(vue@3.5.33(typescript@6.0.3)): + dependencies: + vue: 3.5.33(typescript@6.0.3) + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -15366,6 +16505,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -15622,7 +16763,7 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@1.17.5(@netlify/blobs@9.1.2)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1): + unstorage@1.17.5(@netlify/blobs@9.1.2)(@upstash/redis@1.38.0)(db0@0.3.4(better-sqlite3@11.10.0))(ioredis@5.10.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -15634,6 +16775,7 @@ snapshots: ufo: 1.6.3 optionalDependencies: '@netlify/blobs': 9.1.2 + '@upstash/redis': 1.38.0 db0: 0.3.4(better-sqlite3@11.10.0) ioredis: 5.10.1 @@ -15677,8 +16819,9 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.0: - optional: true + uuid@11.1.0: {} + + uuid@14.0.0: {} valibot@1.3.1(typescript@6.0.3): optionalDependencies: @@ -15934,8 +17077,7 @@ snapshots: web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: - optional: true + web-streams-polyfill@3.3.3: {} web-vitals@5.2.0: {} @@ -16037,8 +17179,20 @@ snapshots: yaml@2.9.0: {} + yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yargs@18.0.0: dependencies: cliui: 9.0.1 diff --git a/scripts/test-abuse-gate.sh b/scripts/test-abuse-gate.sh new file mode 100755 index 000000000..479f2c1db --- /dev/null +++ b/scripts/test-abuse-gate.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +URL="${1:-http://localhost:3000/docs/__ai__/chat}" +BODY='{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"What is Directus?"}]}]}' + +curl -i "$URL" \ + -H 'Content-Type: application/json' \ + -H 'Origin: http://localhost:3000' \ + -H 'Referer: http://localhost:3000/docs' \ + -H 'Sec-Fetch-Site: same-origin' \ + -H 'Sec-Fetch-Mode: cors' \ + -H 'Sec-Fetch-Dest: empty' \ + -H 'User-Agent: Mozilla/5.0 Chrome/120 Safari/537.36' \ + -H 'Accept-Language: en-US' \ + --data "$BODY" diff --git a/server/api/docs/get.get.ts b/server/api/docs/get.get.ts index 54367bafc..a390398ed 100644 --- a/server/api/docs/get.get.ts +++ b/server/api/docs/get.get.ts @@ -1,13 +1,8 @@ import getDoc from '~~/server/mcp/tools/get-doc'; -import { checkDocsApiRateLimit } from '~~/server/utils/docs-api-rate-limit'; +import { enforceDocsApiLimit } from '~~/server/utils/docs-api-limit'; export default defineEventHandler(async (event) => { - const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; - const limit = checkDocsApiRateLimit(`docs-api:${ip}`); - if (!limit.ok) { - setResponseHeader(event, 'Retry-After', limit.retryAfter ?? 60); - throw createError({ statusCode: 429, message: 'Rate limit exceeded' }); - } + await enforceDocsApiLimit(event); const query = getQuery(event); if (typeof query.path !== 'string' || !query.path) { diff --git a/server/api/docs/index.get.ts b/server/api/docs/index.get.ts index 98239b0d3..7eacb350a 100644 --- a/server/api/docs/index.get.ts +++ b/server/api/docs/index.get.ts @@ -1,13 +1,8 @@ import listDocs from '~~/server/mcp/tools/list-docs'; -import { checkDocsApiRateLimit } from '~~/server/utils/docs-api-rate-limit'; +import { enforceDocsApiLimit } from '~~/server/utils/docs-api-limit'; export default defineEventHandler(async (event) => { - const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; - const limit = checkDocsApiRateLimit(`docs-api:${ip}`); - if (!limit.ok) { - setResponseHeader(event, 'Retry-After', limit.retryAfter ?? 60); - throw createError({ statusCode: 429, message: 'Rate limit exceeded' }); - } + await enforceDocsApiLimit(event); const query = getQuery(event); const input: Record = {}; diff --git a/server/api/docs/search.get.ts b/server/api/docs/search.get.ts index 4d86fd47b..27de13312 100644 --- a/server/api/docs/search.get.ts +++ b/server/api/docs/search.get.ts @@ -1,13 +1,8 @@ import searchDocs from '~~/server/mcp/tools/search-docs'; -import { checkDocsApiRateLimit } from '~~/server/utils/docs-api-rate-limit'; +import { enforceDocsApiLimit } from '~~/server/utils/docs-api-limit'; export default defineEventHandler(async (event) => { - const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; - const limit = checkDocsApiRateLimit(`docs-api:${ip}`); - if (!limit.ok) { - setResponseHeader(event, 'Retry-After', limit.retryAfter ?? 60); - throw createError({ statusCode: 429, message: 'Rate limit exceeded' }); - } + await enforceDocsApiLimit(event); const query = getQuery(event); if (typeof query.q !== 'string' || !query.q) { diff --git a/server/mcp/tools/get-directus-page.ts b/server/mcp/tools/get-directus-page.ts new file mode 100644 index 000000000..019ff9331 --- /dev/null +++ b/server/mcp/tools/get-directus-page.ts @@ -0,0 +1,90 @@ +import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'; +import { z } from 'zod'; +import { sliceUtf8 } from '../../utils/sliceUtf8'; + +const DEFAULT_CHUNK_BYTES = 50 * 1024; +const MAX_CHUNK_BYTES = 100 * 1024; + +// Marketing/website pages serve clean Markdown via the `?md` query param (and +// `Accept: text/markdown` content negotiation). Restrict fetches to this host +// so the tool can't be used as a general web fetcher. +const ALLOWED_HOSTS = new Set(['directus.com']); + +function resolveUrl(input: string): URL | null { + let url: URL; + try { + // Accept bare paths ("/pricing") and full URLs alike. + url = input.startsWith('http') ? new URL(input) : new URL(input, 'https://directus.com'); + } + catch { + return null; + } + + if (url.protocol !== 'https:') return null; + if (!ALLOWED_HOSTS.has(url.hostname)) return null; + + url.searchParams.set('md', ''); + return url; +} + +export default defineMcpTool({ + name: 'get-directus-page', + title: 'Fetch a page from the Directus website', + description: + 'Fetch a page from the Directus marketing website (directus.com) as Markdown. Use for current pricing, plans, the Open Innovation Grant, partner programs, comparisons, and other non-docs pages. Pass a path like "/pricing" or "/oig". Large pages are returned in chunks; use offset with nextOffset to continue.', + inputSchema: { + path: z + .string() + .min(1) + .max(300) + .describe('Page path or full URL on directus.com, e.g. "/pricing" or "/oig".'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Byte offset to start reading from. Use nextOffset from a truncated result to continue.'), + bytes: z + .number() + .int() + .min(1024) + .max(MAX_CHUNK_BYTES) + .optional() + .describe('Max bytes to return. Default 50KB, max 100KB.'), + }, + cache: '1h', + handler: async ({ path, offset, bytes }) => { + const url = resolveUrl(path); + if (!url) { + throw createError({ statusCode: 400, message: 'path must be an https page on directus.com' }); + } + + let text: string; + try { + const response = await fetch(url, { + headers: { 'User-Agent': 'directus-docs-mcp', 'Accept': 'text/markdown' }, + }); + if (!response.ok) throw new Error(`Website returned ${response.status}`); + text = await response.text(); + } + catch { + throw createError({ statusCode: 502, message: 'Website fetch failed' }); + } + + const start = offset ?? 0; + const chunkSize = bytes ?? DEFAULT_CHUNK_BYTES; + const chunk = sliceUtf8(text, start, chunkSize); + const suffix = chunk.truncated + ? `\n\n[truncated: call get-directus-page again with offset=${chunk.nextOffset} to read the next chunk]` + : ''; + + return { + path, + url: url.href, + offset: start, + nextOffset: chunk.nextOffset, + truncated: chunk.truncated, + content: `\n${chunk.content}${suffix}\n`, + }; + }, +}); diff --git a/server/mcp/tools/search-directus-code.ts b/server/mcp/tools/search-directus-code.ts index 130bb9f27..c35930c24 100644 --- a/server/mcp/tools/search-directus-code.ts +++ b/server/mcp/tools/search-directus-code.ts @@ -2,7 +2,7 @@ import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'; import { ofetch } from 'ofetch'; import { z } from 'zod'; -import { checkMcpRateLimit } from '../../utils/mcp-rate-limit'; +import { checkRateLimit } from '../../utils/rate-limit'; import { DIRECTUS_REPO_SLUGS, directusRepoSearchQualifier } from '../../utils/directus-repos'; async function fetchWithRetry(url: string, opts: any, retries = 1): Promise { @@ -75,7 +75,8 @@ export default defineMcpTool({ }); } - const localLimit = checkMcpRateLimit('search-directus-code', 20, 60_000); + // Intentionally global to protect the shared GITHUB_TOKEN. + const localLimit = await checkRateLimit('search-directus-code', { max: 20, windowSeconds: 60, onStoreError: 'deny' }); if (!localLimit.ok) { throw createError({ statusCode: 429, diff --git a/server/utils/docs-api-limit.test.ts b/server/utils/docs-api-limit.test.ts new file mode 100644 index 000000000..3fe408703 --- /dev/null +++ b/server/utils/docs-api-limit.test.ts @@ -0,0 +1,54 @@ +import { createEvent, type H3Event } from 'h3'; +import { describe, expect, it, vi } from 'vitest'; +import { enforceDocsApiLimit } from './docs-api-limit'; +import type { RateLimitStore } from './rate-limit'; + +function storeReturning(count: number): RateLimitStore { + return { incr: vi.fn(async () => count) }; +} + +function buildEvent(): { event: H3Event; headers: Record } { + const headers: Record = {}; + const req = { + method: 'GET', + url: '/api/docs/search', + headers: { 'x-forwarded-for': '203.0.113.7' }, + socket: { remoteAddress: '203.0.113.7' }, + } as unknown as import('node:http').IncomingMessage; + + const res = { + statusCode: 200, + setHeader(name: string, value: string | number) { + headers[name] = value; + }, + getHeader() {}, + headersSent: false, + } as unknown as import('node:http').ServerResponse; + + return { event: createEvent(req, res), headers }; +} + +describe('enforceDocsApiLimit', () => { + it('resolves when the request is within the limit', async () => { + const { event } = buildEvent(); + await expect(enforceDocsApiLimit(event, storeReturning(60))).resolves.toBeUndefined(); + }); + + it('throws a 429 with Retry-After when over the limit', async () => { + const { event, headers } = buildEvent(); + await expect(enforceDocsApiLimit(event, storeReturning(61))).rejects.toMatchObject({ statusCode: 429 }); + expect(headers['Retry-After']).toBe(60); + }); + + it('fails open when the store errors (public reads must survive an Upstash blip)', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const store: RateLimitStore = { incr: vi.fn(async () => { + throw new Error('down'); + }) }; + + const { event } = buildEvent(); + await expect(enforceDocsApiLimit(event, store)).resolves.toBeUndefined(); + + errorSpy.mockRestore(); + }); +}); diff --git a/server/utils/docs-api-limit.ts b/server/utils/docs-api-limit.ts new file mode 100644 index 000000000..b0332ed4e --- /dev/null +++ b/server/utils/docs-api-limit.ts @@ -0,0 +1,24 @@ +// The public docs API rate limit, stated once. +// +// Three docs endpoints (get, index, search) share the same per-IP limit and +// the same fail-open decision from ADR-0001: these are cheap public reads, so +// an Upstash blip must not take down docs search. This wraps the shared +// checkRateLimit limiter with the docs-API key and policy; it does not +// reimplement the algorithm. + +import { createError, getRequestIP, setResponseHeader, type H3Event } from 'h3'; +import { checkRateLimit, type RateLimitStore } from './rate-limit'; + +const DOCS_API_POLICY = { max: 60, windowSeconds: 60, onStoreError: 'allow' } as const; + +// Enforce the public docs API limit for this request. Resolves when the request +// is within the limit; throws a 429 (with Retry-After) when it is over. +// `store` is for tests — production uses the shared default store. +export async function enforceDocsApiLimit(event: H3Event, store?: RateLimitStore): Promise { + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'; + const verdict = await checkRateLimit(`docs-api:${ip}`, DOCS_API_POLICY, store); + if (!verdict.ok) { + setResponseHeader(event, 'Retry-After', verdict.retryAfter ?? 60); + throw createError({ statusCode: 429, message: 'Rate limit exceeded' }); + } +} diff --git a/server/utils/docs-api-rate-limit.ts b/server/utils/docs-api-rate-limit.ts deleted file mode 100644 index 28c9fe89a..000000000 --- a/server/utils/docs-api-rate-limit.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Per-IP rate limit for the public docs HTTP API (60/min). Lower budget than chat -// because these are read-only and cheap, but still protect against scraping abuse. - -interface Bucket { - count: number; - resetAt: number; -} - -const WINDOW_MS = 60_000; -const MAX = 60; -const buckets = new Map(); -let lastSweep = 0; - -export function checkDocsApiRateLimit(key: string): { ok: boolean; retryAfter?: number } { - const now = Date.now(); - - if (now - lastSweep > WINDOW_MS) { - for (const [k, b] of buckets) { - if (b.resetAt < now) buckets.delete(k); - } - lastSweep = now; - } - - const bucket = buckets.get(key); - if (!bucket || bucket.resetAt < now) { - buckets.set(key, { count: 1, resetAt: now + WINDOW_MS }); - return { ok: true }; - } - - bucket.count++; - if (bucket.count > MAX) { - return { ok: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) }; - } - return { ok: true }; -} diff --git a/server/utils/mcp-rate-limit.ts b/server/utils/mcp-rate-limit.ts deleted file mode 100644 index 794b30f23..000000000 --- a/server/utils/mcp-rate-limit.ts +++ /dev/null @@ -1,27 +0,0 @@ -const buckets = new Map(); -let lastSweep = 0; - -export function checkMcpRateLimit(key: string, max: number, windowMs: number): { ok: boolean; retryAfter?: number } { - const now = Date.now(); - - if (now - lastSweep > windowMs) { - for (const [k, b] of buckets) { - if (b.resetAt < now) buckets.delete(k); - } - lastSweep = now; - } - - const bucket = buckets.get(key); - if (!bucket || bucket.resetAt <= now) { - buckets.set(key, { count: 1, resetAt: now + windowMs }); - return { ok: true }; - } - - bucket.count++; - if (bucket.count <= max) return { ok: true }; - - return { - ok: false, - retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), - }; -} diff --git a/server/utils/rate-limit.test.ts b/server/utils/rate-limit.test.ts new file mode 100644 index 000000000..eda5abfe7 --- /dev/null +++ b/server/utils/rate-limit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { checkRateLimit, type RateLimitStore } from './rate-limit'; + +function storeReturning(count: number): RateLimitStore { + return { incr: vi.fn(async () => count) }; +} + +describe('checkRateLimit', () => { + it('allows requests within the policy max', async () => { + const result = await checkRateLimit('key', { max: 2, windowSeconds: 60, onStoreError: 'deny' }, storeReturning(2)); + expect(result).toEqual({ ok: true }); + }); + + it('returns the full fixed window as retryAfter when rejected', async () => { + const result = await checkRateLimit('key', { max: 2, windowSeconds: 60, onStoreError: 'deny' }, storeReturning(3)); + expect(result).toEqual({ ok: false, retryAfter: 60 }); + }); + + it('fails closed when configured to deny store errors', async () => { + const store = { incr: vi.fn(async () => { throw new Error('down'); }) }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await checkRateLimit('key', { max: 2, windowSeconds: 60, onStoreError: 'deny' }, store); + + expect(result).toEqual({ ok: false, retryAfter: 60 }); + errorSpy.mockRestore(); + }); + + it('fails open when configured to allow store errors', async () => { + const store = { incr: vi.fn(async () => { throw new Error('down'); }) }; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await checkRateLimit('key', { max: 2, windowSeconds: 60, onStoreError: 'allow' }, store); + + expect(result).toEqual({ ok: true }); + errorSpy.mockRestore(); + }); +}); diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts new file mode 100644 index 000000000..e9c618704 --- /dev/null +++ b/server/utils/rate-limit.ts @@ -0,0 +1,112 @@ +// Shared fixed-window rate limiter. One algorithm, pluggable store. +// +// Counting lives in the store (memory or Upstash); limits and failure policy +// live in the policy the caller passes. Assistant daily limits stay separate +// because they track multiple keys, overrides, reset status, and degraded mode. + +export interface RateLimitStore { + // Increment the counter for `key`, returning the new count. On the first + // increment within a window the store sets the key to expire after `ttlSeconds`. + incr(key: string, ttlSeconds: number): Promise; +} + +export interface RateLimitPolicy { + max: number; + windowSeconds: number; + // What to do when the store itself errors. Expensive/abusable endpoints + // (the assistant, code search) deny; cheap public reads (docs API) allow. + onStoreError: 'deny' | 'allow'; +} + +export interface RateLimitVerdict { + ok: boolean; + retryAfter?: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +// In-memory fixed-window store with periodic eviction of expired buckets. +class MemoryStore implements RateLimitStore { + private buckets = new Map(); + private lastSweep = 0; + + async incr(key: string, ttlSeconds: number): Promise { + const now = Date.now(); + const windowMs = ttlSeconds * 1000; + + if (now - this.lastSweep > windowMs) { + for (const [k, b] of this.buckets) { + if (b.resetAt < now) this.buckets.delete(k); + } + this.lastSweep = now; + } + + const bucket = this.buckets.get(key); + if (!bucket || bucket.resetAt < now) { + this.buckets.set(key, { count: 1, resetAt: now + windowMs }); + return 1; + } + + bucket.count++; + return bucket.count; + } +} + +// Upstash/Vercel KV store. INCR + EXPIRE on first hit is atomic server-side, +// so counts hold across serverless instances with no read-modify-write race. +class UpstashStore implements RateLimitStore { + constructor(private url: string, private token: string) {} + + private async client() { + const { Redis } = await import('@upstash/redis'); + return new Redis({ url: this.url, token: this.token }); + } + + async incr(key: string, ttlSeconds: number): Promise { + const kv = await this.client(); + const count = await kv.incr(key); + if (count === 1) await kv.expire(key, ttlSeconds); + return count; + } +} + +function upstashUrl(): string | undefined { + return process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL; +} + +function upstashToken(): string | undefined { + return process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN; +} + +let store: RateLimitStore | undefined; + +// Memory when Upstash isn't configured (local dev), Upstash otherwise. +// Matches the daily-limit backend choice without sharing its deeper logic. +function defaultStore(): RateLimitStore { + if (store) return store; + const url = upstashUrl(); + const token = upstashToken(); + store = url && token ? new UpstashStore(url, token) : new MemoryStore(); + return store; +} + +export async function checkRateLimit( + key: string, + policy: RateLimitPolicy, + override?: RateLimitStore, +): Promise { + try { + const count = await (override ?? defaultStore()).incr(key, policy.windowSeconds); + // Stores expose count only, so retryAfter is the full fixed window. + if (count > policy.max) return { ok: false, retryAfter: policy.windowSeconds }; + return { ok: true }; + } + catch (error) { + console.error('[rate-limit] store failed', error); + if (policy.onStoreError === 'deny') return { ok: false, retryAfter: policy.windowSeconds }; + return { ok: true }; + } +}