diff --git a/apps/docs/ai-sdk/overview.mdx b/apps/docs/ai-sdk/overview.mdx index 168be519..b4d6aad9 100644 --- a/apps/docs/ai-sdk/overview.mdx +++ b/apps/docs/ai-sdk/overview.mdx @@ -39,13 +39,13 @@ const result = await generateText({ ``` - **Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, enable it explicitly: - + **Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`: + ```typescript const modelWithMemory = withSupermemory(openai("gpt-5"), { containerTag: "user-123", customId: "conversation-456", - addMemory: "always", + addMemory: "never", }) ``` diff --git a/apps/docs/ai-sdk/user-profiles.mdx b/apps/docs/ai-sdk/user-profiles.mdx index 6d85772e..c0027aa2 100644 --- a/apps/docs/ai-sdk/user-profiles.mdx +++ b/apps/docs/ai-sdk/user-profiles.mdx @@ -46,13 +46,13 @@ The `withSupermemory` middleware: All of this happens transparently - you write code as if using a normal model, but get personalized responses. - **Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, set `addMemory: "always"`: - + **Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`: + ```typescript const model = withSupermemory(openai("gpt-5"), { containerTag: "user-123", customId: "conversation-456", - addMemory: "always", + addMemory: "never", }) ``` diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 5731c945..0f7b54c0 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -144,6 +144,28 @@ "pages": ["supermemory-mcp/claude-desktop"] } ] + }, + { + "anchor": "SMFS", + "icon": "database", + "pages": [ + "smfs/overview", + "smfs/install", + "smfs/mount", + "smfs/bash-tool", + "smfs/bash-tool-python", + { + "group": "Providers", + "icon": "cloud", + "pages": [ + "smfs/providers/daytona", + "smfs/providers/e2b", + "smfs/providers/vercel", + "smfs/providers/cloudflare" + ] + }, + "smfs/examples" + ] } ], "tab": "Developer Platform" @@ -170,7 +192,12 @@ "integrations/pipecat", "integrations/n8n", "integrations/viasocket", - "integrations/zapier" + "integrations/zapier", + { + "group": "Migration Guides", + "icon": "arrow-up-right", + "pages": ["migration/tools-v2-upgrade"] + } ] } ], diff --git a/apps/docs/integrations/ai-sdk.mdx b/apps/docs/integrations/ai-sdk.mdx index c71a1ec1..bdf73fd3 100644 --- a/apps/docs/integrations/ai-sdk.mdx +++ b/apps/docs/integrations/ai-sdk.mdx @@ -7,6 +7,10 @@ icon: "triangle" The Supermemory AI SDK provides native integration with Vercel's AI SDK through two approaches: **User Profiles** for automatic personalization and **Memory Tools** for agent-based interactions. + + Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade). + + Check out the NPM page for more details @@ -46,14 +50,21 @@ const result = await generateText({ }) ``` +### Required fields + +Both `containerTag` and `customId` are required. + +- **`containerTag`** — *who* the memories belong to. Use a stable identifier per user, workspace, or tenant (e.g. `"user-123"`, `"acme-workspace"`). Memory search and writes are scoped to this tag. +- **`customId`** — *which conversation* this turn belongs to. Use it to group messages from the same chat session into a single document (e.g. `"chat-2026-04-25"`, a thread ID, or a UUID per session). + - **Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories: + **Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`: ```typescript const modelWithMemory = withSupermemory(openai("gpt-5"), { containerTag: "user-123", customId: "conversation-456", - addMemory: "always", + addMemory: "never", }) ``` diff --git a/apps/docs/integrations/mastra.mdx b/apps/docs/integrations/mastra.mdx index b7462698..db526de4 100644 --- a/apps/docs/integrations/mastra.mdx +++ b/apps/docs/integrations/mastra.mdx @@ -7,6 +7,10 @@ icon: "/images/mastra-icon.svg" Integrate Supermemory with [Mastra](https://mastra.ai) to give your AI agents persistent memory. Use the `withSupermemory` wrapper for zero-config setup or processors for fine-grained control. + + Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade). + + Check out the NPM page for more details diff --git a/apps/docs/integrations/openai.mdx b/apps/docs/integrations/openai.mdx index 13cbb58f..80751d74 100644 --- a/apps/docs/integrations/openai.mdx +++ b/apps/docs/integrations/openai.mdx @@ -10,6 +10,10 @@ Add memory capabilities to the official OpenAI SDKs using Supermemory. Two appro 1. **`withSupermemory` wrapper** - Automatic memory injection into system prompts (zero-config) 2. **Function calling tools** - Explicit tool calls for search/add memory operations + + Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade). + + **New to Supermemory?** Start with `withSupermemory` for the simplest integration. It automatically injects relevant memories into your prompts. diff --git a/apps/docs/integrations/voltagent.mdx b/apps/docs/integrations/voltagent.mdx index 66f12826..b5447938 100644 --- a/apps/docs/integrations/voltagent.mdx +++ b/apps/docs/integrations/voltagent.mdx @@ -7,6 +7,10 @@ icon: "bolt" Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent), providing long-term memory capabilities for AI agents. Your VoltAgent applications will remember past conversations and provide personalized responses based on user history. + + Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade). + + Check out the NPM page for more details diff --git a/apps/docs/migration/tools-v2-upgrade.mdx b/apps/docs/migration/tools-v2-upgrade.mdx new file mode 100644 index 00000000..a948da52 --- /dev/null +++ b/apps/docs/migration/tools-v2-upgrade.mdx @@ -0,0 +1,188 @@ +--- +title: 'Upgrading @supermemory/tools to v2.0.0' +description: 'Migrate your code from @supermemory/tools 1.4.x to 2.0.0 — config-object signature, customId, and new defaults' +sidebarTitle: 'Tools: v1.4 → 2.0' +--- + +`@supermemory/tools` v2.0.0 unifies the API across all four integrations (Vercel AI SDK, OpenAI, Mastra, VoltAgent) around a single config-object signature and a consistent conversation-grouping concept. This guide walks you through the breaking changes. + + + This release is **breaking**. Update calls and re-test before bumping in + production. + + +## What changed at a glance + +| Area | v1.4.x | v2.0.0 | +| --------------------- | ----------------------------------------------------- | ----------------------------------------------------------- | +| Signature | `withSupermemory(model, "user-123", { ... })` | `withSupermemory(model, { containerTag: "user-123", ... })` | +| Conversation grouping | `conversationId` (Vercel/OpenAI), `threadId` (Mastra) | **`customId`** everywhere | +| `customId` | Optional | **Required** — throws if missing or empty | +| `containerTag` | Positional argument | **Required** field on options object | +| `addMemory` default | `"never"` | `"always"` | +| VoltAgent `verbose` | Hardcoded to `false` | Honored from options | + +## Install + +```bash +npm install @supermemory/tools@^2.0.0 +``` + +## 1. Vercel AI SDK + +```typescript +// v1.4.x +import { withSupermemory } from '@supermemory/tools/ai-sdk'; + +const model = withSupermemory(openai('gpt-4'), 'user-123', { + conversationId: 'conv-456', + mode: 'full', +}); +``` + +```typescript +// v2.0.0 +import { withSupermemory } from '@supermemory/tools/ai-sdk'; + +const model = withSupermemory(openai('gpt-4'), { + containerTag: 'user-123', + customId: 'conv-456', + mode: 'full', +}); +``` + + + `customId` is now **required**. Passing an empty string or omitting it throws + at construction time. + + +## 2. OpenAI SDK + +```typescript +// v1.4.x +import { withSupermemory } from '@supermemory/tools/openai'; + +const client = withSupermemory(openai, 'user-123', { + conversationId: 'conv-456', +}); +``` + +```typescript +// v2.0.0 +import { withSupermemory } from '@supermemory/tools/openai'; + +const client = withSupermemory(openai, { + containerTag: 'user-123', + customId: 'conv-456', +}); +``` + +Both `containerTag` and `customId` are validated and throw with explicit error messages if missing. + +## 3. Mastra + +Processor constructors and factory functions both moved to a single options argument. `threadId` is gone — use `customId` instead. + +```typescript +// v1.4.x +import { + SupermemoryInputProcessor, + createSupermemoryOutputProcessor, +} from '@supermemory/tools/mastra'; + +const input = new SupermemoryInputProcessor('user-123', { + mode: 'full', +}); + +const output = createSupermemoryOutputProcessor('user-123', { + threadId: 'conv-456', + addMemory: 'always', +}); +``` + +```typescript +// v2.0.0 +import { + SupermemoryInputProcessor, + createSupermemoryOutputProcessor, +} from '@supermemory/tools/mastra'; + +const input = new SupermemoryInputProcessor({ + containerTag: 'user-123', + customId: 'conv-456', + mode: 'full', +}); + +const output = createSupermemoryOutputProcessor({ + containerTag: 'user-123', + customId: 'conv-456', +}); +``` + + + In server setups, Mastra's `RequestContext` thread ID still takes precedence + over the construction-time `customId` — the option now acts as the fallback + when no per-request thread ID is provided. + + +## 4. VoltAgent + +VoltAgent already used a config-object signature, so the call shape is unchanged. Two behavior fixes ship in v2.0.0: + +- `verbose: true` is now honored (was hardcoded to `false` in v1.4.x). +- A runtime warning is logged when advanced search params (`threshold`, `limit`, `rerank`, `rewriteQuery`, `filters`, `include`, `searchMode`) are set while `mode: "profile"` — those parameters are ignored in profile mode. + +If you were relying on `verbose: false` implicitly while passing `verbose: true`, you will now see logs. Adjust as needed. + +## 5. New default: `addMemory: "always"` + +Across all four integrations, `addMemory` now defaults to `"always"`. If your v1.4.x code relied on the old default of `"never"`, set it explicitly: + +```typescript +const model = withSupermemory(openai('gpt-4'), { + containerTag: 'user-123', + customId: 'conv-456', + addMemory: 'never', // preserve v1.4.x behavior +}); +``` + +## Conversation persistence + +In v1.4.x the Vercel middleware fell back to `client.add` with a synthesized `customId` when no `conversationId` was passed. In v2.0.0, because `customId` is required, all conversation persistence goes through the `/v4/conversations` endpoint via `addConversation`. There is no fallback path. + +## Migration checklist + + + + `npm install @supermemory/tools@^2.0.0` + + + Grep your codebase for `withSupermemory(`, `SupermemoryInputProcessor`, + `SupermemoryOutputProcessor`, `createSupermemoryProcessor`, + `createSupermemoryOutputProcessor`. + + + Drop the positional `containerTag` argument and add it to the options + object. + + + Make sure every call site provides a non-empty `customId`. + + + If you depended on the old `"never"` default, pass `addMemory: "never"` + explicitly. + + + Validation throws happen at construction time, so missing fields surface + immediately. + + + +## Need help? + +- [Vercel AI SDK integration](/integrations/ai-sdk) +- [OpenAI integration](/integrations/openai) +- [Mastra integration](/integrations/mastra) +- [VoltAgent integration](/integrations/voltagent) + +If you hit something this guide does not cover, open an issue on [GitHub](https://github.com/supermemoryai/supermemory). diff --git a/apps/docs/smfs/bash-tool-python.mdx b/apps/docs/smfs/bash-tool-python.mdx new file mode 100644 index 00000000..61092813 --- /dev/null +++ b/apps/docs/smfs/bash-tool-python.mdx @@ -0,0 +1,252 @@ +--- +title: "Bash Tool (Python)" +sidebarTitle: "Bash Tool (Python)" +description: "supermemory-bash. The SMFS idea wrapped as a single agent tool, for Python agents and serverless runtimes." +icon: "terminal" +--- + +`supermemory-bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere Python runs. AWS Lambda, Modal, Fly Machines, Cloud Run, your laptop. No mount, no FUSE, no local disk. + +Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem. + +## Install + +```bash +pip install supermemory-bash +``` + +Or with uv: + +```bash +uv add supermemory-bash +``` + +## Quickstart + +```python +import asyncio +import os +from supermemory_bash import create_bash + + +async def main() -> None: + result = await create_bash( + api_key=os.environ["SUPERMEMORY_API_KEY"], + container_tag="user_42", + ) + bash = result.bash + r = await bash.exec("ls /") + print(r.stdout) + + +asyncio.run(main()) +``` + +`create_bash` returns a `CreateBashResult` with: + +- `bash`: a `Shell` instance with `.exec(cmd)` +- `tool_description`: a pre-written tool description ready to hand to the model +- `configure_memory_paths(paths)`: scope which paths get extracted into Supermemory +- `refresh()`: re-prime the path index after external writes + +## Use it as a model tool + +### Anthropic SDK + +Pass `tool_description` straight into Claude's tool definition and run a normal agent loop. Each `tool_use` block calls `bash.exec` and the result goes back as a `tool_result`. + +```python +import asyncio +import os + +import anthropic +from supermemory_bash import create_bash + + +async def run_agent(user_message: str) -> str: + result = await create_bash( + api_key=os.environ["SUPERMEMORY_API_KEY"], + container_tag="user_42", + ) + bash = result.bash + + client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) + tools = [ + { + "name": "bash", + "description": result.tool_description, + "input_schema": { + "type": "object", + "properties": { + "cmd": {"type": "string", "description": "The bash command to run."} + }, + "required": ["cmd"], + }, + } + ] + + messages = [{"role": "user", "content": user_message}] + + for _ in range(10): + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + tools=tools, + messages=messages, + ) + + if response.stop_reason == "end_turn": + for block in response.content: + if hasattr(block, "text"): + return block.text + return "" + + messages.append({"role": "assistant", "content": response.content}) + tool_results = [] + for block in response.content: + if block.type == "tool_use": + cmd = block.input.get("cmd", "") + r = await bash.exec(cmd) + output = r.stdout + if r.stderr: + output += f"\n[stderr]: {r.stderr}" + if r.exit_code != 0: + output += f"\n[exit_code]: {r.exit_code}" + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": output or "(no output)", + } + ) + messages.append({"role": "user", "content": tool_results}) + + return "(max steps reached)" + + +asyncio.run(run_agent("What's in my notes about the Q3 launch?")) +``` + +### OpenAI SDK + +Same idea with OpenAI's function-calling format. Define a single `bash` function, dispatch each `tool_calls` entry to `bash.exec`, and feed the output back as a `tool` message. + +```python +import asyncio +import json +import os + +from openai import OpenAI +from supermemory_bash import create_bash + + +async def run_agent(user_message: str) -> str: + result = await create_bash( + api_key=os.environ["SUPERMEMORY_API_KEY"], + container_tag="user_42", + ) + bash = result.bash + + client = OpenAI() + tools = [ + { + "type": "function", + "function": { + "name": "bash", + "description": result.tool_description, + "parameters": { + "type": "object", + "properties": {"cmd": {"type": "string"}}, + "required": ["cmd"], + }, + }, + } + ] + + messages = [{"role": "user", "content": user_message}] + + for _ in range(10): + response = client.chat.completions.create( + model="gpt-4o", + messages=messages, + tools=tools, + ) + message = response.choices[0].message + + if not message.tool_calls: + return message.content or "" + + messages.append(message.model_dump(exclude_none=True)) + for call in message.tool_calls: + args = json.loads(call.function.arguments or "{}") + r = await bash.exec(args.get("cmd", "")) + output = r.stdout + if r.stderr: + output += f"\n[stderr]: {r.stderr}" + if r.exit_code != 0: + output += f"\n[exit_code]: {r.exit_code}" + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": output or "(no output)", + } + ) + + return "(max steps reached)" + + +asyncio.run(run_agent("List my notes")) +``` + +### Claude Agent SDK + +The [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) ships with built-in `Bash`, `Read`, and `Write` tools. If your agent runs somewhere SMFS can be mounted (a long-lived process on macOS or Linux), point those built-ins at an SMFS mount and you don't need `supermemory-bash` at all — the agent just sees your container as a directory. + +See [Mount SMFS](/smfs/mount) for setup, or the [provider guides](/smfs/overview#use-smfs-with-your-sandbox-provider) for sandbox-specific instructions. + +## Memory + +The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction: + +```python +result = await create_bash(api_key=api_key, container_tag=container_tag) +bash = result.bash +await result.configure_memory_paths(["/notes/", "/journal.md"]) +``` + +Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation. + +The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file. + +```python +r = await bash.exec("cat /profile.md") +print(r.stdout) +``` + +## Commands the agent can run + +The Python tool exposes the same command surface as the TypeScript version: standard Unix builtins (`pwd`, `cd`, `ls`, `cat`, `stat`, `mkdir`, `rm`, `mv`, `cp`, `echo`), search and text utilities (`grep`, `find`, `head`, `tail`, `wc`, `sort`, `sed`, `awk`), plus the custom `sgrep [path]` for semantic search across the container. Pipes, redirects, conditionals, loops, and file tests all work. + +See the [TypeScript Bash Tool reference](/smfs/bash-tool#commands-the-agent-can-run) for the full list. + +## Configuration + +| Option | Default | Purpose | +| --- | --- | --- | +| `api_key` | required | Supermemory API key | +| `container_tag` | required | Container to expose as the filesystem | +| `base_url` | `None` | Override the API endpoint | +| `eager_load` | `True` | Warm the path index when the instance starts | +| `eager_content` | `True` | Also warm the content cache during eager load | +| `cwd` | `"/home/user"` | Initial working directory | +| `env` | `None` | Extra environment variables | +| `cache_ttl_ms` | `150_000` | Content cache TTL in ms. `None` = never expires (single-writer). `0` = no cache. | + +The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change the files the agent sees. + +## Limitations + +- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) raise `ENOSYS`. +- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead. +- Binary uploads aren't supported. Text is extracted server-side. diff --git a/apps/docs/smfs/bash-tool.mdx b/apps/docs/smfs/bash-tool.mdx new file mode 100644 index 00000000..19aadd60 --- /dev/null +++ b/apps/docs/smfs/bash-tool.mdx @@ -0,0 +1,203 @@ +--- +title: "Bash Tool" +sidebarTitle: "Bash Tool" +description: "@supermemory/bash. The SMFS idea wrapped as a single agent tool, for serverless and edge runtimes." +icon: "terminal" +--- + +`@supermemory/bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere TypeScript runs. Cloudflare Workers, AWS Lambda, Vercel, Node, the browser. No mount, no FUSE, no local disk. + +Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem. + +## Install + +```bash +npm install @supermemory/bash +``` + +Or with bun: + +```bash +bun add @supermemory/bash +``` + +## Quickstart + +```typescript +import { createBash } from "@supermemory/bash"; + +const { bash, toolDescription } = await createBash({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + containerTag: "user_42", +}); + +const result = await bash.exec("ls /"); +console.log(result.stdout); +``` + +`createBash` returns: + +- `bash`: the instance with `.exec(cmd)` +- `toolDescription`: a pre-written tool description ready to hand to the model +- `configureMemoryPaths(paths)`: scope which paths get extracted into Supermemory +- `refresh()`: re-prime the path index after external writes + +## Use it as a model tool + +### Vercel AI SDK + +```typescript +import { generateText, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +const { bash, toolDescription } = await createBash({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + containerTag: "user_42", +}); + +const result = await generateText({ + model: openai("gpt-4o"), + tools: { + bash: tool({ + description: toolDescription, + inputSchema: z.object({ cmd: z.string() }), + execute: async ({ cmd }) => bash.exec(cmd), + }), + }, + prompt: "What's in my notes about the Q3 launch?", +}); +``` + +### Anthropic SDK + +```typescript +import Anthropic from "@anthropic-ai/sdk"; + +const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const { bash, toolDescription } = await createBash({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + containerTag: "user_42", +}); + +const response = await client.messages.create({ + model: "claude-opus-4-7", + max_tokens: 4096, + tools: [ + { + name: "bash", + description: toolDescription, + input_schema: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }, + ], + messages: [{ role: "user", content: "List my notes" }], +}); +``` + +### OpenAI SDK + +```typescript +import OpenAI from "openai"; + +const client = new OpenAI(); +const { bash, toolDescription } = await createBash({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + containerTag: "user_42", +}); + +const response = await client.chat.completions.create({ + model: "gpt-4o", + messages: [{ role: "user", content: "List my notes" }], + tools: [ + { + type: "function", + function: { + name: "bash", + description: toolDescription, + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }, + }, + ], +}); +``` + +## Memory + +The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction: + +```typescript +const { configureMemoryPaths } = await createBash({ apiKey, containerTag }); + +await configureMemoryPaths(["/notes/", "/journal.md"]); +``` + +Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation. + +The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file. + +```typescript +const { stdout } = await bash.exec("cat /profile.md"); +``` + +## Commands the agent can run + +Standard Unix surface, plus one custom command. Each does what you'd expect. + +### Filesystem + +- `pwd`: print working directory +- `cd`: change directory +- `ls`, `ls -la`: list +- `cat`: read a file +- `stat`: file metadata +- `mkdir`: create directory +- `rm`, `rm -rf`: delete +- `rmdir`: delete empty directory +- `mv`: move or rename +- `cp`: copy +- `echo`: write or append (`echo "x" > file`, `echo "x" >> file`) + +### Search and text + +- `grep`: literal substring match against a known path +- `sgrep [path]`: **semantic** search across the container. Trailing `/` on path scopes to a directory. No path searches everything. +- `find`: search by name or properties +- `head`, `tail`: first or last N lines +- `wc`: word, line, byte counts +- `sort`: sort lines +- `sed`, `awk`: text transformation + +### Shell features + +- Pipes (`|`) +- Redirects (`>`, `>>`) +- Conditionals (`&&`, `||`) +- Loops (`for`, `while`) +- File tests (`[ -f ]`, `[ -d ]`, `[ -e ]`) + +## Configuration + +| Option | Default | Purpose | +| --- | --- | --- | +| `apiKey` | required | Supermemory API key | +| `containerTag` | required | Container to expose as the filesystem | +| `baseURL` | SDK default | Override the API endpoint | +| `eagerLoad` | `true` | Warm the path index when the instance starts | +| `eagerContent` | `true` | Also warm the content cache during eager load | +| `cacheTtlMs` | `150_000` | Content cache TTL in ms. `null` = never expires (single-writer). `0` = no cache. | + +Other options (`customCommands`, `logger`, plus `just-bash` pass-throughs like `executionLimits`, `network`, `python`, `javascript`, `cwd`, `env`) exist but aren't part of the supported surface for the SMFS use case. The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change that. + +## Limitations + +- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) throw `ENOSYS`. +- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead. +- Binary uploads aren't supported. Text is extracted server-side. diff --git a/apps/docs/smfs/examples.mdx b/apps/docs/smfs/examples.mdx new file mode 100644 index 00000000..59cf60ff --- /dev/null +++ b/apps/docs/smfs/examples.mdx @@ -0,0 +1,48 @@ +--- +title: "Examples" +sidebarTitle: "Examples" +description: "Full web-based demo apps you can clone and run." +icon: "code" +--- + +Web-based example apps showing SMFS in realistic use cases. Each one is a +complete project with its own README, dependencies, and a working UI. + + + + Upload documents and chat with an AI that can search and cite them. + Next.js + TypeScript + `@supermemory/bash`. + + + Add notes and chat with an AI that can search your knowledge base. + FastAPI + Python + `supermemory-bash`. + + + Write and run code in an E2B sandbox with persistent AI memory. + Next.js + E2B SDK + SMFS mount. + + + +The Research Assistant and Knowledge Base examples use the +[Bash Tool](/smfs/bash-tool) — the serverless-friendly way to give an agent a +Supermemory-backed filesystem. The Code Sandbox example uses an +[E2B](/smfs/providers/e2b) sandbox with a real SMFS mount. + +## Running an example + +1. Clone the [examples repo](https://github.com/supermemoryai/examples) +2. `cd` into the example you want +3. Follow the README — typically: install deps, copy `.env.example` to `.env`, + fill in your API keys, and start the dev server diff --git a/apps/docs/smfs/install.mdx b/apps/docs/smfs/install.mdx new file mode 100644 index 00000000..64d20d0b --- /dev/null +++ b/apps/docs/smfs/install.mdx @@ -0,0 +1,68 @@ +--- +title: "Install SMFS" +sidebarTitle: "Install" +description: "Install, log in, mount." +icon: "download" +--- + +## 1. Install the binary + +```bash +curl -fsSL https://smfs.ai/install | bash +``` + +Drops `smfs` into `~/.local/bin`. Works on macOS (arm64, x64) and Linux (arm64, x64). + +If `smfs` isn't on your `PATH` after install, add `~/.local/bin` to your shell profile and reopen the terminal. + +## 2. Log in + +```bash +smfs login +``` + +One-time. Prompts you for your Supermemory API key and stores it in your global credentials. Get a key at [console.supermemory.ai](https://console.supermemory.ai). + +You can also pass the key directly: + +```bash +smfs login --key sm_... +``` + +## 3. Mount a container + +```bash +smfs mount agent_memory +``` + +`agent_memory` is your container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there. + +That's it. Read it with `ls`, `cat`, `grep`. See [Mount](/smfs/mount) for memory paths, sync modes, flags, and every subcommand. + +To mount somewhere else, pass `--path`: + +```bash +smfs mount agent_memory --path ~/memory +``` + +## Optional: refresh the semantic grep wrapper + +`smfs mount` installs the shell wrapper automatically the first time you mount. If you ever need to force a clean reinstall (after upgrading the binary, for example): + +```bash +smfs init +``` + +It writes the wrapper into your `~/.zshrc` directly. Then reopen your terminal (or `source ~/.zshrc`) so the new shell picks it up. + +Inside any mount, plain `grep` becomes semantic. Outside a mount, your normal `grep` is untouched. Pass any flag (`grep -r`, `grep -i`, anything) and you get the real `grep` back. + +## Refresh the binary + +If anything ever feels broken: + +```bash +smfs install +``` + +Re-copies the binary into `~/.local/bin` and resets permissions. diff --git a/apps/docs/smfs/mount.mdx b/apps/docs/smfs/mount.mdx new file mode 100644 index 00000000..1c31bcb2 --- /dev/null +++ b/apps/docs/smfs/mount.mdx @@ -0,0 +1,289 @@ +--- +title: "Mount" +sidebarTitle: "Mount" +description: "Mount a Supermemory container, generate memories, and sync." +icon: "hard-drive" +--- + +A mount turns a Supermemory container into a directory on your machine. macOS uses NFSv3, Linux uses FUSE. Both are handled for you. + +```bash +smfs mount +``` + +Example: + +```bash +smfs mount agent_memory +``` + +`agent_memory` is the container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there. The mount runs as a background daemon. A marker file `.smfs` is written at the mount root so other tools (and the semantic `grep` wrapper from `smfs init`) can find the mount. + +To mount at a different path: + +```bash +smfs mount agent_memory --path ~/memory +``` + +## Memory + +This is the part most people miss. SMFS isn't a normal filesystem. It generates **memories** from files at specific paths. Memories are extracted, summarized, and indexed by Supermemory. + +Files outside those paths are still semantically searchable; they're indexed through **SuperRAG** by default. Nothing in the mount is dead weight. + +### Defaults + +By default, files named `user.md` or `memory.md` are treated as memory paths. Drop those files anywhere in your mount and Supermemory generates memories from them automatically. + +### Configure your own memory paths + +Pass `--memory-paths` at mount time to control which files become memories: + +```bash +smfs mount agent_memory --memory-paths "/notes/,/journal.md" +``` + +Rules: + +- Paths are **absolute**, anchored at the mount root. Always start with `/`. +- Trailing `/` matches every file inside that folder, recursively (`/notes/` covers `/notes/foo.md`, `/notes/2026/march.md`, etc.). +- No trailing slash matches one exact file (`/journal.md`). +- Comma-separated. Multiple paths are fine. +- Empty string disables memory generation entirely (`--memory-paths ""`). +- Omit the flag and Supermemory keeps whatever the container tag already has, falling back to `user.md` and `memory.md`. + +### profile.md + +Every mount has a virtual file at the root called `profile.md`. It's auto-generated, read-only, and backed by Supermemory. The model can `cat profile.md` to get a live digest of everything in the container without walking every file. Useful as a first call at the start of a session. + +```bash +cat agent_memory/profile.md +``` + +You can't write to it. As the underlying memories change, Supermemory regenerates it. + +## Sync modes + +Three modes plus a force-sync command. Pick by what your agent actually needs. + +### Bidirectional (default) + +Local reads hit the cache. Local writes queue and push to Supermemory in the background. Remote changes are pulled on a poll. This is what you get if you pass no flags. + +```bash +smfs mount agent_memory +``` + +Use this when more than one writer (you, another agent, the dashboard) might touch the container. + +### No-sync + +Writes still push to Supermemory. Polling for remote changes is off. The agent sees a view that doesn't shift under it mid-task. + +```bash +smfs mount agent_memory --no-sync +``` + +Use this when your agent is the only writer, or when you want predictable reads. + +### Ephemeral + +Cache is in memory only. Nothing persists after unmount. Writes still push. + +```bash +smfs mount agent_memory --ephemeral +``` + +Use this for short-lived sandboxes. CI jobs, throwaway containers, one-shot agent runs. + +### Force a sync now + +```bash +smfs sync +``` + +Pushes pending writes and pulls remote changes immediately. Useful right before tearing down a sandbox. + +## All mount flags + +| Flag | What it does | +| --- | --- | +| `--path ` | Override the default mount path (`.//`). | +| `--memory-paths ` | Scope which files become memories. See [Memory](#memory). | +| `--no-sync` | Stop polling for remote changes. Writes still push. | +| `--clean` | Wipe local cache before mounting. Pulls fresh from the API. | +| `--ephemeral` | In-memory cache. Nothing persists after unmount. | +| `--sync-interval ` | Remote-change poll interval. Default `30`. | +| `--drain-timeout ` | Max time to flush pending writes during unmount. Default `30`. | +| `--foreground` | Run the daemon inline instead of detaching. | +| `--backend ` | Linux only. `fuse` (default) or `nfs`. | +| `--key ` | Pass an API key explicitly. Saved to project credentials. | + +## Multiple agents and multiple containers + +- **Different devices, same container tag**: fully supported. Many agents can mount the same container concurrently from different machines. +- **Same device, same container tag, mounted twice**: not supported. Use one mount per container per device. +- **Same device, different containers**: mount as many as you want in parallel. + +## Commands + +Every `smfs` subcommand. Click any one to expand. + + + + Mount a container. Defaults to `.//`; pass `--path` to mount elsewhere. + + ```bash + smfs mount agent_memory + smfs mount agent_memory --path ~/memory + ``` + + See the flags table above for everything you can pass. + + + + Unmount a running mount. Drains pending writes up to `--drain-timeout`, then exits the daemon. Anything not drained resumes on the next mount. + + ```bash + smfs unmount agent_memory + ``` + + Inside the mount, you can omit the tag and let SMFS resolve it from the nearest `.smfs` marker. + + ```bash + smfs unmount + smfs unmount --force + ``` + + + + List every SMFS mount running on this machine. + + ```bash + smfs list + ``` + + + + Show daemon status for a mount: connectivity, queue depth, last sync. Auto-detects the tag via the nearest `.smfs` marker. + + ```bash + smfs status + smfs status agent_memory + smfs status --json + ``` + + + + Tail the daemon log for a mount. Auto-detects the tag via the nearest `.smfs` marker. + + ```bash + smfs logs + smfs logs -f + smfs logs -n 500 + ``` + + + + Force an immediate sync cycle. Push pending writes, pull remote changes. + + ```bash + smfs sync agent_memory + ``` + + Inside the mount, the tag is optional (resolved from the nearest `.smfs` marker). + + ```bash + smfs sync + ``` + + + + Semantic search across a container without being inside the mount. The optional second argument scopes the search to a subpath inside the container. Inside a mount, plain `grep` already does this; `smfs grep` is the explicit form for scripts. + + ```bash + smfs grep "deadline" + smfs grep "deadline" /notes/ + ``` + + + + One-time auth. Prompts for your Supermemory API key and stores it in your global credentials. You can also pass it directly with `--key`. + + ```bash + smfs login + smfs login --key sm_... + ``` + + + + Print the currently-authenticated user, organization, and API endpoint. + + ```bash + smfs whoami + ``` + + + + Remove stored credentials. Active mounts keep running until you `smfs unmount` them. + + ```bash + smfs logout + ``` + + + + Force-installs the shell wrapper that makes plain `grep` semantic inside mounts. Writes directly to `~/.zshrc`. `smfs mount` also installs it automatically the first time, so you only need this to refresh after an upgrade. + + ```bash + smfs init + ``` + + Reopen your terminal (or `source ~/.zshrc`) after running it. + + + + Self-install. Copies the running binary to `~/.local/bin` and resets permissions. Run this if your `smfs` install ever feels broken. + + ```bash + smfs install + ``` + + + +## FAQ + + + + Run `smfs init` to force-install the shell wrapper. It writes directly to `~/.zshrc`. Then reopen your terminal so the new shell picks it up. + + The wrapper only triggers when you're inside a mount (it looks for the `.smfs` marker file at the mount root). Outside a mount, `grep` stays normal. Inside a mount, any flag you pass falls through to the real `grep`. + + + + Not yet. SMFS supports macOS (arm64, x64) and Linux (arm64, x64) for now. Windows isn't on the v0 roadmap. + + On Windows, use the [Bash Tool](/smfs/bash-tool) instead. It runs anywhere TypeScript runs and gives your agent the same `ls`, `cat`, `grep`, `sgrep` surface without needing a mount. + + + + Re-mount with `--clean` to wipe the local cache and pull everything fresh from the API: + + ```bash + smfs unmount agent_memory + smfs mount agent_memory --clean + ``` + + Nothing on the server changes; only the local SQLite cache gets reset. + + + + Yes. Once a container is mounted, anything on that machine can read and write through the mount path. The constraint is one mount per container tag per device. Mount it once, point both agents at the same folder. + + + + Yes, absolutely. Mount the same container tag from each sandbox. Bidirectional sync keeps everything in step as either side writes, so Agent A in sandbox 1 sees Agent B's writes from sandbox 2 within a sync interval. + + To avoid stepping on each other, give each agent its own subdirectory (`/agent_a/`, `/agent_b/`, etc.). They can still read across the whole mount, cross-reference each other's findings, and build on each other's work. The shared container is the point. + + diff --git a/apps/docs/smfs/overview.mdx b/apps/docs/smfs/overview.mdx new file mode 100644 index 00000000..74884a12 --- /dev/null +++ b/apps/docs/smfs/overview.mdx @@ -0,0 +1,71 @@ +--- +title: "SMFS" +sidebarTitle: "Overview" +description: "Memory your agent can grep." +icon: "database" +--- + +**SMFS** mounts your Supermemory container as a real directory. Agents read it with `ls`, `cat`, and `grep`. No SDK to learn, no client to wire up, no embeddings to think about. + +SMFS is open source and free for everyone. + +## Why a filesystem + +Every model already knows how a filesystem works. It can `ls`, `cat`, `grep`, `find`, redirect with `>`, pipe with `|`. You don't have to teach it a new API surface, and the grammar carries across runtimes. + +The catch: a filesystem on its own isn't great for memory. Search means walking the tree. Long files burn through context. The model has to hold the directory structure in its head. None of that scales as memory grows. + +SMFS fixes the catch. The shell is real, but underneath: + +- **Semantic `grep` by default.** One call surfaces what matters across the whole container, ranked by meaning. Pass any flag and you fall through to the real `grep` for exact matches. +- **Memory paths get distilled.** Files marked as memory paths are extracted and indexed by Supermemory. They don't bloat the model's context. +- **Virtual `profile.md`.** A live digest of the container at the mount root. The model can `cat profile.md` for a one-shot summary instead of walking every file. +- **Bidirectional sync** runs in the background. Local reads hit cache; writes push to Supermemory. + +You get filesystem ergonomics without paying the filesystem tax in tokens. + +## Two ways to use SMFS + +Pick by where your agent runs. + + + + For agents and tools with a real filesystem. Claude Code, Cursor, devcontainers, Docker, Codespaces. NFSv3 on macOS, FUSE on Linux. + + + For agents running serverless or at the edge. Cloudflare Workers, AWS Lambda, Vercel, Modal. A virtual bash where the filesystem is your container. Available as [`@supermemory/bash`](/smfs/bash-tool) for TypeScript and [`supermemory-bash`](/smfs/bash-tool-python) for Python. + + + +## Use SMFS with your sandbox provider + +Already using a sandbox or agent platform? Jump straight to the guide for your provider. + + + + Isolated Linux sandboxes with millisecond boot times. Mount SMFS inside or use the bash tool from your orchestrating code. + + + Firecracker microVMs for AI code execution. Install SMFS directly or use a custom template with it pre-installed. + + + The most popular TypeScript agent framework. Add memory as a tool with one function call. + + + Edge-first agents. Use the bash tool in Workers, or mount SMFS in Cloudflare Containers. + + + +## Next steps + + + + One curl, one mount, you're done. + + + Drop SMFS into a TypeScript or Python agent without mounting anything. + + + Full working apps you can clone and run — legal docs, support agents, and more. + + diff --git a/apps/docs/smfs/providers/cloudflare.mdx b/apps/docs/smfs/providers/cloudflare.mdx new file mode 100644 index 00000000..ec0e581a --- /dev/null +++ b/apps/docs/smfs/providers/cloudflare.mdx @@ -0,0 +1,337 @@ +--- +title: "Cloudflare" +description: "Give your AI agent persistent memory inside a Cloudflare Container using SMFS" +--- + +Mount a Supermemory container inside a +[Cloudflare Container](https://developers.cloudflare.com/containers/) so your +agent can read and write memory using standard filesystem commands. + +## How it works + +There are two ways to wire SMFS into a Cloudflare Container — pick the one that +fits your architecture. + +### Agent inside the container + +The agent process runs inside the container with direct access to the SMFS +mount. The entrypoint sets up the mount and starts the agent. + +```mermaid +graph LR + subgraph Cloudflare Container + Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +### Agent outside the container + +The agent runs in a Cloudflare Worker and sends commands to the container over +HTTP. The container exposes a simple exec endpoint. + +```mermaid +graph LR + Agent["Worker
(agent logic)"] -->|"containerFetch('/exec')"| Container + subgraph Container ["Cloudflare Container"] + Mount["/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +## Prerequisites + +- A [Supermemory API key](https://supermemory.ai) +- An [Anthropic API key](https://console.anthropic.com) +- A [Cloudflare account](https://dash.cloudflare.com) with Containers enabled (Workers Paid plan) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) +- The [`@cloudflare/containers`](https://www.npmjs.com/package/@cloudflare/containers) package: `npm install @cloudflare/containers` + + + Cloudflare Containers are implemented as container-enabled Durable Objects. + You declare a `Container` subclass, bind it as a Durable Object, and + reference its image in the `containers` array. Worker secrets are **not** + automatically visible inside the container — you have to pass them through + `envVars` when starting the container (see below). + + +--- + +## Pattern A: Agent inside the container + +SMFS and the Claude Agent SDK are baked into the container image. On startup, +the entrypoint mounts memory and runs the agent. + +### Dockerfile + +```dockerfile Dockerfile +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/* +RUN echo 'user_allow_other' >> /etc/fuse.conf + +RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2 +ENV PATH="/root/.local/bin:$PATH" +RUN pip install claude-agent-sdk + +COPY agent.py /app/agent.py +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +### Entrypoint + +```bash entrypoint.sh +#!/bin/bash +set -e + +smfs login --key "$SUPERMEMORY_API_KEY" +smfs mount my_agent --ephemeral --path /memory --foreground & +sleep 3 + +exec python3 /app/agent.py +``` + +### Agent code + +```python agent.py +import asyncio +from claude_agent_sdk import query, ClaudeAgentOptions + +MEMORY = "/memory" + +async def main(): + async for message in query( + prompt=f"You have a persistent memory filesystem at {MEMORY}. " + "Read profile.md to learn about the user, then create " + "session_notes.md summarizing what you found.", + options=ClaudeAgentOptions( + allowed_tools=["Bash", "Read", "Write"], + cwd=MEMORY, + ), + ): + print(message) + +asyncio.run(main()) +``` + +### Worker + +The Worker defines the `Container` subclass and forwards Worker secrets into +the container via `envVars`: + +```typescript worker.ts +import { Container, getContainer } from "@cloudflare/containers"; + +export class MyAgentContainer extends Container { + defaultPort = 8080; + // Forward Worker secrets into the container at start time. + // `this.env` is the Worker env object, populated from wrangler secrets. + envVars = { + SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY, + ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY, + }; +} + +export default { + async fetch(request: Request, env: Env) { + // The container runs the agent and exits; this Worker route just kicks + // it off (e.g. on a queue message or scheduled trigger). + const container = getContainer(env.MY_CONTAINER, "agent-singleton"); + return container.fetch(request); + }, +}; + +interface Env { + MY_CONTAINER: DurableObjectNamespace; + SUPERMEMORY_API_KEY: string; + ANTHROPIC_API_KEY: string; +} +``` + +### Config + +```jsonc wrangler.jsonc +{ + "name": "memory-agent", + "main": "worker.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "class_name": "MyAgentContainer", + "image": "./Dockerfile", + "max_instances": 5 + } + ], + "durable_objects": { + "bindings": [ + { "name": "MY_CONTAINER", "class_name": "MyAgentContainer" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["MyAgentContainer"] } + ] +} +``` + +```bash +wrangler secret put SUPERMEMORY_API_KEY +wrangler secret put ANTHROPIC_API_KEY +wrangler deploy +``` + +--- + +## Pattern B: Agent outside the container + +The agent logic lives in a Worker. The container just runs SMFS and exposes an +HTTP endpoint for executing commands against the mount. + + + The `/exec` endpoint below runs arbitrary shell commands inside the + container. **Only call it from your Worker** — never expose it publicly, + and never pass user input straight into `command` without validation. + Cloudflare Containers are addressable only through their Worker by default, + so this is safe as long as you don't add a public route that proxies to + `/exec`. + + +### Container (exec server) + +The Dockerfile and entrypoint are nearly identical to Pattern A — the only +differences are the Python deps (`flask` instead of `claude-agent-sdk`) and +the file we exec at the end. + +```dockerfile Dockerfile +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/* +RUN echo 'user_allow_other' >> /etc/fuse.conf + +RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2 +ENV PATH="/root/.local/bin:$PATH" +RUN pip install flask gunicorn + +COPY server.py /app/server.py +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +The entrypoint differs from Pattern A only in the final `exec` line — we run +gunicorn against the Flask app instead of `python3 agent.py`: + +```bash entrypoint.sh +#!/bin/bash +set -e + +smfs login --key "$SUPERMEMORY_API_KEY" +smfs mount my_agent --ephemeral --path /memory --foreground & +sleep 3 + +exec gunicorn -b 0.0.0.0:8080 --chdir /app server:app +``` + +```python server.py +import subprocess +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route("/exec", methods=["POST"]) +def exec_command(): + cmd = request.json["command"] + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, cwd="/memory", timeout=10 + ) + return jsonify(stdout=result.stdout, stderr=result.stderr, code=result.returncode) +``` + + + We use gunicorn instead of `app.run(...)` because Flask's built-in dev + server isn't meant for production traffic. If you'd rather just see it + work, you can replace the `exec` line with + `exec python3 /app/server.py` and add `app.run(host="0.0.0.0", port=8080)` + to `server.py` — but switch back to gunicorn before you ship. + + +### Worker (agent logic) + +```typescript worker.ts +import { Container, getContainer } from "@cloudflare/containers"; + +export class ExecContainer extends Container { + defaultPort = 8080; + envVars = { + SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY, + }; +} + +export default { + async fetch(_request: Request, env: Env) { + const container = getContainer(env.MY_CONTAINER, "agent-singleton"); + + const profile = await container + .fetch(new Request("http://container/exec", { + method: "POST", + body: JSON.stringify({ command: "cat /memory/profile.md" }), + headers: { "Content-Type": "application/json" }, + })) + .then((r) => r.json<{ stdout: string }>()); + + return Response.json({ profile: profile.stdout }); + }, +}; + +interface Env { + MY_CONTAINER: DurableObjectNamespace; + SUPERMEMORY_API_KEY: string; +} +``` + +### Config + +```jsonc wrangler.jsonc +{ + "name": "memory-exec", + "main": "worker.ts", + "compatibility_date": "2025-04-03", + "containers": [ + { + "class_name": "ExecContainer", + "image": "./Dockerfile", + "max_instances": 5 + } + ], + "durable_objects": { + "bindings": [ + { "name": "MY_CONTAINER", "class_name": "ExecContainer" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["ExecContainer"] } + ] +} +``` + +```bash +wrangler secret put SUPERMEMORY_API_KEY +wrangler deploy +``` + +--- + +## Tips + +- Use `--ephemeral` for container mounts — keeps the cache in memory only, but + writes still push to Supermemory +- Use `smfs grep 'query'` for semantic search across all files +- Worker secrets aren't automatically visible inside the container. Pass each + one through the `envVars` field on your `Container` subclass (see the Worker + snippets above) +- Use `containerFetch` from within a Container class method (e.g., lifecycle + hooks) to call the container's own HTTP server. From the Worker, use the + stub's `.fetch()` method instead diff --git a/apps/docs/smfs/providers/daytona.mdx b/apps/docs/smfs/providers/daytona.mdx new file mode 100644 index 00000000..7026783b --- /dev/null +++ b/apps/docs/smfs/providers/daytona.mdx @@ -0,0 +1,282 @@ +--- +title: "Daytona" +description: "Give your AI agent persistent memory inside a Daytona sandbox using SMFS" +--- + +Mount a Supermemory container inside a [Daytona](https://daytona.io) sandbox so +your agent can read and write memory using standard filesystem commands. + + + Daytona sandboxes currently cannot reach `api.supermemory.ai` from their + datacenter IPs. The SMFS binary still installs (we download it directly from + GitHub Releases), the FUSE mount still starts, and `pip install + claude-agent-sdk` still works — but the runtime sync to Supermemory fails. We're + working with Daytona to resolve this. In the meantime, use + [E2B](/smfs/providers/e2b) or a [self-hosted mount](/smfs/providers/vercel). + + +## How it works + +There are two ways to wire SMFS into a Daytona sandbox — pick the one that fits +your architecture. + +### Agent inside the sandbox + +The agent process runs inside the sandbox and accesses the SMFS mount directly. + +```mermaid +graph LR + subgraph Daytona Sandbox + Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/daytona/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +### Agent outside the sandbox + +The agent runs in your orchestrating code and executes commands inside the +sandbox remotely. + +```mermaid +graph LR + Agent["Claude Agent
(your server)"] -->|"sandbox.process.exec()"| Sandbox + subgraph Sandbox ["Daytona Sandbox"] + Mount["/home/daytona/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +## Prerequisites + +- A [Supermemory API key](https://supermemory.ai) +- A [Daytona API key](https://app.daytona.io) — go to **API Keys** in the sidebar +- An [Anthropic API key](https://console.anthropic.com) + +--- + +## Install SMFS in a Daytona sandbox + +Both patterns below run the same setup snippet inside the sandbox before +mounting. Daytona can't reach `smfs.ai`, so we download the binary directly +from GitHub Releases and add `~/.local/bin` to PATH. + + + + ```python + SMFS_INSTALL = ( + "mkdir -p $HOME/.local/bin && " + "curl -sL https://github.com/supermemoryai/smfs/releases/download/" + "v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && " + "chmod +x $HOME/.local/bin/smfs && " + "echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && " + "pip install claude-agent-sdk" + ) + ``` + + + ```typescript + const SMFS_INSTALL = + "mkdir -p $HOME/.local/bin && " + + "curl -sL https://github.com/supermemoryai/smfs/releases/download/" + + "v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && " + + "chmod +x $HOME/.local/bin/smfs && " + + "echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && " + + "pip install claude-agent-sdk"; + ``` + + + +--- + +## Pattern A: Agent inside the sandbox + +### Agent code + +```python agent.py +import asyncio +from claude_agent_sdk import query, ClaudeAgentOptions + +MEMORY = "/home/daytona/memory" + +async def main(): + async for message in query( + prompt=f"You have a persistent memory filesystem at {MEMORY}. " + "Read profile.md to learn about the user, then create " + "session_notes.md summarizing what you found.", + options=ClaudeAgentOptions( + allowed_tools=["Bash", "Read", "Write"], + cwd=MEMORY, + ), + ): + print(message) + +asyncio.run(main()) +``` + +### Orchestration + + + + ```python run.py + import os + from pathlib import Path + from daytona_sdk import Daytona, DaytonaConfig + + daytona = Daytona(DaytonaConfig( + api_key=os.environ["DAYTONA_API_KEY"], + )) + sandbox = daytona.create( + env_vars={ + "SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"], + "ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"], + }, + ) + + # See "Install SMFS in a Daytona sandbox" above + sandbox.process.exec(SMFS_INSTALL) + + # Mount memory + sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY") + sandbox.process.exec( + "bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral" + " --path /home/daytona/memory --foreground &' && sleep 3" + ) + + # Upload and run the agent + sandbox.fs.upload_file(Path("agent.py").read_bytes(), "agent.py") + result = sandbox.process.exec("python3 agent.py") + print(result.result) + + daytona.delete(sandbox) + ``` + + + ```typescript run.ts + import { Daytona } from "@daytonaio/sdk"; + import { readFileSync } from "fs"; + + const daytona = new Daytona({ + apiKey: process.env.DAYTONA_API_KEY!, + }); + const sandbox = await daytona.create({ + envVars: { + SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, + }, + }); + + // See "Install SMFS in a Daytona sandbox" above + await sandbox.process.exec(SMFS_INSTALL); + + // Mount memory + await sandbox.process.exec( + "$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY" + ); + await sandbox.process.exec( + "bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " + + "--path /home/daytona/memory --foreground &' && sleep 3" + ); + + // Upload and run the agent + await sandbox.fs.uploadFile(readFileSync("agent.py"), "agent.py"); + const result = await sandbox.process.exec("python3 agent.py"); + console.log(result.result); + + await daytona.delete(sandbox); + ``` + + + +--- + +## Pattern B: Agent outside the sandbox + +The agent runs in your server process and executes commands inside the sandbox +remotely via `sandbox.process.exec()`. + + + + ```python run.py + import os + from daytona_sdk import Daytona, DaytonaConfig + + daytona = Daytona(DaytonaConfig( + api_key=os.environ["DAYTONA_API_KEY"], + )) + sandbox = daytona.create( + env_vars={ + "SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"], + }, + ) + + # See "Install SMFS in a Daytona sandbox" above + sandbox.process.exec(SMFS_INSTALL) + sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY") + sandbox.process.exec( + "bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral" + " --path /home/daytona/memory --foreground &' && sleep 3" + ) + + # Agent runs here — executes commands in the sandbox + profile = sandbox.process.exec("cat /home/daytona/memory/profile.md") + print("Profile:", profile.result) + + sandbox.process.exec( + "bash -c 'echo \"Session started at $(date)\" > /home/daytona/memory/session_notes.md'" + ) + + files = sandbox.process.exec("ls /home/daytona/memory") + print("Files:", files.result) + + daytona.delete(sandbox) + ``` + + + ```typescript run.ts + import { Daytona } from "@daytonaio/sdk"; + + const daytona = new Daytona({ + apiKey: process.env.DAYTONA_API_KEY!, + }); + const sandbox = await daytona.create({ + envVars: { + SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!, + }, + }); + + // See "Install SMFS in a Daytona sandbox" above + await sandbox.process.exec(SMFS_INSTALL); + await sandbox.process.exec( + "$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY" + ); + await sandbox.process.exec( + "bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " + + "--path /home/daytona/memory --foreground &' && sleep 3" + ); + + // Agent runs here — executes commands in the sandbox + const profile = await sandbox.process.exec("cat /home/daytona/memory/profile.md"); + console.log("Profile:", profile.result); + + await sandbox.process.exec( + `bash -c 'echo "Session started at $(date)" > /home/daytona/memory/session_notes.md'` + ); + + const files = await sandbox.process.exec("ls /home/daytona/memory"); + console.log("Files:", files.result); + + await daytona.delete(sandbox); + ``` + + + +--- + +## Tips + +- FUSE is available in Daytona sandboxes but `user_allow_other` needs to be + added to `/etc/fuse.conf` +- We invoke SMFS as `$HOME/.local/bin/smfs` in the examples because Daytona's + default zsh PATH doesn't include `~/.local/bin`. Alternatively, prepend it + once with `export PATH=$HOME/.local/bin:$PATH` +- Use `pip install claude-agent-sdk` to install the agent SDK (PyPI is reachable) diff --git a/apps/docs/smfs/providers/e2b.mdx b/apps/docs/smfs/providers/e2b.mdx new file mode 100644 index 00000000..e014b8e1 --- /dev/null +++ b/apps/docs/smfs/providers/e2b.mdx @@ -0,0 +1,265 @@ +--- +title: "E2B" +description: "Give your AI agent persistent memory inside an E2B sandbox using SMFS" +--- + +Mount a Supermemory container inside an [E2B](https://e2b.dev) sandbox so your +agent can read and write memory using standard filesystem commands. + +## How it works + +There are two ways to wire SMFS into an E2B sandbox — pick the one that fits +your architecture. + +### Agent inside the sandbox + +The agent process runs inside the sandbox and accesses the SMFS mount directly. +Your orchestrating code just boots the sandbox and kicks off the agent. + +```mermaid +graph LR + subgraph E2B Sandbox + Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/user/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +### Agent outside the sandbox + +The agent runs in your orchestrating code and executes commands inside the +sandbox remotely. Useful when you want to keep the agent loop in your own +infra. + +```mermaid +graph LR + Agent["Claude Agent
(your server)"] -->|"sbx.commands.run()"| Sandbox + subgraph Sandbox ["E2B Sandbox"] + Mount["/home/user/memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +## Prerequisites + +- A [Supermemory API key](https://supermemory.ai) +- An [E2B API key](https://e2b.dev) +- An [Anthropic API key](https://console.anthropic.com) + +## 1. Create a custom template + +Bake SMFS and the Claude Agent SDK into a template so sandboxes start ready: + +```dockerfile e2b.Dockerfile +FROM e2b/code-interpreter:latest + +RUN apt-get update && apt-get install -y fuse3 && rm -rf /var/lib/apt/lists/* +RUN echo 'user_allow_other' >> /etc/fuse.conf +RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2 +ENV PATH="/root/.local/bin:$PATH" +RUN pip install claude-agent-sdk +``` + +```bash +e2b template build -d e2b.Dockerfile +``` + +--- + +## Pattern A: Agent inside the sandbox + +The agent runs inside the sandbox as a Python script. Your orchestrating code +just sets up the mount and starts it. + +### Agent code + +```python agent.py +import asyncio +from claude_agent_sdk import query, ClaudeAgentOptions + +MEMORY = "/home/user/memory" + +async def main(): + async for message in query( + prompt=f"You have a persistent memory filesystem at {MEMORY}. " + "Read profile.md to learn about the user, then create " + "session_notes.md summarizing what you found.", + options=ClaudeAgentOptions( + allowed_tools=["Bash", "Read", "Write"], + cwd=MEMORY, + ), + ): + print(message) + +asyncio.run(main()) +``` + +### Orchestration + + + + ```python run.py + import os + from pathlib import Path + from e2b_code_interpreter import Sandbox + + sbx = Sandbox.create( + template="your-template-id", + timeout=300, + envs={ + "SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"], + "ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"], + }, + ) + + # /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox. + sbx.commands.run("sudo chmod 666 /dev/fuse") + + # Mount memory. We background the foreground daemon so this command returns, + # then sleep briefly to let the FUSE mount come up before the agent reads it. + sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY") + sbx.commands.run( + "bash -c 'smfs mount my_agent --ephemeral" + " --path /home/user/memory --foreground &' && sleep 3" + ) + + # Upload and run the agent + sbx.files.write("/home/user/agent.py", Path("agent.py").read_text()) + result = sbx.commands.run("python3 /home/user/agent.py", timeout=120) + print(result.stdout) + + sbx.kill() + ``` + + + ```typescript run.ts + import { Sandbox } from "@e2b/code-interpreter"; + import { readFileSync } from "fs"; + + const sbx = await Sandbox.create({ + template: "your-template-id", + timeoutMs: 300_000, + envs: { + SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, + }, + }); + + // /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox. + await sbx.commands.run("sudo chmod 666 /dev/fuse"); + + // Mount memory. We background the foreground daemon so this command returns, + // then sleep briefly to let the FUSE mount come up before the agent reads it. + await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY"); + await sbx.commands.run( + "bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3" + ); + + // Upload and run the agent + await sbx.files.write("/home/user/agent.py", readFileSync("agent.py", "utf-8")); + const result = await sbx.commands.run("python3 /home/user/agent.py", { + timeoutMs: 120_000, + }); + console.log(result.stdout); + + await sbx.kill(); + ``` + + + +--- + +## Pattern B: Agent outside the sandbox + +The agent runs in your server process and executes commands inside the sandbox +remotely via `sbx.commands.run()`. The SMFS mount lives inside the sandbox — +the agent never touches the filesystem directly. + + + The FUSE mount is owned by root inside the sandbox. When writing to it from + outside the agent, wrap the command in `sudo bash -c '…'` so the redirect + runs with the right permissions. You'll see this in the write examples below. + + + + + ```python run.py + import os + from e2b_code_interpreter import Sandbox + + sbx = Sandbox.create( + template="your-template-id", + timeout=300, + envs={ + "SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"], + }, + ) + + # Set up SMFS inside the sandbox + sbx.commands.run("sudo chmod 666 /dev/fuse") + sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY") + sbx.commands.run( + "bash -c 'smfs mount my_agent --ephemeral" + " --path /home/user/memory --foreground &' && sleep 3" + ) + + # Agent runs here — executes commands in the sandbox + profile = sbx.commands.run("cat /home/user/memory/profile.md").stdout + print("Profile:", profile) + + sbx.commands.run( + "sudo bash -c 'echo \"Session started at $(date)\" > /home/user/memory/session_notes.md'" + ) + + files = sbx.commands.run("ls /home/user/memory").stdout + print("Files:", files) + + sbx.kill() + ``` + + + ```typescript run.ts + import { Sandbox } from "@e2b/code-interpreter"; + + const sbx = await Sandbox.create({ + template: "your-template-id", + timeoutMs: 300_000, + envs: { + SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!, + }, + }); + + // Set up SMFS inside the sandbox + await sbx.commands.run("sudo chmod 666 /dev/fuse"); + await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY"); + await sbx.commands.run( + "bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3" + ); + + // Agent runs here — executes commands in the sandbox + const profile = await sbx.commands.run("cat /home/user/memory/profile.md"); + console.log("Profile:", profile.stdout); + + await sbx.commands.run( + `sudo bash -c 'echo "Session started at $(date)" > /home/user/memory/session_notes.md'` + ); + + const files = await sbx.commands.run("ls /home/user/memory"); + console.log("Files:", files.stdout); + + await sbx.kill(); + ``` + + + +--- + +## Tips + +- Use `--ephemeral` for sandbox mounts — keeps the cache in memory only, but + writes still push to Supermemory +- Use `smfs grep 'query'` for semantic search across all files in the container +- Without a custom template, add the install steps to your run script: + ```python + sbx.commands.run("curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2", timeout=60) + sbx.commands.run("pip install claude-agent-sdk", timeout=60) + ``` diff --git a/apps/docs/smfs/providers/vercel.mdx b/apps/docs/smfs/providers/vercel.mdx new file mode 100644 index 00000000..d3e13daa --- /dev/null +++ b/apps/docs/smfs/providers/vercel.mdx @@ -0,0 +1,169 @@ +--- +title: "Vercel AI SDK" +description: "Give your AI agent persistent memory using SMFS with the Vercel AI SDK" +--- + +This guide is about the [Vercel AI SDK](https://ai-sdk.dev) — the TypeScript +agent framework — not Vercel hosting. The choice of pattern depends on where +your code actually runs: + +- **Self-hosted Node** (your own VM, ECS, Fly.io, Railway, a Vercel Sandbox, + etc.): you can mount SMFS as a real filesystem on the server. +- **Vercel Functions / serverless / edge**: there's no long-lived process to + hold a FUSE mount, so use the [Bash Tool](/smfs/bash-tool) + (`@supermemory/bash`) instead. The container becomes the filesystem; no mount + needed. + +## How it works + +### Self-hosted Node (real mount) + +The agent runs as a separate process with direct access to the SMFS mount. +Best when you want full bash, read, and write capabilities and your server is +long-lived. + +```mermaid +graph LR + subgraph Your Server + Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["./memory
(SMFS mount)"] + end + Mount -->|sync| SM["Supermemory"] +``` + +### Vercel Functions / serverless (Bash Tool) + +The agent runs inside `generateText` and accesses memory through `@supermemory/bash`, +which proxies bash commands to your Supermemory container over HTTP. No mount, +no FUSE, no long-lived process required. + +```mermaid +graph LR + subgraph Vercel Function + AI["generateText()"] -->|"bash tool"| Bash["@supermemory/bash"] + end + Bash -->|HTTPS| SM["Supermemory"] +``` + +## Prerequisites + +- A [Supermemory API key](https://supermemory.ai) +- An [Anthropic API key](https://console.anthropic.com) +- For Pattern A only: SMFS installed on your server (`curl -fsSL https://smfs.ai/install | bash`) + +--- + +## Pattern A: Claude Agent SDK on self-hosted Node + +Use this when the Vercel AI SDK is just the orchestrator and your real workload +is a Claude agent running on a long-lived server you control. + +Start the mount once when your server boots — not per-request: + +```bash +smfs login --key $SUPERMEMORY_API_KEY +smfs mount my_agent --path ./memory +``` + + + This won't work on Vercel Functions or any serverless runtime: there's no + process between requests to hold the mount, and FUSE isn't available. For + those targets, jump to Pattern B. + + +Write a standalone agent script. Nothing server-specific — just Python that +reads and writes files: + +```python agent.py +import asyncio +from claude_agent_sdk import query, ClaudeAgentOptions + +MEMORY = "./memory" + +async def main(): + async for message in query( + prompt=f"You have a persistent memory filesystem at {MEMORY}. " + "Read profile.md to learn about the user, then create " + "session_notes.md summarizing what you found.", + options=ClaudeAgentOptions( + allowed_tools=["Bash", "Read", "Write"], + cwd=MEMORY, + ), + ): + print(message) + +asyncio.run(main()) +``` + +```bash +python3 agent.py +``` + +--- + +## Pattern B: Vercel AI SDK + Bash Tool (serverless-friendly) + +`@supermemory/bash` exposes your Supermemory container as a single agent tool +— `run_bash(command)` — without mounting anything. It runs anywhere TypeScript +runs, including Vercel Functions, edge runtimes, and Lambda. + +```bash +npm install @supermemory/bash ai @ai-sdk/anthropic zod +``` + +```typescript api/agent.ts +import { generateText, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { createBash } from "@supermemory/bash"; +import { z } from "zod"; + +export async function POST(req: Request) { + const { prompt } = await req.json(); + + const { bash, toolDescription } = await createBash({ + apiKey: process.env.SUPERMEMORY_API_KEY!, + containerTag: "my_agent", + }); + + const result = await generateText({ + model: anthropic("claude-sonnet-4-5"), + tools: { + bash: tool({ + description: toolDescription, + inputSchema: z.object({ cmd: z.string() }), + execute: async ({ cmd }) => bash.exec(cmd), + }), + }, + maxSteps: 10, + prompt, + }); + + return Response.json({ text: result.text }); +} +``` + +A few things worth calling out: + +- **`maxSteps: 10`** lets the agent chain multiple bash calls per request + (read `profile.md`, then `cat` a few notes, then write a summary). Bump it + if your agent needs deeper chains; lower it to cap cost per request. +- **`toolDescription`** is a pre-written description of the available bash + surface (semantic `sgrep`, `cat`, `ls`, redirects, etc.). Hand it straight + to the model — don't roll your own. +- **No timeout/abort plumbing.** `bash.exec` already runs against the + container over HTTPS, so it returns when the command returns. No event-loop + blocking and no FUSE. + +See the [Bash Tool reference](/smfs/bash-tool) for the full command surface, +memory path configuration, and other framework integrations. + +--- + +## Tips + +- **Pattern A**: mount SMFS once when your server starts, not per-request. + Use `--ephemeral` if you don't need a local cache on the server. +- **Pattern B**: configure memory paths once at startup with + `configureMemoryPaths(["/notes/", "/journal.md"])` to control which files + get distilled into Supermemory memories. +- Both: use `smfs grep 'query'` (Pattern A) or `sgrep 'query'` inside the + bash tool (Pattern B) for semantic search across all files. diff --git a/apps/web/app/(app)/onboarding/layout.tsx b/apps/web/app/(app)/onboarding/layout.tsx deleted file mode 100644 index 644b2018..00000000 --- a/apps/web/app/(app)/onboarding/layout.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client" - -import { - createContext, - useContext, - useState, - useEffect, - useCallback, - type ReactNode, -} from "react" -import { useAuth } from "@lib/auth-context" - -export type MemoryFormData = { - twitter: string - linkedin: string - description: string - otherLinks: string[] -} | null - -interface OnboardingContextValue { - name: string - setName: (name: string) => void - memoryFormData: MemoryFormData - setMemoryFormData: (data: MemoryFormData) => void - resetOnboarding: () => void -} - -const OnboardingContext = createContext(null) - -export function useOnboardingContext() { - const ctx = useContext(OnboardingContext) - if (!ctx) { - throw new Error("useOnboardingContext must be used within OnboardingLayout") - } - return ctx -} - -export default function OnboardingLayout({ - children, -}: { - children: ReactNode -}) { - const { user } = useAuth() - - const [name, setNameState] = useState("") - const [memoryFormData, setMemoryFormDataState] = - useState(null) - - useEffect(() => { - const storedName = localStorage.getItem("onboarding_name") - const storedMemoryFormData = localStorage.getItem( - "onboarding_memoryFormData", - ) - - if (storedName) { - setNameState(storedName) - } else if (user?.displayUsername) { - setNameState(user.displayUsername) - localStorage.setItem("onboarding_name", user.displayUsername) - } else if (user?.name) { - setNameState(user.name) - localStorage.setItem("onboarding_name", user.name) - } - - if (storedMemoryFormData) { - try { - setMemoryFormDataState(JSON.parse(storedMemoryFormData)) - } catch { - // ignore parse errors - } - } - }, [user?.displayUsername, user?.name]) - - const setName = useCallback((newName: string) => { - setNameState(newName) - localStorage.setItem("onboarding_name", newName) - localStorage.setItem("username", newName) - }, []) - - const setMemoryFormData = useCallback((data: MemoryFormData) => { - setMemoryFormDataState(data) - if (data) { - localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data)) - } else { - localStorage.removeItem("onboarding_memoryFormData") - } - }, []) - - const resetOnboarding = useCallback(() => { - localStorage.removeItem("onboarding_name") - localStorage.removeItem("onboarding_memoryFormData") - setNameState("") - setMemoryFormDataState(null) - }, []) - - const contextValue: OnboardingContextValue = { - name, - setName, - memoryFormData, - setMemoryFormData, - resetOnboarding, - } - - return ( - - {children} - - ) -} diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 460fe92f..00696dec 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,18 +1,1115 @@ "use client" -import { useEffect } from "react" +import { + useState, + useRef, + useCallback, + useEffect, + useMemo, + type ReactNode, +} from "react" import { useRouter } from "next/navigation" +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 { + CHROME_EXTENSION_URL, + RAYCAST_EXTENSION_URL, + ADD_MEMORY_SHORTCUT_URL, +} from "@repo/lib/constants" +import { + ChromeIcon, + AppleShortcutsIcon, + RaycastIcon, +} from "@/components/integration-icons" +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react" +import { analytics } from "@/lib/analytics" + +type DetectedSource = "x" | "linkedin" | "resume" | null +type Status = "idle" | "processing" | "done" | "error" +type DocStatus = + | "unknown" + | "queued" + | "extracting" + | "chunking" + | "embedding" + | "indexing" + | "done" + | "failed" + +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 — press Enter to continue", + linkedin: "LinkedIn profile detected — press Enter to continue", +} + +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", + ) + }, + }, + ], + } +} export default function OnboardingPage() { const router = useRouter() + const { user, organizations, refetchOrganizations, setActiveOrg } = 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 [spotlightCategory, setSpotlightCategory] = + useState("productivity") + const [pauseSpotlight, setPauseSpotlight] = useState(false) + + const spotlightCatalog = useMemo( + () => buildSpotlightCatalog(router), + [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], + ) useEffect(() => { - router.replace("/onboarding/welcome?step=input") - }, [router]) + 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]) + + useEffect(() => { + const t = setTimeout(() => inputRef.current?.focus(), 500) + return () => clearTimeout(t) + }, []) + + useEffect(() => { + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } + }, []) + + 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]) + + const handleChange = (v: string) => { + setValue(v) + setDetected(detectSource(v)) + } + + const ensureOrg = useCallback(async () => { + if (organizations && organizations.length > 0) return + const name = user?.name || user?.email || "Personal" + const slug = generateOrgSlug(name) + const result = await authClient.organization.create({ + name, + slug, + metadata: { signupSource: "consumer" }, + }) + await setActiveOrg(result.data?.slug ?? slug) + if (user?.name) { + await authClient.updateUser({ + displayUsername: user.name, + username: generateUsername(user.name), + }) + } + await refetchOrganizations() + }, [user, organizations, refetchOrganizations, setActiveOrg]) + + const pollDocument = useCallback((docId: string) => { + const maxAttempts = 60 + let attempt = 0 + + 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) => { + 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 canSubmit = detected && detected !== "resume" return ( -
-
Loading...
+ // 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", + )} + /> + + {canSubmit && ( + handleSubmit(detected as "x" | "linkedin")} + className="absolute right-1 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%)", + }} + > + + + )} +
+ + + {detected && detected !== "resume" && ( + + {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}

+
+ + +
+
+ )} + +
) } diff --git a/apps/web/app/(app)/onboarding/setup/layout.tsx b/apps/web/app/(app)/onboarding/setup/layout.tsx deleted file mode 100644 index 2ef7d486..00000000 --- a/apps/web/app/(app)/onboarding/setup/layout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client" - -import { - createContext, - useContext, - useCallback, - useEffect, - useRef, - type ReactNode, -} from "react" -import { useRouter, useSearchParams } from "next/navigation" -import { useOnboardingContext, type MemoryFormData } from "../layout" -import { analytics } from "@/lib/analytics" - -export const SETUP_STEPS = ["relatable", "integrations"] as const -export type SetupStep = (typeof SETUP_STEPS)[number] - -interface SetupContextValue { - memoryFormData: MemoryFormData - currentStep: SetupStep - goToStep: (step: SetupStep) => void - goToWelcome: (step?: string) => void - finishOnboarding: () => void -} - -const SetupContext = createContext(null) - -export function useSetupContext() { - const ctx = useContext(SetupContext) - if (!ctx) { - throw new Error("useSetupContext must be used within SetupLayout") - } - return ctx -} - -export default function SetupLayout({ children }: { children: ReactNode }) { - const router = useRouter() - const searchParams = useSearchParams() - const { memoryFormData, resetOnboarding } = useOnboardingContext() - - const stepParam = searchParams.get("step") - const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep) - ? (stepParam as SetupStep) - : "relatable" - const hasTrackedInitialStep = useRef(false) - - const goToStep = useCallback( - (step: SetupStep) => { - analytics.onboardingStepViewed({ step, trigger: "user" }) - router.push(`/onboarding/setup?step=${step}`) - }, - [router], - ) - - const goToWelcome = useCallback( - (step = "input") => { - router.push(`/onboarding/welcome?step=${step}`) - }, - [router], - ) - - const finishOnboarding = useCallback(() => { - resetOnboarding() - router.push("/") - }, [router, resetOnboarding]) - - useEffect(() => { - if (!hasTrackedInitialStep.current) { - analytics.onboardingStepViewed({ step: currentStep, trigger: "user" }) - hasTrackedInitialStep.current = true - } - }, [currentStep]) - - const contextValue: SetupContextValue = { - memoryFormData, - currentStep, - goToStep, - goToWelcome, - finishOnboarding, - } - - return ( - - {children} - - ) -} diff --git a/apps/web/app/(app)/onboarding/setup/page.tsx b/apps/web/app/(app)/onboarding/setup/page.tsx deleted file mode 100644 index 35035233..00000000 --- a/apps/web/app/(app)/onboarding/setup/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client" - -import { motion, AnimatePresence } from "motion/react" - -import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question" -import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step" - -import { SetupHeader } from "@/components/onboarding/setup/header" -import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar" -import { AnimatedGradientBackground } from "@/components/animated-gradient-background" -import { useIsMobile } from "@hooks/use-mobile" - -import { useSetupContext, type SetupStep } from "./layout" - -function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) { - return ( - -

Unknown step

- -
- ) -} - -export default function SetupPage() { - const { memoryFormData, currentStep, goToStep } = useSetupContext() - const isMobile = useIsMobile() - - const renderStep = () => { - switch (currentStep) { - case "relatable": - return - case "integrations": - return - default: - return - } - } - - return ( -
- - - - -
-
-
-
- {renderStep()} -
- - {!isMobile && ( - - - - )} -
-
-
- - {isMobile && } -
- ) -} diff --git a/apps/web/app/(app)/onboarding/welcome/layout.tsx b/apps/web/app/(app)/onboarding/welcome/layout.tsx deleted file mode 100644 index 8a685368..00000000 --- a/apps/web/app/(app)/onboarding/welcome/layout.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client" - -import { - createContext, - useContext, - useState, - useEffect, - useCallback, - useRef, - type ReactNode, -} from "react" -import { useRouter, useSearchParams } from "next/navigation" -import { useOnboardingContext, type MemoryFormData } from "../layout" -import { useAuth } from "@lib/auth-context" -import { analytics } from "@/lib/analytics" - -export const WELCOME_STEPS = [ - "input", - "greeting", - "welcome", - "username", - "features", - "memories", -] as const -export type WelcomeStep = (typeof WELCOME_STEPS)[number] - -interface WelcomeContextValue { - name: string - setName: (name: string) => void - isSubmitting: boolean - setIsSubmitting: (value: boolean) => void - showWelcomeContent: boolean - memoryFormData: MemoryFormData - setMemoryFormData: (data: MemoryFormData) => void - currentStep: WelcomeStep - goToStep: (step: WelcomeStep) => void - goToSetup: (step?: string) => void -} - -const WelcomeContext = createContext(null) - -export function useWelcomeContext() { - const ctx = useContext(WelcomeContext) - if (!ctx) { - throw new Error("useWelcomeContext must be used within WelcomeLayout") - } - return ctx -} - -export default function WelcomeLayout({ children }: { children: ReactNode }) { - const router = useRouter() - const searchParams = useSearchParams() - const { name, setName, memoryFormData, setMemoryFormData } = - useOnboardingContext() - const { organizations } = useAuth() - const hasOrgs = Array.isArray(organizations) && organizations.length > 0 - - const stepParam = searchParams.get("step") - const resolvedStep: WelcomeStep = WELCOME_STEPS.includes( - stepParam as WelcomeStep, - ) - ? (stepParam as WelcomeStep) - : "input" - const currentStep: WelcomeStep = - resolvedStep === "input" && hasOrgs ? "greeting" : resolvedStep - - const [isSubmitting, setIsSubmitting] = useState(false) - const [showWelcomeContent, setShowWelcomeContent] = useState(false) - const isMountedRef = useRef(true) - const hasTrackedInitialStep = useRef(false) - - useEffect(() => { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - - useEffect(() => { - if (currentStep === "input") { - setShowWelcomeContent(false) - const timer = setTimeout(() => { - if (isMountedRef.current) { - setShowWelcomeContent(true) - } - }, 1000) - return () => clearTimeout(timer) - } - setShowWelcomeContent(true) - }, [currentStep]) - - useEffect(() => { - const timers: NodeJS.Timeout[] = [] - - if (currentStep === "greeting") { - timers.push( - setTimeout(() => { - if (isMountedRef.current) { - analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" }) - router.replace("/onboarding/welcome?step=welcome") - } - }, 2000), - ) - } else if (currentStep === "welcome") { - timers.push( - setTimeout(() => { - if (isMountedRef.current) { - analytics.onboardingStepViewed({ - step: "username", - trigger: "auto", - }) - router.replace("/onboarding/welcome?step=username") - } - }, 2000), - ) - } - - return () => { - timers.forEach(clearTimeout) - } - }, [currentStep, router]) - - useEffect(() => { - if (!hasTrackedInitialStep.current) { - analytics.onboardingStepViewed({ - step: currentStep, - trigger: "user", - }) - hasTrackedInitialStep.current = true - } - }, [currentStep]) - - const goToStep = useCallback( - (step: WelcomeStep) => { - analytics.onboardingStepViewed({ step, trigger: "user" }) - router.push(`/onboarding/welcome?step=${step}`) - }, - [router], - ) - - const goToSetup = useCallback( - (step = "relatable") => { - router.push(`/onboarding/setup?step=${step}`) - }, - [router], - ) - - const contextValue: WelcomeContextValue = { - name, - setName, - isSubmitting, - setIsSubmitting, - showWelcomeContent, - memoryFormData, - setMemoryFormData, - currentStep, - goToStep, - goToSetup, - } - - return ( - - {children} - - ) -} diff --git a/apps/web/app/(app)/onboarding/welcome/page.tsx b/apps/web/app/(app)/onboarding/welcome/page.tsx deleted file mode 100644 index ac3ea599..00000000 --- a/apps/web/app/(app)/onboarding/welcome/page.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client" - -import { useRef } from "react" -import { motion, AnimatePresence } from "motion/react" -import { cn } from "@lib/utils" - -import { InputStep } from "@/components/onboarding/welcome/input-step" -import { GreetingStep } from "@/components/onboarding/welcome/greeting-step" -import { WelcomeStep } from "@/components/onboarding/welcome/welcome-step" -import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-step" - -import { InitialHeader } from "@/components/initial-header" -import { Logo } from "@ui/assets/Logo" -import NovaOrb from "@/components/nova/nova-orb" -import { AnimatedGradientBackground } from "@/components/animated-gradient-background" - -import { - useWelcomeContext, - type WelcomeStep as WelcomeStepType, -} from "./layout" -import { gapVariants, orbVariants } from "@/lib/variants" -import { authClient } from "@lib/auth" -import { useAuth } from "@lib/auth-context" -import { analytics } from "@/lib/analytics" -import { toast } from "sonner" - -function generateSlugFromName(value: string) { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") || "org" - ) -} - -function generateOrgSlug(name: string) { - const base = generateSlugFromName(name.trim()) - const randomNum = Math.floor(100000 + Math.random() * 900000) - return `${base}-${randomNum}` -} - -function generateUsername(name: string) { - const base = generateSlugFromName(name.trim()).replace(/-/g, "_") - const randomNum = Math.floor(100000 + Math.random() * 900000) - return `${base}${randomNum}` -} - -function UserSupermemory({ name }: { name: string }) { - return ( - - -
-

- {name.split(" ")[0]}'s -

-

- supermemory -

-
-
- ) -} - -function StepNotFound({ - goToStep, -}: { - goToStep: (step: WelcomeStepType) => void -}) { - return ( - -

Unknown step

- -
- ) -} - -export default function WelcomePage() { - const { - name, - setName, - isSubmitting, - setIsSubmitting, - showWelcomeContent, - setMemoryFormData, - currentStep, - goToStep, - } = useWelcomeContext() - - const { refetchOrganizations, setActiveOrg } = useAuth() - const submitLockRef = useRef(false) - - const handleSubmit = async () => { - const trimmed = name.trim() - if (!trimmed) return - if (submitLockRef.current) return - submitLockRef.current = true - localStorage.setItem("username", trimmed) - setIsSubmitting(true) - - try { - await authClient.updateUser({ - displayUsername: trimmed, - username: generateUsername(trimmed), - }) - - const refetchResult = await refetchOrganizations() - const refetchData = ( - refetchResult as { data?: unknown[] | null | undefined } - )?.data - const existingOrgs = Array.isArray(refetchData) ? refetchData : [] - - if (existingOrgs.length > 0) { - analytics.onboardingNameSubmitted({ - name_length: trimmed.length, - }) - goToStep("greeting") - return - } - - const uniqueSlug = generateOrgSlug(trimmed) - const completedAt = new Date().toISOString() - const newOrg = await authClient.organization.create({ - name: trimmed, - slug: uniqueSlug, - metadata: { - signupSource: "consumer", - webOnboarding: { - completedAt: null, - steps: { - welcomeInput: { - startedAt: completedAt, - completedAt, - data: {}, - }, - }, - }, - }, - }) - - await setActiveOrg(newOrg.slug) - - analytics.onboardingNameSubmitted({ name_length: trimmed.length }) - goToStep("greeting") - } catch (error) { - console.error("Onboarding submit failed:", error) - toast.error( - error instanceof Error - ? error.message - : "Could not set up your workspace. Please try again.", - ) - } finally { - submitLockRef.current = false - setIsSubmitting(false) - } - } - - const renderStep = () => { - switch (currentStep) { - case "input": - return ( - - ) - case "greeting": - return - case "welcome": - return - case "username": - case "features": - case "memories": - return ( - - ) - default: - return - } - } - - const minimizeNovaOrb = ["features", "memories"].includes(currentStep) - const novaSize = currentStep === "memories" ? 150 : 300 - const showUserSupermemory = currentStep === "username" - - return ( -
- - - {currentStep === "input" && ( - - )} - - {showWelcomeContent && ( -
- - - - - {showUserSupermemory && } - - - {renderStep()} - -
- )} -
- ) -} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 9ad372ed..e7d7caff 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -1,12 +1,27 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react" +import { AnimatePresence, motion } from "motion/react" import { useQueryState } from "nuqs" -import { Header } from "@/components/header" -import { ChatSidebar } from "@/components/chat" +import { Header, PublicHeader } from "@/components/header" +import { ChatSidebar, HomeChatComposer } from "@/components/chat" +import { DashboardView } from "@/components/dashboard-view" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" -import { IntegrationsView } from "@/components/integrations-view" +import { IntegrationsView, DetailWrapper } from "@/components/integrations-view" +import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view" +import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view" +import { ChromeDetail } from "@/components/integrations/chrome-detail" +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 { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" @@ -15,7 +30,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal" import type { HighlightItem } from "@/components/highlights-card" import { HotkeysProvider } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook" -import { AnimatePresence } from "motion/react" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" import { useProject } from "@/stores" @@ -26,11 +40,14 @@ import { useQuickNoteDraft, } from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" +import type { ModelId } from "@/lib/models" import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { useViewMode } from "@/lib/view-mode-context" +import type { MemoryOfDay } from "@/components/dashboard-view" import { ErrorBoundary } from "@/components/error-boundary" import { cn } from "@lib/utils" import { @@ -39,15 +56,35 @@ import { qParam, docParam, fullscreenParam, - chatParam, - integrationParam, - pluginsPanelParam, + threadParam, type IntegrationParamValue, } from "@/lib/search-params" +import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] +function subscribeViewportWidth(cb: () => void) { + window.addEventListener("resize", cb) + return () => window.removeEventListener("resize", cb) +} + +function getViewportWidth() { + return window.innerWidth +} + +const GRADIENT_TOP_WIDTH_MAX = 1440 + +function gradientTopPositionForWidth(width: number) { + const minW = 320 + const pctWide = 15 + const pctNarrow = 70 + const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) + const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) + const eased = t * t + return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` +} + function ViewErrorFallback() { return (
@@ -68,34 +105,28 @@ function ViewErrorFallback() { export default function NewPage() { const isMobile = useIsMobile() const { user, session } = useAuth() - const { - selectedProject, - isNovaSpaces, - novaContainerTags, - selectedProjects, - setSelectedProjects, - } = useProject() - const selectedProjectTag = selectedProjects[0] - const isNovaContext = - isNovaSpaces || - (selectedProjectTag !== undefined && - novaContainerTags.includes(selectedProjectTag)) - const { allProjects } = useContainerTags() - const emptyStateSpaceName = - !isNovaSpaces && selectedProjectTag - ? selectedProjectTag === DEFAULT_PROJECT_ID - ? "My Space" - : (allProjects.find((p) => p.containerTag === selectedProjectTag) - ?.name ?? selectedProjectTag) - : undefined - const handleSwitchToAllSpacesFromEmptyState = useCallback(() => { - analytics.spaceSwitched({ space_id: "nova_spaces" }) - setSelectedProjects([]) - }, [setSelectedProjects]) + const { selectedProject, selectedProjects } = useProject() + const selectedProjectTag = selectedProjects[0] + const { allProjects } = useContainerTags() + const dashboardSpaceLabel = useMemo( + () => + getChatSpaceDisplayLabel({ + selectedProject, + allProjects, + }), + [selectedProject, allProjects], + ) + const emptyStateSpaceName = selectedProjectTag + ? selectedProjectTag === DEFAULT_PROJECT_ID + ? "My Space" + : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ?? + selectedProjectTag) + : undefined const { viewMode, setViewMode } = useViewMode() const queryClient = useQueryClient() + const [highlightsForceAt, setHighlightsForceAt] = useState(0) // Chrome extension auth: send session token via postMessage so the content script can store it useEffect(() => { @@ -122,22 +153,15 @@ export default function NewPage() { "fullscreen", fullscreenParam, ) - const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam) - const [integrationFromUrl, setIntegration] = useQueryState( - "integration", - integrationParam, - ) - const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam) - - useEffect(() => { - if (integrationFromUrl || pluginsPanelFromUrl === true) { - void setViewMode("integrations") - } - }, [integrationFromUrl, pluginsPanelFromUrl, setViewMode]) + const [, setThreadIdUrl] = useQueryState("thread", threadParam) // Ephemeral local state (not worth URL-encoding) const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) + const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedMessageSource, setQueuedMessageSource] = useState< + "highlight" | "home" + >("highlight") const [selectedDocument, setSelectedDocument] = useState(null) @@ -146,6 +170,10 @@ export default function NewPage() { if (!docId) setSelectedDocument(null) }, [docId]) + useEffect(() => { + if (viewMode === "dashboard") void setThreadIdUrl(null) + }, [viewMode, setThreadIdUrl]) + // Resolve document from cache when loading with ?doc= (deep link / refresh) useEffect(() => { if (!docId || selectedDocument) return @@ -177,6 +205,8 @@ export default function NewPage() { const resetDraft = useQuickNoteDraftReset(selectedProject) const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") + const quickNoteDraftRef = useRef(quickNoteDraft) + quickNoteDraftRef.current = quickNoteDraft const { noteMutation, bulkDeleteMutation } = useDocumentMutations({ onClose: () => { @@ -247,20 +277,31 @@ export default function NewPage() { const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours + const handleResetHighlights = useCallback(async () => { + toast.success("Refreshing daily brief…") + try { + await caches.delete(HIGHLIGHTS_CACHE_NAME) + } catch {} + setHighlightsForceAt(Date.now()) + }, []) + const { data: highlightsData, isLoading: isLoadingHighlights } = useQuery({ - queryKey: ["space-highlights", selectedProject], + queryKey: ["space-highlights", selectedProject, highlightsForceAt], queryFn: async (): Promise => { const spaceId = selectedProject || "sm_project_default" + const forceRefresh = highlightsForceAt > 0 const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` - const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) - const cached = await cache.match(cacheKey) - if (cached) { - const age = - Date.now() - Number(cached.headers.get("x-cached-at") || 0) - if (age < HIGHLIGHTS_MAX_AGE) { - return cached.json() + if (!forceRefresh) { + const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cached = await cache.match(cacheKey) + if (cached) { + const age = + Date.now() - Number(cached.headers.get("x-cached-at") || 0) + if (age < HIGHLIGHTS_MAX_AGE) { + return cached.json() + } } } @@ -276,6 +317,7 @@ export default function NewPage() { questionsCount: 4, includeHighlights: true, includeQuestions: true, + forceRefresh, }), }, ) @@ -286,13 +328,21 @@ export default function NewPage() { const data = await response.json() - const cacheResponse = new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - "x-cached-at": String(Date.now()), - }, - }) - await cache.put(cacheKey, cacheResponse) + // Update browser cache with fresh data (works for both normal and forced refresh) + try { + const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cacheResponse = new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "x-cached-at": String(Date.now()), + }, + }) + await freshCache.put(cacheKey, cacheResponse) + } catch {} + + // Reset force flag after the forced fetch completes so future project-switches + // use the normal cache path instead of always bypassing it. + if (forceRefresh) setHighlightsForceAt(0) return data }, @@ -300,6 +350,37 @@ export default function NewPage() { refetchOnWindowFocus: false, }) + const { data: memoryOfDay = null } = useQuery({ + queryKey: [ + "memory-of-day", + user?.id, + new Date().toISOString().slice(0, 10), + ], + queryFn: async (): Promise => { + const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}` + try { + const stored = localStorage.getItem(cacheKey) + if (stored) return JSON.parse(stored) as MemoryOfDay + } catch {} + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`, + { credentials: "include" }, + ) + if (!response.ok) return null + const data = (await response.json()) as MemoryOfDay | null + if (data) { + try { + localStorage.setItem(cacheKey, JSON.stringify(data)) + } catch {} + } + return data + }, + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!user, + }) + useHotkeys("c", () => { analytics.addDocumentModalOpened() setAddDoc("note") @@ -324,7 +405,7 @@ export default function NewPage() { const handleQuickNoteSave = useCallback( (content: string) => { if (content.trim()) { - const hadPreviousContent = quickNoteDraft.trim().length > 0 + const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0 noteMutation.mutate( { content, project: selectedProject }, { @@ -339,7 +420,7 @@ export default function NewPage() { ) } }, - [selectedProject, noteMutation, quickNoteDraft], + [selectedProject, noteMutation], ) const handleFullScreenSave = useCallback( @@ -375,11 +456,29 @@ export default function NewPage() { const handleHighlightsChat = useCallback( (seed: string) => { setQueuedChatSeed(seed) - setIsChatOpen(true) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + void setViewMode("chat") }, - [setIsChatOpen], + [setViewMode], ) + const handleHomeChatStart = useCallback( + (message: string, model: ModelId) => { + setQueuedChatSeed(message) + setQueuedChatModel(model) + setQueuedMessageSource("home") + void setViewMode("chat") + }, + [setViewMode], + ) + + const consumeQueuedChat = useCallback(() => { + setQueuedChatSeed(null) + setQueuedChatModel(null) + setQueuedMessageSource("highlight") + }, []) + const handleHighlightsShowRelated = useCallback( (query: string) => { analytics.searchOpened({ source: "highlight_related" }) @@ -391,16 +490,15 @@ export default function NewPage() { const handleOpenIntegrations = useCallback( (integration?: IntegrationParamValue) => { - setViewMode("integrations") - if (integration) { - setIntegration(integration) - } else { - setIntegration(null) - } + void setViewMode(integration ?? "integrations") }, - [setViewMode, setIntegration], + [setViewMode], ) + const handleOpenPlugins = useCallback(() => { + void setViewMode("plugins") + }, [setViewMode]) + const handleAddMemory = useCallback( (tab: "note" | "link") => { analytics.addDocumentModalOpened() @@ -409,120 +507,221 @@ export default function NewPage() { [setAddDoc], ) - const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile + const viewportWidth = useSyncExternalStore( + subscribeViewportWidth, + getViewportWidth, + () => GRADIENT_TOP_WIDTH_MAX, + ) + const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) + + const isChatView = viewMode === "chat" const isGraphMode = viewMode === "graph" && !isMobile + const isMemoriesDesktop = viewMode === "list" && !isMobile + const isHomeDesktop = viewMode === "dashboard" && !isMobile + const showNovaBackdrop = isGraphMode || isMemoriesDesktop || isHomeDesktop + const isDashboardShell = + viewMode === "dashboard" || (viewMode === "graph" && isMobile) return (
- - {isGraphMode && ( -
+ +
+
+ + )} + {!session && viewMode === "mcp" ? ( + + ) : ( +
{ + analytics.addDocumentModalOpened() + setAddDoc("note") + }} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} /> )} -
{ - analytics.addDocumentModalOpened() - setAddDoc("note") - }} - onOpenChat={() => setIsChatOpen(true)} - onOpenSearch={() => { - analytics.searchOpened({ source: "header" }) - setIsSearchOpen(true) - }} - /> -
-
- }> - {viewMode === "integrations" ? ( -
- -
- ) : viewMode === "graph" && !isMobile ? ( -
- -
- ) : ( -
- -
+ + +
-
- - - setIsChatOpen(open)} - queuedMessage={queuedChatSeed} - onConsumeQueuedMessage={() => setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} + > + }> + {isChatView ? ( +
+ { + if (!open) void setViewMode("dashboard") + }} + queuedMessage={queuedChatSeed} + onConsumeQueuedMessage={consumeQueuedChat} + queuedMessageSource={queuedMessageSource} + initialSelectedModel={queuedChatModel} + emptyStateSuggestions={highlightsData?.questions} + /> +
+ ) : viewMode === "integrations" ? ( +
+ +
+ ) : viewMode === "mcp" ? ( + void setViewMode("integrations")} /> -
-
+ ) : viewMode === "plugins" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "chrome" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "shortcuts" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "raycast" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "import" ? ( + void setViewMode("integrations")} + /> + ) : viewMode === "graph" && !isMobile ? ( +
+ +
+ ) : viewMode === "list" ? ( +
+ +
+ ) : ( + + + Graph view is available on desktop. + {" "} + Use a larger screen for the full graph, or keep + working from this home view. +
+ ) : undefined + } + highlights={highlightsData?.highlights ?? []} + isLoadingHighlights={isLoadingHighlights} + onAddMemory={handleAddMemory} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + onOpenIntegrations={handleOpenIntegrations} + onOpenPlugins={handleOpenPlugins} + onNavigateToMemories={() => void setViewMode("list")} + onNavigateToGraph={() => void setViewMode("graph")} + onOpenDocument={handleOpenDocument} + onHighlightsChat={handleHighlightsChat} + onHighlightsShowRelated={handleHighlightsShowRelated} + onResetHighlights={handleResetHighlights} + memoryOfDay={memoryOfDay} + /> + )} + +
+
+
+ + {isDashboardShell && ( +
+
+
-
- - {isMobile && ( - setIsChatOpen(open)} - queuedMessage={queuedChatSeed} - onConsumeQueuedMessage={() => setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} - /> )} { analytics.addDocumentModalOpened() diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 9d47b838..4f092cbb 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -6,7 +6,7 @@ import { motion } from "motion/react" import NovaOrb from "@/components/nova/nova-orb" import { useState, useEffect, useRef } from "react" import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import Account from "@/components/settings/account" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" @@ -16,7 +16,11 @@ 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 { Sun } from "lucide-react" +import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react" +import { authClient } from "@lib/auth" +import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { useResetOrganization } from "@/hooks/use-reset-organization" +import { useDeleteUserAccount } from "@/hooks/use-account-settings" const TABS = ["account", "integrations", "connections", "support"] as const type SettingsTab = (typeof TABS)[number] @@ -28,6 +32,14 @@ type NavItem = { icon: React.ReactNode } +type DangerItem = { + id: "logout" | "reset" | "delete" + label: string + description: string + icon: React.ReactNode + color: "neutral" | "amber" | "red" +} + const NAV_ITEMS: NavItem[] = [ { id: "account", @@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [ }, ] +const DANGER_ITEMS: DangerItem[] = [ + { + id: "logout", + label: "Log out", + description: "Sign out of your account on this device", + icon: , + color: "neutral", + }, + { + id: "reset", + label: "Reset data", + description: "Erase all memories, connections and spaces", + icon: , + color: "amber", + }, + { + id: "delete", + label: "Delete account", + description: "Permanently delete your account and all data", + icon: , + color: "red", + }, +] + +const DANGER_COLORS: Record< + DangerItem["color"], + { idle: string; hover: string; icon: string } +> = { + neutral: { + idle: "text-white/50", + hover: "hover:text-white", + icon: "text-white/40", + }, + amber: { + idle: "text-[#7A6030]", + hover: "hover:text-[#C7991B]", + icon: "text-[#7A6030]", + }, + red: { + idle: "text-[#6B2A2A]", + hover: "hover:text-[#C73B1B]", + icon: "text-[#6B2A2A]", + }, +} + function parseHashToTab(hash: string): SettingsTab { const cleaned = hash.replace("#", "").toLowerCase() return TABS.includes(cleaned as SettingsTab) @@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) { } export default function SettingsPage() { - const { user } = useAuth() + const { user, org } = useAuth() const [activeTab, setActiveTab] = useState("account") const hasInitialized = useRef(false) 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 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 @@ -277,6 +361,55 @@ export default function SettingsPage() { )} ))} + + {/* Divider */} + {!isMobile &&
} + + {DANGER_ITEMS.map((item) => { + const colors = DANGER_COLORS[item.color] + const handleClick = () => { + if (item.id === "logout") handleLogout() + else if (item.id === "reset") setIsResetDialogOpen(true) + else if (item.id === "delete") setIsDeleteDialogOpen(true) + } + return ( + + ) + })}
@@ -303,6 +436,169 @@ export default function SettingsPage() {
+ + {/* 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" + /> +
+
+ + + + +
+
+
+
) } diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index e3d2a1c5..421d0f06 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -204,18 +204,12 @@ export default function LoginPage() { email_domain: email.split("@")[1] || "unknown", }) - try { - await signIn.magicLink({ - callbackURL: getCallbackURL(), - email, - }) - setSubmittedEmail(email) - setPendingLoginMethod("magic_link") - // Track successful magic link send - posthog.capture("login_magic_link_sent", { - email_domain: email.split("@")[1] || "unknown", - }) - } catch (error) { + const { error } = await signIn.magicLink({ + callbackURL: getCallbackURL(), + email, + }) + + if (error) { console.error(error) // Track login failure @@ -232,6 +226,12 @@ export default function LoginPage() { return } + setSubmittedEmail(email) + setPendingLoginMethod("magic_link") + posthog.capture("login_magic_link_sent", { + email_domain: email.split("@")[1] || "unknown", + }) + setIsLoading(false) setIsLoadingEmail(false) } @@ -355,6 +355,9 @@ export default function LoginPage() { callbackURL: getCallbackURL(), provider: "google", }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + }) .finally(() => { setIsLoading(false) }) @@ -419,6 +422,9 @@ export default function LoginPage() { callbackURL: getCallbackURL(), provider: "github", }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + }) .finally(() => { setIsLoading(false) }) diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 5465c38b..544002e0 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -349,7 +349,7 @@ function AuthConnectContent() { = { + scoped: "Files & Folders", + full: "Whole Drive", +} + type Connection = z.infer type ConnectorProvider = "google-drive" | "notion" | "onedrive" @@ -24,26 +47,178 @@ const CONNECTORS: Record< { title: string description: string + documentLabel: string icon: React.ComponentType<{ className?: string }> } > = { "google-drive": { title: "Google Drive", description: "Connect your Google docs, sheets and slides", + documentLabel: "documents", icon: GoogleDrive, }, notion: { title: "Notion", description: "Import your Notion pages and databases", + documentLabel: "pages", icon: Notion, }, onedrive: { title: "OneDrive", description: "Access your Microsoft Office documents", + documentLabel: "documents", icon: OneDrive, }, } as const +function formatRelativeTime(date: string | null | undefined): string { + if (!date) return "Never" + const d = new Date(date) + const diffMs = Date.now() - d.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + if (diffHours < 1) return "Just now" + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString() +} + +function ConnectionRow({ + connection, + onDelete, + isDeleting, + projects, +}: { + connection: Connection + onDelete: () => void + isDeleting: boolean + projects: Project[] +}) { + const config = CONNECTORS[connection.provider as ConnectorProvider] + if (!config) return null + + const Icon = config.icon + const isConnected = + !connection.expiresAt || new Date(connection.expiresAt) > new Date() + + const getProjectName = (tag: string): string => { + if (tag === DEFAULT_PROJECT_ID) return "Default" + return ( + projects.find((p) => p.containerTag === tag)?.name ?? + tag.replace(/^sm_project_/, "").replace(/_/g, " ") + ) + } + + const documentCount = (connection.metadata?.documentCount as number) ?? 0 + const containerTags = ( + connection as Connection & { containerTags?: string[] } + ).containerTags + const projectName = containerTags?.[0] + ? getProjectName(containerTags[0]) + : null + + return ( +
+
+
+ +
+
+ + {config.title} + +
+
+ + {isConnected ? "Connected" : "Disconnected"} + +
+
+ + {connection.email || "Unknown"} + +
+ +
+
+
+ {projectName && ( +
+ + + {projectName} + +
+ )} +
+ + + {formatRelativeTime(connection.createdAt)} + +
+
+
+ + {documentCount} + + + {config.documentLabel} + +
+
+
+
+ ) +} + interface ConnectContentProps { selectedProject: string } @@ -54,12 +229,17 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { const isProUser = hasActivePlan(autumn.customer?.products, "api_pro") const [connectingProvider, setConnectingProvider] = useState(null) + const [gdriveSyncScope, setGdriveSyncScope] = + useState("scoped") const [isUpgrading, setIsUpgrading] = useState(false) const [removeDialog, setRemoveDialog] = useState<{ open: boolean connection: Connection | null }>({ open: false, connection: null }) + const projects = (queryClient.getQueryData(["projects"]) || + []) as Project[] + const handleUpgrade = async () => { setIsUpgrading(true) try { @@ -114,7 +294,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { // Connect mutation const addConnectionMutation = useMutation({ - mutationFn: async (provider: ConnectorProvider) => { + mutationFn: async ({ + provider, + syncScope, + }: { + provider: ConnectorProvider + syncScope?: GDriveSyncScope + }) => { if (!canAddConnection && !isProUser) { throw new Error( "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", @@ -126,6 +312,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { body: { redirectUrl: window.location.href, containerTags: [selectedProject], + metadata: + provider === "google-drive" && syncScope === "full" + ? { syncScope: "full" } + : undefined, }, }) @@ -165,7 +355,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { onSuccess: (_data, variables) => { toast.success( variables.deleteDocuments - ? "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes." + ? "Connection removal has started. Documents will be permanently deleted in the next few minutes." : "Connection removed. Your memories have been kept.", ) setRemoveDialog({ open: false, connection: null }) @@ -180,76 +370,38 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { const handleConnect = (provider: ConnectorProvider) => { setConnectingProvider(provider) - addConnectionMutation.mutate(provider) - } - - const handleDisconnect = (connection: Connection) => { - setRemoveDialog({ open: true, connection }) + addConnectionMutation.mutate({ + provider, + syncScope: provider === "google-drive" ? gdriveSyncScope : undefined, + }) } const hasConnections = connections.length > 0 - // Helper function to format connection subtext safely - const getConnectionSubtext = (connection: Connection): string => { - if (connection.email) { - return connection.email - } - - return "Connected" - } + const isAnyConnecting = + connectingProvider !== null || addConnectionMutation.isPending return (
-
-

Supermemory Connections

- - PRO - -
- - {/* Connector section - conditional layout based on hasConnections */} - {hasConnections ? ( -
- {Object.entries(CONNECTORS).map(([provider, config]) => { - const Icon = config.icon - const isConnecting = - connectingProvider === provider || - (addConnectionMutation.isPending && - addConnectionMutation.variables === provider) - - return ( - - ) - })} + {/* Top header — only when empty; once connected, the Add CTA moves into the list header below */} + {!hasConnections && ( +
+

Add a connection

+ + PRO +
- ) : ( + )} + + {/* Provider rows — only on empty state. Each is a labelled, descriptive CTA. */} + {!hasConnections && (
{Object.entries(CONNECTORS).map(([provider, config]) => { const Icon = config.icon - const connection = connections.find( - (conn) => conn.provider === provider, - ) - const isConnected = !!connection const isConnecting = connectingProvider === provider || (addConnectionMutation.isPending && - addConnectionMutation.variables === provider) + addConnectionMutation.variables?.provider === provider) return (
-
-

{config.title}

- {isConnected && ( - - {connection.metadata?.syncInProgress - ? "Syncing..." - : "Connected"} - - )} -
+

{config.title}

{config.description}

- {isConnected ? ( - + {provider === "google-drive" ? ( +
+ +
+ + + + + + {( + Object.entries(GDRIVE_SCOPE_LABELS) as [ + GDriveSyncScope, + string, + ][] + ).map(([scope, label]) => ( + { + e.stopPropagation() + setGdriveSyncScope(scope) + }} + className="flex items-center justify-between" + > + {label} + {gdriveSyncScope === scope && ( + + )} + + ))} + + +
) : (
)} - {/* Connected list panel - only when hasConnections */} + {/* Connected list - rich rows with status / project / last sync / doc count */} {hasConnections && ( -
-
-

- Connected to Supermemory -

- {connectionsLimit > 0 && ( -

- {connections.length}/{connectionsLimit} connections used -

- )} -
-
- {connections.map((connection) => { - const config = - CONNECTORS[connection.provider as ConnectorProvider] - if (!config) return null +
+
+
+
+

+ Connected to Supermemory +

+ + PRO + +
+ {connectionsLimit > 0 && ( +

+ {connections.length}/{connectionsLimit} connections used +

+ )} +
- const Icon = config.icon - const subtext = getConnectionSubtext(connection) - - return ( -
+ + + + +
+
+ + Choose a service + +
+
+ { + setConnectingProvider("google-drive") + addConnectionMutation.mutate({ + provider: "google-drive", + syncScope: "scoped", + }) + }} + 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" + > + +
+ + Google Drive + + + Pick specific files & folders + +
+
+ { + setConnectingProvider("google-drive") + addConnectionMutation.mutate({ + provider: "google-drive", + syncScope: "full", + }) + }} + 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" + > + +
+ + Google Drive + + + Sync entire drive + +
+
+ { + setConnectingProvider("notion") + addConnectionMutation.mutate({ provider: "notion" }) + }} + 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" + > + +
+ + Notion + + + Pages and databases + +
+
+ { + setConnectingProvider("onedrive") + addConnectionMutation.mutate({ provider: "onedrive" }) + }} + 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" + > + +
+ + OneDrive + + + Office documents + +
+
-
- ) - })} +
+ +
+
+ {connections.map((connection) => ( + setRemoveDialog({ open: true, connection })} + isDeleting={deleteConnectionMutation.isPending} + /> + ))}
)} @@ -429,6 +708,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { )}
)} + { diff --git a/apps/web/components/animated-gradient-background.tsx b/apps/web/components/animated-gradient-background.tsx index 8b37c8c4..4a749d56 100644 --- a/apps/web/components/animated-gradient-background.tsx +++ b/apps/web/components/animated-gradient-background.tsx @@ -8,55 +8,51 @@ export function AnimatedGradientBackground({ animateFromBottom?: boolean }) { return ( -
+
extractHighlightDocumentIdsFromMessages(messages), + [messages], + ) + + return ( +
+ +
+
+
+

Memory map

+

+ {highlightIds.length > 0 + ? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova` + : "Memories used by Nova will be highlighted here"} +

+
+
+
+ 0} + maxNodes={160} + /> +
+
+ ) +} diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx new file mode 100644 index 00000000..00179658 --- /dev/null +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import ChatInput from "./input" +import ChatModelSelector from "./model-selector" +import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" +import { useProject } from "@/stores" +import { useContainerTags } from "@/hooks/use-container-tags" +import { dmSansClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import type { ModelId } from "@/lib/models" + +export function HomeChatComposer({ + onStartChat, + className, +}: { + onStartChat: (message: string, model: ModelId) => void + className?: string +}) { + const [input, setInput] = useState("") + const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") + const { selectedProject } = useProject() + const { allProjects } = useContainerTags() + const chatSpaceLabel = useMemo( + () => + getChatSpaceDisplayLabel({ + selectedProject, + allProjects, + }), + [selectedProject, allProjects], + ) + + const send = useCallback(() => { + const t = input.trim() + if (!t) return + onStartChat(t, selectedModel) + setInput("") + }, [input, onStartChat, selectedModel]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + send() + } + } + + return ( +
+
+ setInput(e.target.value)} + onSend={send} + onStop={() => {}} + onKeyDown={handleKeyDown} + isResponding={false} + showStatusStrip={false} + stackedToolbar={ + <> + +
+ + {chatSpaceLabel} + +
+ + } + /> +
+
+ ) +} diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 0a0503f3..4f67a318 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -3,21 +3,21 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useQueryState } from "nuqs" import type { UIMessage } from "@ai-sdk/react" -import { motion, AnimatePresence } from "motion/react" +import { motion } from "motion/react" import { useChat } from "@ai-sdk/react" import { DefaultChatTransport } from "ai" import NovaOrb from "@/components/nova/nova-orb" import { Button } from "@ui/components/button" import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@ui/components/dialog" + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@ui/components/sheet" import { ScrollArea } from "@ui/components/scroll-area" import { + ArrowLeft, Check, ChevronDownIcon, HistoryIcon, @@ -41,6 +41,7 @@ import { modelNames, type ModelId } from "@/lib/models" import { SuperLoader } from "../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage } from "./message/agent-message" +import { ChatGraphContextRail } from "./chat-graph-context-rail" import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" @@ -99,20 +100,69 @@ function ChatEmptyStatePlaceholder({ ) } +export function ChatLaunchFab({ + onOpen, + isMobile, +}: { + onOpen: () => void + isMobile: boolean +}) { + return ( + + + + Chat with Nova + + + ) +} + export function ChatSidebar({ isChatOpen, setIsChatOpen, queuedMessage, onConsumeQueuedMessage, + queuedMessageSource = "highlight", + initialSelectedModel = null, emptyStateSuggestions, + layout = "sidebar", }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void queuedMessage?: string | null onConsumeQueuedMessage?: () => void + queuedMessageSource?: "highlight" | "home" + initialSelectedModel?: ModelId | null emptyStateSuggestions?: string[] + layout?: "sidebar" | "page" }) { const isMobile = useIsMobile() + const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState("claude-sonnet-4.6") @@ -183,6 +233,7 @@ export function ChatSidebar({ useEffect(() => { if (isMobile) return if (viewMode === "graph") return + if (layout === "page") return const handleWindowScroll = () => { const scrollThreshold = 80 @@ -196,7 +247,7 @@ export function ChatSidebar({ handleWindowScroll() return () => window.removeEventListener("scroll", handleWindowScroll) - }, [isMobile, viewMode]) + }, [isMobile, viewMode, layout]) const { messages, @@ -251,6 +302,7 @@ export function ChatSidebar({ const handleSend = () => { if (!input.trim() || status === "submitted" || status === "streaming") return + if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) sendMessage({ text: input }) setInput("") @@ -264,10 +316,6 @@ export function ChatSidebar({ } } - const toggleChat = () => { - setIsChatOpen(!isChatOpen) - } - const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) @@ -356,12 +404,17 @@ export function ChatSidebar({ (m: { id: string role: string - parts: unknown + parts: Array<{ type: string }> createdAt: string }) => ({ id: m.id, role: m.role, - parts: m.parts || [], + // Strip tool parts — persisted format doesn't round-trip through + // convertToModelMessages correctly and causes tool_use/tool_result + // mismatch errors. Text history is sufficient for context. + parts: (m.parts || []).filter( + (p) => p.type === "text" || p.type === "reasoning", + ), createdAt: new Date(m.createdAt), }), ) @@ -378,6 +431,15 @@ export function ChatSidebar({ [setThreadId], ) + // Auto-restore thread from URL on mount (e.g. reload or direct link) + const didAutoLoadRef = useRef(false) + useEffect(() => { + if (didAutoLoadRef.current) return + if (!threadId) return + didAutoLoadRef.current = true + loadThread(threadId) + }, [threadId, loadThread]) + const deleteThread = useCallback( async (threadId: string) => { try { @@ -441,11 +503,22 @@ export function ChatSidebar({ sentQueuedMessageRef.current !== queuedMessage ) { sentQueuedMessageRef.current = queuedMessage - analytics.chatMessageSent({ source: "highlight" }) + if (!threadId) setThreadId(fallbackChatId) + analytics.chatMessageSent({ source: queuedMessageSource }) sendMessage({ text: queuedMessage }) onConsumeQueuedMessage?.() } - }, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage]) + }, [ + isChatOpen, + queuedMessage, + queuedMessageSource, + status, + sendMessage, + onConsumeQueuedMessage, + fallbackChatId, + setThreadId, + threadId, + ]) // Reset the sent message ref when queued message is consumed useEffect(() => { @@ -497,431 +570,477 @@ export function ChatSidebar({ } }, [checkIfScrolledToBottom]) - return ( - - {!isChatOpen ? ( - - - - - Chat with Nova - - - - ) : ( - -
-
- -
- - {chatSpaceLabel} - + if (!isChatOpen) { + return null + } + + const isStackedInput = layout === "page" + const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput + + const chatHistorySheet = ( + { + setIsHistoryOpen(open) + if (open) { + fetchThreads() + analytics.chatHistoryViewed?.() + } else { + setConfirmingDeleteId(null) + } + }} + > + button]:text-[#FAFAFA]", + dmSansClassName(), + )} + > + + Chat History + + Space: {chatSpaceLabel} + + + +
+ {isLoadingThreads ? ( +
+
-
-
- { - setIsHistoryOpen(open) - if (open) { - fetchThreads() - analytics.chatHistoryViewed?.() - } else { - setConfirmingDeleteId(null) - } - }} - > - - - - - - Chat History - - Space: {chatSpaceLabel} - - - - {isLoadingThreads ? ( -
- + ) : threads.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {threads.map((thread) => { + const isActive = thread.id === currentChatId + return ( + - -
- ) : ( - - )} - - ) - })} -
- )} -
- -
-
- - {/* - {isMobile ? ( - - ) : ( - - )} - */} -
-
-
- {isInputExpanded && ( -
- )} - {messages.length === 0 && ( - { - analytics.chatSuggestedQuestionClicked() - analytics.chatMessageSent({ source: "suggested" }) - sendMessage({ text: suggestion }) - }} - suggestions={emptyStateSuggestions} - /> - )} -
0 - ? "flex flex-col space-y-3 min-h-full justify-end pt-14" - : "", - )} - > - {messages.map((message, index) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions -
- message.role === "assistant" && - setHoveredMessageId(message.id) - } - onMouseLeave={() => - message.role === "assistant" && setHoveredMessageId(null) - } - > - {message.role === "user" ? ( - - ) : ( - - )} -
- ))} - {(status === "submitted" || status === "streaming") && ( -
- -
- )} -
-
- - {!isScrolledToBottom && messages.length > 0 && ( -
- -
- )} - - {chatStreamError && ( -
-
-
-

- {chatStreamError.title} -

-

- {chatStreamError.body} -

- {chatStreamError.otherModels.length > 0 && ( -
- {chatStreamError.otherModels.map((id) => { - const m = modelNames[id] - return ( + {confirmingDeleteId === thread.id ? ( +
- ) - })} -
- )} -
- + +
+ ) : ( + + )} + + ) + })}
+ )} +
+ +
+ +
+ + + ) + + const chatToolbarActions = ( +
+ + +
+ ) + + const pageDesktopToolbarRow = isPageDesktop ? ( +
+ {chatToolbarActions} +
+ ) : null + + const shell = ( + <> + {showHeaderRow ? ( +
+
+ {layout === "page" && isMobile && ( + + )} + {!isStackedInput && ( + <> + +
+ + {chatSpaceLabel} + +
+ + )} +
+ {chatToolbarActions} +
+ ) : null} +
+ {isInputExpanded && ( +
+ )} + {messages.length === 0 && ( + { + analytics.chatSuggestedQuestionClicked() + analytics.chatMessageSent({ source: "suggested" }) + sendMessage({ text: suggestion }) + }} + suggestions={emptyStateSuggestions} + /> + )} +
0 + ? cn( + "flex flex-col space-y-3 min-h-full justify-end", + isPageDesktop ? "pt-2" : "pt-14", + ) + : "" + } + > + {messages.map((message, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions +
+ message.role === "assistant" && setHoveredMessageId(message.id) + } + onMouseLeave={() => + message.role === "assistant" && setHoveredMessageId(null) + } + > + {message.role === "user" ? ( + + ) : ( + + )} +
+ ))} + {(status === "submitted" || status === "streaming") && ( +
+
)} +
+
- setInput(e.target.value)} - onSend={handleSend} - onStop={stop} - onKeyDown={handleKeyDown} - isResponding={status === "submitted" || status === "streaming"} - activeStatus={ - status === "submitted" - ? "Thinking..." - : status === "streaming" - ? "Structuring response..." - : "Waiting for input..." - } - onExpandedChange={setIsInputExpanded} - chainOfThoughtComponent={ - messages.length > 0 ? ( - - ) : null - } - /> - + {!isScrolledToBottom && messages.length > 0 && ( +
+ +
)} - + + {chatStreamError && ( +
+
+
+

+ {chatStreamError.title} +

+

+ {chatStreamError.body} +

+ {chatStreamError.otherModels.length > 0 && ( +
+ {chatStreamError.otherModels.map((id) => { + const m = modelNames[id] + return ( + + ) + })} +
+ )} +
+ +
+
+ )} + +
+ setInput(e.target.value)} + onSend={handleSend} + onStop={stop} + onKeyDown={handleKeyDown} + isResponding={status === "submitted" || status === "streaming"} + activeStatus={ + status === "submitted" + ? "Thinking..." + : status === "streaming" + ? "Structuring response..." + : "Waiting for input..." + } + onExpandedChange={setIsInputExpanded} + chainOfThoughtComponent={ + messages.length > 0 ? : null + } + stackedToolbar={ + isStackedInput ? ( + <> + +
+ + {chatSpaceLabel} + +
+ + ) : undefined + } + /> +
+ + ) + + return ( + + {chatHistorySheet} + {isPageDesktop ? ( +
+ +
+ {pageDesktopToolbarRow} +
+ {shell} +
+
+
+ ) : ( + shell + )} +
) } + +export { HomeChatComposer } from "./home-chat-composer" diff --git a/apps/web/components/chat/input/actions.tsx b/apps/web/components/chat/input/actions.tsx index a8b2351d..44f8132f 100644 --- a/apps/web/components/chat/input/actions.tsx +++ b/apps/web/components/chat/input/actions.tsx @@ -14,10 +14,10 @@ export function SendButton({ onClick={onClick} disabled={disabled} className={cn( - "bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 transition-opacity", + "bg-surface-card border-surface-border border p-2 rounded-lg shrink-0 transition-opacity", disabled ? "opacity-50 cursor-not-allowed" - : "cursor-pointer hover:bg-[#161F2C]", + : "cursor-pointer hover:bg-surface-hover", )} > void }) { diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index 40c1949d..b1ee1109 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { useRef, useState } from "react" +import { type ReactNode, useRef, useState } from "react" import { motion } from "motion/react" import { SendButton, StopButton } from "./actions" @@ -18,6 +18,10 @@ interface ChatInputProps { activeStatus?: string chainOfThoughtComponent?: React.ReactNode onExpandedChange?: (expanded: boolean) => void + /** Model + space controls on one row with send; textarea full-width above */ + stackedToolbar?: ReactNode + /** Nova status row + chain-of-thought toggle (off for e.g. home composer) */ + showStatusStrip?: boolean } export default function ChatInput({ @@ -30,6 +34,8 @@ export default function ChatInput({ activeStatus, chainOfThoughtComponent, onExpandedChange, + stackedToolbar, + showStatusStrip = true, }: ChatInputProps) { const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) @@ -52,82 +58,122 @@ export default function ChatInput({ -
- {chainOfThoughtComponent} -
- + + ) : null} + {stackedToolbar ? ( +
+