diff --git a/README.md b/README.md index fefa4560..4a17c593 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@

Docs · Quickstart · + Self-host · Dashboard · Discord

@@ -77,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies. **[→ Jump to developer quickstart](#build-with-supermemory-api)** + + + + + +

🖥️ I want to run it myself

+ +State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama. + +```bash +curl -fsSL https://supermemory.ai/install | bash +``` + +**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)** + @@ -301,6 +317,38 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs) --- +## Supermemory local — run it yourself + +State-of-the-art memory, on your machine. One binary. Zero config. + +```bash +curl -fsSL https://supermemory.ai/install | bash +# or +npx supermemory local +``` + +```bash +supermemory-server +``` + +First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`. + +```typescript +const client = new Supermemory({ + apiKey: "sm_...", + baseURL: "http://localhost:6767", // that's the only change +}); +``` + +- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot. +- **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). + +--- + ## Benchmarks Supermemory is state of the art across all major AI memory benchmarks: @@ -354,6 +402,7 @@ Your app / AI tool - 📖 [Documentation](https://supermemory.ai/docs) - 🚀 [Quickstart](https://supermemory.ai/docs/quickstart) +- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview) - 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview) - 🔌 [Integrations](https://supermemory.ai/docs/integrations) - 💬 [Discord](https://supermemory.link/discord) diff --git a/apps/docs/concepts/container-tags.mdx b/apps/docs/concepts/container-tags.mdx new file mode 100644 index 00000000..71b8b0d7 --- /dev/null +++ b/apps/docs/concepts/container-tags.mdx @@ -0,0 +1,176 @@ +--- +title: "Container Tags" +sidebarTitle: "Container Tags" +description: "The isolation boundary that groups and partitions memories by user, project, or any logical scope" +icon: "folder" +--- + +A **container tag** is the primary way you organize and isolate memories in Supermemory. It's a simple string identifier you attach to content when you add it — and that you pass back when you search, list, or update it. + +Think of a container tag as a **namespace**: every memory tagged with `user_alex` lives in its own isolated space, completely separate from memories tagged `user_jordan`. This is what makes Supermemory safe to use in multi-tenant applications — one user can never see another user's memories unless you explicitly query across both tags. + + + + Bucket memories by user, project, agent, workspace, or any boundary that makes sense for your app. + + + Each container tag maps to its own vector namespace, so search and retrieval never leak across boundaries. + + + +--- + +## How it works + +When you add a memory with a container tag, Supermemory automatically creates a **space** for that tag (scoped to your organization) if one doesn't already exist. You don't need to provision anything ahead of time — the first write with a new tag creates the container, and subsequent writes reuse it. + +```typescript +// First call auto-creates the "user_alex" container +await client.add({ + content: "Alex prefers dark mode and concise answers", + containerTag: "user_alex", +}); + +// Later, retrieve only Alex's memories +const results = await client.search.memories({ + q: "what are the user's UI preferences?", + containerTag: "user_alex", +}); +``` + +Under the hood, each container tag is hashed into a dedicated vector namespace. Embeddings, chunks, and memory entries for one tag are stored and searched independently of every other tag — there is no shared index to filter through, which is why isolation is strict rather than best-effort. + + +A container tag is an **opaque identifier you choose**. Supermemory does not parse meaning out of it — `user_123`, `project_mobile`, and `org:acme:team:growth` are all equally valid. Pick a convention that mirrors the access boundaries in your own application. + + +--- + +## Naming rules + +Container tags are validated on every request. A tag must: + +- Be **100 characters or less** +- Contain only **alphanumeric characters, hyphens (`-`), underscores (`_`), and colons (`:`)** + +Matching pattern: `^[a-zA-Z0-9_:-]+$` + +```typescript +// ✅ Valid +"user_123" +"project-mobile-app" +"org:acme:user:john" +"tenant_42_workspace_7" + +// ❌ Invalid — spaces, slashes, and other symbols are rejected +"user 123" +"project/mobile" +"team@acme" +``` + +The colon is intentionally allowed so you can build **hierarchical** tags (for example `org:acme:user:john`) that encode several levels of structure in a single identifier. + +--- + +## `containerTag` vs `containerTags` + +Supermemory's current API uses a **single** `containerTag` string per request. + + +The plural `containerTags` array field is **deprecated**. It still works for backward compatibility on older (`/v3`) endpoints, but new integrations should use the singular `containerTag` string. The `/v4` API only accepts `containerTag`. + + +| API field | Type | Status | +|-----------|------|--------| +| `containerTag` | `string` | ✅ Current — use this | +| `containerTags` | `string[]` | ⚠️ Deprecated | + +--- + +## Where container tags are used + +The same tag flows through the entire lifecycle of a memory. Pass it consistently and your data stays neatly partitioned. + +| Operation | Behavior | +|-----------|----------| +| **Add** | Writes the memory into the tag's container (auto-creating the space). | +| **Search** | Restricts retrieval to the given tag's namespace. | +| **List** | Returns only memories belonging to the tag(s). | +| **Update / Delete** | Targets the memory inside the specified tag's container. | + +```typescript +// Add +await client.add({ content: "Q1 planning notes", containerTag: "project_q1" }); + +// Search within the same container +await client.search.memories({ q: "planning", containerTag: "project_q1" }); + +// List everything in the container +await client.documents.list({ containerTags: ["project_q1"] }); +``` + +--- + +## Access control + +Container tags are also an **authorization boundary**, not just an organizational one. Two mechanisms can restrict which tags a given caller may touch: + +- **API key scopes** — an API key can be limited to a specific set of container tags, with read or write permission per tag. +- **Member restrictions** — an organization member can be granted access to only certain container tags. + +When a request is restricted, Supermemory validates the requested tag against the caller's allowed set: + +- Requesting a tag outside the allowed set returns `403 Forbidden`. +- A write (add/update/delete) to a read-only tag returns `403 Forbidden`. +- If no tag is supplied by a restricted caller, the request is automatically scoped to their allowed tag(s). + +This means you can hand out an API key that is physically incapable of reading or writing another tenant's data, enforced at the data layer rather than in your application code. + +--- + +## Per-container settings + +Each container tag can carry its own configuration, independent of other tags in the same organization: + +| Setting | Purpose | +|---------|---------| +| `name` | A human-friendly display name for the container. | +| `entityContext` | A custom context prompt applied when processing documents in this container — useful for steering extraction and summarization per project or tenant. | + +```typescript +await client.containerTags.update("project_research", { + entityContext: "This project contains research papers about machine learning.", +}); +``` + +Container tags can also be **merged** when you need to consolidate two buckets of memories into one. + +--- + +## Choosing a convention + +Pick a tagging scheme that maps onto the isolation boundaries your application actually needs. + +| Pattern | Example | Use case | +|---------|---------|----------| +| User isolation | `user_{userId}` | Per-user memory in a consumer app | +| Project grouping | `project_{projectId}` | Project- or workspace-scoped content | +| Agent scoping | `agent_{agentId}` | Separate long-term memory per AI agent | +| Hierarchical | `org:{orgId}:user:{userId}` | Multi-level, multi-tenant SaaS | + + +Keep tags **deterministic** — derive them directly from IDs you already have (a user ID, a tenant ID) so you can always reconstruct the right tag at query time without a lookup. + + +--- + +## Next steps + + + + Combine container tags with metadata filters for precise retrieval. + + + See container tags in action across the add API. + + diff --git a/apps/docs/deployment/self-hosting.mdx b/apps/docs/deployment/self-hosting.mdx deleted file mode 100644 index 7f7db4d3..00000000 --- a/apps/docs/deployment/self-hosting.mdx +++ /dev/null @@ -1,243 +0,0 @@ ---- -title: 'Self Hosting' -description: 'Deploy your own instance of the supermemory API on Cloudflare Workers' ---- - - -This guide is intended for **enterprise customers only** who have specifically opted for self-hosting as part of their enterprise plan. If you're on a standard plan, please use our hosted API at [console.supermemory.ai](https://console.supermemory.ai). - - -## Prerequisites - -Before you start, you'll need to gather several API keys and set up accounts with various services. This comprehensive guide will walk you through obtaining each required component. - -### Enterprise Deployment Package - -Your enterprise deployment package is provided by the supermemory team and contains: -- Your unique Host ID (`NEXT_PUBLIC_HOST_ID`) -- The compiled JavaScript bundle -- The deployment script - -Contact your supermemory enterprise representative to receive your deployment package. - -### Cloudflare - - -#### Create Account - -1. Go to [cloudflare.com](https://dash.cloudflare.com/sign-up) and create an account -3. Your **Account ID** is the long randon string in the URL bar - -#### Create API Token - -1. Navigate to [Cloudflare API Tokens](https://dash.cloudflare.com/?to=/:account/api-tokens) -2. Click **"Create Token"** -3. Use the **"Custom token"** template -4. Configure the token with these permissions: - - **Account:AI Gateway:Edit** - - **Account:Hyperdrive:Edit** - - **Account:Workers KV Storage:Edit** - - **Account:Workers R2 Storage:Edit** -7. Click **"Continue to summary"** → **"Create Token"** -8. **Important**: Copy and securely store the token immediately (it won't be shown again) - -#### Enable Workers - -1. In your Cloudflare dashboard, go to **Workers & Pages** -2. If prompted, accept the Workers terms of service -3. Choose a subdomain for your workers (e.g., `yourcompany.workers.dev`) - -Your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are now ready. - -### Database - -You'll need to provide a PostgreSQL connection string via the `DATABASE_URL` environment variable. - -The database must: -- Support the **pgvector extension** for vector operations -- Be accessible from Cloudflare Workers -- Support SSL connections -- Allow connections from Cloudflare's IP ranges - -Your connection string should follow this format: -``` -postgresql://username:password@hostname:port/database -``` - -### LLM Providers - -#### OpenAI - -1. Go to [platform.openai.com](https://platform.openai.com) -2. Sign in or create an account -3. Navigate to **API Keys** in the left sidebar -4. Click **"Create new secret key"** -5. Name your key (e.g., "supermemory Self-Hosted") -6. Copy the key and store it securely -7. Add billing information if you haven't already - -#### Anthropic - -1. Go to [console.anthropic.com](https://console.anthropic.com) -2. Create an account and complete verification -3. Navigate to **API Keys** -4. Click **"Create Key"** -5. Name your key and copy it securely - -#### Gemini - -1. Go to [Google AI Studio](https://aistudio.google.com) -2. Sign in with your Google account -3. Click **"Get API key"** → **"Create API key"** -4. Choose an existing Google Cloud project or create a new one -5. Copy your API key - -#### Groq - -1. Go to [console.groq.com](https://console.groq.com) -2. Sign up for an account -3. Navigate to **API Keys** -4. Click **"Create API Key"** -5. Name your key and copy it - - - -{/* TODO: Add OAuth documentation */} -{/* ### Authentication Providers -#### GitHub OAuth (Optional) - -1. Go to [GitHub Developer Settings](https://github.com/settings/developers) -2. Click **"New OAuth App"** -3. Fill in the application details: - - **Application name**: Your app name - - **Homepage URL**: Your API domain (e.g., `https://api.yourdomain.com`) - - **Authorization callback URL**: `https://api.yourdomain.com/api/auth/callback/github` -4. Click **"Register application"** -5. Note the **Client ID** and generate a **Client Secret** -6. Use these for `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET` - -#### Google OAuth (Optional) - -1. Go to [Google Cloud Console](https://console.cloud.google.com) -2. Create a new project or select an existing one -3. Enable the **Google+ API** -4. Go to **Credentials** → **Create Credentials** → **OAuth client ID** -5. Choose **Web application** -6. Add your domain to **Authorized JavaScript origins** -7. Add `https://api.yourdomain.com/api/auth/callback/google` to **Authorized redirect URIs** -8. Copy the **Client ID** and **Client secret** -9. Use these for `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` */} - -### Email Service Setup - -#### Resend - -1. Go to [resend.com](https://resend.com) and create an account -2. Navigate to **API Keys** -3. Click **"Create API Key"** -4. Name your key (e.g., "supermemory Production") -5. Copy the key for `RESEND_API_KEY` -6. Verify your sending domain in the **Domains** section - -### Connectors (Optional) - -#### Google Drive - -1. Go to [Google Cloud Console](https://console.cloud.google.com) -2. Create or select a project -3. Enable the **Google Drive API** -4. Go to **Credentials** → **Create Credentials** → **OAuth client ID** -5. Configure the OAuth consent screen if required -6. Choose **Web application** -7. Add authorized redirect URIs for your domain -8. Copy `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` - -#### Microsoft OneDrive - -1. Go to [Azure Portal](https://portal.azure.com) -2. Navigate to **Microsoft Entra ID** → **App registrations** -3. Click **"New registration"** -4. Name your app and set redirect URI -5. Go to **Certificates & secrets** → **New client secret** -6. Copy the **Application (client) ID** and **Client secret** -7. Use for `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET` - -#### Notion - -1. Go to [Notion Developers](https://developers.notion.com) -2. Click **"Create new integration"** -3. Fill in the integration details -4. Copy the **Internal Integration Token** -5. Set up OAuth if needed for user connections -6. Use for `NOTION_CLIENT_ID` and `NOTION_CLIENT_SECRET` - ---- - -## Setup deployment files - -Extract the deployment package provided by the supermemory team to your preferred directory: - -```bash -# Extract the deployment package -$ unzip supermemory-enterprise-deployment.zip -$ cd supermemory-deployment -``` - ---- - -## Configure environment variables - -The deployment script reads **all** environment variables from your shell at runtime. We ship an example file that lists the full set supported by the worker. - -```bash -# Copy the template and start editing -$ cp packages/alchemy/env.example .env - -# Open the file in your editor of choice and fill in the blanks -$ $EDITOR .env -``` - -Below is a quick reference. -**Required** values are mandatory for a successful deploy – leave optional ones empty if you don't need the related feature. - -| Name | Required? | Description | -|------|-----------|-------------| -| `NODE_ENV` | ✅ | `development`, `staging` or `production`. | -| `NEXT_PUBLIC_HOST_ID` | ✅ | Your unique Host ID provided by the supermemory team. | -| `BETTER_AUTH_SECRET` | ✅ | Random 32-byte string – run `openssl rand -base64 32`. | -| `BETTER_AUTH_URL` | ✅ | Public base URL for the API (no trailing `/`). Example: `https://api.example.com`. | -| `DATABASE_URL` | ✅ | Postgres connection string (e.g. `postgres://user:pass@host:5432/db`). | -| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Your Cloudflare account ID. | -| `CLOUDFLARE_API_TOKEN` | ✅ | Token created in *Prerequisites*. | -| `OPENAI_API_KEY` | ✅ | Key from [platform.openai.com](https://platform.openai.com). | -| `RESEND_API_KEY` | ✅ | E-mail provider key if you plan to send e-mails. | -| `ANTHROPIC_API_KEY` | | Needed to use Claude models. | -| `GEMINI_API_KEY` | | Key for Google Gemini models. | -| `GROQ_API_KEY` | | Key for Groq models. | -| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | | Enable GitHub OAuth login. | -| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | | Enable Google OAuth login. | -| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | | Needed for Google Drive connector. | -| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | | Needed for OneDrive connector. | -| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | | Needed for Notion connector. | -| `CLOUDFLARE_AI_GATEWAY_NAME` / `CLOUDFLARE_AI_GATEWAY_TOKEN` | | Only if you want to route requests through an AI Gateway. | -| `SENTRY_DSN` | | If you use Sentry for error reporting. | - ---- - -## Deploy - -With your `.env` in place, run the deployment script: - -```bash -# Run the deployment script provided in your package -$ bun ./deploy.ts -``` - - ---- - -## Updating Your Deployment - -To update your supermemory deployment, follow the same process as the initial deployment described in the **Deploy** section above. You can reuse your existing `.env` file and add/remove any new environment variables as needed. - ---- \ No newline at end of file diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f2bb9b80..6be467ff 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -71,6 +71,15 @@ "group": "Getting Started", "pages": ["intro", "quickstart", "vibe-coding"] }, + { + "group": "Self-Hosting", + "pages": [ + "self-hosting/overview", + "self-hosting/quickstart", + "self-hosting/configuration", + "self-hosting/local-vs-enterprise" + ] + }, { "group": "Concepts", "pages": [ @@ -79,6 +88,7 @@ "concepts/content-types", "concepts/super-rag", "concepts/memory-vs-rag", + "concepts/container-tags", "concepts/filtering", "concepts/user-profiles", "concepts/customization", diff --git a/apps/docs/integrations/claude-code.mdx b/apps/docs/integrations/claude-code.mdx index 7e12099d..20401471 100644 --- a/apps/docs/integrations/claude-code.mdx +++ b/apps/docs/integrations/claude-code.mdx @@ -19,6 +19,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c [Claude-Supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects. + +**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot. + + ## Get Your API Key Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions: diff --git a/apps/docs/integrations/codex.mdx b/apps/docs/integrations/codex.mdx index 46eef945..6307d996 100644 --- a/apps/docs/integrations/codex.mdx +++ b/apps/docs/integrations/codex.mdx @@ -10,6 +10,10 @@ icon: "terminal" - **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session. - **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand. + +**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` (or set `baseUrl` in `~/.codex/supermemory.json`) and use the API key printed on first boot. + + ## Get Your API Key Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile: diff --git a/apps/docs/integrations/openclaw.mdx b/apps/docs/integrations/openclaw.mdx index 436022e8..aacc7ef0 100644 --- a/apps/docs/integrations/openclaw.mdx +++ b/apps/docs/integrations/openclaw.mdx @@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c [OpenClaw](https://github.com/supermemoryai/openclaw-supermemory) is a multi-platform AI messaging gateway that connects to WhatsApp, Telegram, Discord, Slack, iMessage, and other messaging channels. The Supermemory plugin gives OpenClaw memory across every channel. + +**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_BASE_URL="http://localhost:6767"` (or set `baseUrl` in the plugin config) and use the API key printed on first boot. + + ## Install the Plugin Get started by installing the plugin with a single command. diff --git a/apps/docs/integrations/opencode.mdx b/apps/docs/integrations/opencode.mdx index d1a11b67..9f47706c 100644 --- a/apps/docs/integrations/opencode.mdx +++ b/apps/docs/integrations/opencode.mdx @@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c [OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects. + +**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot. + + ## Get Your API Key Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions: diff --git a/apps/docs/integrations/supermemory-sdk.mdx b/apps/docs/integrations/supermemory-sdk.mdx index 33c80517..474976d5 100644 --- a/apps/docs/integrations/supermemory-sdk.mdx +++ b/apps/docs/integrations/supermemory-sdk.mdx @@ -14,6 +14,10 @@ icon: "/images/supermemory.svg" + +Both SDKs also work against [self-hosted Supermemory](/self-hosting/overview) — pass `baseURL: "http://localhost:6767"` (TypeScript) or `base_url="http://localhost:6767"` (Python) when creating the client. + + ## Installation diff --git a/apps/docs/intro.mdx b/apps/docs/intro.mdx index 0712406d..319a55ea 100644 --- a/apps/docs/intro.mdx +++ b/apps/docs/intro.mdx @@ -82,5 +82,8 @@ All three approaches share the **same context pool** when using the same user ID Understand the knowledge graph architecture + + Run Supermemory on your own machine — one binary, zero config, fully offline + diff --git a/apps/docs/quickstart.mdx b/apps/docs/quickstart.mdx index 963f1389..8953c175 100644 --- a/apps/docs/quickstart.mdx +++ b/apps/docs/quickstart.mdx @@ -8,6 +8,10 @@ icon: "play" **Using Vercel AI SDK?** Check out the [AI SDK integration](/integrations/ai-sdk) for the cleanest implementation with `@supermemory/tools/ai-sdk`. + +**Prefer to run it locally?** Supermemory is also a [self-hostable single binary](/self-hosting/overview) — `curl -fsSL https://supermemory.ai/install | bash` and you're running. + + ## Memory API **Step 1.** Sign up for [Supermemory's Developer Platform](http://console.supermemory.ai) to get the API key. Click on **API Keys -> Create API Key** to generate one. diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx new file mode 100644 index 00000000..49580e6d --- /dev/null +++ b/apps/docs/self-hosting/configuration.mdx @@ -0,0 +1,135 @@ +--- +title: "Self-Hosting Configuration" +sidebarTitle: "Configuration" +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 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. + +## Core + +| Variable | Purpose | Default | +|---|---|---| +| `PORT` (or `SUPERMEMORY_PORT`) | HTTP listen port | `6767` | +| `SUPERMEMORY_DATA_DIR` | Where the graph engine's data, auth secret, and model cache live | `./.supermemory` | + +## 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**: + +| Variable | Provider | +|---|---| +| `OPENAI_API_KEY` | OpenAI — or any OpenAI-compatible endpoint, see below | +| `ANTHROPIC_API_KEY` | Anthropic | +| `GEMINI_API_KEY` | Google AI Studio (Gemini) | +| `GROQ_API_KEY` | Groq | +| `WORKERS_AI_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Workers AI | +| `GOOGLE_VERTEX_PROJECT_ID` + `GOOGLE_VERTEX_LOCATION` | GCP Vertex AI | + + +No key set? The server walks you through it. On first boot, an interactive setup wizard asks which provider you want, securely prompts for the key, and saves it encrypted — including a custom base URL and model name if you pick an OpenAI-compatible endpoint. + + +With multiple providers configured, the first one in the order above is used. + + +Image, video, and high-fidelity PDF understanding require a Gemini or Vertex AI key. Text ingestion, memory extraction, and search work with any provider. + + +### Fully offline with local models + +`OPENAI_API_KEY` + `OPENAI_BASE_URL` covers any OpenAI-compatible endpoint: Ollama, LM Studio, vLLM, llama.cpp server, Together, Fireworks, and more. + +```bash +# Ollama example — gpt-oss-20b works great +OPENAI_BASE_URL=http://localhost:11434/v1 +OPENAI_API_KEY=ollama # any non-empty string for local runners +OPENAI_MODEL=gpt-oss:20b +``` + +| Variable | Purpose | Default | +|---|---|---| +| `OPENAI_BASE_URL` | OpenAI-compatible endpoint URL | OpenAI | +| `OPENAI_MODEL` | Model ID sent to that endpoint | `gpt-5.1` | +| `OPENAI_FAST_MODEL` | Override for fast/light tasks | `OPENAI_MODEL` | +| `OPENAI_TEXT_MODEL` | Override for heavier text tasks | `OPENAI_MODEL` | + +## File storage + +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 + +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: + +| 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 | + +## Memory limits & ingestion queue + +The server manages memory for you and separates the two kinds of work you send it: + +- **Searches are always served immediately.** They never wait behind ingestion, regardless of how much is queued. +- **Adds are accepted instantly but processed through a queue.** A `POST /v3/documents` call returns in milliseconds with status `queued`; extraction, embedding, and indexing happen in the background at a controlled pace. + +Ingestion may grow the server's memory usage by at most `SUPERMEMORY_EMBEDDING_RAM_LIMIT` (default **1 GB**) above its post-boot baseline. Past that, new documents simply wait in the queue until memory drops back under the limit — nothing is dropped, ingestion just slows down. The limit is measured above the boot baseline because the built-in local embeddings and storage engine have a fixed footprint that exists before any document is processed. + +The limit is printed at boot, and whenever adds are waiting the binary shows a live status line in the terminal: + +``` +[ingest] memory limit 1.0 GB above baseline (1.6 GB) · 2 concurrent — set SUPERMEMORY_EMBEDDING_RAM_LIMIT=ngb to change +[ingest] 2 running · 193 queued · 0.4 GB / 1.0 GB ingest memory +[ingest] 2 running · 193 queued · paused — 1.1 GB / 1.0 GB ingest memory, waiting for it to drop +[ingest] resumed — memory back under the 1.0 GB ingest limit +``` + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_EMBEDDING_RAM_LIMIT` | Memory ingestion may use above the boot baseline. Accepts `1gb`, `1.5gb`, `512mb`, or a bare number (GB). | `1gb` | +| `SUPERMEMORY_INGEST_CONCURRENCY` | Documents processed concurrently | `2` | + +```bash +# Give ingestion 4 GB of headroom on a larger machine +SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server +``` + +Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly. + +## Telemetry + +The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch: + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_DISABLE_TELEMETRY` | Set to `1` to also disable internal AI SDK telemetry instrumentation | unset | + +## Platform-only features + +These exist in the codebase but are exclusive to the [hosted platform](https://console.supermemory.ai) — the self-hosted binary doesn't include them: + +- **Connectors** — Google Drive, Notion, Gmail, OneDrive background sync +- **Supermemory MCP** — managed MCP server endpoints +- **Optimized memory extraction** — the platform's extraction pipeline is tuned for higher quality at lower cost than bring-your-own-key +- **Managed scale** — globally distributed infrastructure, no capacity planning + +Any other environment variables you may find referenced in the codebase are platform-only: the self-hosted binary ignores them even when set. + +## Example: production-ish `.env` + +```dotenv +# Persistent data location +SUPERMEMORY_DATA_DIR=/var/lib/supermemory + +# One LLM provider +OPENAI_API_KEY=sk-... +``` + +That's enough for full ingestion, memory extraction, and hybrid search. diff --git a/apps/docs/self-hosting/local-vs-enterprise.mdx b/apps/docs/self-hosting/local-vs-enterprise.mdx new file mode 100644 index 00000000..3aecdf10 --- /dev/null +++ b/apps/docs/self-hosting/local-vs-enterprise.mdx @@ -0,0 +1,56 @@ +--- +title: "Local vs. Enterprise" +sidebarTitle: "Local vs. Enterprise" +description: "Supermemory local is for builders. Supermemory Enterprise is for organizations." +icon: "building-2" +--- + +Supermemory local — the self-hosted binary — is free, open source, and built for individual developers: local-first workflows, prototyping, air-gapped experiments, privacy-sensitive side projects. + +**Supermemory Enterprise** is the full platform, run for your organization: the same memory engine with proprietary models, organizational controls, and infrastructure that scales with you — without you operating any of it. + +## At a glance + +| | Supermemory local | Enterprise | +|---|---|---| +| **Memory engine** | Full graph engine, embedded | Full graph engine, managed | +| **Models** | Bring your own key (any provider, incl. fully offline) | Proprietary models tuned for long-horizon data understanding | +| **Authentication** | Single auto-generated API key | Organization-wide authentication and access controls | +| **Team access** | Single org on one machine | Multi-member organizations, roles, and scoped API keys | +| **Observability** | Server logs | Control dashboard: usage analytics, ingestion monitoring, request logs | +| **Control** | Env vars on your box | Org-wide settings, key management, and governance from the console | +| **Connectors** | — | Google Drive, Notion, Gmail, OneDrive with continuous background sync | +| **Scalability** | One machine, one process | Globally distributed, scales elastically with your workload | +| **Hosting** | You run it | Fully managed — or dedicated deployments for compliance needs | +| **Support** | Community ([GitHub](https://git.new/memory)) | Dedicated support, onboarding, and SLAs | + +## What Enterprise adds + +### Auth and team access + +Local runs as a single-tenant server with one API key. Enterprise gives your whole organization structured access: member roles, and API keys scoped per environment, per team, or per app — all revocable from one place. + +### Observability and the control dashboard + +Local gives you logs. Enterprise gives you the console: live usage analytics, ingestion pipeline visibility, search and request logs, and per-key attribution — so you always know what your agents are remembering, and what it costs. + +### Memory quality + +Local runs the extraction pipeline on whatever model you bring. Enterprise runs it on Supermemory's proprietary models, purpose-tuned for long-horizon data understanding — higher-quality memories at a lower effective cost than any bring-your-own-key setup. + +### Scale and hosting + +Local is bounded by one machine — which is the point. Enterprise runs on globally distributed infrastructure that scales with your ingestion volume and query load, with no capacity planning on your side. For strict data residency or compliance requirements, dedicated deployment options are available. + +## Moving between them + +The two speak the same API. Code written against your local server moves to Enterprise by changing the `baseURL` — and vice versa. Prototype locally, ship on Enterprise. + + + + Get a walkthrough of Supermemory Enterprise for your team + + + Install the binary and build against the same API today + + diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx new file mode 100644 index 00000000..b9db33e4 --- /dev/null +++ b/apps/docs/self-hosting/overview.mdx @@ -0,0 +1,85 @@ +--- +title: "Self-Hosting Supermemory" +sidebarTitle: "Overview" +description: "State-of-the-art memory, running on your machine. One binary, zero config." +icon: "server" +--- + +Supermemory runs on your own hardware. It's the same memory engine behind the [hosted platform](https://console.supermemory.ai) — ingestion, memory extraction, hybrid semantic search, and the full API — as a single self-contained binary. + + +```bash curl +curl -fsSL https://supermemory.ai/install | bash +``` + +```bash npx +npx supermemory local +``` + + +No Docker. No database to provision. No config files. It boots in seconds with everything built in, and it's [open source](https://git.new/memory). + +## Zero config, actually + +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. +- **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. + +The only thing you bring is a model. In production, Supermemory runs its own proprietary models, purpose-tuned for long-horizon data understanding and memory extraction. Self-hosted, the same pipeline runs on whatever model you point it at — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. Bring a key and go. Or don't bring one at all: + +## Runs fully offline + +Supermemory works with any OpenAI-compatible endpoint, which means it runs end-to-end on your machine with a local model — Ollama, LM Studio, vLLM, llama.cpp. `gpt-oss-20b` is a great fit: + +```bash +OPENAI_BASE_URL=http://localhost:11434/v1 \ +OPENAI_API_KEY=ollama \ +OPENAI_MODEL=gpt-oss:20b \ +supermemory-server +``` + +Local graph engine, local embeddings, local LLM. Your data never leaves the building. + +## Drop-in with your existing code + +The self-hosted server speaks the same API as the hosted platform. Point any Supermemory SDK at it with a one-line change: + +```typescript +const client = new Supermemory({ + apiKey: "sm_...", // printed on first boot + baseURL: "http://localhost:6767", +}) +``` + +Everything in the [Memory API docs](/quickstart) works the same way. The coding plugins do too — [Claude Code](/integrations/claude-code), [Codex](/integrations/codex), and [OpenCode](/integrations/opencode) all target your local server with `SUPERMEMORY_API_URL=http://localhost:6767`. + +## Self-hosted vs. the platform + +Self-hosted is free, open source, and great for local development, air-gapped environments, and privacy-sensitive workloads. The hosted platform is where the full product lives: + +| | Self-hosted | Platform | +|---|---|---| +| Full Memory API | ✅ | ✅ | +| Hybrid semantic search | ✅ | ✅ | +| Local embeddings | ✅ | Managed | +| File ingestion (PDFs, images) | ✅ | ✅ | +| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ | +| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ | +| Memory extraction | Your model, your key | Proprietary long-horizon models — higher quality, cheaper at scale | +| Infrastructure | Your machine | Globally distributed, scales with you | + +If you outgrow a single machine — or want connectors, MCP, and the best-tuned extraction pipeline — [the platform](https://console.supermemory.ai) is one `baseURL` change away. Running this for a team or organization? See [Local vs. Enterprise](/self-hosting/local-vs-enterprise). + +## Next steps + + + + Install, run, and store your first memory in under two minutes + + + Every environment variable: LLM providers, storage, auth, tuning + + diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx new file mode 100644 index 00000000..713ea98b --- /dev/null +++ b/apps/docs/self-hosting/quickstart.mdx @@ -0,0 +1,151 @@ +--- +title: "Self-Hosting Quickstart" +sidebarTitle: "Quickstart" +description: "From zero to your first memory in under two minutes." +icon: "play" +--- + +## Install + + + +```bash +curl -fsSL https://supermemory.ai/install | bash +``` + + +```bash +npx supermemory local +``` + + +```bash +bunx supermemory local +``` + + + +The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64). + +## Run + +```bash +supermemory-server +``` + +First boot sets everything up — the embedded Supermemory graph engine, local embeddings, and your credentials: + +``` + ┌──────────────────────────────────────────────────┐ + │ url http://localhost:6767 │ + │ database ./.supermemory │ + │ api key sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │ + │ org id xxxxxxxxxxxxxxxxxxxxxx │ + └──────────────────────────────────────────────────┘ +``` + +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). + + +## Add your first memory + + + +```typescript +import Supermemory from "supermemory" + +const client = new Supermemory({ + apiKey: "sm_...", + baseURL: "http://localhost:6767", +}) + +await client.memories.add({ + content: "I'm Dhravya. I love building dev tools and I'm allergic to peanuts.", + containerTag: "user_dhravya", +}) +``` + + +```python +from supermemory import Supermemory + +client = Supermemory( + api_key="sm_...", + base_url="http://localhost:6767", +) + +client.memories.add( + content="I'm Dhravya. I love building dev tools and I'm allergic to peanuts.", + container_tag="user_dhravya", +) +``` + + +```bash +curl http://localhost:6767/v3/documents \ + -H "Authorization: Bearer sm_..." \ + -H "Content-Type: application/json" \ + -d '{ + "content": "I am Dhravya. I love building dev tools and I am allergic to peanuts.", + "containerTag": "user_dhravya" + }' +``` + + + +## Search it + + + +```typescript +const results = await client.search.memories({ + q: "what food should I avoid?", + containerTag: "user_dhravya", +}) +``` + + +```python +results = client.search.memories( + q="what food should I avoid?", + container_tag="user_dhravya", +) +``` + + +```bash +curl http://localhost:6767/v3/search \ + -H "Authorization: Bearer sm_..." \ + -H "Content-Type: application/json" \ + -d '{ + "q": "what food should I avoid?", + "containerTag": "user_dhravya" + }' +``` + + + +That's it. Everything in the [Memory API](/quickstart) — documents, memories, user profiles, spaces, filtering — works identically against your local server. + +## Where things live + +By default, all state lives in a single directory you can back up or move: + +| Path | Contents | +|---|---| +| `./.supermemory/` (or `$SUPERMEMORY_DATA_DIR`) | The Supermemory graph engine's data, auth secret, embedding model cache | +| `~/.supermemory/env` | API keys saved by the installer, loaded on every launch | + +## Next steps + + + + LLM providers, local models, performance tuning + + + The full API — it all works against your local server + + diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 6578939c..761c8ac1 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -170,6 +170,54 @@ The server will start at `http://localhost:8788`. **Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation. +### End-to-End Tests + +The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the +core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` → +`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads, +the `context` prompt, container-tag isolation, and auth rejections. + +```bash +export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it) +export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default +export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server +bun run test:e2e +``` + +| File | Covers | +|------|--------| +| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) | +| `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/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 | + +#### OAuth flow tests + +`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main +API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers: + +- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize + negatives. These exercise the protocol wiring with no key and no browser, so they always run. +- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to + `/mcp` with it, exercising the OAuth-token validation path (not the `sm_` API-key path). It + **skips** unless both env vars below are set. + +```bash +# One-time capture (opens a browser for login + consent, prints the env vars): +bun e2e/capture-oauth-token.ts +export SUPERMEMORY_MCP_CLIENT_ID=... +export SUPERMEMORY_MCP_REFRESH_TOKEN=... +``` + +Notes: +- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the + refresh-token env vars — so CI is safe without secrets. +- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip + **polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort. +- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account. + ### Deploy ```bash diff --git a/apps/mcp/e2e/auth.test.ts b/apps/mcp/e2e/auth.test.ts new file mode 100644 index 00000000..404487d6 --- /dev/null +++ b/apps/mcp/e2e/auth.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest" +import { MCP_URL, ORIGIN } from "./helpers" + +const initBody = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "smtest", version: "0.0.1" }, + }, +}) + +const mcpHeaders = (auth?: string) => ({ + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...(auth ? { Authorization: auth } : {}), +}) + +// No API key needed — exercises the public surface and auth rejections. +describe("MCP — transport & auth (raw HTTP)", () => { + it("GET / returns service info", async () => { + const res = await fetch(`${ORIGIN}/`) + expect(res.status).toBe(200) + const body = (await res.json()) as { name?: string; version?: string } + expect(body.name).toBe("supermemory-mcp") + expect(body.version).toBeTruthy() + }) + + it("exposes OAuth protected-resource discovery", async () => { + const res = await fetch( + `${ORIGIN}/.well-known/oauth-protected-resource/mcp`, + ) + expect(res.status).toBe(200) + const body = (await res.json()) as { + resource?: string + authorization_servers?: string[] + } + expect(body.resource).toMatch(/\/mcp$/) + expect(Array.isArray(body.authorization_servers)).toBe(true) + expect(body.authorization_servers?.length).toBeGreaterThan(0) + }) + + it("rejects a request with no token (401 + WWW-Authenticate)", async () => { + const res = await fetch(MCP_URL, { + method: "POST", + headers: mcpHeaders(), + body: initBody, + }) + expect(res.status).toBe(401) + expect(res.headers.get("www-authenticate")).toMatch(/Bearer/) + }) + + it("rejects an invalid API key (401 with JSON-RPC error)", async () => { + const res = await fetch(MCP_URL, { + method: "POST", + headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"), + body: initBody, + }) + expect(res.status).toBe(401) + const body = (await res.json()) as { error?: { message?: string } } + expect(body.error?.message).toMatch(/invalid|expired/i) + }) +}) diff --git a/apps/mcp/e2e/capture-oauth-token.ts b/apps/mcp/e2e/capture-oauth-token.ts new file mode 100644 index 00000000..aad14d90 --- /dev/null +++ b/apps/mcp/e2e/capture-oauth-token.ts @@ -0,0 +1,103 @@ +// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts + +import { createHash, randomBytes } from "node:crypto" +import { createServer } from "node:http" +import { exec } from "node:child_process" + +const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai" +const PORT = 8765 +const REDIRECT_URI = `http://localhost:${PORT}/callback` + +const b64url = (b: Buffer) => + b + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + +async function main() { + const meta = (await ( + await fetch(`${API_URL}/.well-known/oauth-authorization-server`) + ).json()) as { + authorization_endpoint: string + token_endpoint: string + registration_endpoint: string + } + + const reg = (await ( + await fetch(meta.registration_endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "sm-mcp-e2e-capture", + redirect_uris: [REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }) + ).json()) as { client_id: string } + + const verifier = b64url(randomBytes(32)) + const challenge = b64url(createHash("sha256").update(verifier).digest()) + const state = b64url(randomBytes(16)) + + const authUrl = new URL(meta.authorization_endpoint) + authUrl.search = new URLSearchParams({ + response_type: "code", + client_id: reg.client_id, + redirect_uri: REDIRECT_URI, + code_challenge: challenge, + code_challenge_method: "S256", + scope: "openid profile email offline_access", + state, + }).toString() + + const code: string = await new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const u = new URL(req.url ?? "", `http://localhost:${PORT}`) + if (u.pathname !== "/callback") return res.end() + if (u.searchParams.get("state") !== state) { + res.end("state mismatch") + return reject(new Error("state mismatch")) + } + const c = u.searchParams.get("code") + res.end("Done — you can close this tab.") + server.close() + c ? resolve(c) : reject(new Error("no code in callback")) + }).listen(PORT, () => { + console.log(`\nOpening browser to log in:\n${authUrl}\n`) + exec(`open "${authUrl}" || xdg-open "${authUrl}"`) + }) + }) + + const tokenRes = (await ( + await fetch(meta.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: reg.client_id, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }), + }) + ).json()) as { refresh_token?: string; error?: string } + + if (!tokenRes.refresh_token) { + console.error("No refresh_token returned:", tokenRes) + process.exit(1) + } + + console.log("\nExport these to enable Tier D OAuth tests:\n") + console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`) + console.log( + `export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`, + ) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts new file mode 100644 index 00000000..e18375cc --- /dev/null +++ b/apps/mcp/e2e/discovery.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { API_KEY, callTool, connect, textOf, type Session } from "./helpers" + +const EXPECTED_TOOLS = [ + "memory", + "recall", + "listProjects", + "whoAmI", + "memory-graph", +] + +describe.skipIf(!API_KEY)("MCP — discovery & identity", () => { + let s: Session + + beforeAll(async () => { + s = await connect() + }) + afterAll(async () => { + await s?.close() + }) + + it("handshakes and lists the expected tools", async () => { + const { tools } = await s.client.listTools() + const names = tools.map((t) => t.name) + for (const t of EXPECTED_TOOLS) expect(names).toContain(t) + }) + + it("lists profile & projects resources", async () => { + const { resources } = await s.client.listResources() + const uris = resources.map((r) => r.uri) + expect(uris).toContain("supermemory://profile") + expect(uris).toContain("supermemory://projects") + }) + + it("lists the context prompt", async () => { + const { prompts } = await s.client.listPrompts() + expect(prompts.map((p) => p.name)).toContain("context") + }) + + it("whoAmI resolves to the authenticated account", async () => { + const res = await callTool(s.client, "whoAmI") + expect(res.isError).toBeFalsy() + const parsed = JSON.parse(textOf(res)) + expect(parsed.userId).toBeTruthy() + }) + + it("listProjects returns content", async () => { + const res = await callTool(s.client, "listProjects", { refresh: true }) + expect(res.isError).toBeFalsy() + expect(textOf(res).length).toBeGreaterThan(0) + }) +}) diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts new file mode 100644 index 00000000..33de0d2b --- /dev/null +++ b/apps/mcp/e2e/graph.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { API_KEY, callTool, connect, type Session, textOf } from "./helpers" + +describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => { + let s: Session + + beforeAll(async () => { + s = await connect() + }) + afterAll(async () => { + await s?.close() + }) + + it("memory-graph returns a summary + structured documents", async () => { + const res = await callTool(s.client, "memory-graph") + expect(res.isError).toBeFalsy() + expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/) + const sc = res.structuredContent as { + documents?: unknown[] + totalCount?: number + } + expect(Array.isArray(sc?.documents)).toBe(true) + }) + + it("fetch-graph-data returns paginated documents", async () => { + const res = await callTool(s.client, "fetch-graph-data", { + page: 1, + limit: 5, + }) + expect(res.isError).toBeFalsy() + const sc = res.structuredContent as { + documents?: unknown[] + pagination?: { limit?: number } + } + expect(Array.isArray(sc?.documents)).toBe(true) + expect(sc?.pagination?.limit).toBe(5) + }) + + it("reads the profile resource", async () => { + const res = await s.client.readResource({ uri: "supermemory://profile" }) + expect(res.contents.length).toBeGreaterThan(0) + expect(res.contents[0].mimeType).toBe("text/plain") + expect(typeof res.contents[0].text).toBe("string") + }) + + it("reads the projects resource as JSON", async () => { + const res = await s.client.readResource({ uri: "supermemory://projects" }) + const text = res.contents[0].text as string + const parsed = JSON.parse(text) + expect(Array.isArray(parsed.projects)).toBe(true) + }) + + it("gets the context prompt as a system message", async () => { + const res = await s.client.getPrompt({ name: "context", arguments: {} }) + expect(res.messages.length).toBeGreaterThan(0) + const text = res.messages[0].content.text as string + expect(text).toMatch(/memory|context/i) + }) +}) diff --git a/apps/mcp/e2e/helpers.ts b/apps/mcp/e2e/helpers.ts new file mode 100644 index 00000000..1c9c2a52 --- /dev/null +++ b/apps/mcp/e2e/helpers.ts @@ -0,0 +1,186 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +export const MCP_URL = + process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp" +export const API_KEY = process.env.SUPERMEMORY_API_KEY +export const ORIGIN = new URL(MCP_URL).origin +export const API_URL = + process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai" + +// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts. +export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN +export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID + +export type AuthServerMetadata = { + authorization_endpoint: string + token_endpoint: string + registration_endpoint: string + grant_types_supported?: string[] + code_challenge_methods_supported?: string[] + response_types_supported?: string[] +} + +// Walk the discovery chain a real MCP client follows: protected-resource → authorization server. +export async function authServerMetadata(): Promise<{ + authServer: string + metadata: AuthServerMetadata +}> { + const prRes = await fetch( + `${ORIGIN}/.well-known/oauth-protected-resource/mcp`, + ) + const pr = (await prRes.json()) as { authorization_servers?: string[] } + const authServer = pr.authorization_servers?.[0] + if (!authServer) throw new Error("no authorization_servers in metadata") + const metaRes = await fetch( + `${authServer}/.well-known/oauth-authorization-server`, + ) + return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata } +} + +export async function registerClient(registrationEndpoint: string): Promise<{ + status: number + body: { client_id?: string; grant_types?: string[] } +}> { + const res = await fetch(registrationEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "sm-mcp-e2e", + redirect_uris: ["http://localhost:8765/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }) + return { status: res.status, body: await res.json() } +} + +export async function exchangeRefreshToken( + tokenEndpoint: string, + refreshToken: string, + clientId: string, +): Promise<{ + status: number + body: { access_token?: string; error?: string } +}> { + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }), + }) + return { status: res.status, body: await res.json() } +} + +export type CallResult = { + content?: Array<{ type: string; text?: string }> + structuredContent?: unknown + isError?: boolean +} + +export function textOf(res: CallResult): string { + return (res.content ?? []) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n") +} + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export type Session = { client: Client; close: () => Promise } + +export async function connect( + opts: { apiKey?: string; token?: string; containerTag?: string } = {}, +): Promise { + const headers: Record = { + Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`, + } + if (opts.containerTag) headers["x-sm-project"] = opts.containerTag + + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + requestInit: { headers }, + }) + const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" }) + await client.connect(transport) + return { + client, + close: () => transport.close().catch(() => {}), + } +} + +export async function callTool( + client: Client, + name: string, + args: Record = {}, +): Promise { + return (await client.callTool({ name, arguments: args })) as CallResult +} + +// recall is eventually-consistent (save → ingestion pipeline → memories), so poll. +export async function recallUntil( + client: Client, + query: string, + needle: string, + { + tries = 18, + delayMs = 5000, + containerTag = undefined as string | undefined, + } = {}, +): Promise { + for (let i = 0; i < tries; i++) { + const res = await callTool(client, "recall", { + query, + includeProfile: false, + ...(containerTag ? { containerTag } : {}), + }) + const txt = textOf(res) + if (txt.includes(needle)) return txt + await sleep(delayMs) + } + return null +} + +// forget only matches extracted memory entries, not raw chunks, so a just-saved doc +// returns "No matching memory found..." until extraction completes — poll for real removal. +export async function forgetUntilForgotten( + client: Client, + content: string, + { + tries = 18, + delayMs = 5000, + containerTag = undefined as string | undefined, + } = {}, +): Promise { + for (let i = 0; i < tries; i++) { + const res = await callTool(client, "memory", { + content, + action: "forget", + ...(containerTag ? { containerTag } : {}), + }) + if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res) + await sleep(delayMs) + } + return null +} + +// poll until a memory is NO LONGER returned (for verifying forget). +export async function recallUntilAbsent( + client: Client, + query: string, + needle: string, + { tries = 12, delayMs = 5000 } = {}, +): Promise { + for (let i = 0; i < tries; i++) { + const res = await callTool(client, "recall", { + query, + includeProfile: false, + }) + if (!textOf(res).includes(needle)) return true + await sleep(delayMs) + } + return false +} diff --git a/apps/mcp/e2e/memory.test.ts b/apps/mcp/e2e/memory.test.ts new file mode 100644 index 00000000..65f93612 --- /dev/null +++ b/apps/mcp/e2e/memory.test.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + API_KEY, + callTool, + connect, + forgetUntilForgotten, + recallUntil, + recallUntilAbsent, + type Session, + textOf, +} from "./helpers" + +describe.skipIf(!API_KEY)("MCP — memory behaviors", () => { + let s: Session + const created: Array<{ content: string; containerTag?: string }> = [] + + beforeAll(async () => { + s = await connect() + }) + afterAll(async () => { + for (const { content, containerTag } of created) { + await callTool(s.client, "memory", { + content, + action: "forget", + ...(containerTag ? { containerTag } : {}), + }).catch(() => {}) + } + await s?.close() + }) + + it("save → recall round-trips the saved memory", async () => { + const marker = `rt-${randomUUID()}` + const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.` + created.push({ content }) + + const save = await callTool(s.client, "memory", { content, action: "save" }) + expect(save.isError).toBeFalsy() + expect(textOf(save)).toMatch(/Saved memory/i) + + const found = await recallUntil(s.client, "test fruit dragonfruit", marker) + expect(found, `recall never returned marker ${marker}`).not.toBeNull() + }, 120_000) + + it("recall includeProfile=true returns profile + memories sections", async () => { + const res = await callTool(s.client, "recall", { + query: "dragonfruit", + includeProfile: true, + }) + expect(res.isError).toBeFalsy() + const txt = textOf(res) + expect(txt).toMatch(/## (User Profile|Relevant Memories)/) + }, 30_000) + + // Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty. + it("recall responds gracefully for an unmatched query", async () => { + const res = await callTool(s.client, "recall", { + query: `zzz-no-such-memory-${randomUUID()}`, + includeProfile: false, + }) + expect(res.isError).toBeFalsy() + expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i) + }) + + // Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort. + it("forget accepts and removes a saved memory", async () => { + const marker = `fg-${randomUUID()}` + const content = `e2e forget target. token=${marker}. Secret animal is axolotl.` + created.push({ content }) + + await callTool(s.client, "memory", { content, action: "save" }) + const found = await recallUntil(s.client, "secret animal axolotl", marker) + expect(found, "memory should exist before forget").not.toBeNull() + + // Polls forget until it confirms real removal ("forgot"), past the extraction window. + const forgotten = await forgetUntilForgotten(s.client, content) + expect( + forgotten, + `forget never confirmed removal for ${marker} (memory entry never extracted in time)`, + ).not.toBeNull() + + const gone = await recallUntilAbsent( + s.client, + "secret animal axolotl", + marker, + ) + if (!gone) { + console.warn( + `[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`, + ) + } + }, 240_000) + + it("containerTag scopes memories (isolation)", async () => { + // Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run. + const tagA = "sm_e2e_scope_a" + const tagB = "sm_e2e_scope_b" + const marker = `sc-${randomUUID()}` + const content = `e2e scoping. token=${marker}. Project color is teal.` + created.push({ content, containerTag: tagA }) + + await callTool(s.client, "memory", { + content, + action: "save", + containerTag: tagA, + }) + + const inA = await recallUntil(s.client, "project color teal", marker, { + containerTag: tagA, + }) + expect(inA, "marker should be found in its own container").not.toBeNull() + + // Same query scoped to a different container must NOT see it. + const leaked = await recallUntil(s.client, "project color teal", marker, { + containerTag: tagB, + tries: 3, + delayMs: 3000, + }) + expect(leaked, "marker leaked across containers").toBeNull() + }, 120_000) + + it("returns an error result for a missing required argument", async () => { + const res = await callTool(s.client, "recall", {}) + expect(res.isError).toBe(true) + expect(textOf(res).length).toBeGreaterThan(0) + }) +}) diff --git a/apps/mcp/e2e/oauth.test.ts b/apps/mcp/e2e/oauth.test.ts new file mode 100644 index 00000000..4c815391 --- /dev/null +++ b/apps/mcp/e2e/oauth.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + authServerMetadata, + type AuthServerMetadata, + callTool, + connect, + exchangeRefreshToken, + OAUTH_CLIENT_ID, + OAUTH_REFRESH_TOKEN, + registerClient, + type Session, + textOf, +} from "./helpers" + +// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser. +describe("MCP — OAuth protocol (no secrets)", () => { + let meta: AuthServerMetadata + + beforeAll(async () => { + meta = (await authServerMetadata()).metadata + }) + + // Tier A — the discovery chain a client walks from a 401 to the auth server. + it("discovers the authorization server from protected-resource metadata", () => { + expect(meta.authorization_endpoint).toMatch(/\/authorize$/) + expect(meta.token_endpoint).toMatch(/\/token$/) + expect(meta.registration_endpoint).toMatch(/\/register$/) + }) + + it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => { + expect(meta.code_challenge_methods_supported).toContain("S256") + expect(meta.response_types_supported).toContain("code") + expect(meta.grant_types_supported).toContain("authorization_code") + expect(meta.grant_types_supported).toContain("refresh_token") + }) + + // Tier B — Dynamic Client Registration, the first authenticated-flow step. + it("issues a client_id via dynamic client registration", async () => { + const { status, body } = await registerClient(meta.registration_endpoint) + expect(status).toBe(201) + expect(body.client_id).toBeTruthy() + expect(body.grant_types).toContain("refresh_token") + }) + + // Tier C — token endpoint rejects forged grants with proper OAuth errors. + it("rejects a bogus refresh_token with invalid_grant", async () => { + const { status, body } = await exchangeRefreshToken( + meta.token_endpoint, + "bogus_rt_for_e2e", + "bogus_client", + ) + expect(status).toBe(401) + expect(body.error).toBe("invalid_grant") + }) + + it("rejects a bogus authorization code with invalid_grant", async () => { + const res = await fetch(meta.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: "bogus_code", + client_id: "bogus", + code_verifier: "abc", + redirect_uri: "http://localhost:8765/callback", + }), + }) + expect(res.status).toBe(401) + expect(((await res.json()) as { error?: string }).error).toBe( + "invalid_grant", + ) + }) + + it("redirects an unauthenticated authorize request to login", async () => { + const url = new URL(meta.authorization_endpoint) + url.search = new URLSearchParams({ + response_type: "code", + client_id: "any", + redirect_uri: "http://localhost:8765/callback", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "openid profile email offline_access", + state: "xyz", + }).toString() + const res = await fetch(url, { redirect: "manual" }) + expect(res.status).toBe(302) + expect(res.headers.get("location")).toMatch(/\/login/) + }) +}) + +// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token. +describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)( + "MCP — real OAuth token round-trip", + () => { + let s: Session + let accessToken: string + + beforeAll(async () => { + const { metadata } = await authServerMetadata() + const { status, body } = await exchangeRefreshToken( + metadata.token_endpoint, + OAUTH_REFRESH_TOKEN as string, + OAUTH_CLIENT_ID as string, + ) + expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe( + 200, + ) + expect(body.access_token).toBeTruthy() + accessToken = body.access_token as string + }) + afterAll(async () => { + await s?.close() + }) + + it("mints an OAuth access token that is not an sm_ API key", () => { + expect(accessToken.startsWith("sm_")).toBe(false) + }) + + it("connects to /mcp with the OAuth token and resolves identity", async () => { + s = await connect({ token: accessToken }) + const res = await callTool(s.client, "whoAmI") + expect(res.isError).toBeFalsy() + expect(JSON.parse(textOf(res)).userId).toBeTruthy() + }) + }, +) diff --git a/apps/mcp/e2e/root-scope.test.ts b/apps/mcp/e2e/root-scope.test.ts new file mode 100644 index 00000000..000490a9 --- /dev/null +++ b/apps/mcp/e2e/root-scope.test.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers" + +type ToolLike = { + name: string + inputSchema?: { properties?: Record } +} + +const propsOf = (tools: ToolLike[], name: string): Record => + tools.find((t) => t.name === name)?.inputSchema?.properties ?? {} + +// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run. +const SCOPE_TAG = "sm_e2e_root" + +// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg. +describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => { + it("strips containerTag from tool schemas when x-sm-project is set", async () => { + const scoped = await connect({ containerTag: SCOPE_TAG }) + const plain = await connect() + try { + const scopedTools = (await scoped.client.listTools()).tools + const plainTools = (await plain.client.listTools()).tools + + expect(propsOf(plainTools, "memory")).toHaveProperty("containerTag") + expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag") + + expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag") + expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag") + } finally { + await scoped.close() + await plain.close() + } + }) + + it("scopes saves to the connection project and isolates them from default", async () => { + const marker = `root-${randomUUID()}` + const content = `e2e root scope. token=${marker}. The root flower is bluebell.` + + const rooted = await connect({ containerTag: SCOPE_TAG }) + try { + const save = await callTool(rooted.client, "memory", { + content, + action: "save", + }) + expect(save.isError).toBeFalsy() + expect(textOf(save)).toContain(SCOPE_TAG) + + const found = await recallUntil( + rooted.client, + "root flower bluebell", + marker, + ) + expect(found, "marker not found within its root scope").not.toBeNull() + } finally { + await callTool(rooted.client, "memory", { + content, + action: "forget", + }).catch(() => {}) + await rooted.close() + } + + // A default connection searches sm_project_default only — must not see it. + const plain = await connect() + try { + const leaked = await recallUntil( + plain.client, + "root flower bluebell", + marker, + { + tries: 3, + delayMs: 3000, + }, + ) + expect(leaked, "rooted memory leaked into the default project").toBeNull() + } finally { + await plain.close() + } + }, 120_000) +}) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 936c8446..2def592a 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -8,7 +8,8 @@ "dev": "portless", "dev:app": "vite build && wrangler dev --port ${PORT:-8788}", "deploy": "vite build && wrangler deploy --minify", - "cf-typegen": "wrangler types --env-interface CloudflareBindings" + "cf-typegen": "wrangler types --env-interface CloudflareBindings", + "test:e2e": "vitest run" }, "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.2", @@ -27,6 +28,7 @@ "typescript": "^5.8.3", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0", + "vitest": "^3.2.4", "wrangler": "^4.4.0" } } diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 762414e2..bc522c02 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -5,21 +5,29 @@ const DEFAULT_PROJECT_ID = "sm_project_default" const DEFAULT_LIST_LIMIT = 50 const MAX_LIST_LIMIT = 200 +interface MemoryRichFields { + metadata?: Record | null + updatedAt?: string + context?: Record + documents?: Array> + isAggregated?: boolean +} + export type Memory = - | { + | ({ id: string memory: string similarity: number title?: string content?: string - } - | { + } & MemoryRichFields) + | ({ id: string chunk: string similarity: number title?: string content?: string - } + } & MemoryRichFields) export interface SearchResult { results: Memory[] @@ -67,6 +75,17 @@ export interface ListMemoriesOptions { export interface ListMemoriesResult { memories: ListedMemory[] nextCursor: string | null +export interface SearchOptions { + searchMode?: "memories" | "hybrid" | "documents" + rerank?: boolean + rewriteQuery?: boolean + include?: { + documents?: boolean + relatedMemories?: boolean + summaries?: boolean + chunks?: boolean + forgottenMemories?: boolean + } } export interface Profile { @@ -142,7 +161,11 @@ interface SDKResult { content?: string similarity: number title?: string - context?: string + metadata?: Record | null + updatedAt?: string + context?: Record + documents?: Array> + isAggregated?: boolean } interface SDKListMemory { @@ -377,26 +400,32 @@ export class SupermemoryClient { query: string, limit = 10, threshold?: number, + options?: SearchOptions, ): Promise { try { const result = await this.client.search.memories({ q: query, limit, containerTag: this.containerTag, - searchMode: "hybrid", + searchMode: options?.searchMode ?? "hybrid", threshold, // Optional threshold parameter + rerank: options?.rerank, + rewriteQuery: options?.rewriteQuery, + include: options?.include, }) - // Normalize and limit response size — preserve memory vs chunk distinction const results: Memory[] = (result.results as SDKResult[]).map((r) => { - const text = limitByChars( - r.content || r.memory || r.chunk || r.context || "", - ) + const text = limitByChars(r.content || r.memory || r.chunk || "") const base = { id: r.id, similarity: r.similarity, title: r.title, content: r.content, + metadata: r.metadata, + updatedAt: r.updatedAt, + context: r.context, + documents: r.documents, + isAggregated: r.isAggregated, } if (r.chunk && !r.memory) { return { ...base, chunk: text } @@ -468,9 +497,7 @@ export class SupermemoryClient { if (result.searchResults) { response.searchResults = { results: (result.searchResults.results as SDKResult[]).map((r) => { - const text = limitByChars( - r.content || r.memory || r.chunk || r.context || "", - ) + const text = limitByChars(r.content || r.memory || r.chunk || "") const base = { id: r.id, similarity: r.similarity, diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts new file mode 100644 index 00000000..cbd074cf --- /dev/null +++ b/apps/mcp/src/format.ts @@ -0,0 +1,157 @@ +export function formatMemories( + response: { results?: Array>; total?: number }, + opts: { + minSimilarity?: number + maxRelations?: number + maxDocuments?: number + maxChunkLength?: number + includeScores?: boolean + includeLegend?: boolean + } = {}, +) { + const { + minSimilarity = 0, + maxRelations = 4, + maxDocuments = 3, + maxChunkLength = Number.POSITIVE_INFINITY, + includeScores = true, + includeLegend = true, + } = opts + + const day = (s: string | null | undefined) => s?.slice(0, 10) ?? "" + const mime = (m: string | undefined) => + !m + ? "" + : m === "application/pdf" + ? "pdf" + : m.includes("spreadsheet") + ? "xlsx" + : m.includes("presentation") + ? "pptx" + : m.includes("document") + ? "doc" + : (m.split("/").pop() ?? "") + + const temporal = (tc: Record | undefined) => { + if (!tc) return [] as string[] + const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean) + return [ + tc.documentDate && `doc ${day(tc.documentDate as string)}`, + ev.length === 1 && `event ${ev[0]}`, + ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`, + ].filter(Boolean) as string[] + } + + const describeMeta = (m: Record | undefined | null) => { + if (!m) return "" + const tags = [ + mime(m.mimeType as string | undefined), + m.source as string | undefined, + ...temporal(m.temporalContext as Record | undefined), + ].filter(Boolean) + return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`] + .filter(Boolean) + .join(" ") + } + + const renderRelations = ( + rels: Array> | undefined, + arrow: string, + root: string, + ) => { + if (!rels?.length) return [] as string[] + const seen = new Set() + const items = rels.filter((r) => { + const k = (r.memory as string).trim() + if (k === root.trim() || seen.has(k)) return false + seen.add(k) + return true + }) + const shown = items.slice(0, maxRelations) + const lines = shown.map((r) => { + const t = temporal( + (r.metadata as Record | undefined)?.temporalContext as + | Record + | undefined, + ) + const when = t.length ? t.join(", ") : day(r.updatedAt as string) + return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}` + }) + if (items.length > shown.length) + lines.push(` ${arrow} … +${items.length - shown.length} more`) + return lines + } + + const renderDocs = (ds: Array> | undefined) => + (ds ?? []).slice(0, maxDocuments).map((d) => { + const title = d.title ? `"${d.title}"` : "(untitled)" + const type = d.type ? ` (${d.type})` : "" + const summary = d.summary ? ` — ${d.summary}` : "" + return ` Document: ${title}${type}${summary}` + }) + + const results = (response.results ?? []).filter( + (m) => ((m.similarity as number) ?? 0) >= minSimilarity, + ) + if (!results.length) return "No relevant memories found." + + const total = response.total ?? results.length + const header = [ + `${results.length} memor${results.length === 1 ? "y" : "ies"}` + + (total !== results.length ? ` of ${total}` : "") + + ", ranked by relevance.", + includeLegend && + "Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.", + ] + .filter(Boolean) + .join(" ") + + const arrows = [ + ["parents", "←"], + ["children", "→"], + ["related", "~"], + ] as const + + const blocks = results.map((m) => { + const score = (m.similarity as number)?.toFixed(2) ?? "—" + const prefix = includeScores ? `${score} ` : "" + const memory = (m.memory as string) ?? "" + + if (m.isAggregated) return `${prefix}agg ${memory}` + + if (m.chunk != null && m.memory == null) { + const body = (m.chunk as string).replace(/\s+$/, "") + const text = + body.length > maxChunkLength + ? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]` + : body + return [ + `${prefix}chunk ${describeMeta(m.metadata as Record | null)}`.trimEnd(), + ...renderDocs( + m.documents as Array> | undefined, + ), + ...text.split("\n").map((l: string) => ` ${l}`), + ].join("\n") + } + + const meta = describeMeta(m.metadata as Record | null) + const ctx = (m.context ?? {}) as Record< + string, + Array> + > + return [ + `${prefix}${memory}`, + meta + ? ` Source: ${meta}` + : day(m.updatedAt as string) + ? ` Source: updated ${day(m.updatedAt as string)}` + : null, + ...renderDocs(m.documents as Array> | undefined), + ...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)), + ] + .filter(Boolean) + .join("\n") + }) + + return [header, "", blocks.join("\n\n")].join("\n") +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 00d3692d..ce0f377c 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -5,7 +5,8 @@ import { registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server" -import { SupermemoryClient, getMemoryText } from "./client" +import { SupermemoryClient } from "./client" +import { formatMemories } from "./format" import { initPosthog, posthog } from "./posthog" import { z } from "zod" import mcpAppHtml from "../dist/mcp-app.html" @@ -26,6 +27,8 @@ type Props = { const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000 +const MAX_RECALL_CHARS = 200000 + export class SupermemoryMCP extends McpAgent { private clientInfo: { name: string; version?: string } | null = null private cachedContainerTags: string[] = [] @@ -680,10 +683,21 @@ export class SupermemoryMCP extends McpAgent { const clientInfo = await this.getClientInfo() const startTime = Date.now() - if (includeProfile) { - const profileResult = await client.getProfile(query) - const parts: string[] = [] + const searchResult = await client.search(query, 10, undefined, { + searchMode: "hybrid", + include: { + documents: true, + relatedMemories: true, + summaries: false, + chunks: false, + forgottenMemories: false, + }, + }) + const parts: string[] = [] + + if (includeProfile) { + const profileResult = await client.getProfile() if ( profileResult.profile.static.length > 0 || profileResult.profile.dynamic.length > 0 @@ -701,54 +715,23 @@ export class SupermemoryMCP extends McpAgent { parts.push(`- ${fact}`) } } - } - - if (profileResult.searchResults?.results.length) { - parts.push("\n## Relevant Memories") - for (const [ - i, - memory, - ] of profileResult.searchResults.results.entries()) { - parts.push( - `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`, - ) - if (memory.title) parts.push(`**${memory.title}**`) - parts.push(getMemoryText(memory)) - } - } - - const endTime = Date.now() - - // Track search event - posthog - .memorySearch({ - query_length: query.length, - results_count: profileResult.searchResults?.results.length || 0, - search_duration_ms: endTime - startTime, - container_tags_count: 1, - source: "mcp", - userId: this.props?.userId || "unknown", - mcp_client_name: clientInfo?.name, - mcp_client_version: clientInfo?.version, - sessionId: this.getMcpSessionId(), - containerTag: containerTag || this.props?.containerTag, - }) - .catch((error) => console.error("PostHog tracking error:", error)) - - return { - content: [ - { - type: "text" as const, - text: - parts.length > 0 - ? parts.join("\n") - : "No memories or profile found.", - }, - ], + parts.push("") } } - const searchResult = await client.search(query, 10) + parts.push("## Relevant Memories") + parts.push( + formatMemories( + { + results: searchResult.results as unknown as Array< + Record + >, + total: searchResult.total, + }, + { includeScores: true, includeLegend: true }, + ), + ) + const endTime = Date.now() // Track search event @@ -767,22 +750,18 @@ export class SupermemoryMCP extends McpAgent { }) .catch((error) => console.error("PostHog tracking error:", error)) - if (searchResult.results.length === 0) { - return { - content: [{ type: "text" as const, text: "No memories found." }], - } + const text = parts.join("\n") + return { + content: [ + { + type: "text" as const, + text: + text.length > MAX_RECALL_CHARS + ? `${text.slice(0, MAX_RECALL_CHARS)}...` + : text, + }, + ], } - - const parts = ["## Relevant Memories"] - for (const [i, memory] of searchResult.results.entries()) { - parts.push( - `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`, - ) - if (memory.title) parts.push(`**${memory.title}**`) - parts.push(getMemoryText(memory)) - } - - return { content: [{ type: "text" as const, text: parts.join("\n") }] } } catch (error) { const message = error instanceof Error ? error.message : "An unexpected error occurred" diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts new file mode 100644 index 00000000..8289ccd9 --- /dev/null +++ b/apps/mcp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["e2e/**/*.test.ts"], + testTimeout: 90_000, + hookTimeout: 30_000, + }, +}) diff --git a/apps/memory-graph-playground/src/app/api/container-tags/route.ts b/apps/memory-graph-playground/src/app/api/container-tags/route.ts new file mode 100644 index 00000000..324e3bc5 --- /dev/null +++ b/apps/memory-graph-playground/src/app/api/container-tags/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server" + +const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai" + +export async function POST(request: Request) { + try { + const { apiKey } = await request.json() + + if (!apiKey) { + return NextResponse.json( + { error: "API key is required" }, + { status: 400 }, + ) + } + + const containerTagsUrl = new URL( + "/v3/container-tags/list", + SUPERMEMORY_API_BASE_URL, + ) + + const response = await fetch(containerTagsUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return NextResponse.json( + { error: errorData.message || `API error: ${response.status}` }, + { status: response.status }, + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error("Container tags API error:", error) + return NextResponse.json( + { error: "Failed to fetch container tags" }, + { status: 500 }, + ) + } +} diff --git a/apps/memory-graph-playground/src/app/api/graph/route.ts b/apps/memory-graph-playground/src/app/api/graph/route.ts index c722c625..67b62d34 100644 --- a/apps/memory-graph-playground/src/app/api/graph/route.ts +++ b/apps/memory-graph-playground/src/app/api/graph/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server" +const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai" + export async function POST(request: Request) { try { const body = await request.json() @@ -9,6 +11,7 @@ export async function POST(request: Request) { limit = 500, sort = "createdAt", order = "desc", + containerTags, } = body if (!apiKey) { @@ -18,23 +21,28 @@ export async function POST(request: Request) { ) } - const response = await fetch( - "https://api.supermemory.ai/v3/documents/documents", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - page, - limit, - sort, - order, - }), - }, + const graphUrl = new URL( + "/v3/documents/documents", + SUPERMEMORY_API_BASE_URL, ) + const response = await fetch(graphUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + page, + limit, + sort, + order, + ...(Array.isArray(containerTags) && containerTags.length > 0 + ? { containerTags } + : {}), + }), + }) + if (!response.ok) { const errorData = await response.json().catch(() => ({})) return NextResponse.json( diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 68a6f945..8131ef69 100644 --- a/apps/memory-graph-playground/src/app/page.tsx +++ b/apps/memory-graph-playground/src/app/page.tsx @@ -1,16 +1,52 @@ "use client" -import { useState, useCallback, useMemo } from "react" +import { useState, useCallback, useEffect, useMemo } from "react" import { MemoryGraph, - type DocumentWithMemories, type GraphApiDocument, type GraphApiMemory, + type GraphThemeColors, + type MemoryRelation, } from "@supermemory/memory-graph" import { generateMockGraphData } from "@supermemory/memory-graph/mock-data" +interface PlaygroundApiMemory { + id: string + memory?: string | null + content?: string | null + isStatic?: boolean + spaceId?: string | null + isLatest?: boolean + isForgotten?: boolean + forgetAfter?: string | null + forgetReason?: string | null + version?: number + parentMemoryId?: string | null + rootMemoryId?: string | null + createdAt: string + updatedAt: string + relation?: MemoryRelation | null + updatesMemoryId?: string | null + nextVersionId?: string | null + memoryRelations?: Record | null + spaceContainerTag?: string | null +} + +interface PlaygroundApiDocument { + id: string + title: string | null + summary?: string | null + documentType?: string + type?: string + containerTags?: string[] + createdAt: string + updatedAt: string + memories?: PlaygroundApiMemory[] + memoryEntries?: PlaygroundApiMemory[] +} + interface DocumentsResponse { - documents: DocumentWithMemories[] + documents: PlaygroundApiDocument[] pagination: { currentPage: number limit: number @@ -19,42 +55,82 @@ interface DocumentsResponse { } } +interface ContainerTagOption { + id: string + name?: string | null + containerTag: string + documentCount?: number + memoryCount?: number + lastActivityAt?: string | null +} + +type GraphVariant = "consumer" | "console" +type LoadBehavior = "zoom" | "manual" | "background" + +const PAGE_SIZE = 100 +const BACKGROUND_LOAD_DELAY_MS = 900 +const CONSUMER_GRAPH_COLORS = { + bg: "transparent", + edgeDerives: "#9ca3af", +} satisfies Partial + /** Convert the external API format to the internal graph format */ -function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] { - return docs.map((doc) => ({ - id: doc.id, - title: doc.title, - summary: doc.summary ?? null, - documentType: doc.documentType, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - memories: doc.memories.map( - (mem): GraphApiMemory => ({ - id: mem.id, - memory: mem.content, - isStatic: mem.isStatic ?? false, - spaceId: mem.spaceId ?? "", - isLatest: mem.isLatest ?? true, - isForgotten: mem.isForgotten ?? false, - forgetAfter: mem.forgetAfter ?? null, - forgetReason: mem.forgetReason ?? null, - version: mem.version ?? 1, - parentMemoryId: mem.parentMemoryId ?? null, - rootMemoryId: mem.rootMemoryId ?? null, - createdAt: mem.createdAt, - updatedAt: mem.updatedAt, - }), - ), - })) +function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] { + return docs.map((doc) => { + const memories = doc.memories ?? doc.memoryEntries ?? [] + + return { + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.documentType ?? doc.type ?? "unknown", + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: memories.map( + (mem): GraphApiMemory => ({ + id: mem.id, + memory: mem.memory ?? mem.content ?? "", + isStatic: mem.isStatic ?? false, + spaceId: mem.spaceId ?? "", + isLatest: mem.isLatest ?? true, + isForgotten: mem.isForgotten ?? false, + forgetAfter: mem.forgetAfter ?? null, + forgetReason: mem.forgetReason ?? null, + version: mem.version ?? 1, + parentMemoryId: mem.parentMemoryId ?? null, + rootMemoryId: mem.rootMemoryId ?? null, + createdAt: mem.createdAt, + updatedAt: mem.updatedAt, + relation: mem.relation ?? null, + updatesMemoryId: mem.updatesMemoryId ?? null, + nextVersionId: mem.nextVersionId ?? null, + memoryRelations: mem.memoryRelations ?? null, + spaceContainerTag: mem.spaceContainerTag ?? null, + }), + ), + } + }) } export default function Home() { const [apiKey, setApiKey] = useState("") - const [documents, setDocuments] = useState([]) + const [containerTag, setContainerTag] = useState("") + const [containerTags, setContainerTags] = useState([]) + const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false) + const [containerTagsError, setContainerTagsError] = useState( + null, + ) + const [documents, setDocuments] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) const [showGraph, setShowGraph] = useState(false) const [stressTestCount, setStressTestCount] = useState(0) + const [graphVariant, setGraphVariant] = useState("consumer") + const [loadBehavior, setLoadBehavior] = useState("zoom") + const [pagination, setPagination] = useState< + DocumentsResponse["pagination"] | null + >(null) // State for slideshow const [isSlideshowActive, setIsSlideshowActive] = useState(false) @@ -64,13 +140,47 @@ export default function Home() { documents: GraphApiDocument[] } | null>(null) - const PAGE_SIZE = 500 + const selectedContainerTags = useMemo(() => { + const trimmed = containerTag.trim() + return trimmed ? [trimmed] : undefined + }, [containerTag]) + + const fetchContainerTags = useCallback(async () => { + if (!apiKey || isLoadingContainerTags) return + + setIsLoadingContainerTags(true) + setContainerTagsError(null) + + try { + const response = await fetch("/api/container-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to fetch container tags") + } + + const data = (await response.json()) as ContainerTagOption[] + setContainerTags(data) + } catch (err) { + setContainerTagsError( + err instanceof Error ? err : new Error("Unknown error"), + ) + } finally { + setIsLoadingContainerTags(false) + } + }, [apiKey, isLoadingContainerTags]) const fetchDocuments = useCallback( async (page: number, append = false) => { if (!apiKey) return - if (page === 1) { + if (append) { + setIsLoadingMore(true) + } else { setIsLoading(true) } setError(null) @@ -87,6 +197,7 @@ export default function Home() { limit: PAGE_SIZE, sort: "createdAt", order: "desc", + containerTags: selectedContainerTags, }), }) @@ -103,6 +214,7 @@ export default function Home() { setDocuments(data.documents) } + setPagination(data.pagination) setShowGraph(true) setMockData(null) setStressTestCount(0) @@ -110,19 +222,27 @@ export default function Home() { setError(err instanceof Error ? err : new Error("Unknown error")) } finally { setIsLoading(false) + setIsLoadingMore(false) } }, - [apiKey], + [apiKey, selectedContainerTags], ) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (apiKey) { setDocuments([]) + setPagination(null) + void fetchContainerTags() fetchDocuments(1) } } + const handleLoadMoreDocuments = useCallback(() => { + if (!pagination || pagination.currentPage >= pagination.totalPages) return + fetchDocuments(pagination.currentPage + 1, true) + }, [fetchDocuments, pagination]) + const handleStressTest = (count: number) => { const data = generateMockGraphData({ documentCount: count, @@ -131,6 +251,7 @@ export default function Home() { }) setMockData({ documents: data.documents }) setDocuments([]) + setPagination(null) setStressTestCount(count) setShowGraph(true) setError(null) @@ -157,7 +278,67 @@ export default function Home() { return toGraphDocuments(documents) }, [documents, mockData]) + const availableContainerTags = useMemo(() => { + const options = new Map() + for (const tag of containerTags) { + if (tag.containerTag) options.set(tag.containerTag, tag) + } + for (const doc of documents) { + for (const tag of doc.containerTags ?? []) { + if (tag && !options.has(tag)) { + options.set(tag, { id: tag, containerTag: tag, name: tag }) + } + } + const memories = doc.memories ?? doc.memoryEntries ?? [] + for (const mem of memories) { + const tag = mem.spaceContainerTag + if (tag && !options.has(tag)) { + options.set(tag, { id: tag, containerTag: tag, name: tag }) + } + } + } + return [...options.values()] + }, [containerTags, documents]) + const displayCount = mockData ? stressTestCount : documents.length + const hasMore = + !mockData && + pagination != null && + pagination.currentPage < pagination.totalPages + const totalCount = mockData + ? stressTestCount + : (pagination?.totalItems ?? documents.length) + const maxNodes = mockData ? 1000 : undefined + const graphHandlesLoadMore = loadBehavior === "zoom" + + useEffect(() => { + if ( + loadBehavior !== "background" || + !showGraph || + mockData || + !hasMore || + isLoading || + isLoadingMore || + error + ) { + return + } + + const timer = window.setTimeout( + handleLoadMoreDocuments, + BACKGROUND_LOAD_DELAY_MS, + ) + return () => window.clearTimeout(timer) + }, [ + error, + handleLoadMoreDocuments, + hasMore, + isLoading, + isLoadingMore, + loadBehavior, + mockData, + showGraph, + ]) return (
@@ -178,9 +359,49 @@ export default function Home() { type="password" placeholder="Enter your Supermemory API key" value={apiKey} - onChange={(e) => setApiKey(e.target.value)} + onChange={(e) => { + setApiKey(e.target.value) + setContainerTags([]) + setContainerTagsError(null) + }} className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" /> +
+ setContainerTag(e.target.value)} + onFocus={() => { + if (availableContainerTags.length === 0) { + void fetchContainerTags() + } + }} + disabled={!apiKey} + placeholder={ + isLoadingContainerTags + ? "Loading container tags..." + : "All container tags" + } + className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50" + /> + + {availableContainerTags.map((tag) => ( + + ))} + + +
+ Mode: +
+ {(["consumer", "console"] as const).map((variant) => ( + + ))} +
+
+ Load: +
+ {(["zoom", "manual", "background"] as const).map((behavior) => ( + + ))} +
+ {loadBehavior === "manual" && ( + + )} + {loadBehavior === "background" && !mockData && hasMore && ( + + Auto paging + + )} +
{/* Stress test buttons */} Stress Test: {[50, 100, 200, 500].map((count) => ( @@ -300,13 +593,24 @@ export default function Home() { 0} isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} onSlideshowStop={handleSlideshowStop} + totalCount={totalCount} + colors={ + graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined + } >

diff --git a/apps/web/app/(app)/brain-home/page.tsx b/apps/web/app/(app)/brain-home/page.tsx new file mode 100644 index 00000000..187ed230 --- /dev/null +++ b/apps/web/app/(app)/brain-home/page.tsx @@ -0,0 +1,992 @@ +"use client" + +import { Suspense } from "react" +import { useSearchParams } from "next/navigation" +import { GoogleDrive, Notion } from "@ui/assets/icons" +import { cn } from "@lib/utils" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" +import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react" +import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { Header } from "@/components/header" +import { HomeChatComposer } from "@/components/chat/home-chat-composer" +import { + RecommendedRail, + SourcesHealthRail, +} from "@/components/brain-home/widgets" + +const modalCardStyle = { + 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 = { + 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)", +} + +type Space = { + id: string + name: string + icon: React.ReactNode + itemCount: number + lastSync: string +} + +const _SPACES: Space[] = [ + { + id: "drive", + name: "My Drive", + icon: , + itemCount: 287, + lastSync: "2h ago", + }, + { + id: "notion", + name: "Company Notion", + icon: , + itemCount: 142, + lastSync: "12m ago", + }, + { + id: "gmail", + name: "Gmail", + icon: , + itemCount: 1843, + lastSync: "Just now", + }, +] + +type Stat = { + label: string + value: string + delta?: string + deltaTint?: "up" | "down" | "neutral" +} + +const STATS: Stat[] = [ + { + label: "Items ingested", + value: "2,272", + delta: "+148 this week", + deltaTint: "up", + }, + { + label: "Agent sessions", + value: "184", + delta: "+42 vs last wk", + deltaTint: "up", + }, + { + label: "Active contributors", + value: "4", + delta: "of 6 invited", + deltaTint: "neutral", + }, + { + label: "Top space", + value: "Gmail", + delta: "1,843 items", + deltaTint: "neutral", + }, +] + +type AgentSeries = { + key: string + label: string + color: string +} + +const AGENT_SERIES: AgentSeries[] = [ + { key: "cursor", label: "Cursor", color: "#4BA0FA" }, + { key: "claudeCode", label: "Claude Code", color: "#FF8A47" }, + { key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" }, + { key: "chatgpt", label: "ChatGPT", color: "#10A37F" }, + { key: "other", label: "Other", color: "#525D6E" }, +] + +const SESSIONS_BY_DAY: { + label: string + values: Record +}[] = [ + { + label: "Mon", + values: { + cursor: 8, + claudeCode: 4, + claudeDesktop: 3, + chatgpt: 2, + other: 1, + }, + }, + { + label: "Tue", + values: { + cursor: 10, + claudeCode: 6, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Wed", + values: { + cursor: 12, + claudeCode: 9, + claudeDesktop: 5, + chatgpt: 4, + other: 1, + }, + }, + { + label: "Thu", + values: { + cursor: 9, + claudeCode: 5, + claudeDesktop: 4, + chatgpt: 3, + other: 1, + }, + }, + { + label: "Fri", + values: { + cursor: 14, + claudeCode: 10, + claudeDesktop: 6, + chatgpt: 4, + other: 2, + }, + }, + { + label: "Sat", + values: { + cursor: 5, + claudeCode: 3, + claudeDesktop: 2, + chatgpt: 1, + other: 1, + }, + }, + { + label: "Sun", + values: { + cursor: 17, + claudeCode: 12, + claudeDesktop: 7, + chatgpt: 4, + other: 1, + }, + }, +] + +const RECENT_ANSWERS = [ + { + question: "What did we decide about pricing?", + answer: "Pro stays at $20/mo. No overages, top-up only.", + who: "You", + when: "2h ago", + }, + { + question: "What's blocking the Acme deal?", + answer: "MSA review — Jane drafting a net-30 counter.", + who: "You", + when: "4h ago", + }, + { + question: "Who owns the onboarding rewrite?", + answer: "Design team. Ship target Friday.", + who: "Sarah Kim", + when: "Yesterday", + }, +] + +const WEEKLY_DIGEST = { + range: "Mon – Fri", + headline: + "Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.", + bullets: [ + { + label: "5 deadlines coming up next week", + tint: "text-[#4BA0FA]", + }, + { + label: "2 conflicts surfaced between Notion and Slack", + tint: "text-[#FF8A47]", + }, + { + label: "Sarah Kim led contributions with 14 docs", + tint: "text-[#A1A1AA]", + }, + ], +} + +const TEAM_THIS_WEEK = [ + { + name: "Sarah Kim", + email: "sarah@acme.com", + contributions: 14, + summary: "14 docs · 3 spaces", + color: "#FF8A47", + }, + { + name: "Jane Doe", + email: "jane@acme.com", + contributions: 11, + summary: "11 docs · 2 spaces", + color: "#4BA0FA", + }, + { + name: "Mahesh S.", + email: "mahesh@acme.com", + contributions: 9, + summary: "9 docs · 1 space", + color: "#A1A1AA", + }, + { + name: "Alex Chen", + email: "alex@acme.com", + contributions: 7, + summary: "7 docs · 2 spaces", + color: "#10A37F", + }, +] + +const TEAM_RECENT = [ + { who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" }, + { who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" }, + { who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" }, + { + who: "Sarah Kim", + what: "connected Notion to Sales space", + when: "Yesterday", + }, +] + +const ACTIVITY = [ + { + when: "2m", + text: "23 new docs ingested from Drive", + source: "drive", + }, + { + when: "14m", + text: "Jane added a memo to acme-deal-q2", + source: "notion", + }, + { + when: "1h", + text: "Decision surfaced: pricing locked at $20 Pro", + source: "slack", + }, + { + when: "3h", + text: "14 Notion pages updated by Sarah Kim", + source: "notion", + }, + { + when: "5h", + text: "New entity formed: pricing-v3", + source: "system", + }, + { + when: "Yesterday", + text: "MSA contract uploaded · auto-tagged contracts/MSA", + source: "drive", + }, +] + +const _RECENT_ITEMS: Record = { + drive: [ + { title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" }, + { title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" }, + { title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" }, + { title: "Customer feedback Q1.docx", meta: "Yesterday" }, + { title: "Brand assets / logos", meta: "2 days ago" }, + ], + notion: [ + { title: "Engineering / Weekly sync", meta: "Updated 12m ago" }, + { title: "Sales / Acme account", meta: "Updated 1h ago" }, + { title: "Brand / Voice & tone", meta: "Yesterday" }, + { title: "Onboarding rewrite spec", meta: "2 days ago" }, + ], + gmail: [ + { title: "Re: Acme contract review", meta: "Just now · jane@acme.com" }, + { title: "Customer feedback digest", meta: "10m ago" }, + { title: "Re: Pricing decision", meta: "1h ago" }, + { title: "Onboarding ship plan", meta: "3h ago" }, + ], +} + +export default function BrainHomePage() { + return ( + + + + ) +} + +function BrainHomeInner() { + const searchParams = useSearchParams() + const empty = searchParams?.get("empty") === "1" + + return ( +

+
+ +
+
+
+ +
{}} onOpenSearch={() => {}} /> + +
+
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+
+ {}} /> +
+
+
+ ) +} + +function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) { + const EMPTY_STATS: Stat[] = [ + { + label: "Items ingested", + value: "—", + delta: "Connect a source", + deltaTint: "neutral", + }, + { + label: "Agent sessions", + value: "—", + delta: "Install an agent", + deltaTint: "neutral", + }, + { + label: "Active contributors", + value: "1", + delta: "Just you so far", + deltaTint: "neutral", + }, + { + label: "Top space", + value: "—", + delta: "Nothing connected", + deltaTint: "neutral", + }, + ] + const visible = empty ? EMPTY_STATS : stats + return ( +
+ {visible.map((s) => ( +
+

+ {s.label} +

+

+ {s.value} +

+ {s.delta && ( +

+ {s.delta} +

+ )} +
+ ))} +
+ ) +} + +function BrainActivityChart({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+
+

+ Sessions by agent +

+

+ 0 sessions yet — once you install Cursor, Claude Code, or any + MCP-capable agent, usage shows up here. +

+
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => ( +
+
+ + {d} + +
+ ))} +
+ +
+ ) + } + const totalsByAgent = AGENT_SERIES.map((s) => ({ + ...s, + total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0), + })) + const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0) + const dayTotals = SESSIONS_BY_DAY.map((d) => + AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0), + ) + const max = Math.max(...dayTotals) + + return ( +
+
+
+
+

+ Sessions by agent +

+

+ {grandTotal} agent sessions, last 7 days — which tool the team + reached for. +

+
+
+ {totalsByAgent.map((s) => ( + + + {s.label}{" "} + {s.total} + + ))} +
+
+ +
+ {SESSIONS_BY_DAY.map((d, dayIdx) => { + const BAR_AREA_PX = 140 + const totalForDay = dayTotals[dayIdx] ?? 0 + const barHeightPx = Math.max( + 6, + Math.round((totalForDay / max) * BAR_AREA_PX), + ) + return ( +
+
+
+ {AGENT_SERIES.map((series, sIdx) => { + const val = d.values[series.key] ?? 0 + if (val === 0) return null + const segHeight = + totalForDay > 0 ? (val / totalForDay) * 100 : 0 + return ( +
+ ) + })} +
+
+ + {d.label} + +
+ ) + })} +
+
+ ) +} + +function _TabButton({ + active, + onClick, + icon, + label, + count, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string + count: number +}) { + return ( + + ) +} + +function SectionHeader({ title, cta }: { title: string; cta?: string }) { + return ( +
+

+ {title} +

+ {cta && ( + + )} +
+ ) +} + +function BrainPulseBlock({ empty }: { empty?: boolean }) { + return ( +
+ + +
+ ) +} + +function RecentAnswersCard({ empty }: { empty?: boolean }) { + return ( +
+
+

+ Recent answers +

+ {!empty && ( + + )} +
+ {empty ? ( +
+

+ No questions asked yet. +

+

+ Ask the brain something below — answers and the team's history will + show up here. +

+
+ ) : ( +
    + {RECENT_ANSWERS.map((a) => ( +
  • +

    + {a.question} +

    +

    + → {a.answer} +

    +

    + {a.who} · {a.when} +

    +
  • + ))} +
+ )} +
+ ) +} + +function WeeklyDigestCard({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+
+

+ This week +

+

+ Your brain is just getting started. +

+

+ Once sources are connected and the team starts asking questions, + you'll see decisions, deadlines, and contributor highlights here. +

+ +
+ ) + } + return ( +
+
+
+

+ This week +

+

+ {WEEKLY_DIGEST.range} +

+
+ +

+ {WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) => + /^\d+$/.test(piece) ? ( + + {piece} + + ) : ( + {piece} + ), + )} +

