diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20842238..80600ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: quality-checks: name: Quality Checks runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/claude-auto-fix-ci.yml b/.github/workflows/claude-auto-fix-ci.yml index 993545c6..246e94c1 100644 --- a/.github/workflows/claude-auto-fix-ci.yml +++ b/.github/workflows/claude-auto-fix-ci.yml @@ -19,6 +19,7 @@ jobs: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.pull_requests[0] runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v5 @@ -41,18 +42,22 @@ jobs: - name: Get CI failure details id: failure_details uses: actions/github-script@v7 + env: + RUN_ID: ${{ github.event.workflow_run.id }} with: script: | + const runId = Number(process.env.RUN_ID); + const run = await github.rest.actions.getWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const jobs = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure'); diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9da46700..38dbdf9d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -4,14 +4,23 @@ on: pull_request: types: [opened, synchronize, ready_for_review, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: claude-review: + # Fork PRs run with a read-only token and no secrets/OIDC id-token, so the + # Claude action can never authenticate there and the job always fails. Skip + # it for forks so those PRs report a clean skipped check instead of a red X. if: | github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'graphite-app[bot]' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 17a26bc7..c37d662d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -13,11 +13,36 @@ on: jobs: claude: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ( + github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + (github.event.review.author_association == 'OWNER' || + github.event.review.author_association == 'MEMBER' || + github.event.review.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + (github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR') + ) runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read pull-requests: read diff --git a/.github/workflows/publish-agent-framework-python.yml b/.github/workflows/publish-agent-framework-python.yml index bb46d3d3..ef74637d 100644 --- a/.github/workflows/publish-agent-framework-python.yml +++ b/.github/workflows/publish-agent-framework-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/agent-framework-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-ai-sdk.yml b/.github/workflows/publish-ai-sdk.yml index 27817b20..1ac60cf1 100644 --- a/.github/workflows/publish-ai-sdk.yml +++ b/.github/workflows/publish-ai-sdk.yml @@ -7,9 +7,14 @@ on: paths: - "packages/ai-sdk/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-cartesia-sdk-python.yml b/.github/workflows/publish-cartesia-sdk-python.yml index 14155ac1..aa65eb4f 100644 --- a/.github/workflows/publish-cartesia-sdk-python.yml +++ b/.github/workflows/publish-cartesia-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/cartesia-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-memory-graph.yml b/.github/workflows/publish-memory-graph.yml index 66dcbf3c..6dff18ea 100644 --- a/.github/workflows/publish-memory-graph.yml +++ b/.github/workflows/publish-memory-graph.yml @@ -7,9 +7,14 @@ on: paths: - "packages/memory-graph/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-openai-sdk-python.yml b/.github/workflows/publish-openai-sdk-python.yml index 141477a8..e6066bcf 100644 --- a/.github/workflows/publish-openai-sdk-python.yml +++ b/.github/workflows/publish-openai-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/openai-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-pipecat-sdk-python.yml b/.github/workflows/publish-pipecat-sdk-python.yml index a0152ce3..f98ab1c8 100644 --- a/.github/workflows/publish-pipecat-sdk-python.yml +++ b/.github/workflows/publish-pipecat-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/pipecat-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-tools.yml b/.github/workflows/publish-tools.yml index 46a56329..8dfb8569 100644 --- a/.github/workflows/publish-tools.yml +++ b/.github/workflows/publish-tools.yml @@ -7,9 +7,14 @@ on: paths: - "packages/tools/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7c29ec..feb1ed41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed: # You'll need to add your API keys and database URLs ``` -4. **Change proxy for local development** - - Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`): - - ```ts - if (url.hostname === "localhost") { - return NextResponse.next(); - } - -5. **Start the Development Server** +4. **Start the Development Server** ```bash bun run dev:local diff --git a/README.md b/README.md index 4a17c593..33a98c5f 100644 --- a/README.md +++ b/README.md @@ -341,11 +341,12 @@ const client = new Supermemory({ ``` - **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot. +- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud. - **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine. - **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move. - **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`. -Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, configuration, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise). +Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise). --- diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index e6dba3eb..f9f9c979 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -148,7 +148,17 @@ export default defineBackground(() => { eventSource: string, ): Promise<{ success: boolean; data?: unknown; error?: string }> => { try { - const responseData = await searchMemories(data) + let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT + try { + const defaultProject = await getDefaultProject() + if (defaultProject?.containerTag) { + containerTag = defaultProject.containerTag + } + } catch (error) { + console.warn("Failed to get default project, using fallback:", error) + } + + const responseData = await searchMemories(data, containerTag) const response = responseData as { results?: Array<{ memory?: string }> } diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json index 621fa129..781af494 100644 --- a/apps/browser-extension/tsconfig.json +++ b/apps/browser-extension/tsconfig.json @@ -4,5 +4,6 @@ "allowImportingTsExtensions": true, "jsx": "react-jsx", "types": ["chrome"] - } + }, + "exclude": ["**/*.test.ts"] } diff --git a/apps/browser-extension/utils/api.ts b/apps/browser-extension/utils/api.ts index dd42d078..59cef3d7 100644 --- a/apps/browser-extension/utils/api.ts +++ b/apps/browser-extension/utils/api.ts @@ -3,6 +3,7 @@ */ import { API_ENDPOINTS } from "./constants" import { bearerToken, defaultProject, userData } from "./storage" +import { buildSearchMemoriesBody } from "./search-request" import { AuthenticationError, type MemoryPayload, @@ -145,14 +146,14 @@ export async function saveMemory(payload: MemoryPayload): Promise { /** * Search memories using Supermemory API */ -export async function searchMemories(query: string): Promise { +export async function searchMemories( + query: string, + containerTag?: string, +): Promise { try { const response = await makeAuthenticatedRequest("/v4/search", { method: "POST", - body: JSON.stringify({ - q: query, - include: { relatedMemories: true }, - }), + body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)), }) return response } catch (error) { diff --git a/apps/browser-extension/utils/search-request.test.ts b/apps/browser-extension/utils/search-request.test.ts new file mode 100644 index 00000000..228d0060 --- /dev/null +++ b/apps/browser-extension/utils/search-request.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test" +import { buildSearchMemoriesBody } from "./search-request" + +describe("buildSearchMemoriesBody", () => { + it("builds the default related-memory search body", () => { + expect(buildSearchMemoriesBody("deploy notes")).toEqual({ + q: "deploy notes", + include: { relatedMemories: true }, + }) + }) + + it("includes the container tag when provided", () => { + expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({ + q: "deploy notes", + include: { relatedMemories: true }, + containerTag: "sm_project_docs", + }) + }) +}) diff --git a/apps/browser-extension/utils/search-request.ts b/apps/browser-extension/utils/search-request.ts new file mode 100644 index 00000000..95c547a1 --- /dev/null +++ b/apps/browser-extension/utils/search-request.ts @@ -0,0 +1,14 @@ +export function buildSearchMemoriesBody( + query: string, + containerTag?: string, +): { + q: string + include: { relatedMemories: boolean } + containerTag?: string +} { + return { + q: query, + include: { relatedMemories: true }, + ...(containerTag ? { containerTag } : {}), + } +} diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 71ff23c0..d3a62eff 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -77,6 +77,7 @@ "self-hosting/overview", "self-hosting/quickstart", "self-hosting/configuration", + "self-hosting/embeddings", "self-hosting/local-vs-enterprise" ] }, diff --git a/apps/docs/integrations/ai-sdk.mdx b/apps/docs/integrations/ai-sdk.mdx index bdf73fd3..eede8429 100644 --- a/apps/docs/integrations/ai-sdk.mdx +++ b/apps/docs/integrations/ai-sdk.mdx @@ -140,6 +140,18 @@ const model = withSupermemory(openai("gpt-5"), { }) ``` +### Persisting Tool Calls (default: off) + +By default, saved conversations include only user and assistant text — tool calls and tool results are dropped, since tool payloads are often large and low-signal and would pollute memory extraction. To persist the full tool round trip (tool calls with their arguments, plus tool results, in their original order), set `includeToolCalls: true`: + +```typescript +const model = withSupermemory(openai("gpt-5"), { + containerTag: "user-123", + customId: "conv-1", + includeToolCalls: true, +}) +``` + --- ## Memory Tools diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx index 49580e6d..9714653f 100644 --- a/apps/docs/self-hosting/configuration.mdx +++ b/apps/docs/self-hosting/configuration.mdx @@ -5,7 +5,7 @@ description: "Every environment variable the self-hosted server understands." icon: "settings" --- -The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it. +The self-hosted server aims for **zero configuration** — the only required input is one LLM provider key, which the first-boot wizard collects interactively (or set via env var for non-interactive deployments). Embeddings default to local English; you can pick another provider in the optional wizard step or via env. Everything else below is opt-in. The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager. @@ -18,7 +18,7 @@ The installer writes API keys to `~/.supermemory/env`, which is loaded on every ## LLM providers -In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**: +In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own LLM for the intelligent steps — summaries, contextual chunking, and memory extraction. Embeddings default to a local model (no API key) and can optionally use OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). Configure **at least one** LLM provider: | Variable | Provider | |---|---| @@ -61,7 +61,20 @@ OPENAI_MODEL=gpt-oss:20b Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`. -## Embedding performance +## Embeddings + +By default, vectors are computed locally with `Xenova/bge-base-en-v1.5` (768d) — no embedding API key. On interactive first boot you can pick a different provider after the LLM key step; for Docker/CI set env vars instead. + +Full provider table, multilingual guidance, remote examples (OpenAI / Gemini / Ollama), and the re-ingestion / dimension-lock warning: **[Embeddings (self-hosted)](/self-hosting/embeddings)**. + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local`, `openai`, `gemini`, or OpenAI-compatible remote | `local` | +| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` | +| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match model and stored data | `768` | +| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs | unset | + +### Embedding performance Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom: @@ -128,8 +141,13 @@ Any other environment variables you may find referenced in the codebase are plat # Persistent data location SUPERMEMORY_DATA_DIR=/var/lib/supermemory -# One LLM provider +# One LLM provider (required for extraction) OPENAI_API_KEY=sk-... + +# Optional — omit to keep local Xenova/bge-base-en-v1.5 (768d) +# SUPERMEMORY_EMBEDDING_PROVIDER=openai +# SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small +# SUPERMEMORY_EMBEDDING_DIMENSIONS=1536 ``` -That's enough for full ingestion, memory extraction, and hybrid search. +That's enough for full ingestion, memory extraction, and hybrid search with the default local embeddings. diff --git a/apps/docs/self-hosting/embeddings.mdx b/apps/docs/self-hosting/embeddings.mdx new file mode 100644 index 00000000..95385ff0 --- /dev/null +++ b/apps/docs/self-hosting/embeddings.mdx @@ -0,0 +1,135 @@ +--- +title: "Embeddings (self-hosted)" +sidebarTitle: "Embeddings" +description: "Local and remote embedding providers for Supermemory local — defaults, env vars, multilingual options, and dimension lock." +icon: "waypoints" +--- + +Self-hosted Supermemory uses the **same embedding provider stack** as the hosted platform: local ONNX models, OpenAI, Gemini, or any OpenAI-compatible embeddings endpoint (including Ollama). LLM keys power extraction and summarization; embeddings are configured separately. + +## Defaults + +| | | +|---|---| +| Provider | `local` | +| Model | `Xenova/bge-base-en-v1.5` | +| Dimensions | `768` | +| API key | None — runs on your machine | + +Press Enter at the optional first-boot picker to keep this default. Nothing is sent off-box to embed. + + +The default local model is **English-only**. Non-English content can ingest successfully while dense semantic recall stays weak. See [Multilingual](#multilingual). + + +## First-time setup (interactive) + +On first boot with a TTY, Supermemory asks for an LLM API key (required), then optionally which embedding model to use. + +1. Choose or paste an LLM provider key (OpenAI, Anthropic, Gemini, Groq, or OpenAI-compatible). +2. Optionally pick an embedding provider/model. **Press Enter to keep the local English model.** +3. Choices are saved encrypted under your data directory (`$SUPERMEMORY_DATA_DIR`, typically `./.supermemory` / `~/.supermemory`). + +Boot order is intentional: LLM keys load first so remote embedding options can reuse them (for example OpenAI or Gemini embeddings with the same key). + + +**First boot (terminal):** Supermemory asks for an LLM API key (required), then optionally which embedding model to use. Press Enter to keep the local English model. Choices are saved encrypted under your data directory. + + +## Configuration (env) + +For Docker, CI, or any non-interactive deploy, set env vars — there is **no interactive prompt without a TTY**. + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_EMBEDDING_PROVIDER` | Embedding backend: `local`, `openai`, `gemini`, or an OpenAI-compatible remote (`ollama` / custom base URL) | `local` | +| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` (local) | +| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match the model and any already-stored data | `768` (local default) | +| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs (Ollama, vLLM, etc.) | unset | +| `OPENAI_API_KEY` | Used when provider is `openai` (or compatible) if not otherwise supplied | unset | +| `GEMINI_API_KEY` | Used when provider is `gemini` | unset | + +Local worker tuning (throughput only — does not change model or dimensions): + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` | +| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` | +| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` | +| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` | +| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset | + +Ingestion memory headroom is controlled by `SUPERMEMORY_EMBEDDING_RAM_LIMIT` — see [Memory limits & ingestion queue](/self-hosting/configuration#memory-limits--ingestion-queue). + + +**Docker / production:** Set at least one LLM key and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `SUPERMEMORY_EMBEDDING_MODEL` / `SUPERMEMORY_EMBEDDING_DIMENSIONS` (and base URL or API key as needed). There is no interactive prompt without a TTY. + + +## Multilingual + +The default `Xenova/bge-base-en-v1.5` model is trained for English. For German, Dutch, and other non-English corpora, dense recall can fail even when hybrid keyword search still finds rare tokens. + +For multilingual or non-English deployments, switch **before** large backfills: + +```bash +# Example: local multilingual (set dimensions to match the model) +SUPERMEMORY_EMBEDDING_PROVIDER=local +SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-m3 +SUPERMEMORY_EMBEDDING_DIMENSIONS=1024 +``` + +Or use a remote multilingual embedding API (OpenAI, Gemini, or Ollama with a multilingual embed model). Set provider, model, and dimensions together. Changing them later requires a fresh data directory or full re-ingestion — see below. + +## Remote providers + +### Local (default) + +```bash +# Explicit local default — no embedding API key +SUPERMEMORY_EMBEDDING_PROVIDER=local +SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5 +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +### OpenAI + +```bash +OPENAI_API_KEY=sk-... +SUPERMEMORY_EMBEDDING_PROVIDER=openai +SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small +SUPERMEMORY_EMBEDDING_DIMENSIONS=1536 +``` + +### Gemini + +```bash +GEMINI_API_KEY=... +SUPERMEMORY_EMBEDDING_PROVIDER=gemini +SUPERMEMORY_EMBEDDING_MODEL=text-embedding-004 +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +### Ollama (OpenAI-compatible) + +```bash +SUPERMEMORY_EMBEDDING_PROVIDER=openai +SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1 +OPENAI_API_KEY=ollama +SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +Use the dimension published for your chosen model. A mismatch with vectors already in the store fails boot. + +## Changing models later + + +**Not supported in place.** Embeddings from different models (or different dimensions) are not comparable. Start from a fresh data directory or re-ingest all content so vectors stay in one space. If configured dimensions disagree with stored data, the server **refuses to boot**. + + +**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable. + +## Related + +- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits +- [Quickstart](/self-hosting/quickstart) — install and first memory diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx index b9db33e4..c1ef34a1 100644 --- a/apps/docs/self-hosting/overview.mdx +++ b/apps/docs/self-hosting/overview.mdx @@ -24,7 +24,7 @@ No Docker. No database to provision. No config files. It boots in seconds with e Run the binary with nothing set and you get a complete memory system: - **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings. -- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded. +- **Built-in local embeddings** — default `Xenova/bge-base-en-v1.5` (768d) on your machine, no API key. Same provider stack as cloud if you opt into OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). - **An API key, generated for you** — printed on first boot, ready to paste into any SDK. - **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works. @@ -64,7 +64,7 @@ Self-hosted is free, open source, and great for local development, air-gapped en |---|---|---| | Full Memory API | ✅ | ✅ | | Hybrid semantic search | ✅ | ✅ | -| Local embeddings | ✅ | Managed | +| Embeddings | Local default (or OpenAI / Gemini / Ollama) | Same provider stack, managed | | File ingestion (PDFs, images) | ✅ | ✅ | | [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ | | [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ | @@ -75,11 +75,14 @@ If you outgrow a single machine — or want connectors, MCP, and the best-tuned ## Next steps - + Install, run, and store your first memory in under two minutes Every environment variable: LLM providers, storage, auth, tuning + + Local default, remote providers, multilingual, dimension lock + diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx index 713ea98b..2e4dfe60 100644 --- a/apps/docs/self-hosting/quickstart.mdx +++ b/apps/docs/self-hosting/quickstart.mdx @@ -47,9 +47,13 @@ First boot sets everything up — the embedded Supermemory graph engine, local e Save that API key — it's your bearer token for every request. -In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models). +In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. After the LLM key, you can optionally pick an embedding model (press Enter to keep local `Xenova/bge-base-en-v1.5`). See [all providers](/self-hosting/configuration#llm-providers), [embeddings](/self-hosting/embeddings), and [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models). + +**Docker / non-interactive:** set an LLM key via env and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `MODEL` / `DIMENSIONS`. There is no wizard without a TTY. + + ## Add your first memory @@ -141,10 +145,13 @@ By default, all state lives in a single directory you can back up or move: ## Next steps - + LLM providers, local models, performance tuning + + Local default, OpenAI / Gemini / Ollama, multilingual + The full API — it all works against your local server diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 761c8ac1..ad396af9 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -110,6 +110,24 @@ Search memories and get user profile. | `includeProfile` | boolean | No | Include user profile summary. Default: `true` | | `containerTag` | string | No | Project tag to scope the search | +### `listMemories` + +Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search. + +```json +{ + "page": 1, + "limit": 10, + "containerTag": "optional-project-tag" +} +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `page` | integer | No | Page number (1-based). Default: `1` | +| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` | +| `containerTag` | string | No | Project tag to scope the listing | + ### `whoAmI` Get the current logged-in user's information. @@ -190,6 +208,7 @@ bun run test:e2e | `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip | | `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` | | `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args | +| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation | | `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection | | `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt | diff --git a/apps/mcp/e2e/list-memories.test.ts b/apps/mcp/e2e/list-memories.test.ts new file mode 100644 index 00000000..bb7a66ad --- /dev/null +++ b/apps/mcp/e2e/list-memories.test.ts @@ -0,0 +1,82 @@ +import { randomUUID } from "node:crypto" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + API_KEY, + callTool, + connect, + type Session, + sleep, + textOf, +} from "./helpers" + +// listMemories reads extracted memory entries, which appear only after the +// async ingestion pipeline finishes — poll like recallUntil does. +async function listUntil( + s: Session, + needle: string, + { tries = 18, delayMs = 5000 } = {}, +): Promise { + for (let i = 0; i < tries; i++) { + // The marker document is the newest, so page 1 is enough. + const res = await callTool(s.client, "listMemories", { limit: 20 }) + const txt = textOf(res) + if (txt.includes(needle)) return txt + await sleep(delayMs) + } + return null +} + +describe.skipIf(!API_KEY)("MCP — listMemories", () => { + let s: Session + const created: string[] = [] + + beforeAll(async () => { + s = await connect() + }) + afterAll(async () => { + for (const content of created) { + await callTool(s.client, "memory", { + content, + action: "forget", + }).catch(() => {}) + } + await s?.close() + }) + + it("appears in tool discovery", async () => { + const tools = await s.client.listTools() + const names = tools.tools.map((t) => t.name) + expect(names).toContain("listMemories") + }) + + it("lists a saved memory without dumping document content", async () => { + const marker = `lm-${randomUUID()}` + const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.` + created.push(content) + + const save = await callTool(s.client, "memory", { content, action: "save" }) + expect(save.isError).toBeFalsy() + + const listing = await listUntil(s, marker) + expect( + listing, + `listMemories never returned marker ${marker}`, + ).not.toBeNull() + // Header shape: "N memories across M documents (page X of Y, ...)" + expect(listing).toMatch(/memor(y|ies) across \d+ document/) + }, 120_000) + + it("paginates with a bounded page size", async () => { + const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 }) + expect(res.isError).toBeFalsy() + const txt = textOf(res) + // With the memory saved above there is at least one document. + expect(txt).toMatch(/page 1 of \d+/) + }, 30_000) + + it("rejects an out-of-range limit", async () => { + const res = await callTool(s.client, "listMemories", { limit: 500 }) + // Zod schema caps limit at 50 — the SDK surfaces this as a tool error. + expect(res.isError).toBeTruthy() + }, 30_000) +}) diff --git a/apps/mcp/src/format.test.ts b/apps/mcp/src/format.test.ts new file mode 100644 index 00000000..8f3bfca6 --- /dev/null +++ b/apps/mcp/src/format.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest" +import type { DocumentsApiResponse } from "./server/client" +import { formatMemoriesList } from "./server/format" + +function makeResponse( + overrides: Partial = {}, +): DocumentsApiResponse { + return { + documents: [], + pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, + ...overrides, + } +} + +function makeEntry(memory: string, extra: Record = {}) { + return { + id: `mem_${memory.slice(0, 8)}`, + memory, + spaceId: "space_1", + createdAt: "2026-06-10T12:00:00Z", + updatedAt: "2026-06-10T12:00:00Z", + ...extra, + } +} + +describe("formatMemoriesList", () => { + it("reports an empty store", () => { + expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.") + }) + + it("reports an out-of-range page distinctly from an empty store", () => { + const result = formatMemoriesList( + makeResponse({ + pagination: { + currentPage: 3, + limit: 10, + totalItems: 12, + totalPages: 2, + }, + }), + ) + expect(result).toBe("No documents on page 3 (2 pages total).") + }) + + it("groups memories under their source document with title, type, and date", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Preferences", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("User prefers dark mode"), + makeEntry("User works in TypeScript"), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain( + "2 memories across 1 document (page 1 of 1, 1 documents total), newest first.", + ) + expect(result).toContain('"Preferences" (text, 2026-06-12)') + expect(result).toContain("- User prefers dark mode") + expect(result).toContain("- User works in TypeScript") + expect(result).not.toContain("More available") + }) + + it("excludes forgotten and superseded memory entries", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Facts", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("Current fact"), + makeEntry("Forgotten fact", { isForgotten: true }), + makeEntry("Old version of a fact", { isLatest: false }), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain("- Current fact") + expect(result).not.toContain("Forgotten fact") + expect(result).not.toContain("Old version of a fact") + expect(result).toContain("1 memory across 1 document") + }) + + it("marks documents whose extraction has not produced memories yet", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Still processing", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain( + '"Still processing" (text, 2026-06-12) - no extracted memories yet', + ) + }) + + it("falls back to (untitled) for documents without a title", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: null, + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [makeEntry("Some fact")], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain('"(untitled)" (text, 2026-06-12)') + }) + + it("flattens multi-line memories and truncates oversized ones", () => { + const longMemory = `start ${"x".repeat(600)}` + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Big", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("line one\nline two\ttabbed"), + makeEntry(longMemory), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain("- line one line two tabbed") + expect(result).toContain("... [truncated]") + const truncatedLine = result + .split("\n") + .find((line) => line.includes("[truncated]")) + expect(truncatedLine).toBeDefined() + expect((truncatedLine as string).length).toBeLessThan(600) + }) + + it("points at the next page when more documents exist", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Page one doc", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [makeEntry("A fact")], + }, + ], + pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 }, + }), + ) + + expect(result).toContain("page 1 of 3, 3 documents total") + expect(result).toContain("More available - call listMemories with page: 2.") + }) +}) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts new file mode 100644 index 00000000..3c116279 --- /dev/null +++ b/apps/mcp/src/server/format.ts @@ -0,0 +1,51 @@ +import type { DocumentsApiResponse } from "./client" + +// Listing must stay lightweight: memory entries are extracted facts, not raw +// document content, so responses fit client output limits at the max page size. +const MAX_LIST_MEMORY_CHARS = 500 + +export function formatMemoriesList(response: DocumentsApiResponse): string { + const { documents, pagination } = response + const day = (s: string | null | undefined) => s?.slice(0, 10) ?? "" + + if (documents.length === 0) { + return pagination.currentPage > 1 + ? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).` + : "No memories stored yet." + } + + let memoryCount = 0 + const blocks = documents.map((doc) => { + const activeEntries = doc.memoryEntries.filter( + (entry) => entry.isForgotten !== true && entry.isLatest !== false, + ) + const title = doc.title?.trim() || "(untitled)" + const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})` + + if (activeEntries.length === 0) { + return `${header} - no extracted memories yet` + } + + memoryCount += activeEntries.length + const lines = activeEntries.map((entry) => { + const text = entry.memory.replace(/\s+/g, " ").trim() + return `- ${ + text.length > MAX_LIST_MEMORY_CHARS + ? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} ... [truncated]` + : text + }` + }) + return [header, ...lines].join("\n") + }) + + const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.` + + const parts = [header, "", blocks.join("\n\n")] + if (pagination.currentPage < pagination.totalPages) { + parts.push( + "", + `More available - call listMemories with page: ${pagination.currentPage + 1}.`, + ) + } + return parts.join("\n") +} diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 6c510400..ddba1c11 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -2,6 +2,7 @@ import * as addMemory from "./add-memory" import * as fetchGraphData from "./fetch-graph-data" import * as guidedSave from "./guided-save" import * as listContainerTags from "./list-container-tags" +import * as listMemories from "./list-memories" import * as memoryGraph from "./memory-graph" import * as saveMemory from "./save-memory" import * as searchMemory from "./search-memory" @@ -15,6 +16,7 @@ import * as whoAmI from "./who-am-i" export function registerAllTools(deps: ToolDeps) { // Always available searchMemory.register(deps) + listMemories.register(deps) listContainerTags.register(deps) whoAmI.register(deps) selectWorkspace.register(deps) diff --git a/apps/mcp/src/server/tools/list-memories.ts b/apps/mcp/src/server/tools/list-memories.ts new file mode 100644 index 00000000..f53da574 --- /dev/null +++ b/apps/mcp/src/server/tools/list-memories.ts @@ -0,0 +1,75 @@ +import { z } from "zod" +import { formatMemoriesList } from "../format" +import type { ToolDeps } from "./types" + +export function register(deps: ToolDeps) { + const containerTagField: Record = deps.rbac + .hasRootContainerTag + ? {} + : { + containerTag: z + .string() + .max(128, "Container tag exceeds maximum length") + .optional(), + } + + const inputSchema = { + page: z + .number() + .int() + .min(1) + .optional() + .default(1) + .describe("Page number (1-based)"), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .default(10) + .describe( + "Documents per page; each document groups its extracted memories (default 10, max 50)", + ), + ...containerTagField, + } + + deps.server.registerTool( + "listMemories", + { + description: + "Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file. For finding memories relevant to a topic, use search_memory instead.", + inputSchema, + }, + async (rawArgs) => { + const args = rawArgs as { + page?: number + limit?: number + containerTag?: string + } + try { + if (args.containerTag && !deps.rbac.canRead(args.containerTag)) { + return deps.errorResult( + new Error( + `No read access to container tag '${args.containerTag}'.`, + ), + ) + } + const effectiveTag = await deps.resolveContainerTag(args.containerTag) + const client = deps.getClient(effectiveTag) + const containerTags = effectiveTag ? [effectiveTag] : undefined + const data = await client.getDocuments( + containerTags, + args.page ?? 1, + args.limit ?? 10, + ) + + return { + content: [{ type: "text" as const, text: formatMemoriesList(data) }], + } + } catch (error) { + return deps.errorResult(error) + } + }, + ) +} diff --git a/apps/mcp/src/widget/hooks/useHostContext.ts b/apps/mcp/src/widget/hooks/useHostContext.ts index 0aa06647..742be62a 100644 --- a/apps/mcp/src/widget/hooks/useHostContext.ts +++ b/apps/mcp/src/widget/hooks/useHostContext.ts @@ -16,7 +16,7 @@ export function useHostContext(): McpUiHostContext | null { app.onhostcontextchanged = handler return () => { if (app.onhostcontextchanged === handler) { - app.onhostcontextchanged = undefined + app.onhostcontextchanged = () => {} } } }, []) diff --git a/apps/mcp/src/widget/hooks/useViewState.ts b/apps/mcp/src/widget/hooks/useViewState.ts index 57d35c33..3e461cb0 100644 --- a/apps/mcp/src/widget/hooks/useViewState.ts +++ b/apps/mcp/src/widget/hooks/useViewState.ts @@ -75,10 +75,10 @@ export function useViewState(): { } return () => { - app.ontoolinput = undefined - app.ontoolinputpartial = undefined - app.ontoolcancelled = undefined - app.ontoolresult = undefined + app.ontoolinput = () => {} + app.ontoolinputpartial = () => {} + app.ontoolcancelled = () => {} + app.ontoolresult = () => {} app.onerror = undefined } }, []) diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts index 8289ccd9..b22e6386 100644 --- a/apps/mcp/vitest.config.ts +++ b/apps/mcp/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { - include: ["e2e/**/*.test.ts"], + include: ["e2e/**/*.test.ts", "src/**/*.test.ts"], testTimeout: 90_000, hookTimeout: 30_000, }, diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index c819d0f8..49718943 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,10 +1,12 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useQueryClient } from "@tanstack/react-query" import { useRouter, useSearchParams } from "next/navigation" import { toast } from "sonner" import { useAuth } from "@lib/auth-context" import { authClient } from "@lib/auth" +import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" import { analytics } from "@/lib/analytics" import { BrainShell } from "@/components/onboarding-brain/shell" import { @@ -16,6 +18,7 @@ import { type SourcesValues, } from "@/components/onboarding-brain/step-sources" import { StepIngest } from "@/components/onboarding-brain/step-ingest" +import { CompanyBrainOnboarding } from "@/components/onboarding-brain/company-brain-onboarding" import { useFeatureFlagEnabled } from "posthog-js/react" import { StepTeam, @@ -31,10 +34,14 @@ import { generateOrgSlug, generateUsername, workspaceDomainFromEmail, + workspaceNameFromDomain, workspaceNameFromEmail, + type CompanyBrainConfirmResult, } from "@/components/onboarding-brain/types" const STORAGE_KEY = "supermemory-brain-onboarding-v1" +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const countsAsConnectedSource = (state: unknown) => state === "connected" || state === "waitlist" @@ -52,6 +59,7 @@ const getErrorMessage = (error: unknown, fallback: string) => { export default function BrainOnboardingPage() { const router = useRouter() const params = useSearchParams() + const queryClient = useQueryClient() const { user, org, organizations, setActiveOrg, refetchOrganizations } = useAuth() @@ -167,13 +175,17 @@ export default function BrainOnboardingPage() { [router], ) + // Team/company-brain routes everything into the shared Team Brain that + // provisioning creates (sm_org_shared) — not a workspace-name slug space. const containerTag = useMemo( () => - containerTagFromWorkspace( - about.workspaceName || suggestedWorkspaceName, - mode, - ), - [about.workspaceName, suggestedWorkspaceName, mode], + allowTeam && mode === "team" + ? SHARED_TEAM_BRAIN_TAG + : containerTagFromWorkspace( + about.workspaceName || suggestedWorkspaceName, + mode, + ), + [allowTeam, about.workspaceName, suggestedWorkspaceName, mode], ) const isScale = useMemo(() => { @@ -230,65 +242,78 @@ export default function BrainOnboardingPage() { const [creatingOrg, setCreatingOrg] = useState(false) const creatingOrgRef = useRef(false) - const ensureOrg = useCallback(async () => { - if (!forceCreate && organizations && organizations.length > 0) return - const name = (about.workspaceName || suggestedWorkspaceName).trim() - const slug = generateOrgSlug(name) - const effectiveMode = allowTeam ? mode : "personal" - const metadata: BrainMetadata & { signupSource: string } = { - signupSource: "consumer", - brainOnboardingVersion: "v1", - brainMode: effectiveMode, - brainWorkspaceName: name, - brainWorkspaceDomain: - effectiveMode === "team" ? about.workspaceDomain || domain : null, - brainContainerTag: containerTag, - ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), - } - const result = await authClient.organization.create({ - name, - slug, - metadata, - }) - if (result.error || !result.data?.slug) { - throw new Error( - getErrorMessage(result.error, "Organization was not created."), - ) - } - await setActiveOrg(result.data.slug) - if (about.name.trim()) { - await authClient.updateUser({ - name: about.name.trim(), - displayUsername: about.name.trim(), - username: generateUsername(about.name), + const ensureOrg = useCallback( + async (domainOverride?: string): Promise => { + if (!forceCreate && organizations && organizations.length > 0) + return false + const name = ( + domainOverride + ? workspaceNameFromDomain(domainOverride) + : about.workspaceName || suggestedWorkspaceName + ).trim() + const slug = generateOrgSlug(name) + const effectiveMode = allowTeam ? mode : "personal" + const metadata: BrainMetadata & { signupSource: string } = { + signupSource: "consumer", + brainOnboardingVersion: "v1", + brainMode: effectiveMode, + brainWorkspaceName: name, + brainWorkspaceDomain: + effectiveMode === "team" + ? domainOverride || about.workspaceDomain || domain + : null, + brainContainerTag: containerTag, + ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), + } + const result = await authClient.organization.create({ + name, + slug, + metadata, }) - } - await refetchOrganizations() - analytics.onboardingWorkspaceCreated({ + if (result.error || !result.data?.slug) { + throw new Error( + getErrorMessage(result.error, "Organization was not created."), + ) + } + await setActiveOrg(result.data.slug) + if (about.name.trim()) { + await authClient.updateUser({ + name: about.name.trim(), + displayUsername: about.name.trim(), + username: generateUsername(about.name), + }) + } + await refetchOrganizations() + analytics.onboardingWorkspaceCreated({ + mode, + has_about: Boolean(about.about.trim()), + has_domain: Boolean( + mode === "team" && (about.workspaceDomain || domain), + ), + }) + // Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate. + if (forceCreate) { + const url = new URL(window.location.href) + url.searchParams.delete("new") + url.searchParams.delete("name") + router.replace(url.pathname + url.search, { scroll: false }) + } + return true + }, + [ + organizations, + about, + suggestedWorkspaceName, mode, - has_about: Boolean(about.about.trim()), - has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)), - }) - // Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate. - if (forceCreate) { - const url = new URL(window.location.href) - url.searchParams.delete("new") - url.searchParams.delete("name") - router.replace(url.pathname + url.search, { scroll: false }) - } - }, [ - organizations, - about, - suggestedWorkspaceName, - mode, - allowTeam, - domain, - containerTag, - setActiveOrg, - refetchOrganizations, - forceCreate, - router, - ]) + allowTeam, + domain, + containerTag, + setActiveOrg, + refetchOrganizations, + forceCreate, + router, + ], + ) const handleAboutContinue = useCallback(async () => { if (creatingOrgRef.current) return @@ -315,6 +340,73 @@ export default function BrainOnboardingPage() { } }, [ensureOrg, goNext, forceCreate, organizations, router]) + // Company Brain (team) onboarding is a single research surface, no stepper. + const isCompanyBrain = allowTeam && mode === "team" + + const handleBrainConfirm = useCallback( + async (confirmedDomain: string): Promise => { + if (creatingOrgRef.current) return { ok: false } + creatingOrgRef.current = true + setCreatingOrg(true) + try { + const workspaceName = workspaceNameFromDomain(confirmedDomain) + setAbout((a) => ({ + ...a, + workspaceDomain: confirmedDomain, + workspaceName: workspaceName || a.workspaceName, + })) + const orgCreated = await ensureOrg(confirmedDomain) + // Re-entering onboarding on an existing org ("Try onboarding") must + // kick research from the client. New orgs rely on the signup hook after + // provisioning — a duplicate /start races and can strand the DO task. + if (!orgCreated) { + const started = await fetch(`${BACKEND}/brain/research/start`, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + "X-App-Source": "nova", + }, + body: JSON.stringify({ domain: confirmedDomain }), + }) + .then((res) => res.ok) + .catch(() => false) + // Don't advance into the research phase if it never started, else the + // poller sits on empty state until timeout. + if (!started) { + toast.error("Couldn't start research", { + description: "Please try again in a moment.", + }) + return { ok: false } + } + } + queryClient.invalidateQueries({ queryKey: ["brain-research-status"] }) + // ensureOrg already fires this when it creates the org; only emit here + // for the existing-org path to avoid a duplicate event. + if (!orgCreated) { + analytics.onboardingWorkspaceCreated({ + mode: "team", + has_about: false, + has_domain: Boolean(confirmedDomain), + }) + } + return { ok: true, serverSchedulesResearch: orgCreated } + } catch (e) { + const message = getErrorMessage(e, "Organization was not created.") + console.error("Failed to create organization:", e) + analytics.onboardingWorkspaceCreateFailed({ error: message }) + toast.error("Organization was not created", { + description: "Please try again.", + }) + return { ok: false } + } finally { + creatingOrgRef.current = false + setCreatingOrg(false) + } + }, + [ensureOrg, queryClient], + ) + const [sendingInvites, setSendingInvites] = useState(false) const sendingInvitesRef = useRef(false) @@ -370,6 +462,19 @@ export default function BrainOnboardingPage() { const mcpUrl = "https://mcp.supermemory.ai/mcp" + if (isCompanyBrain) { + return ( + + ) + } + return ( ): boolean { @@ -276,7 +276,7 @@ export default function LoginPage() { const token = formData.get("token") as string const callbackURL = getCallbackURL() router.push( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, + `${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, ) } diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index 7753e6b5..e23b055c 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -13,29 +13,137 @@ function isValidUrl(urlString: string): boolean { } } +function isPrivateIPv4Octets(a: number, b: number): boolean { + // 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback), + // 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16 + if (a === 0) return true + if (a === 10) return true + if (a === 127) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true + return false +} + +// Parse an IPv6 hostname into its eight 16-bit groups. The WHATWG URL parser +// already canonicalizes literals, but it leaves the brackets on (e.g. "[::1]") +// and performs no SSRF filtering. Handles "::" compression and embedded IPv4 +// suffixes (e.g. ::ffff:1.2.3.4). Returns null when not an IPv6 literal. +function parseIPv6Groups(input: string): number[] | null { + let host = input + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1) + const zone = host.indexOf("%") // strip scope/zone id, e.g. fe80::1%eth0 + if (zone !== -1) host = host.slice(0, zone) + if (!host.includes(":")) return null + + // Convert an embedded IPv4 tail (e.g. ::ffff:1.2.3.4) into two hex groups. + const dot = host.indexOf(".") + if (dot !== -1) { + const colon = host.lastIndexOf(":", dot) + if (colon === -1) return null + const octets = host.slice(colon + 1).split(".") + if (octets.length !== 4) return null + const bytes: number[] = [] + for (const octet of octets) { + if (!/^\d{1,3}$/.test(octet)) return null + const n = Number(octet) + if (n > 255) return null + bytes.push(n) + } + const hi = (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)).toString(16) + const lo = (((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0)).toString(16) + host = `${host.slice(0, colon + 1)}${hi}:${lo}` + } + + const parseSide = (side: string): number[] | null => { + if (side === "") return [] + const groups: number[] = [] + for (const group of side.split(":")) { + if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null + groups.push(Number.parseInt(group, 16)) + } + return groups + } + + const halves = host.split("::") + if (halves.length > 2) return null + + if (halves.length === 2) { + const head = parseSide(halves[0] ?? "") + const tail = parseSide(halves[1] ?? "") + if (!head || !tail) return null + const missing = 8 - head.length - tail.length + if (missing < 1) return null + return [...head, ...new Array(missing).fill(0), ...tail] + } + + const all = parseSide(host) + if (all?.length !== 8) return null + return all +} + +function isPrivateIPv6(input: string): boolean { + const g = parseIPv6Groups(input) + if (!g) return false + + // Unspecified (::) and loopback (::1) + if (g.every((x) => x === 0)) return true + if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true + + // Link-local fe80::/10 + if (((g[0] ?? 0) & 0xffc0) === 0xfe80) return true + + // Unique local address fc00::/7 (fc00–fdff) + if ((((g[0] ?? 0) >> 8) & 0xfe) === 0xfc) return true + + // IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d): + // re-check the embedded IPv4 against the private ranges so loopback/metadata + // can't be reached via an IPv6 wrapper. + const firstFiveZero = g.slice(0, 5).every((x) => x === 0) + const ipv4Mapped = firstFiveZero && g[5] === 0xffff + const ipv4Compatible = + firstFiveZero && g[5] === 0 && (g[6] !== 0 || g[7] !== 0) + if (ipv4Mapped || ipv4Compatible) { + const a = ((g[6] ?? 0) >> 8) & 0xff + const b = (g[6] ?? 0) & 0xff + return isPrivateIPv4Octets(a, b) + } + + return false +} + +// NOTE: This blocks private/loopback/metadata IP *literals* only. It cannot stop +// DNS rebinding (a public hostname that resolves to a private address), which +// would require resolving + pinning the address before connecting — not available +// in the edge fetch runtime here. function isPrivateHost(hostname: string): boolean { const lowerHost = hostname.toLowerCase() - if ( - lowerHost === "localhost" || - lowerHost === "127.0.0.1" || - lowerHost === "0.0.0.0" || - lowerHost === "::1" || - lowerHost === "::" || - lowerHost.startsWith("127.") || - lowerHost.startsWith("0.0.0.0") - ) { + // Hostname-based loopback (RFC 6761 reserves localhost and *.localhost) + if (lowerHost === "localhost" || lowerHost.endsWith(".localhost")) { return true } - const privateIpPatterns = [ - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[01])\./, - /^192\.168\./, - /^169\.254\./, // Link-local / Metadata service - ] + // IPv6 literals arrive bracketed (e.g. "[::1]"), so the previous + // string-equality checks never matched them. + if (lowerHost.includes(":")) { + return isPrivateIPv6(lowerHost) + } - return privateIpPatterns.some((pattern) => pattern.test(hostname)) + // IPv4. The WHATWG URL parser canonicalizes alternate encodings + // (decimal/hex/octal, e.g. http://2130706433 -> 127.0.0.1) to dotted-quad, + // so matching the dotted form here also blocks those encodings. + const octets = lowerHost.split(".") + if ( + octets.length === 4 && + octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) + ) { + const [a, b] = octets.map(Number) as [number, number, number, number] + return isPrivateIPv4Octets(a, b) + } + + return false } // File extensions that are not HTML and can't be scraped for OG data diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 73cf4025..56339a08 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -1,7 +1,7 @@ "use client" import { $fetch } from "@lib/api" -import { hasActivePlan } from "@lib/queries" +import { useConnectorAccess } from "@/hooks/use-connector-access" import type { ConnectionResponseSchema } from "@repo/validation/api" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons" @@ -309,7 +309,7 @@ interface ConnectContentProps { export function ConnectContent({ selectedProject }: ConnectContentProps) { const queryClient = useQueryClient() const autumn = useCustomer() - const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro") + const { connectorAccess } = useConnectorAccess() const [connectingProvider, setConnectingProvider] = useState(null) const [granolaModalOpen, setGranolaModalOpen] = useState(false) @@ -398,7 +398,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { provider: ConnectorProvider syncScope?: GDriveSyncScope }) => { - if (!canAddConnection && !isProUser) { + if (!canAddConnection && !connectorAccess) { throw new Error( "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", ) @@ -557,7 +557,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { type="button" onClick={() => handleConnect("google-drive")} disabled={ - !isProUser || + !connectorAccess || isConnecting || addConnectionMutation.isPending } @@ -605,14 +605,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { ) : provider === "granola" ? ( <> - {!isProUser && ( + {!connectorAccess && ( Pro )} + ) : undefined, + }, + setupDone < 3 + ? { label: "Setup", value: `${setupDone}/3` } + : { + label: "Last updated", + value: formatWhen(lastUpdatedAt) || "—", + }, ] return (
{tiles.map((t) => (
-

- {t.label} -

+
+

+ {t.label} +

+ {t.action} +

= { + linear: "What's blocking the sprint?", + github: "Summarize the open PRs on auth", + sentry: "Any new errors since the deploy?", + notion: "Find our launch checklist", + posthog: "How's activation trending this week?", + plain: "What are customers asking about?", + granola: "Recap yesterday's standup", +} + +function titleCase(s: string) { + return s.replace(/\b\w/g, (c) => c.toUpperCase()) +} export function ConnectionsBoard() { - const [brainRows, setBrainRows] = useState(null) + const [catalog, setCatalog] = useState(null) + const [rows, setRows] = useState([]) const [slack, setSlack] = useState<{ connected: boolean teamName: string | null } | null>(null) const [busy, setBusy] = useState(null) - const loadBrain = useCallback(async () => { + const load = useCallback(async () => { try { - const [c, s] = await Promise.all([ - fetch(`${BACKEND}/brain/connections`, { credentials: "include" }), + const [cat, conn, s] = await Promise.all([ + fetch(`${MCP_BASE}/catalog`, { credentials: "include" }), + fetch(`${MCP_BASE}/`, { credentials: "include" }), fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }), ]) - if (c.ok) - setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits) - if (s.ok) setSlack(await s.json()) - } catch {} + // Parse each response independently so one bad payload can't strand the others. + try { + if (cat.ok) { + const data: { catalog?: CatalogEntry[] } = await cat.json() + setCatalog(data.catalog ?? []) + } else setCatalog([]) + } catch { + setCatalog([]) + } + try { + if (conn.ok) { + const data: { connections?: ConnRow[] } = await conn.json() + setRows(data.connections ?? []) + } else setRows([]) + } catch { + setRows([]) + } + try { + if (s.ok) setSlack(await s.json()) + } catch {} + } catch { + setCatalog([]) + setRows([]) + } }, []) useEffect(() => { - void loadBrain() - const onFocus = () => void loadBrain() + void load() + const onFocus = () => void load() window.addEventListener("focus", onFocus) return () => window.removeEventListener("focus", onFocus) - }, [loadBrain]) + }, [load]) - const { data: connectors } = useQuery({ - queryKey: ["brain-home", "connectors"], - queryFn: async () => { - const res = await $fetch("@post/connections/list", { - body: { containerTags: [] }, - }) - if (res.error) return [] as Array<{ provider?: string }> - return (res.data ?? []) as Array<{ provider?: string }> - }, - staleTime: 30_000, - }) + const isConnected = (slug: string) => + rows.some((r) => r.serverSlug === slug && r.status === "active") - const connectorConnected = (provider: string) => - Boolean(connectors?.some((c) => c.provider === provider)) - - const connectBrain = async (toolkit: string) => { - setBusy(`brain:${toolkit}`) + const connect = async (entry: CatalogEntry) => { + setBusy(entry.slug) try { - const res = await fetch( - `${BACKEND}/brain/connections/${toolkit}/link?scope=user`, - { method: "POST", credentials: "include" }, - ) + if (entry.authType === "static") { + const token = window.prompt( + `Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`, + ) + if (!token) return + const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token, shared: false }), + }) + if (!res.ok) { + toast.error("Couldn't connect.") + return + } + toast.success(`${entry.name} connected.`) + await load() + return + } + const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + shared: false, + redirectUrl: window.location.href, + }), + }) if (!res.ok) { toast.error("Couldn't start the connection.") return } - const data = (await res.json()) as { url?: string } - if (data.url) window.open(data.url, "_blank", "noopener") - else toast.error("Couldn't start the connection.") + const data: { authUrl?: string; ok?: boolean } = await res.json() + if (data.authUrl) window.open(data.authUrl, "_blank", "noopener") + else if (data.ok) { + toast.success(`${entry.name} connected.`) + await load() + } else toast.error("Couldn't start the connection.") } catch { toast.error("Couldn't start the connection.") } finally { @@ -87,99 +155,259 @@ export function ConnectionsBoard() { } } - const connectConnector = async ( - provider: "google-drive" | "notion" | "onedrive", - ) => { - setBusy(`conn:${provider}`) - try { - const res = await $fetch("@post/connections/:provider", { - params: { provider }, - body: { redirectUrl: window.location.href, containerTags: [] }, - }) - const data = res.data as { authLink?: string } | undefined - if (data?.authLink) window.location.href = data.authLink - else toast.error("Couldn't start the connection.") - } catch { - toast.error("Couldn't start the connection.") - } finally { - setBusy(null) - } - } - - const brainConnected = (toolkit: string) => - Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.org) || - Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.user) + const { openSettings } = useSettingsModal() + const apps = catalog ?? [] + const loading = catalog === null + const featured = FEATURED_SLUGS.map((slug) => + apps.find((a) => a.slug === slug), + ).filter((a): a is CatalogEntry => Boolean(a)) + const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) => + apps.find((a) => a.slug === slug), + ).filter((a): a is CatalogEntry => Boolean(a)) + const remainingCount = Math.max(apps.length - featured.length, 0) + const connectedCount = apps.filter((a) => isConnected(a.slug)).length return (

{slack && !slack.connected && } -
- +
- } - name="GitHub" - subtitle="Repos, pull requests and issues." - connected={brainConnected("github")} - busy={busy === "brain:github"} - onConnect={() => connectBrain("github")} - /> - } - name="Linear" - subtitle="Issues, projects and cycles." - connected={brainConnected("linear")} - busy={busy === "brain:linear"} - onConnect={() => connectBrain("linear")} - /> - - - +

- All connectors - - - } - > - } - name="Google Drive" - subtitle="Docs, sheets and slides." - connected={connectorConnected("google-drive")} - busy={busy === "conn:google-drive"} - onConnect={() => connectConnector("google-drive")} - /> - } - name="Notion" - subtitle="Pages, databases and blocks." - connected={connectorConnected("notion")} - busy={busy === "conn:notion"} - onConnect={() => connectConnector("notion")} - /> - } - name="OneDrive" - subtitle="Files from Microsoft 365." - connected={connectorConnected("onedrive")} - busy={busy === "conn:onedrive"} - onConnect={() => connectConnector("onedrive")} - /> - + Connect your tools +

+

+ Give your Slack agent live access to the apps your team already + uses. +

+
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + )) + ) : ( + <> + {featured.map((entry, i) => ( + connect(entry)} + showDivider={i < featured.length - 1 || remainingCount > 0} + /> + ))} + {remainingCount > 0 && ( + openSettings("company-brain")} + /> + )} + + )} +
+
+ + ) } +function AgentPreview({ + apps, + isConnected, + connectedCount, +}: { + apps: CatalogEntry[] + isConnected: (slug: string) => boolean + connectedCount: number +}) { + const prompts = apps + .filter((a) => AGENT_PROMPTS[a.slug]) + .slice(0, 6) + .map((a) => ({ + slug: a.slug, + name: a.name, + prompt: AGENT_PROMPTS[a.slug], + connected: isConnected(a.slug), + })) + + return ( +
+
+ +

+ Ask in Slack +

+
+

+ {connectedCount > 0 + ? "Things your agent can answer now:" + : "Connect a tool and your agent can answer:"} +

+ +
+ {prompts.map((p, i) => ( +
+
+ {brainConnectorIcon(p.slug, p.name, "size-4")} +
+

+ "{p.prompt}" +

+
+ ))} +
+
+ ) +} + +function AppTile({ + icon, + name, + subtitle, + connected, + busy, + onConnect, + showDivider = false, +}: { + icon: React.ReactNode + name: string + subtitle: string + connected: boolean + busy: boolean + onConnect: () => void + showDivider?: boolean +}) { + return ( +
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {connected ? ( + + + Connected + + ) : ( + + )} +
+ ) +} + +function MoreTile({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + ) +} + +function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) { + return ( +
+
+
+
+
+
+
+
+ ) +} + function SlackBanner() { return (
) } - -function Group({ - title, - subtitle, - accent, - cta, - children, -}: { - title: string - subtitle: string - accent?: boolean - cta?: React.ReactNode - children: React.ReactNode -}) { - return ( -
- {accent && ( -
- )} -
-
-

- {title} -

-

- {subtitle} -

-
- {cta} -
- {children} -
- ) -} - -function AppCard({ - icon, - name, - subtitle, - connected, - busy, - onConnect, -}: { - icon: React.ReactNode - name: string - subtitle: string - connected: boolean - busy: boolean - onConnect: () => void -}) { - return ( -
-
- {icon} -
-
-

- {name} -

-

- {subtitle} -

-
- {connected ? ( - - - Connected - - ) : ( - - )} -
- ) -} - -function GithubMark({ className }: { className?: string }) { - return ( - - GitHub - - - ) -} - -function LinearMark({ className }: { className?: string }) { - return ( - - Linear - - - ) -} - -function SlackMark({ className }: { className?: string }) { - return ( - - ) -} diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index aae5920b..c78df7f7 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -1111,7 +1111,7 @@ export function ChatSidebar({ const params = new URLSearchParams({ projectId: chatProject }) if (historyScope === "all") params.set("scope", "all") const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`, + `${chatApiBase}/chat/threads?${params.toString()}`, { credentials: "include" }, ) if (response.ok) { @@ -1123,7 +1123,7 @@ export function ChatSidebar({ } finally { setIsLoadingThreads(false) } - }, [chatProject, historyScope]) + }, [chatApiBase, chatProject, historyScope]) useEffect(() => { if (!isHistoryOpen) return @@ -1134,10 +1134,9 @@ export function ChatSidebar({ const loadThread = useCallback( async (id: string) => { try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`, - { credentials: "include" }, - ) + const response = await fetch(`${chatApiBase}/chat/threads/${id}`, { + credentials: "include", + }) if (response.ok) { const data = await response.json() const uiMessages = data.messages.map( @@ -1179,7 +1178,7 @@ export function ChatSidebar({ console.error("Failed to load thread:", error) } }, - [setThreadId], + [chatApiBase, setThreadId], ) // Auto-restore thread from URL on mount (e.g. reload or direct link) @@ -1197,7 +1196,7 @@ export function ChatSidebar({ async (threadId: string) => { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, + `${chatApiBase}/chat/threads/${threadId}`, { method: "DELETE", credentials: "include" }, ) if (response.ok) { @@ -1213,7 +1212,7 @@ export function ChatSidebar({ setConfirmingDeleteId(null) } }, - [currentChatId, handleNewChat], + [chatApiBase, currentChatId, handleNewChat], ) const formatRelativeTime = (isoString: string): string => { diff --git a/apps/web/components/company-brain-header.tsx b/apps/web/components/company-brain-header.tsx new file mode 100644 index 00000000..41ccb1c4 --- /dev/null +++ b/apps/web/components/company-brain-header.tsx @@ -0,0 +1,604 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { Button } from "@ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@ui/components/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip" +import { useIsMobile } from "@hooks/use-mobile" +import { useQuery } from "@tanstack/react-query" +import { + Building2, + Code2, + ExternalLink, + Home, + LifeBuoy, + Link2, + LayoutGrid, + MenuIcon, + SearchIcon, + Settings, + UserPlus, + ChevronRight, + Sun, +} from "lucide-react" +import Link from "next/link" +import { useQueryState } from "nuqs" +import { useCallback } from "react" +import { DomainLogo } from "@/components/onboarding-brain/step-about" +import { FeedbackModal } from "@/components/feedback-modal" +import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" +import { SlackMark } from "@/components/brain-connector-icons" +import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons" +import { SpaceSelector } from "@/components/space-selector" +import { UserProfileMenu } from "@/components/user-profile-menu" +import { useTokenUsage } from "@/hooks/use-token-usage" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { getBrainWorkspaceDomain } from "@/lib/billing-utils" +import { dmSansClassName } from "@/lib/fonts" +import { useViewMode } from "@/lib/view-mode-context" +import { feedbackParam } from "@/lib/search-params" +import { useSettingsModal } from "@/components/settings/settings-modal" +import { useProject } from "@/stores" +import { useCustomer } from "autumn-js/react" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +type SlackStatus = { connected: boolean; teamName: string | null } + +interface CompanyBrainHeaderProps { + onOpenSearch?: () => void +} + +const brainItemClass = (active: boolean) => + cn( + "gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors", + "hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white", + active ? "bg-white/[0.06] text-white" : "text-white/85", + ) + +const brainTileClass = (active: boolean) => + cn( + "flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold", + active + ? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]" + : "border-white/[0.08] bg-white/[0.04] text-white/70", + ) + +const menuItemClass = + "gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" + +const circleNavClass = (active: boolean) => + cn( + "flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors", + active + ? "border-[#2261CA33] bg-[#00173C] text-white" + : "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5", + dmSansClassName(), + ) + +const tabClass = (active: boolean) => + cn( + "inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm", + active + ? "border-[#2261CA33] bg-[#00173C] text-white" + : "text-foreground hover:bg-white/5", + dmSansClassName(), + ) + +function useSlackStatus() { + return useQuery({ + queryKey: ["brain-slack-status"], + queryFn: async (): Promise => { + const res = await fetch(`${BACKEND}/brain/slack/status`, { + credentials: "include", + }) + if (!res.ok) return { connected: false, teamName: null } + return (await res.json()) as SlackStatus + }, + staleTime: 30_000, + }) +} + +export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { + const { user, org, organizations, setActiveOrg } = useAuth() + const autumn = useCustomer() + const { currentPlan } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + const { viewMode, setViewMode } = useViewMode() + const { selectedProjects, setSelectedProjects } = useProject() + const { openSettings } = useSettingsModal() + const isMobile = useIsMobile() + const [feedbackOpen, setFeedbackOpen] = useQueryState( + "feedback", + feedbackParam, + ) + const [, setInvite] = useQueryState("invite") + const [settingsTab] = useQueryState("settings") + const { data: slackStatus } = useSlackStatus() + + const planByOrgId = new Map( + (orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const), + ) + + const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim() + const brandLabel = orgLabel || "Workspace" + const domain = getBrainWorkspaceDomain( + org?.metadata as Record | string | null | undefined, + ) + const hasOrgs = (organizations?.length ?? 0) > 0 + + const memberRole = org?.members + ?.find((m) => m.userId === user?.id) + ?.role?.toLowerCase() + const canInvite = memberRole === "owner" || memberRole === "admin" + + const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain" + const isGraph = viewMode === "graph" + const isMemories = viewMode === "list" + const isConnections = settingsTab === "company-brain" + const slackConnected = slackStatus?.connected ?? false + + const selectOrg = useCallback( + (slug: string, isActive: boolean) => { + if (isActive) return + void setActiveOrg(slug).then(() => window.location.reload()) + }, + [setActiveOrg], + ) + + const goOverview = useCallback(() => { + void setViewMode("dashboard") + }, [setViewMode]) + + const goGraph = useCallback(() => { + void setViewMode("graph") + }, [setViewMode]) + + const goMemories = useCallback(() => { + void setViewMode("list") + }, [setViewMode]) + + const goConnections = useCallback(() => { + openSettings("company-brain") + }, [openSettings]) + + const goIntegrations = useCallback(() => { + void setViewMode("integrations") + }, [setViewMode]) + + const handleInvite = useCallback(() => { + setInvite("1") + openSettings("account") + }, [openSettings, setInvite]) + + const handleFeedback = useCallback(() => { + void setFeedbackOpen(true) + }, [setFeedbackOpen]) + + return ( +
+
+ + + + + + {hasOrgs && ( + <> +

+ Switch brain +

+
+ {organizations?.map((o) => { + const active = org?.id === o.id + const plan = resolveOrgPlan( + o.id, + active, + currentPlan, + planByOrgId, + ) + return ( + selectOrg(o.slug, active)} + className={cn(brainItemClass(active))} + > + + {o.name?.trim().charAt(0).toUpperCase() || "?"} + + {o.name} + + + ) + })} +
+ + + )} + + + + Home + + + + + Connections + + + + Integrations + + openSettings()} + className={menuItemClass} + > + + Settings + + + + + + Developer console + + + + + + supermemory.ai + + +
+
+ {!isMobile && ( + <> + + + + )} +
+ + {!isMobile && ( +
+ + + + + + Overview + + +
+ + + +
+ +
+ )} + +
+ {isMobile ? ( + <> + + + + + + + + + Overview + + + + Graph + + + + Memories + + + + Connections + + + + Integrations + + {slackConnected ? ( + + + Slack connected + + ) : ( + + + + Add to Slack + + + )} + + {canInvite && ( + + + Invite teammates + + )} + + + Search + + + + + Feedback + + openSettings()} + className={menuItemClass} + > + + Settings + + + + + ) : ( + <> + {canInvite && ( + + + + + + Invite teammates + + + )} + + + + + + Search (⌘K) + + + + )} + +
+ + setFeedbackOpen(false)} + /> +
+ ) +} + +function SlackNavButton({ + connected, + teamName, + active, + onManage, +}: { + connected: boolean + teamName: string | null + active: boolean + onManage: () => void +}) { + const label = connected + ? `Slack${teamName ? ` · ${teamName}` : ""}` + : "Add to Slack" + + if (!connected) { + return ( + + + + + + + + Add to Slack + + + ) + } + + return ( + + + + + + {label} + + + ) +} diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index b24e93fa..37355282 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -43,6 +43,7 @@ import { import { normalizePluginClientId } from "@/lib/plugin-catalog" import { detectPluginSpace } from "@/lib/plugin-space" import { useDigests } from "@/hooks/use-digests" +import { ReviewMemoriesCard } from "@/components/review-memories-card" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -1181,6 +1182,7 @@ export function DashboardView({ }) { const { user, org } = useAuth() const { effectiveContainerTags } = useProject() + const primaryContainerTag = effectiveContainerTags?.[0] const _router = useRouter() const { data: recentsData, isPending: isRecentsLoading } = useQuery({ queryKey: ["dashboard-recents", effectiveContainerTags], @@ -1600,7 +1602,8 @@ export function DashboardView({ )}
-
+
+ Suggested for you

-
+
+ } - if (url?.includes("youtube.com") || url?.includes("youtu.be")) { + if (isYouTubeUrl(url)) { return } diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index 8e1c9a11..2232d159 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -3,7 +3,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import dynamic from "next/dynamic" -import { isTwitterUrl } from "@/lib/url-helpers" +import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers" import { ImagePreview } from "./image-preview" import { TweetContent } from "./tweet" import { NotionDoc } from "./notion-doc" @@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType { if (document.type === "google_doc") return "google_doc" if (document.type === "google_sheet") return "google_sheet" if (document.type === "google_slide") return "google_slide" - if (document.url?.includes("youtube.com")) return "youtube" + if (isYouTubeUrl(document.url)) return "youtube" if (document.type === "webpage") return "webpage" return null diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 3d977209..c30f84c4 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -44,6 +44,8 @@ import { useTokenUsage } from "@/hooks/use-token-usage" import { useOrgSummaries } from "@/hooks/use-org-summaries" import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" import { useSettingsModal } from "@/components/settings/settings-modal" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { CompanyBrainHeader } from "@/components/company-brain-header" interface HeaderProps { onAddMemory?: () => void @@ -65,7 +67,15 @@ const brainTileClass = (active: boolean) => : "border-white/[0.08] bg-white/[0.04] text-white/70", ) -export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { +export function Header(props: HeaderProps) { + const hasCompanyBrain = useHasCompanyBrain() + if (hasCompanyBrain) { + return + } + return +} + +function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) { const { user, isRestoring, org, organizations, setActiveOrg } = useAuth() const autumn = useCustomer() const { currentPlan } = useTokenUsage(autumn) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 9263bbe5..ed04a456 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" -import { hasActivePlan } from "@lib/queries" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" @@ -44,6 +43,7 @@ import { Zap, } from "lucide-react" import { formatRelativeTime } from "@/components/settings/sync-utils" +import { useConnectorAccess } from "@/hooks/use-connector-access" import { useConnectionHealth } from "@/hooks/use-connection-health" import { useContainerTags } from "@/hooks/use-container-tags" import { DEFAULT_PROJECT_ID } from "@lib/constants" @@ -2555,8 +2555,11 @@ export function IntegrationsView({ const { allProjects } = useContainerTags() const shortcutsConnect = useShortcutsConnect() const autumn = useCustomer({ queryOptions: { enabled: !publicMode } }) - const hasProProduct = - !publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro") + // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins + // stay on hasProProduct. See useConnectorAccess. + const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({ + enabled: !publicMode, + }) const isAutumnLoading = !publicMode && autumn.isLoading const [connectingPlugin, setConnectingPlugin] = useState(null) @@ -2601,7 +2604,7 @@ export function IntegrationsView({ return response.data as Connection[] }, staleTime: 30 * 1000, - enabled: !publicMode && hasProProduct, + enabled: !publicMode && connectorAccess, }) const { @@ -2915,7 +2918,7 @@ export function IntegrationsView({ } if (target === "granola") { - if (!hasProProduct) { + if (!connectorAccess) { void setConnectTarget(null) handleUpgrade("api_pro") } else { @@ -2939,6 +2942,7 @@ export function IntegrationsView({ connectTarget, isAutumnLoading, hasProProduct, + connectorAccess, publicMode, redirectToLogin, setConnectTarget, @@ -3397,7 +3401,7 @@ export function IntegrationsView({ case "connector": { const count = connectionsByProvider[item.provider].length const isGranola = item.provider === "granola" - const needsPlanUpgrade = !isAutumnLoading && !hasProProduct + const needsPlanUpgrade = !isAutumnLoading && !connectorAccess if (count > 0) { return (
@@ -3408,7 +3412,7 @@ export function IntegrationsView({ onClick={() => { trackCard(item) if (isGranola) { - if (!hasProProduct) { + if (!connectorAccess) { handleUpgrade("api_pro") return } @@ -3440,7 +3444,7 @@ export function IntegrationsView({ onClick={() => { trackCard(item) if (isGranola) { - if (!hasProProduct) { + if (!connectorAccess) { handleUpgrade("api_pro") return } @@ -4260,9 +4264,9 @@ export function IntegrationsView({ { - setGranolaModalOpen(open && hasProProduct) + setGranolaModalOpen(open && connectorAccess) if (!open) void setConnectTarget(null) }} /> diff --git a/apps/web/components/integrations/install-steps.tsx b/apps/web/components/integrations/install-steps.tsx index 4616b07c..de2aaa50 100644 --- a/apps/web/components/integrations/install-steps.tsx +++ b/apps/web/components/integrations/install-steps.tsx @@ -15,14 +15,16 @@ export function PillButton({ children, onClick, disabled, + type = "button", }: { children: ReactNode onClick?: () => void disabled?: boolean + type?: "button" | "submit" }) { return ( +
+ )} + + + {phase === "research" && ( + +
+
+ +
+ + + +
+
+ )} +
+ +
+ ) +} + +function ConfirmBody({ + firstName, + name, + avatarUrl, + domain, + onDomainChange, + onConfirm, + submitting, +}: { + firstName: string + name: string + avatarUrl: string | null + domain: string + onDomainChange: (v: string) => void + onConfirm: () => void + submitting: boolean +}) { + return ( + <> +
+ +
+

+ {firstName ? `Hey ${firstName} 👋` : "Hey there 👋"} +

+

+ I'll research your company and set up its Brain. +

+
+
+ +
+

Company domain

+
+
+ +
+ onDomainChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !submitting) onConfirm() + }} + placeholder="your-team.com" + spellCheck={false} + autoComplete="off" + className={cn(inputClass, "pl-14")} + style={inputBevelStyle} + /> +
+
+ + ) +} + +function DockedHeader({ + domain, + done, + onContinue, +}: { + domain: string + done: boolean + onContinue: () => void +}) { + const brandName = workspaceNameFromDomain(domain) || domain + return ( +
+
+ +
+ + {brandName} + + + {done ? "Company Brain ready" : "Building your Company Brain…"} + + {done ? ( + + ) : ( + + )} +
+ ) +} + +function ResearchTranscript() { + const { status, events } = useResearchStatus() + const running = status !== "done" + const scrollRef = useRef(null) + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events + useEffect(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }) + }, [events.length]) + + return ( +
+
+
+ +
+ {/* Top-only overlay in the card color: text slides up under a solid edge. */} +
+
+
+ ) +} + +function Timeline({ + events, + running, +}: { + events: ResearchEvent[] + running: boolean +}) { + if (events.length === 0) { + return ( +
+ + Starting deep research… +
+ ) + } + + return ( +
    + + {events.map((e, i) => { + const isLast = i === events.length - 1 + const active = running && e.status === "in_progress" + return ( + +
    + + {!isLast && ( + + )} +
    +
    + + {e.label} + + {e.detail && e.status !== "in_progress" && ( + + + + )} + {e.status === "complete" && e.stats.length > 0 && ( + + )} + {e.status === "complete" && e.highlights.length > 0 && ( + + )} + {e.status === "complete" && e.sources.length > 0 && ( + + )} + {active && } +
    +
    + ) + })} +
    +
+ ) +} + +// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them +// as compact superscript links and collapse any leftover `[n]` bare markers. +const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g + +function CitedText({ text }: { text: string }) { + const nodes: ReactNode[] = [] + let last = 0 + let m: RegExpExecArray | null + CITE_RE.lastIndex = 0 + // biome-ignore lint/suspicious/noAssignInExpressions: regex walk + while ((m = CITE_RE.exec(text)) !== null) { + if (m.index > last) { + nodes.push(cleanBareCites(text.slice(last, m.index))) + } + nodes.push( + + {m[1]} + , + ) + last = m.index + m[0].length + } + if (last < text.length) nodes.push(cleanBareCites(text.slice(last))) + return <>{nodes} +} + +function cleanBareCites(s: string): string { + return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ") +} + +const THINKING_PHRASES = [ + "Searching the web…", + "Reading sources…", + "Cross-checking facts…", + "Summarizing findings…", +] + +function ThinkingLine() { + const [i, setI] = useState(0) + useEffect(() => { + const t = setInterval( + () => setI((v) => (v + 1) % THINKING_PHRASES.length), + 1600, + ) + return () => clearInterval(t) + }, []) + return ( + + {THINKING_PHRASES[i]} + + ) +} + +// Boxless stat strip: values over tiny labels, split by hairline dividers. +function StatStrip({ stats }: { stats: ResearchStat[] }) { + const shown = stats.slice(0, 3) + if (!shown.length) return null + return ( + + {shown.map((s, i) => ( +
0 && "border-l border-[rgba(82,89,102,0.2)] pl-5", + )} + > + + {s.value} + + + {s.label} + +
+ ))} +
+ ) +} + +// Quiet outline chips, short entities only (no sentences). +function Chips({ items }: { items: string[] }) { + const shown = items.filter((t) => t.length <= 42).slice(0, 4) + if (!shown.length) return null + return ( + + {shown.map((t) => ( + + {t} + + ))} + + ) +} + +function hostname(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, "") + } catch { + return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "") + } +} + +function SourceFavicon({ host }: { host: string }) { + const sources = [ + `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`, + `https://icons.duckduckgo.com/ip3/${host}.ico`, + ] + const [idx, setIdx] = useState(0) + if (idx >= sources.length) { + return + } + return ( + setIdx((i) => i + 1)} + /> + ) +} + +// Minimal, deduped source row: favicon + muted domain, no boxes. +function SourceChips({ urls }: { urls: string[] }) { + const seen = new Set() + const hosts: { host: string; url: string }[] = [] + for (const url of urls) { + const host = hostname(url) + if (seen.has(host)) continue + seen.add(host) + hosts.push({ host, url }) + } + const shown = hosts.slice(0, 4) + const extra = hosts.length - shown.length + if (!shown.length) return null + return ( + + + Sources + + {shown.map(({ host, url }) => ( + + + + + {host} + + ))} + {extra > 0 && ( + +{extra} + )} + + ) +} + +// All dots render in a 14px box so their centers align regardless of shape. +function EventDot({ status, active }: { status: string; active: boolean }) { + if (active) { + return + } + if (status === "complete") { + return ( + + + + ) + } + return ( + + + + ) +} + +function Backdrop() { + return ( + <> +
+
+ + ) +} diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx new file mode 100644 index 00000000..9033d5e1 --- /dev/null +++ b/apps/web/components/onboarding-brain/research-action-rail.tsx @@ -0,0 +1,777 @@ +"use client" + +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { Button } from "@ui/components/button" +import { Input } from "@ui/components/input" +import { AnimatePresence, motion } from "motion/react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ArrowRight, Check, Loader2, Mail, Plus, Trash2 } from "lucide-react" +import { toast } from "sonner" +import { + brainConnectorIcon, + SlackMark, +} from "@/components/brain-connector-icons" +import { useSettingsModal } from "@/components/settings/settings-modal" +import { useResearchStatus } from "@/hooks/use-research-status" +import { dmSans125ClassName } from "@/lib/fonts" +import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +const MCP_BASE = `${BACKEND}/brain/mcp-connections` + +const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const +const SETUP_STEPS = ["slack", "apps", "invite"] as const +type SetupStepId = (typeof SETUP_STEPS)[number] + +const ROTATE_MS = 35_000 +const REVEAL_DELAY_MS = 1_800 + +const tileStyle = { + boxShadow: + "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", +} + +const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi + +type AuthType = "oauth" | "static" | "none" +type CatalogEntry = { + slug: string + name: string + category: string + authType: AuthType + tokenHint?: string +} +type ConnRow = { + serverSlug: string + status: "active" | "pending" | "error" + userId: string | null +} + +export type ParallelSetupStats = { + slackConnected: boolean + appsConnected: number + invitesSent: number +} + +const STEP_META: Record = { + slack: { + label: "Add to Slack", + hint: "Ask your company brain in-channel.", + }, + apps: { + label: "Connect apps", + hint: "", + }, + invite: { + label: "Invite teammates", + hint: "Multiply what the brain remembers.", + }, +} + +function titleCase(s: string) { + return s.replace(/\b\w/g, (c) => c.toUpperCase()) +} + +export function ResearchActionRail({ + domain, + onStatsChange, +}: { + domain: string + onStatsChange?: (stats: ParallelSetupStats) => void +}) { + const { org } = useAuth() + const { openSettings } = useSettingsModal() + const { events } = useResearchStatus() + const [catalog, setCatalog] = useState(null) + const [rows, setRows] = useState([]) + const [slack, setSlack] = useState<{ + connected: boolean + teamName: string | null + } | null>(null) + const [busy, setBusy] = useState(null) + const [invites, setInvites] = useState<{ email: string }[]>([]) + const [draft, setDraft] = useState("") + const [invitesSent, setInvitesSent] = useState(0) + const [sendingInvites, setSendingInvites] = useState(false) + const [spotlight, setSpotlight] = useState("slack") + const [rotationPaused, setRotationPaused] = useState(false) + const [minDelayPassed, setMinDelayPassed] = useState(false) + const pauseTimerRef = useRef | null>(null) + + const domainOrFallback = (domain || "your-team.com").trim().toLowerCase() + + const load = useCallback(async () => { + try { + const [cat, conn, s] = await Promise.all([ + fetch(`${MCP_BASE}/catalog`, { credentials: "include" }), + fetch(`${MCP_BASE}/`, { credentials: "include" }), + fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }), + ]) + try { + if (cat.ok) { + const data: { catalog?: CatalogEntry[] } = await cat.json() + setCatalog(data.catalog ?? []) + } else setCatalog([]) + } catch { + setCatalog([]) + } + try { + if (conn.ok) { + const data: { connections?: ConnRow[] } = await conn.json() + setRows(data.connections ?? []) + } else setRows([]) + } catch { + setRows([]) + } + try { + if (s.ok) setSlack(await s.json()) + } catch {} + } catch { + setCatalog([]) + setRows([]) + } + }, []) + + useEffect(() => { + void load() + const onFocus = () => void load() + window.addEventListener("focus", onFocus) + return () => window.removeEventListener("focus", onFocus) + }, [load]) + + useEffect(() => { + const t = window.setTimeout(() => setMinDelayPassed(true), REVEAL_DELAY_MS) + return () => window.clearTimeout(t) + }, []) + + const isConnected = useCallback( + (slug: string) => + rows.some((r) => r.serverSlug === slug && r.status === "active"), + [rows], + ) + + const apps = catalog ?? [] + const featured = FEATURED_SLUGS.map((slug) => + apps.find((a) => a.slug === slug), + ).filter((a): a is CatalogEntry => Boolean(a)) + const appsConnected = apps.filter((a) => isConnected(a.slug)).length + const slackConnected = slack?.connected ?? false + + const stepDone = useCallback( + (id: SetupStepId) => { + if (id === "slack") return slackConnected + if (id === "apps") return appsConnected > 0 + return invitesSent > 0 + }, + [slackConnected, appsConnected, invitesSent], + ) + + const incompleteSteps = useMemo( + () => SETUP_STEPS.filter((id) => !stepDone(id)), + [stepDone], + ) + + const pauseRotation = useCallback((ms = 60_000) => { + setRotationPaused(true) + if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current) + pauseTimerRef.current = setTimeout(() => setRotationPaused(false), ms) + }, []) + + useEffect(() => { + return () => { + if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current) + } + }, []) + + useEffect(() => { + if (rotationPaused || incompleteSteps.length === 0) return + if (!incompleteSteps.includes(spotlight)) { + setSpotlight(incompleteSteps[0] ?? "slack") + } + const timer = window.setInterval(() => { + setSpotlight((prev) => { + const pool = SETUP_STEPS.filter((id) => !stepDone(id)) + if (pool.length === 0) return prev + const idx = pool.indexOf(prev) + return pool[(idx + 1) % pool.length] ?? pool[0] ?? "slack" + }) + }, ROTATE_MS) + return () => window.clearInterval(timer) + }, [rotationPaused, incompleteSteps, spotlight, stepDone]) + + useEffect(() => { + onStatsChange?.({ + slackConnected, + appsConnected, + invitesSent, + }) + }, [slackConnected, appsConnected, invitesSent, onStatsChange]) + + const setupBeatDone = events.some( + (e) => e.aspect === "prepare" && e.status === "complete", + ) + const revealed = minDelayPassed && (setupBeatDone || events.length >= 2) + + const focusStep = (id: SetupStepId) => { + setSpotlight(id) + pauseRotation() + } + + const connect = async (entry: CatalogEntry) => { + pauseRotation() + setBusy(entry.slug) + try { + if (entry.authType === "static") { + const token = window.prompt( + `Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`, + ) + if (!token) return + const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token, shared: false }), + }) + if (!res.ok) { + toast.error("Couldn't connect.") + return + } + toast.success(`${entry.name} connected.`) + await load() + return + } + const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + shared: false, + redirectUrl: window.location.href, + }), + }) + if (!res.ok) { + toast.error("Couldn't start the connection.") + return + } + const data: { authUrl?: string; ok?: boolean } = await res.json() + if (data.authUrl) window.open(data.authUrl, "_blank", "noopener") + else if (data.ok) { + toast.success(`${entry.name} connected.`) + await load() + } else toast.error("Couldn't start the connection.") + } catch { + toast.error("Couldn't start the connection.") + } finally { + setBusy(null) + } + } + + const addInvites = (text: string) => { + const found = text.match(EMAIL_RE) ?? [] + if (found.length === 0) return + const existing = new Set(invites.map((i) => i.email.toLowerCase())) + const next: { email: string }[] = [] + for (const raw of found) { + const email = raw.trim().toLowerCase() + if (!email || existing.has(email)) continue + existing.add(email) + next.push({ email }) + } + if (next.length === 0) { + setDraft("") + return + } + setInvites((prev) => [...prev, ...next]) + setDraft("") + } + + const sendInvites = async () => { + if (sendingInvites || invites.length === 0) return + if (!org?.id) { + toast.error("Organization isn't ready yet.") + return + } + setSendingInvites(true) + try { + const results = await Promise.allSettled( + invites.map((inv) => + authClient.organization.inviteMember({ + email: inv.email, + role: "member", + organizationId: org.id, + resend: true, + }), + ), + ) + const failed = results.filter( + (r) => + r.status === "rejected" || + (r.status === "fulfilled" && Boolean(r.value?.error)), + ).length + const sent = invites.length - failed + if (sent > 0) { + setInvitesSent((n) => n + sent) + setInvites([]) + toast.success( + `Sent ${sent} invite${sent === 1 ? "" : "s"} to your team.`, + ) + } + if (failed > 0) { + toast.error( + `${failed} invite${failed === 1 ? "" : "s"} couldn't be sent.`, + ) + } + } catch { + toast.error("Couldn't send invites. Try again in a moment.") + } finally { + setSendingInvites(false) + } + } + + const doneSummary = (id: SetupStepId) => { + if (id === "slack") return slack?.teamName ?? "Connected" + if (id === "apps") return `${appsConnected} connected` + return `${invitesSent} sent` + } + + return ( +
+
+

+ While we research +

+ + {!revealed ? ( +
    + {SETUP_STEPS.map((id, i) => { + const isLast = i === SETUP_STEPS.length - 1 + const meta = STEP_META[id] + return ( +
  1. +
    + + + + {!isLast && ( + + )} +
    + + {meta.label} + +
  2. + ) + })} +
+ ) : ( +
    + {SETUP_STEPS.map((id, i) => { + const done = stepDone(id) + const active = !done && spotlight === id + const isLast = i === SETUP_STEPS.length - 1 + const meta = STEP_META[id] + + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
    + + + + {active && ( + + {meta.hint ? ( +

    + {meta.hint} +

    + ) : null} + +
    + {id === "slack" && ( + + )} + {id === "apps" && ( + { + pauseRotation() + openSettings("company-brain") + }} + /> + )} + {id === "invite" && ( + + setInvites((prev) => + prev.filter((i) => i.email !== email), + ) + } + onSendInvites={sendInvites} + onFocus={() => pauseRotation()} + /> + )} +
    +
    + )} +
    +
    +
  2. + ) + })} +
+ )} +
+
+ ) +} + +function SetupDot({ done, active }: { done: boolean; active: boolean }) { + if (done) { + return ( + + + + ) + } + if (active) { + return ( + + + + ) + } + return ( + + + + ) +} + +function SlackStepBody({ + connected, + teamName, +}: { + connected: boolean + teamName: string | null +}) { + if (connected) { + return ( +

+ Live in {teamName ?? "your workspace"} +

+ ) + } + return ( + + + Add to Slack + + ) +} + +function AppsStepBody({ + catalog, + featured, + isConnected, + busy, + onConnect, + onBrowse, +}: { + catalog: CatalogEntry[] | null + featured: CatalogEntry[] + isConnected: (slug: string) => boolean + busy: string | null + onConnect: (entry: CatalogEntry) => void + onBrowse: () => void +}) { + if (catalog === null) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) + } + + if (featured.length === 0) { + return ( + + ) + } + + const allConnected = featured.every((e) => isConnected(e.slug)) + + return ( +
+
+ {featured.map((entry, i) => ( + onConnect(entry)} + showDivider={i < featured.length - 1} + /> + ))} +
+ +
+ ) +} + +function InviteStepBody({ + domain, + draft, + invites, + invitesSent, + sendingInvites, + onDraftChange, + onAddInvites, + onRemoveInvite, + onSendInvites, + onFocus, +}: { + domain: string + draft: string + invites: { email: string }[] + invitesSent: number + sendingInvites: boolean + onDraftChange: (v: string) => void + onAddInvites: (text: string) => void + onRemoveInvite: (email: string) => void + onSendInvites: () => void + onFocus: () => void +}) { + return ( +
+
{ + e.preventDefault() + onAddInvites(draft) + }} + className="flex gap-2" + > +
+ + onDraftChange(e.target.value)} + onFocus={onFocus} + onPaste={(e) => { + const pasted = e.clipboardData.getData("text") + if (pasted && EMAIL_RE.test(pasted)) { + e.preventDefault() + onAddInvites(pasted) + } + }} + placeholder={`teammate@${domain}`} + className={cn(inputClass, "h-10 pl-9 text-[13px]")} + style={inputBevelStyle} + /> +
+ +
+ {invites.length > 0 && ( +
+ {invites.map((inv) => ( +
+ + {inv.email} + + +
+ ))} + +
+ )} + {invitesSent > 0 && invites.length === 0 && ( +

+ {invitesSent} invite{invitesSent === 1 ? "" : "s"} sent +

+ )} +
+ ) +} + +function AppTile({ + icon, + name, + subtitle, + connected, + busy, + onConnect, + showDivider = false, +}: { + icon: React.ReactNode + name: string + subtitle: string + connected: boolean + busy: boolean + onConnect: () => void + showDivider?: boolean +}) { + return ( +
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {connected ? ( + + ) : ( + + )} +
+ ) +} diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx index 7da4ffd1..22db1387 100644 --- a/apps/web/components/onboarding-brain/step-about.tsx +++ b/apps/web/components/onboarding-brain/step-about.tsx @@ -1,7 +1,7 @@ "use client" -import { useEffect, useState } from "react" -import { motion } from "motion/react" +import { useEffect, useRef, useState } from "react" +import { AnimatePresence, motion } from "motion/react" import { Button } from "@ui/components/button" import { Input } from "@ui/components/input" import { Textarea } from "@ui/components/textarea" @@ -14,14 +14,17 @@ import { Mail, Plug, Terminal, - User2, UserPlus, Users2, + Wand2, } from "lucide-react" import { cn } from "@lib/utils" import { dmSans125ClassName } from "@/lib/fonts" import type { BrainMode } from "./types" +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + export interface AboutValues { name: string about: string @@ -43,18 +46,18 @@ interface Props { submitting?: boolean } -const cardSurfaceStyle = { +export const cardSurfaceStyle = { boxShadow: "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset", } -const inputBevelStyle = { +export const inputBevelStyle = { boxShadow: "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)", } -const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]" -const inputClass = +export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]" +export const inputClass = "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors" export function StepAbout({ @@ -84,36 +87,115 @@ export function StepAbout({ }, [defaultName, suggestedWorkspaceName, domain]) const teamGated = mode === "team" && !allowTeam + const isTeam = mode === "team" && !teamGated + + // Default the workspace name per mode: name-derived for Personal (field hidden), + // email-derived for Team unless the user has typed their own. + const workspaceTouched = useRef(false) + // biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only + useEffect(() => { + if (mode === "personal") { + const first = values.name.trim().split(/\s+/)[0] + const derived = first + ? `${first}'s Brain` + : suggestedWorkspaceName || "My brain" + if (values.workspaceName !== derived) { + onChange({ ...values, workspaceName: derived }) + } + } else if (!workspaceTouched.current) { + const derived = suggestedWorkspaceName || "" + if (values.workspaceName !== derived) { + onChange({ ...values, workspaceName: derived }) + } + } + }, [mode, values.name, suggestedWorkspaceName]) + + // Auto-draft the "what does your company do" blurb from the domain. + const valuesRef = useRef(values) + valuesRef.current = values + const aboutTouched = useRef(false) + const summarizedDomain = useRef(null) + const latestDraftDomain = useRef(null) + const [drafting, setDrafting] = useState(false) + const [drafted, setDrafted] = useState(false) + + const draftCompany = async (rawDomain: string) => { + const d = rawDomain.trim().toLowerCase() + if (!d || summarizedDomain.current === d) return + if (aboutTouched.current && valuesRef.current.about.trim()) return + summarizedDomain.current = d + latestDraftDomain.current = d + setDrafting(true) + try { + const res = await fetch(`${BACKEND}/brain/company-summary`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ domain: d }), + }) + // Ignore a stale response if the domain changed mid-flight. + if (latestDraftDomain.current !== d) return + if (!res.ok) return + const data = (await res.json()) as { summary?: string | null } + if ( + data.summary && + !aboutTouched.current && + latestDraftDomain.current === d + ) { + onChange({ ...valuesRef.current, about: data.summary }) + setDrafted(true) + } + } catch { + if (latestDraftDomain.current === d) summarizedDomain.current = null + } finally { + if (latestDraftDomain.current === d) setDrafting(false) + } + } + + // Draft once when entering Team with a domain already filled (e.g. from email). + // biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only + useEffect(() => { + if (!isTeam) return + const d = (values.workspaceDomain || domain || "").trim() + if (d && !values.about.trim()) draftCompany(d) + }, [isTeam]) + const canContinue = !teamGated && values.name.trim().length > 0 && values.workspaceName.trim().length > 0 return ( -
-
-
+
+
+ + +
-

- Tell us about you -

-

- So your brain sounds like yours, not the docs. -

+
+

+ Tell us about you +

+

+ So your brain sounds like yours, not the docs. +

+
+
-
+
+

Your name

+ {isTeam && ( +
+

Workspace name

+ { + workspaceTouched.current = true + onChange({ ...values, workspaceName: e.target.value }) + }} + placeholder={suggestedWorkspaceName || "Acme"} + className={inputClass} + style={inputBevelStyle} + /> +
+ )} +
+ + {/* Company domain slides in only for Team mode. */} + + {isTeam && ( + +

Company domain

+
+
+ {values.workspaceDomain || domain ? ( + + ) : ( + + )} +
+ + onChange({ ...values, workspaceDomain: e.target.value }) + } + onBlur={(e) => { + const d = e.target.value.trim() + if (d !== values.workspaceDomain) { + onChange({ ...values, workspaceDomain: d }) + } + draftCompany(d) + }} + placeholder="your-team.com" + className={cn(inputClass, "pl-14")} + style={inputBevelStyle} + /> +
+
+ )} +
+ + {!teamGated && (
-

- What are you here for?{" "} - (optional) -

+
+

+ {isTeam ? ( + "What does your company do?" + ) : ( + <> + What are you here for?{" "} + + (optional) + + + )} +

+ {isTeam && + (drafting ? ( + + + Drafting from your site… + + ) : drafted ? ( + + + Drafted from your site · edit anything + + ) : null)} +