+ +
    + {WEEKLY_DIGEST.bullets.map((b) => ( +
  • + + + {b.label} + +
  • + ))} +
+ + +
+ ) +} + +function ActivityFeedBlock({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+ +
+
+ +
+
+

+ Nothing happening yet. +

+

+ As sources sync and the team starts using the brain, every event + will show up here in real time. +

+
+
+
+ ) + } + const sourceIcon = (s: string) => { + if (s === "drive") return + if (s === "notion") return + if (s === "slack") + return + return + } + return ( +
+ +
+ {ACTIVITY.map((a, i) => ( +
+
+ {sourceIcon(a.source)} +
+

+ {a.text} +

+ + {a.when} + +
+ ))} +
+
+ ) +} + +function TeamActivityRail({ empty }: { empty?: boolean }) { + if (empty) { + return ( +
+

+ Just you so far +

+

+ A brain gets sharper as more people contribute. Invite teammates to + see who's adding what. +

+ +
+ ) + } + return ( +
+
+

+ Team activity +

+ + This week + +
+

+ What your team's been adding to the brain. +

+ +
+ {TEAM_THIS_WEEK.map((member) => ( +
+
+ {member.name[0]} +
+
+

+ {member.name} +

+

+ {member.summary} +

+
+ + {member.contributions} + +
+ ))} +
+ +
+

+ Recent +

+
    + {TEAM_RECENT.map((event, i) => ( +
  • + {event.who} {event.what} + · {event.when} +
  • + ))} +
+
+ + +
+ ) +} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 331f950c..700e93a0 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -2,12 +2,13 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { PWAInstallPrompt } from "@/components/pwa-install-prompt" +import { SettingsModalProvider } from "@/components/settings/settings-modal" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( - <> + {children} - + ) } diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 3d67b3ba..64ee0299 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,1372 +1,300 @@ "use client" -import { - useState, - useRef, - useCallback, - useEffect, - useMemo, - type Dispatch, - type RefObject, - type ReactNode, - type SetStateAction, -} from "react" -import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { toast } from "sonner" import { useAuth } from "@lib/auth-context" -import { Logo } from "@ui/assets/Logo" -import { motion, AnimatePresence } from "motion/react" -import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" -import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" -import NovaOrb from "@/components/nova/nova-orb" -import Image from "next/image" -import { IntegrationGridCard } from "@/components/integrations/integration-grid-card" +import { BrainShell } from "@/components/onboarding-brain/shell" import { - CHROME_EXTENSION_URL, - RAYCAST_EXTENSION_URL, - ADD_MEMORY_SHORTCUT_URL, -} from "@repo/lib/constants" + StepAbout, + type AboutValues, +} from "@/components/onboarding-brain/step-about" import { - ChromeIcon, - AppleShortcutsIcon, - RaycastIcon, -} from "@/components/integration-icons" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" + StepSources, + type SourcesValues, +} from "@/components/onboarding-brain/step-sources" +import { StepIngest } from "@/components/onboarding-brain/step-ingest" import { - Sparkles, - ChevronLeft, - ChevronRight, - AlertCircle, - CheckCircle2, - Loader2, -} from "lucide-react" -import { analytics, type OnboardingStep } from "@/lib/analytics" -import { consumePendingConnectUrl } from "@/lib/constants" + StepTeam, + type TeamValues, +} from "@/components/onboarding-brain/step-team" +import { + BRAIN_STEPS, + type BrainMetadata, + type BrainMode, + type BrainStep, + containerTagFromWorkspace, + detectModeFromEmail, + generateOrgSlug, + generateUsername, + workspaceDomainFromEmail, + workspaceNameFromEmail, +} from "@/components/onboarding-brain/types" -type DetectedSource = "x" | "linkedin" | "resume" | null -type Status = "idle" | "processing" | "done" | "error" -type AccountLookupStatus = "checking" | "found" | "not_found" | "error" -type AccountLookup = { - source: "x" | "linkedin" - status: AccountLookupStatus - message: string -} -type DocStatus = - | "unknown" - | "queued" - | "extracting" - | "chunking" - | "embedding" - | "indexing" - | "done" - | "failed" +const STORAGE_KEY = "supermemory-brain-onboarding-v1" -function XIcon({ className }: { className?: string }) { - return ( - - ) -} - -function LinkedInIcon({ className }: { className?: string }) { - return ( - - ) -} - -function SubmitArrow() { - return ( - - Submit - - - ) -} - -function detectSource(value: string): DetectedSource { - const v = value.trim().toLowerCase() - if (!v) return null - if (v.includes("linkedin.com/in/") || v.includes("linkedin.com/pub/")) - return "linkedin" - if (v.includes("x.com/") || v.includes("twitter.com/") || v.startsWith("@")) - return "x" - if (/^[a-z0-9_]{1,50}$/i.test(v)) return "x" - return null -} - -function generateUsername(name: string) { - const base = - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/(^_|_$)/g, "") || "user" - return `${base}${Math.floor(100000 + Math.random() * 900000)}` -} - -function generateOrgSlug(name: string) { - const base = - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") || "org" - return `${base}-${Math.floor(100000 + Math.random() * 900000)}` -} - -const SOURCE_ICON: Record< - "x" | "linkedin", - React.FC<{ className?: string }> -> = { - x: XIcon, - linkedin: LinkedInIcon, -} - -const SOURCE_LABEL: Record<"x" | "linkedin", string> = { - x: "X profile detected - checking account", - linkedin: "LinkedIn profile detected - checking account", -} - -const SOURCE_NAME: Record<"x" | "linkedin", string> = { - x: "X", - linkedin: "LinkedIn", -} - -type SpotlightItem = { - id: string - title: string - description: string - icon: ReactNode - pro?: boolean - onOpen: () => void -} - -type SpotlightCategoryId = "coding" | "productivity" | "agents" - -const SPOTLIGHT_CATEGORY_TABS: { id: SpotlightCategoryId; label: string }[] = [ - { id: "coding", label: "Coding" }, - { id: "productivity", label: "Productivity" }, - { id: "agents", label: "Agents" }, -] - -const SPOTLIGHT_CATEGORY_ORDER: SpotlightCategoryId[] = - SPOTLIGHT_CATEGORY_TABS.map((t) => t.id) - -function spotlightPluginCornerIcon(src: string, alt: string) { - return ( - {alt} - ) -} - -const spotlightConnectionsIcon = ( -
- - - -
-) - -function buildSpotlightCatalog( - router: ReturnType, -): Record { - const track = (integration: string) => - analytics.onboardingIntegrationClicked({ integration }) - - const openPluginsPanel = () => { - void router.push("/?view=plugins") - } - - return { - coding: [ - { - id: "mcp", - title: "Connect to AI", - description: - "Set up MCP to use your memory in Cursor, Claude, and more", - icon: ( - MCP - ), - onOpen: () => { - track("mcp") - void router.push("/?view=integrations") - }, - }, - { - id: "coding-claude-supermemory", - title: "Claude Supermemory", - description: - "Persistent memory for Claude Code — context and decisions across sessions.", - icon: spotlightPluginCornerIcon( - "/images/plugins/claude-code.svg", - "Claude Supermemory", - ), - pro: true, - onOpen: () => { - track("plugin_claude_supermemory") - openPluginsPanel() - }, - }, - { - id: "coding-opencode", - title: "OpenCode", - description: - "Memory layer for OpenCode — search past sessions and inject context.", - icon: spotlightPluginCornerIcon( - "/images/plugins/opencode.svg", - "OpenCode", - ), - pro: true, - onOpen: () => { - track("plugin_opencode") - openPluginsPanel() - }, - }, - { - id: "connections", - title: "Connections", - description: - "Link Notion, Google Drive, or OneDrive to import your docs", - icon: spotlightConnectionsIcon, - pro: true, - onOpen: () => { - track("connections") - void router.push("/?add=connect") - }, - }, - ], - productivity: [ - { - id: "chrome", - title: "Chrome Extension", - description: - "Save any webpage, import bookmarks, sync ChatGPT memories", - icon: , - onOpen: () => { - window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer") - analytics.onboardingChromeExtensionClicked({ source: "onboarding" }) - }, - }, - { - id: "raycast", - title: "Raycast", - description: "Add and search memories from Raycast on Mac", - icon: , - onOpen: () => { - track("raycast") - window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer") - }, - }, - { - id: "shortcuts", - title: "Apple Shortcuts", - description: "Add memories directly from iPhone, iPad or Mac", - icon: , - onOpen: () => { - track("shortcuts") - window.open(ADD_MEMORY_SHORTCUT_URL, "_blank", "noopener,noreferrer") - }, - }, - { - id: "import", - title: "Import Bookmarks", - description: "Bring in X/Twitter bookmarks and turn them into memories", - icon: X, - onOpen: () => { - track("import_x") - void router.push("/?view=import") - }, - }, - ], - agents: [ - { - id: "agents-openclaw", - title: "OpenClaw", - description: - "Multi-platform memory for OpenClaw — Telegram, WhatsApp, Discord, Slack, and more.", - icon: spotlightPluginCornerIcon( - "/images/plugins/openclaw.svg", - "OpenClaw", - ), - pro: true, - onOpen: () => { - track("plugin_openclaw") - openPluginsPanel() - }, - }, - { - id: "agents-hermes", - title: "Hermes", - description: - "Memory layer for the Hermes agent — recall, capture, and user profile.", - icon: spotlightPluginCornerIcon("/images/plugins/hermes.svg", "Hermes"), - onOpen: () => { - track("plugin_hermes") - openPluginsPanel() - }, - }, - { - id: "agents-claude-supermemory", - title: "Claude Supermemory", - description: - "Persistent memory for Claude Code — context and decisions across sessions.", - icon: spotlightPluginCornerIcon( - "/images/plugins/claude-code.svg", - "Claude Supermemory", - ), - pro: true, - onOpen: () => { - track("plugin_claude_supermemory") - openPluginsPanel() - }, - }, - { - id: "agents-opencode", - title: "OpenCode", - description: - "Memory layer for OpenCode — search past sessions and inject context.", - icon: spotlightPluginCornerIcon( - "/images/plugins/opencode.svg", - "OpenCode", - ), - pro: true, - onOpen: () => { - track("plugin_opencode") - openPluginsPanel() - }, - }, - { - id: "console-api", - title: "Console & API", - description: - "API keys, orgs, and the hosted API for production agent workloads", - icon: , - onOpen: () => { - track("console_api") - window.open( - "https://console.supermemory.ai", - "_blank", - "noopener,noreferrer", - ) - }, - }, - ], - } -} - -function isAccountSource(source: DetectedSource): source is "x" | "linkedin" { - return source === "x" || source === "linkedin" -} - -const STATUS_TO_STEP: Record = { - idle: "profile_input", - processing: "processing", - done: "done", - error: "error", -} - -function useSpotlightAutoRotation( - status: Status, - pauseSpotlight: boolean, - setSpotlightCategory: Dispatch>, -) { - useEffect(() => { - if (status !== "processing") return - if (pauseSpotlight) return - const n = SPOTLIGHT_CATEGORY_ORDER.length - if (n <= 1) return - const t = setInterval(() => { - setSpotlightCategory((cur) => { - const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur) - const from = i >= 0 ? i : 0 - const next = (from + 1) % n - return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur - }) - }, 8000) - return () => clearInterval(t) - }, [status, pauseSpotlight, setSpotlightCategory]) -} - -function useInitialInputFocus(inputRef: RefObject) { - useEffect(() => { - const t = setTimeout(() => inputRef.current?.focus(), 500) - return () => clearTimeout(t) - }, [inputRef]) -} - -function useAccountLookup({ - detected, - status, - value, -}: { - detected: DetectedSource - status: Status - value: string -}) { - const [accountLookup, setAccountLookup] = useState(null) - - useEffect(() => { - if (status !== "idle") return - - const source = isAccountSource(detected) ? detected : null - const trimmedValue = value.trim() - - if (!source || !trimmedValue) { - setAccountLookup(null) - return - } - - const controller = new AbortController() - setAccountLookup({ - source, - status: "checking", - message: SOURCE_LABEL[source], - }) - - const timeout = setTimeout(async () => { - try { - const params = new URLSearchParams({ - source, - value: trimmedValue, - }) - const response = await fetch( - `/api/onboarding/account-status?${params.toString()}`, - { signal: controller.signal }, - ) - const data: { - found?: boolean - handle?: string - reason?: string - verified?: boolean - } = await response.json().catch(() => ({})) - - if (controller.signal.aborted) return - - if (response.ok && data.found === true) { - const account = - source === "x" && data.handle ? ` @${data.handle}` : "" - setAccountLookup({ - source, - status: "found", - message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`, - }) - return - } - - if ( - (response.ok && data.found === false) || - data.reason === "invalid" - ) { - setAccountLookup({ - source, - status: "not_found", - message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`, - }) - return - } - - setAccountLookup({ - source, - status: "error", - message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, - }) - } catch (err) { - if (controller.signal.aborted) return - console.error(err) - setAccountLookup({ - source, - status: "error", - message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`, - }) - } - }, 450) - - return () => { - clearTimeout(timeout) - controller.abort() - } - }, [detected, status, value]) - - return accountLookup -} - -function usePollingCleanup( - pollingRef: RefObject | null>, -) { - useEffect(() => { - return () => { - if (pollingRef.current) clearInterval(pollingRef.current) - } - }, [pollingRef]) -} - -function useDoneAnimation( - status: Status, - setStampLanded: Dispatch>, - setVisibleSnippets: Dispatch>, -) { - useEffect(() => { - if (status !== "done") return - setStampLanded(false) - setVisibleSnippets(0) - const t1 = setTimeout(() => setStampLanded(true), 400) - const t2 = setTimeout(() => setVisibleSnippets(1), 900) - const t3 = setTimeout(() => setVisibleSnippets(2), 1200) - const t4 = setTimeout(() => setVisibleSnippets(3), 1500) - return () => { - clearTimeout(t1) - clearTimeout(t2) - clearTimeout(t3) - clearTimeout(t4) - } - }, [status, setStampLanded, setVisibleSnippets]) -} - -export default function OnboardingPage() { +export default function BrainOnboardingPage() { const router = useRouter() - const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth() + const params = useSearchParams() + const { user, org, organizations, setActiveOrg, refetchOrganizations } = + useAuth() - const [value, setValue] = useState("") - const [detected, setDetected] = useState(null) - const [resumeFile, setResumeFile] = useState(null) - const [isDragging, setIsDragging] = useState(false) - const [status, setStatus] = useState("idle") - const [_docStatus, setDocStatus] = useState("queued") - const [memoriesCount, setMemoriesCount] = useState(0) - const [memorySnippets, setMemorySnippets] = useState([]) - const [docTitle, setDocTitle] = useState("") - const [errorMsg, setErrorMsg] = useState("") - const [stampLanded, setStampLanded] = useState(false) - const [visibleSnippets, setVisibleSnippets] = useState(0) - const inputRef = useRef(null) - const fileRef = useRef(null) - const pollingRef = useRef | null>(null) - const skippingRef = useRef(false) - const completedTrackedRef = useRef(false) - const [isSkipping, setIsSkipping] = useState(false) - const [spotlightCategory, setSpotlightCategory] = - useState("productivity") + const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about" + const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl) + ? stepFromUrl + : "about" - /** Navigate home, or back to the plugin connect page if one is pending. */ - const goHomeOrPendingConnect = useCallback(() => { - const pendingPath = consumePendingConnectUrl() - router.push(pendingPath ?? "/") - }, [router]) - const [pauseSpotlight, setPauseSpotlight] = useState(false) + const [step, setStep] = useState(initialStep) - const spotlightCatalog = useMemo( - () => buildSpotlightCatalog(router), + const detectedMode = useMemo( + () => detectModeFromEmail(user?.email), + [user?.email], + ) + const suggestedWorkspaceName = useMemo( + () => workspaceNameFromEmail(user?.email), + [user?.email], + ) + const domain = useMemo( + () => workspaceDomainFromEmail(user?.email), + [user?.email], + ) + + const [mode, setMode] = useState(detectedMode) + const [about, setAbout] = useState({ + name: user?.name ?? "", + about: "", + workspaceName: suggestedWorkspaceName, + workspaceDomain: domain ?? "", + }) + const [sources, setSources] = useState({ + connected: {}, + driveScope: "selective", + }) + const [team, setTeam] = useState({ + invites: [], + visibility: "team-private", + suggestChanges: false, + }) + + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return + const cached = JSON.parse(raw) as { + mode?: BrainMode + about?: AboutValues + sources?: SourcesValues + team?: TeamValues + } + if (cached.mode) setMode(cached.mode) + if (cached.about) setAbout((a) => ({ ...a, ...cached.about })) + if (cached.sources) setSources((s) => ({ ...s, ...cached.sources })) + if (cached.team) setTeam((t) => ({ ...t, ...cached.team })) + } catch {} + }, []) + + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ mode, about, sources, team }), + ) + } catch {} + }, [mode, about, sources, team]) + + const setStepAndUrl = useCallback( + (next: BrainStep) => { + setStep(next) + const url = new URL(window.location.href) + url.searchParams.set("step", next) + router.replace(url.pathname + url.search, { scroll: false }) + }, [router], ) - const categoryCards = spotlightCatalog[spotlightCategory] ?? [] - const bumpSpotlightCategory = useCallback( - (delta: number) => { - const n = SPOTLIGHT_CATEGORY_ORDER.length - if (n === 0) return - const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(spotlightCategory) - const from = i >= 0 ? i : 0 - const next = (from + delta + n) % n - const id = SPOTLIGHT_CATEGORY_ORDER[next] - if (id) setSpotlightCategory(id) - }, - [spotlightCategory], + const containerTag = useMemo( + () => + containerTagFromWorkspace( + about.workspaceName || suggestedWorkspaceName, + mode, + ), + [about.workspaceName, suggestedWorkspaceName, mode], ) - useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory) - useInitialInputFocus(inputRef) - const accountLookup = useAccountLookup({ detected, status, value }) - usePollingCleanup(pollingRef) - useDoneAnimation(status, setStampLanded, setVisibleSnippets) + const isScale = useMemo(() => { + const plan = (org?.metadata as Record | undefined)?.plan + return plan === "scale" || plan === "scale_yearly" + }, [org]) - // biome-ignore lint/correctness/useExhaustiveDependencies: fire per status transition only - useEffect(() => { - analytics.onboardingStepViewed({ - step: STATUS_TO_STEP[status], - trigger: "auto", - }) - if (status === "done" && !completedTrackedRef.current) { - completedTrackedRef.current = true - analytics.onboardingCompleted({ - source: isAccountSource(detected) ? detected : undefined, - memories_count: memoriesCount, - }) + const finish = useCallback(async () => { + try { + localStorage.removeItem(STORAGE_KEY) + } catch {} + router.push("/?onboarded=1") + }, [router]) + + const goNext = useCallback(() => { + const idx = BRAIN_STEPS.indexOf(step) + const next = BRAIN_STEPS[idx + 1] + if (!next) { + finish() + return } - }, [status]) + setStepAndUrl(next) + }, [step, setStepAndUrl, finish]) - const handleChange = (v: string) => { - setValue(v) - setDetected(detectSource(v)) - } + const [creatingOrg, setCreatingOrg] = useState(false) + const creatingOrgRef = useRef(false) const ensureOrg = useCallback(async () => { if (organizations && organizations.length > 0) return - const name = user?.name || user?.email || "Personal" + const name = (about.workspaceName || suggestedWorkspaceName).trim() const slug = generateOrgSlug(name) + const metadata: BrainMetadata & { signupSource: string } = { + signupSource: "consumer", + brainOnboardingVersion: "v1", + brainMode: mode, + brainWorkspaceName: name, + brainWorkspaceDomain: + mode === "team" ? about.workspaceDomain || domain : null, + brainContainerTag: containerTag, + ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), + } const result = await authClient.organization.create({ name, slug, - metadata: { signupSource: "consumer" }, + metadata, }) await setActiveOrg(result.data?.slug ?? slug) - if (user?.name) { + if (about.name.trim()) { await authClient.updateUser({ - displayUsername: user.name, - username: generateUsername(user.name), + name: about.name.trim(), + displayUsername: about.name.trim(), + username: generateUsername(about.name), }) } await refetchOrganizations() - }, [user, organizations, refetchOrganizations, setActiveOrg]) + }, [ + organizations, + about, + suggestedWorkspaceName, + mode, + domain, + containerTag, + setActiveOrg, + refetchOrganizations, + ]) - const handleSkip = useCallback(async () => { - if (skippingRef.current) return - skippingRef.current = true - setIsSkipping(true) - analytics.onboardingSkipped({ from_step: STATUS_TO_STEP[status] }) + const handleAboutContinue = useCallback(async () => { + if (creatingOrgRef.current) return + creatingOrgRef.current = true + setCreatingOrg(true) try { await ensureOrg() - const pendingPath = consumePendingConnectUrl() - router.push(pendingPath ?? "/") - } catch (err) { - console.error(err) - skippingRef.current = false - setIsSkipping(false) + goNext() + } catch (e) { + console.error("Failed to create organization:", e) + toast.error("Couldn't create your workspace. Please try again.") + } finally { + creatingOrgRef.current = false + setCreatingOrg(false) } - }, [ensureOrg, router, status]) + }, [ensureOrg, goNext]) - const pollDocument = useCallback((docId: string) => { - const maxAttempts = 60 - let attempt = 0 + const [sendingInvites, setSendingInvites] = useState(false) + const sendingInvitesRef = useRef(false) - pollingRef.current = setInterval(async () => { - attempt++ - if (attempt > maxAttempts) { - if (pollingRef.current) clearInterval(pollingRef.current) - setErrorMsg("Processing is taking too long. Try again later.") - setStatus("error") - return - } - - try { - const res = await $fetch("@get/documents/:id", { - params: { id: docId }, - disableValidation: true, - }) - - if (!res.data) return - - const doc = res.data as { - status?: DocStatus - memories?: { memory: string; title?: string }[] - title?: string - } - - const s = doc.status ?? "queued" - setDocStatus(s) - - if (doc.memories) { - setMemoriesCount(doc.memories.length) - setMemorySnippets( - doc.memories - .slice(0, 3) - .map((m: { memory: string; title?: string }) => m.memory) - .filter(Boolean), - ) - } - if (doc.title) setDocTitle(doc.title) - - if (s === "done") { - if (pollingRef.current) clearInterval(pollingRef.current) - await new Promise((r) => setTimeout(r, 600)) - setStatus("done") - } else if (s === "failed") { - if (pollingRef.current) clearInterval(pollingRef.current) - setErrorMsg("Processing failed. You can skip and try later.") - setStatus("error") - } - } catch { - // keep polling on transient errors - } - }, 1500) - }, []) - - const handleSubmit = useCallback( - async (source: "x" | "linkedin" | "resume", resumeFileOverride?: File) => { - analytics.onboardingProfileSubmitted({ source }) - setStatus("processing") - setSpotlightCategory("productivity") - setPauseSpotlight(false) - setDocStatus("queued") - setMemoriesCount(0) - setDocTitle("") - - try { - await ensureOrg() - - let docId: string | undefined - - if (source === "x" || source === "linkedin") { - const raw = value.trim() - const content = raw.startsWith("http") - ? raw - : source === "x" - ? `https://x.com/${raw.replace(/^@/, "")}` - : `https://${raw}` - const res = await $fetch("@post/documents", { - body: { - content, - metadata: { sm_source: "onboarding" }, - }, - }) - docId = (res.data as { id?: string } | undefined)?.id - } else if (source === "resume") { - const file = resumeFileOverride ?? resumeFile - if (!file) throw new Error("No resume file selected") - const formData = new FormData() - formData.append("file", file) - const uploadRes = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`, - { method: "POST", body: formData, credentials: "include" }, - ) - if (!uploadRes.ok) throw new Error("Resume upload failed") - const uploadData = await uploadRes.json() - docId = uploadData?.id - } - - if (docId) { - pollDocument(docId) - } else { - await new Promise((r) => setTimeout(r, 2000)) - setStatus("done") - } - } catch (err) { - console.error(err) - setErrorMsg("Something went wrong. You can skip and try later.") - setStatus("error") - } - }, - [value, resumeFile, ensureOrg, pollDocument], - ) - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - const f = e.dataTransfer.files[0] - if (f?.type === "application/pdf") { - setResumeFile(f) - handleSubmit("resume", f) + const handleTeamContinue = useCallback(async () => { + if (sendingInvitesRef.current) return + const pending = team.invites.filter((i) => i.email.trim()) + if (pending.length === 0) { + goNext() + return } - } + sendingInvitesRef.current = true + setSendingInvites(true) + try { + if (!org?.id) throw new Error("No active organization") + const results = await Promise.allSettled( + pending.map((inv) => + authClient.organization.inviteMember({ + email: inv.email.trim().toLowerCase(), + role: inv.role, + organizationId: org.id, + resend: true, + }), + ), + ) + const failed = results.filter( + (r) => + r.status === "rejected" || + (r.status === "fulfilled" && Boolean(r.value?.error)), + ).length + if (failed > 0) { + toast.error( + `${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`, + ) + } else { + toast.success( + `Sent ${pending.length} invite${pending.length === 1 ? "" : "s"}.`, + ) + } + } catch (e) { + console.error("Failed to send invites:", e) + toast.error("Couldn't send invites. You can invite teammates later.") + } finally { + sendingInvitesRef.current = false + setSendingInvites(false) + goNext() + } + }, [team.invites, org, goNext]) - const hasDetectedAccount = detected === "x" || detected === "linkedin" - const currentAccountLookup = - accountLookup?.source === detected ? accountLookup : null - const isCheckingAccount = - hasDetectedAccount && - (!currentAccountLookup || currentAccountLookup.status === "checking") - const canSubmit = Boolean( - hasDetectedAccount && - currentAccountLookup && - currentAccountLookup.status !== "checking" && - currentAccountLookup.status !== "not_found", - ) + const mcpUrl = "https://mcp.supermemory.ai/mcp" return ( - // biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF -
{ - e.preventDefault() - setIsDragging(true) - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} + - - {isDragging && ( - -

- Drop your PDF resume -

-
- )} -
- -
- - -
- -
- - {/* ── IDLE ── */} - {status === "idle" && ( - - - -

- Let NOVA know about you -

- -
-
- - {detected && detected !== "resume" && ( - - {(() => { - const Icon = SOURCE_ICON[detected as "x" | "linkedin"] - return - })()} - - )} - - - handleChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && canSubmit) - handleSubmit(detected as "x" | "linkedin") - }} - placeholder="Paste an X handle, LinkedIn URL, or drop a PDF" - className={cn( - "w-full py-3 bg-[#070E1B] border rounded-xl text-white text-sm placeholder:text-[#525966] focus:outline-none transition-all", - detected && detected !== "resume" - ? "pl-8 pr-11" - : "px-4 pr-11", - detected - ? "border-[#2261CA]/50 focus:border-[#2261CA]" - : "border-[#52596633] focus:border-white/20", - )} - /> - - - {hasDetectedAccount && detected !== "resume" && ( - - {isCheckingAccount ? ( - - ) : canSubmit ? ( - - handleSubmit(detected as "x" | "linkedin") - } - className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" - style={{ - background: - "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", - }} - > - - - ) : null} - - )} - -
- - - {detected && detected !== "resume" && ( - - {currentAccountLookup?.status === "found" && ( - - )} - {currentAccountLookup?.status === "not_found" && ( - - )} - {currentAccountLookup?.status === "error" && ( - - )} - - {currentAccountLookup?.message ?? - SOURCE_LABEL[detected as "x" | "linkedin"]} - - - )} - - - {!detected && ( - - {[ - { - label: "@yourhandle", - action: () => { - handleChange("@") - inputRef.current?.focus() - }, - }, - { - label: "linkedin.com/in/you", - action: () => { - handleChange("linkedin.com/in/") - inputRef.current?.focus() - }, - }, - { - label: "Drop a PDF resume", - action: () => fileRef.current?.click(), - }, - ].map((chip) => ( - - ))} - - )} -
- - { - const f = e.target.files?.[0] - if (f) { - setResumeFile(f) - handleSubmit("resume", f) - } - }} - /> -
- )} - - {/* ── PROCESSING ── */} - {status === "processing" && ( - - - -
-

- Finishing your first save -

-

- Most finish in under a minute. Below is optional: ways to add - more later. -

-
- -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: pause category rotation on hover/focus within */} -
setPauseSpotlight(true)} - onMouseLeave={() => setPauseSpotlight(false)} - onFocus={() => setPauseSpotlight(true)} - onBlur={(e) => { - if ( - !e.currentTarget.contains(e.relatedTarget as Node | null) - ) { - setPauseSpotlight(false) - } - }} - > -
- -
- {SPOTLIGHT_CATEGORY_TABS.map((tab) => ( - - ))} -
- -
- -
- {SPOTLIGHT_CATEGORY_TABS.map((tab) => ( -
- - - - {categoryCards.map((card) => ( - - ))} - - -
- - -
-
- )} - - {/* ── DONE ── */} - {status === "done" && ( - -
-

- It's in your memory -

-

- Your first save is ready. When you want more, use Integrations - for browser, phone, editor, and AI tools, all in one place. -

-
- - {/* Document card with stamp */} -
- {/* Clickable document card */} - router.push("/?view=list")} - className="group w-full text-left bg-[#080E18] border border-[rgba(255,255,255,0.07)] rounded-2xl p-4 cursor-pointer hover:border-[rgba(255,255,255,0.14)] transition-colors" - > - {/* Faux document lines */} -
-
-
-
-
-
-
-
-
-

- {docTitle || "Your document"} -

- - {memoriesCount} memories - -
-

- View in memories → -

- - - {/* Stamp */} - -
- {/* Ink ring ripple */} - {stampLanded && ( - - )} -
- - - Memorized - -
-
-
-
- - - - {/* Memory snippets */} -
-

- Nova learned -

- {memorySnippets.slice(0, 3).map((snippet, i) => ( - i - ? { opacity: 1, x: 0 } - : { opacity: 0, x: -8 } - } - transition={{ duration: 0.35, ease: "easeOut" }} - className="flex items-start gap-2 text-left" - > - -

- {snippet} -

-
- ))} -
- - {/* CTAs */} -
- - -
- - )} - - {/* ── ERROR ── */} - {status === "error" && ( - -

{errorMsg}

-
- - -
-
- )} - -
-
+ {step === "about" && ( + + )} + {step === "sources" && ( + + )} + {step === "ingest" && } + {step === "team" && ( + router.push("/settings/billing")} + /> + )} + ) } diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 17433857..98febe44 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -13,6 +13,7 @@ import { useQueryState } from "nuqs" import { Header, PublicHeader } from "@/components/header" import { MobileBottomNav } from "@/components/bottom-nav" import { ChatSidebar, HomeChatComposer } from "@/components/chat" +import type { ChatAttachmentDraft } from "@/components/chat/attachments" import { DashboardView } from "@/components/dashboard-view" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" @@ -24,6 +25,7 @@ import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" import { RaycastDetail } from "@/components/integrations/raycast-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" import { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" import { DocumentsCommandPalette } from "@/components/documents-command-palette" @@ -106,7 +108,7 @@ function ViewErrorFallback() { export default function NewPage() { const isMobile = useIsMobile() - const { user, session } = useAuth() + const { user, session, isSessionPending } = useAuth() const { selectedProject, selectedProjects, setSelectedProject } = useProject() const selectedProjectTag = selectedProjects[0] @@ -166,6 +168,9 @@ export default function NewPage() { const [queuedChatProject, setQueuedChatProject] = useState( null, ) + const [queuedChatAttachments, setQueuedChatAttachments] = useState< + ChatAttachmentDraft[] | null + >(null) const [queuedHighlightContent, setQueuedHighlightContent] = useState< string | null >(null) @@ -494,6 +499,7 @@ export default function NewPage() { setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedMessageSource("highlight") void setViewMode("chat") }, @@ -506,12 +512,14 @@ export default function NewPage() { model: ModelId, projectId: string, reasoningEffort: ReasoningEffort, + attachments?: ChatAttachmentDraft[], ) => { setQueuedHighlightContent(null) setQueuedChatSeed(message) setQueuedChatModel(model) setQueuedChatReasoningEffort(reasoningEffort) setQueuedChatProject(projectId) + setQueuedChatAttachments(attachments ?? null) setQueuedMessageSource("home") void setViewMode("chat") }, @@ -523,6 +531,7 @@ export default function NewPage() { setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) + setQueuedChatAttachments(null) setQueuedHighlightContent(null) setQueuedMessageSource("highlight") }, []) @@ -572,17 +581,20 @@ export default function NewPage() { const isDashboardShell = viewMode === "dashboard" || (viewMode === "graph" && isMobile) const isGraphMode = viewMode === "graph" - const showBottomNav = isMobile && !!session + const showBottomNav = isMobile && !!session && !isChatView + const isPublicIntegrations = + !session && !isSessionPending && viewMode === "integrations" return ( +
{showNovaBackdrop && ( @@ -598,7 +610,9 @@ export default function NewPage() { />
)} - {!session && viewMode === "mcp" ? ( + {isPublicIntegrations ? ( + + ) : !session && viewMode === "mcp" ? ( ) : (
) : viewMode === "integrations" ? (
- +
) : viewMode === "mcp" ? ( void setViewMode("integrations")} /> ) : viewMode === "graph" ? ( -
+
) : viewMode === "list" ? ( @@ -761,7 +779,7 @@ export default function NewPage() { className={cn( "pointer-events-none fixed inset-x-0 z-30", showBottomNav - ? "bottom-[4.25rem]" + ? "bottom-[calc(4rem+env(safe-area-inset-bottom))]" : "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12", )} > diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 51745b80..32b74126 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -1,756 +1,23 @@ "use client" -import { Logo } from "@ui/assets/Logo" -import { UserProfileMenu } from "@/components/user-profile-menu" -import { useAuth } from "@lib/auth-context" -import NovaOrb from "@/components/nova/nova-orb" -import { useState, useEffect, useRef, useMemo } from "react" -import { cn } from "@lib/utils" -import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" -import Account from "@/components/settings/account" -import Billing from "@/components/settings/billing" -import Integrations from "@/components/settings/integrations" -import ConnectionsMCP from "@/components/settings/connections-mcp" -import Support from "@/components/settings/support" -import { ErrorBoundary } from "@/components/error-boundary" + +import { useEffect } from "react" import { useRouter } from "next/navigation" -import { useIsMobile } from "@hooks/use-mobile" -import { useLocalStorageUsername } from "@hooks/use-local-storage-username" -import { analytics } from "@/lib/analytics" -import { - LogOut, - RotateCcw, - Trash2, - Sun, - LoaderIcon, - User as UserIcon, - Zap, - HelpCircle, - CreditCard, - ShieldAlert, - ChevronRight, - ChevronsUpDown, - Check, - Building2, -} from "lucide-react" -import { authClient } from "@lib/auth" -import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" -import { useResetOrganization } from "@/hooks/use-reset-organization" -import { useDeleteUserAccount } from "@/hooks/use-account-settings" -import { useCustomer } from "autumn-js/react" -import { useOrgSummaries } from "@/hooks/use-org-summaries" -import { - PLAN_DISPLAY_NAMES, - useTokenUsage, - type PlanType, -} from "@/hooks/use-token-usage" +import { parseHashToTab } from "@/components/settings/settings-content" -const TABS = [ - "account", - "billing", - "integrations", - "connections", - "support", -] as const -type SettingsTab = (typeof TABS)[number] - -type NavItem = { - id: SettingsTab - label: string - description: string - icon: React.ReactNode -} - -const NAV_ITEMS: NavItem[] = [ - { - id: "account", - label: "Account", - description: "Your profile and organization", - icon: , - }, - { - id: "billing", - label: "Billing", - description: "Plan, usage and payments", - icon: , - }, - { - id: "integrations", - label: "Integrations", - description: "Save, sync and search across tools", - icon: , - }, - { - id: "connections", - label: "Connections & MCP", - description: "Drive, Notion, OneDrive, MCP", - icon: , - }, - { - id: "support", - label: "Support & Help", - description: "Get help or share feedback", - icon: , - }, -] - -function parseHashToTab(hash: string): SettingsTab { - const cleaned = hash.replace("#", "").toLowerCase() - return TABS.includes(cleaned as SettingsTab) - ? (cleaned as SettingsTab) - : "account" -} - -const ORG_PLAN_BADGE_STYLES: Record = { - free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", - pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", - max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]", - scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]", - enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]", -} - -function OrgPlanBadge({ plan }: { plan: PlanType }) { - return ( - - {PLAN_DISPLAY_NAMES[plan]} - - ) -} - -function resolveOrgPlan( - orgId: string, - isCurrent: boolean, - currentPlan: PlanType, - planByOrgId: Map, -): PlanType { - const fromSummary = planByOrgId.get(orgId) - if (fromSummary) return fromSummary - if (isCurrent) return currentPlan - return "free" -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ) -} - -function IdentityCard({ displayName }: { displayName: string }) { - const firstName = displayName?.split(" ")[0] || "" - - return ( -
- -
- -
-

- {firstName ? `${firstName}'s` : "Your"} -

-

- supermemory -

-
-
-
- ) -} - -export default function SettingsPage() { - const { user, org, organizations, setActiveOrg } = useAuth() - const [activeTab, setActiveTab] = useState("account") - const hasInitialized = useRef(false) +/** + * Legacy redirect: /settings (and /settings#billing etc.) now open the + * settings modal via the ?settings= URL state on the home page. + */ +export default function SettingsRedirect() { const router = useRouter() - const isMobile = useIsMobile() - const localStorageUsername = useLocalStorageUsername() - - const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) - const [resetConfirmation, setResetConfirmation] = useState("") - const resetOrganization = useResetOrganization() - - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") - const deleteUserAccount = useDeleteUserAccount() - - const [dangerMenuOpen, setDangerMenuOpen] = useState(false) - const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false) - const [switchingOrgId, setSwitchingOrgId] = useState(null) - const canSwitchOrg = (organizations?.length ?? 0) > 1 - - const autumn = useCustomer() - const { currentPlan } = useTokenUsage(autumn) - const { data: orgSummaries } = useOrgSummaries() - const planByOrgId = useMemo(() => { - const map = new Map() - for (const summary of orgSummaries ?? []) { - map.set(summary.orgId, summary.plan) - } - return map - }, [orgSummaries]) - const activeOrgPlan = org?.id - ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) - : currentPlan - - const handleOrgSwitch = async (orgSlug: string, orgId: string) => { - if (orgId === org?.id) { - setOrgSwitcherOpen(false) - return - } - setSwitchingOrgId(orgId) - try { - await setActiveOrg(orgSlug) - window.location.reload() - } catch (error) { - console.error("Failed to switch organization:", error) - setSwitchingOrgId(null) - } - } - - const handleLogout = async () => { - await authClient.signOut() - router.push("/login") - } - - const handleDeleteAccount = async () => { - if (deleteEmailConfirm !== user?.email) return - deleteUserAccount.mutate( - { confirmation: deleteEmailConfirm }, - { - onSuccess: () => { - setIsDeleteDialogOpen(false) - setDeleteEmailConfirm("") - router.push("/login") - }, - }, - ) - } useEffect(() => { - if (hasInitialized.current) return - hasInitialized.current = true - - const hash = window.location.hash + const hash = typeof window !== "undefined" ? window.location.hash : "" const tab = parseHashToTab(hash) - setActiveTab(tab) - analytics.settingsTabChanged({ tab }) + router.replace( + tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`, + ) + }, [router]) - if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { - window.history.pushState(null, "", "#account") - } - }, []) - - useEffect(() => { - const handleHashChange = () => { - const tab = parseHashToTab(window.location.hash) - setActiveTab(tab) - analytics.settingsTabChanged({ tab }) - } - - window.addEventListener("hashchange", handleHashChange) - return () => window.removeEventListener("hashchange", handleHashChange) - }, []) - - const headerDisplayName = - user?.displayUsername || - localStorageUsername || - user?.name || - user?.email?.split("@")[0] || - "" - - return ( -
-
- -
- {!isMobile && - (canSwitchOrg ? ( - - - - - - {[...(organizations ?? [])] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((organization) => { - const isCurrent = organization.id === org?.id - const isSwitching = switchingOrgId === organization.id - const plan = resolveOrgPlan( - organization.id, - isCurrent, - currentPlan, - planByOrgId, - ) - return ( - - ) - })} - - - ) : ( -
- - - - - {org?.name ?? "Personal"} - - -
- ))} - -
-
- -
-
- {/* Left rail */} - - - {/* Content */} -
- - Something went wrong loading this section.{" "} - -

- } - > - {activeTab === "account" && } - {activeTab === "billing" && } - {activeTab === "integrations" && } - {activeTab === "connections" && } - {activeTab === "support" && } -
-
-
-
- - {/* Reset data dialog */} - {(() => { - const confirmText = org?.name || user?.name || "" - return ( - { - setIsResetDialogOpen(open) - if (!open) setResetConfirmation("") - }} - > - -
-
-

- Reset all data? -

-

- This permanently removes: -

-
    -
  • All documents and memories
  • -
  • All connections (Google Drive, Notion, etc.)
  • -
  • All custom spaces (default space stays)
  • -
  • Organization settings and filters
  • -
-

- Your account and billing plan stay intact.{" "} - - This cannot be undone. - -

-
-
-

- Type{" "} - - {confirmText || "your name"} - {" "} - to confirm: -

- setResetConfirmation(e.target.value)} - placeholder={confirmText || "Your name"} - autoComplete="off" - className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors" - /> -
-
- - - - -
-
-
-
- ) - })()} - - {/* Delete account dialog */} - { - setIsDeleteDialogOpen(open) - if (!open) setDeleteEmailConfirm("") - }} - > - -
-
-

- Delete your account? -

-

- Permanently deletes all your data and cancels any active - subscriptions.{" "} - - This cannot be undone. - -

-
-
-

- Type your email{" "} - {user?.email} to - confirm: -

- setDeleteEmailConfirm(e.target.value)} - placeholder={user?.email ?? "your@email.com"} - className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors" - /> -
-
- - - - -
-
-
-
-
- ) + return null } diff --git a/apps/web/app/auth/agent-connect/page.tsx b/apps/web/app/auth/agent-connect/page.tsx new file mode 100644 index 00000000..86838d41 --- /dev/null +++ b/apps/web/app/auth/agent-connect/page.tsx @@ -0,0 +1 @@ +export { default } from "../connect/page" diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 37f61d3d..0f96e52c 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -99,7 +99,7 @@ const PLUGIN_INFO: Record = { "Captures coding decisions and patterns automatically", "Builds persistent user profile across projects", ], - icon: "/images/plugins/codex.svg", + icon: "/images/plugins/codex.png", }, } @@ -199,6 +199,7 @@ function AuthConnectContent() { const redirectUrl = new URL(callback) redirectUrl.searchParams.set("apikey", data.key) + redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { console.error("Failed to get API key:", err) @@ -395,7 +396,7 @@ function AuthConnectContent() { (["projects"]) || []) as Project[] - const handleUpgrade = async () => { + const handleUpgrade = async (planId: "api_pro" | "api_max" = "api_pro") => { setIsUpgrading(true) try { const result = await autumn.attach({ - planId: "api_pro", + planId, successUrl: window.location.href, }) if (result?.paymentUrl) { @@ -614,11 +614,21 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { )} ) : ( @@ -783,8 +793,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
setGranolaModalOpen(true)} + disabled={isUpgrading || autumn.isLoading} + onClick={() => { + if (!isMaxUser) { + handleUpgrade("api_max") + return + } + setGranolaModalOpen(true) + }} className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100 data-disabled:opacity-40 data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent" > @@ -798,7 +814,9 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { )} - Meeting notes & transcripts + {isMaxUser + ? "Meeting notes & transcripts" + : "Upgrade to Max"}
diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index beb31878..aa9388e7 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook" import { toast } from "sonner" export const FILE_ACCEPT = - "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown" + "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,.html,.htm,text/markdown,application/json,text/html" export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error" @@ -46,10 +46,15 @@ function isAcceptedFile(file: File): boolean { ".txt", ".md", ".mdx", + ".json", + ".html", + ".htm", ]) if (allowedExt.has(ext)) return true if (file.type.startsWith("image/")) return true if (file.type === "text/markdown") return true + if (file.type === "application/json") return true + if (file.type === "text/html") return true return false } @@ -57,6 +62,18 @@ function fileQueueKey(file: File): string { return `${file.name}:${file.size}:${file.lastModified}` } +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ["KB", "MB", "GB", "TB"] + let size = bytes / 1024 + let i = 0 + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}` +} + export function FileContent({ data, onDataChange, @@ -197,7 +214,7 @@ export function FileContent({

Upload files

- Images, PDF, documents, sheets, markdown + Images, PDF, documents, sheets, markdown, HTML