diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 444e3ac8..cf7a3002 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 7bff4dfc..f31c2bb6 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index 6ece78df..f819d3d6 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) { if (response?.success && response?.data && input) { const memoryText = showMemorySuggestion("gemini", input, response.data) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) iconElement.dataset.supermemories = memoryText if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 1722e71e..27b65b08 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -12,6 +12,46 @@ export function buildSupermemoryText(memories: unknown): string { return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}` } +function normalizeMemoryList(memories: unknown): string[] { + const list = Array.isArray(memories) + ? memories + : memories == null + ? [] + : [memories] + return list + .map((memory) => (typeof memory === "string" ? memory : String(memory))) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0) +} + +export function serializeMemoriesForDataset(memories: unknown): string { + const list = normalizeMemoryList(memories) + return list.length > 0 ? JSON.stringify(list) : "" +} + +export function parseMemoriesFromDataset( + raw: string | null | undefined, +): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return normalizeMemoryList(parsed) + } catch { + // Not JSON — fall through to the legacy delimiter split. + } + return raw + .split(/[,\n]/) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0 && memory !== ",") +} + +export function renumberIncludedMemories(memories: string[]): string[] { + return memories.map((memory, index) => { + const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "") + return `${index + 1}. ${text} \n` + }) +} + export function showMemorySuggestion( platform: string, input: SuggestionInput, @@ -305,10 +345,7 @@ export function showMarkerPopover( color: rgba(255, 255, 255, 0.76); ` - memories - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + parseMemoriesFromDataset(memories) .slice(0, 5) .forEach((memory) => { const item = document.createElement("div") diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 66a11235..bddd83ed 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -10,11 +10,30 @@ import { autoCapturePromptsEnabled, } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" +import { + buildSupermemoryText, + parseMemoriesFromDataset, + renumberIncludedMemories, + serializeMemoriesForDataset, +} from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null let t3UrlCheckInterval: NodeJS.Timeout | null = null let t3ObserverThrottle: NodeJS.Timeout | null = null +let t3IncludedPopup: { + el: HTMLElement + onClick: (event: MouseEvent) => void + timer: ReturnType +} | null = null + +function disposeT3IncludedPopup() { + if (!t3IncludedPopup) return + document.removeEventListener("click", t3IncludedPopup.onClick) + clearTimeout(t3IncludedPopup.timer) + t3IncludedPopup.el.remove() + t3IncludedPopup = null +} export function initializeT3() { if (!DOMUtils.isOnDomain(DOMAINS.T3)) { @@ -53,6 +72,7 @@ function setupT3RouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { + disposeT3IncludedPopup() currentUrl = window.location.href setTimeout(() => { addSupermemoryIconToT3Input() @@ -231,9 +251,13 @@ async function getRelatedMemoriesForT3(actionSource: string) { } if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` + textareaElement.dataset.supermemories = buildSupermemoryText( + response.data, + ) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) updateT3IconFeedback("Included Memories", iconElement) } else { @@ -268,6 +292,8 @@ function updateT3IconFeedback( iconElement.dataset.originalHtml = iconElement.innerHTML } + disposeT3IncludedPopup() + const feedbackDiv = document.createElement("div") feedbackDiv.style.cssText = ` display: flex; @@ -329,11 +355,9 @@ function updateT3IconFeedback( overflow-y: auto; ` - const memoriesText = iconElement.dataset.memoriesData || "" - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const individualMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) individualMemories.forEach((memory, index) => { const memoryItem = document.createElement("div") @@ -405,66 +429,65 @@ function updateT3IconFeedback( popup.style.display = "block" }) - document.addEventListener("click", (e) => { + const onClick = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) { popup.style.display = "none" } - }) + } + document.addEventListener("click", onClick) + t3IncludedPopup = { + el: popup, + onClick, + timer: setTimeout(disposeT3IncludedPopup, 300000), + } content.querySelectorAll("button[data-memory-index]").forEach((button) => { const htmlButton = button as HTMLButtonElement htmlButton.addEventListener("click", () => { const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10) - const memoryItem = htmlButton.parentElement + htmlButton.parentElement?.remove() - if (memoryItem) { - content.removeChild(memoryItem) - } - - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) - - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = updatedMemories + const remainingMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) + remainingMemories.splice(index, 1) + const remaining = renumberIncludedMemories(remainingMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || (document.querySelector('div[contenteditable="true"]') as HTMLElement) + + // Only wipe when nothing remains — `<= 1` used to discard the last kept memory. + if (remaining.length === 0) { + if (textareaElement?.dataset.supermemories) { + delete textareaElement.dataset.supermemories + } + delete iconElement.dataset.memoriesData + iconElement.innerHTML = iconElement.dataset.originalHtml || "" + delete iconElement.dataset.originalHtml + disposeT3IncludedPopup() + return + } + + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(remaining) if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` + textareaElement.dataset.supermemories = + buildSupermemoryText(remaining) } content .querySelectorAll("button[data-memory-index]") .forEach((btn, newIndex) => { const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() + htmlBtn.dataset.memoryIndex = String(newIndex) + const label = htmlBtn.previousElementSibling + if (label) { + label.textContent = remaining[newIndex].trim() + } }) - - if (currentMemories.length <= 1) { - if (textareaElement?.dataset.supermemories) { - delete textareaElement.dataset.supermemories - delete iconElement.dataset.memoriesData - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - } - popup.style.display = "none" - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - } }) }) - - setTimeout(() => { - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - }, 300000) } iconElement.innerHTML = "" @@ -556,6 +579,7 @@ function setupT3PromptCapture() { if (textareaElement?.dataset.supermemories) { delete textareaElement.dataset.supermemories } + disposeT3IncludedPopup() } const handleT3SendButtonClick = async (event: Event) => { @@ -711,6 +735,7 @@ async function setupT3AutoFetch() { if (textareaElement.dataset.supermemories) { delete textareaElement.dataset.supermemories } + disposeT3IncludedPopup() } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) } diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index f40426a8..0db9933d 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -9,9 +9,9 @@ "dev:firefox": "wxt -b firefox", "build": "wxt build", "build:firefox": "wxt build -b firefox", + "check-types": "wxt prepare && tsc --noEmit", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", - "compile": "tsc --noEmit", "postinstall": "wxt prepare" }, "dependencies": { diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 9400ae9f..3891a43b 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -194,6 +194,11 @@ { "group": "Other resources", "pages": [ + { + "group": "General", + "icon": "book-open", + "pages": ["ingestion/batch-ingest-historical-data"] + }, { "group": "Benchmarking", "icon": "flask-conical", diff --git a/apps/docs/ingestion/add-memories.mdx b/apps/docs/ingestion/add-memories.mdx index 39d7c103..ab19a505 100644 --- a/apps/docs/ingestion/add-memories.mdx +++ b/apps/docs/ingestion/add-memories.mdx @@ -496,6 +496,7 @@ console.log(doc.status); // "queued" | "processing" | "done" ## Next Steps +- [How to backfill historical data](/ingestion/batch-ingest-historical-data) — Import dated content with the batch API - [Search Memories](/recall/search) — Query your content - [User Profiles](/recall/user-profiles) — Get user context - [Organizing & Filtering](/concepts/filtering) — Container tags and metadata diff --git a/apps/docs/ingestion/batch-ingest-historical-data.mdx b/apps/docs/ingestion/batch-ingest-historical-data.mdx new file mode 100644 index 00000000..17ff4e1b --- /dev/null +++ b/apps/docs/ingestion/batch-ingest-historical-data.mdx @@ -0,0 +1,145 @@ +--- +title: "How to backfill historical data into Supermemory" +sidebarTitle: "Backfill historical data" +description: "Backfill historical documents into Supermemory with documentDate, stable custom IDs, and the batch ingestion API." +icon: "history" +--- + +Use `POST /v3/documents/batch` to backfill exports, emails, messages, or other dated records. + + + Sort the source data oldest to newest, add `documentDate` to every document. + + +## Backfill in batches + +Backfill dated content by setting `documentDate` on each document, sorting the source records oldest to newest, and sending them in batches. Each request can contain up to 600 documents. + +**Endpoint:** [`POST /v3/documents/batch`](/api-reference/ingest/batch-add-documents) + + + +```typescript TypeScript +import Supermemory from "supermemory"; + +type SourceDocument = { + id: string; + content: string; + createdAt: string; +}; + +const client = new Supermemory(); +const batchSize = 100; + +async function backfillHistoricalData(sourceDocuments: SourceDocument[]) { + const documents = sourceDocuments + .map((document) => ({ + content: document.content, + customId: document.id, + documentDate: new Date(document.createdAt).toISOString() + })) + .sort((a, b) => a.documentDate.localeCompare(b.documentDate)); + + for (let offset = 0; offset < documents.length; offset += batchSize) { + const result = await client.documents.batchAdd({ + containerTag: "historical_import", + documents: documents.slice(offset, offset + batchSize) + }); + + if (result.failed > 0) { + throw new Error(`${result.failed} documents failed to ingest`); + } + } +} +``` + +```python Python +from datetime import datetime, timezone +from supermemory import Supermemory + +client = Supermemory() +batch_size = 100 + +def to_utc(value: str) -> str: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("created_at must include a timezone") + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + +def backfill_historical_data(source_documents: list[dict[str, str]]) -> None: + documents = sorted( + [ + { + "content": document["content"], + "custom_id": document["id"], + "document_date": to_utc(document["created_at"]), + } + for document in source_documents + ], + key=lambda document: document["document_date"], + ) + + for offset in range(0, len(documents), batch_size): + result = client.documents.batch_add( + container_tag="historical_import", + documents=documents[offset : offset + batch_size], + ) + + if result.failed > 0: + raise RuntimeError(f"{result.failed} documents failed to ingest") +``` + + + +## Optional: wait for processing to finish + +**Endpoint:** [`GET /v3/documents/{id}`](/api-reference/documents/get-document) + +The batch endpoint returns after accepting the documents. If a later step depends on completed memory generation, poll the returned document IDs until both `status` and `dreamingStatus` are `done`. + + + +```typescript TypeScript +async function waitUntilDone(ids: string[]) { + while (true) { + const documents = await Promise.all( + ids.map((id) => client.documents.get(id)) + ); + + if (documents.some((document) => document.status === "failed")) { + throw new Error("A document failed to process"); + } + + if ( + documents.every( + (document) => + document.status === "done" && document.dreamingStatus === "done" + ) + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } +} +``` + +```python Python +import time + +def wait_until_done(ids: list[str]) -> None: + while True: + documents = [client.documents.get(document_id) for document_id in ids] + + if any(document.status == "failed" for document in documents): + raise RuntimeError("A document failed to process") + + if all( + document.status == "done" and document.dreaming_status == "done" + for document in documents + ): + return + + time.sleep(10) +``` + + diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx index cb86ee33..d0b37991 100644 --- a/apps/docs/self-hosting/quickstart.mdx +++ b/apps/docs/self-hosting/quickstart.mdx @@ -27,6 +27,28 @@ bunx supermemory local The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64). +### Pin or change versions + +Pass an explicit version to install (or roll back to) a specific release instead of `latest`: + +```bash +curl -fsSL https://supermemory.ai/install | bash -s -- 0.0.3 +``` + + +Before rolling back, back up your [data directory](#where-things-live). The installer replaces the binary, but an older server may not understand data or schema changes made by a newer release. + + +Release tags are `server-v` on [GitHub Releases](https://github.com/supermemoryai/supermemory/releases) (for example [`server-v0.0.3`](https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.3)). + +To move to the newest release later: + +```bash +supermemory-server upgrade +``` + +The binary may also print an “update available” notification on startup. If you intentionally pinned an older version (for example while debugging a regression), you can ignore that message until you are ready to upgrade. + ## Run ```bash diff --git a/apps/docs/using-supermemory.mdx b/apps/docs/using-supermemory.mdx index f65ed2e3..be38bf80 100644 --- a/apps/docs/using-supermemory.mdx +++ b/apps/docs/using-supermemory.mdx @@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi + diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index e3501890..8e8d043e 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -1,6 +1,6 @@ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose" import { afterEach, beforeAll, describe, expect, it, vi } from "vitest" -import { fetchSession, validateOAuthToken } from "./index" +import { fetchSession, validateApiKey, validateOAuthToken } from "./index" const API_URL = "https://api.example.com" const ISSUER = `${API_URL}/api/auth` @@ -120,4 +120,64 @@ describe("MCP authentication", () => { status: 403, }) }) + + function sessionResponse() { + return Response.json({ + user: { id: "user_test", email: "test@example.com" }, + org: { id: "org_test" }, + role: "owner", + accessType: "full", + scope: { type: "full", permission: "write" }, + }) + } + + it("validates an sm_ API key via the session endpoint", async () => { + const fetchSpy = vi.fn().mockResolvedValue(sessionResponse()) + vi.stubGlobal("fetch", fetchSpy) + const key = "sm_valid_key_0123456789abcdef" + + await expect(validateApiKey(key, API_URL)).resolves.toEqual({ + userId: "user_test", + organizationId: "org_test", + bearerToken: key, + scopes: [], + }) + expect(fetchSpy).toHaveBeenCalledWith( + `${API_URL}/v3/session`, + expect.objectContaining({ + headers: { Authorization: `Bearer ${key}` }, + }), + ) + }) + + it("caches a validated API key within the TTL", async () => { + const fetchSpy = vi.fn().mockResolvedValue(sessionResponse()) + vi.stubGlobal("fetch", fetchSpy) + const key = "sm_cached_key_0123456789abcdef" + + await validateApiKey(key, API_URL) + await validateApiKey(key, API_URL) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it("rejects an API key the session endpoint refuses", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 401 })), + ) + + await expect( + validateApiKey("sm_revoked_key_0123456789abcdef", API_URL), + ).resolves.toBeNull() + }) + + it("rejects malformed API keys without an API request", async () => { + const fetchSpy = vi.fn() + vi.stubGlobal("fetch", fetchSpy) + + await expect(validateApiKey("sm_short", API_URL)).resolves.toBeNull() + await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull() + expect(fetchSpy).not.toHaveBeenCalled() + }) }) diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index a0996933..425f0597 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -52,6 +52,51 @@ export async function fetchSession( return result.data } +// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint +// instead of JWT verification. Successful lookups are cached per isolate so a +// busy MCP session doesn't re-validate on every JSON-RPC message. +const API_KEY_PATTERN = /^sm_\S{17,}$/ +const API_KEY_CACHE_TTL_MS = 60_000 +const API_KEY_CACHE_MAX_ENTRIES = 1000 + +const apiKeyCache = new Map() + +export function isApiKey(token: string): boolean { + return API_KEY_PATTERN.test(token) +} + +export async function validateApiKey( + token: string, + apiUrl: string, +): Promise { + if (!isApiKey(token)) return null + + const cached = apiKeyCache.get(token) + if (cached && cached.expiresAt > Date.now()) return cached.user + + try { + const session = await fetchSession(token, apiUrl) + const organizationId = session.org?.id + if (!organizationId) return null + + const user: AuthUser = { + userId: session.user.id, + organizationId, + bearerToken: token, + scopes: [], + } + if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear() + apiKeyCache.set(token, { + user, + expiresAt: Date.now() + API_KEY_CACHE_TTL_MS, + }) + return user + } catch (error) { + console.error("API key validation error:", error) + return null + } +} + export async function validateOAuthToken( token: string, apiUrl: string, diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index cc45d438..a2ccc8e7 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -7,11 +7,14 @@ import { z } from "zod" import { containerTagSchema, documentsApiResponseSchema, - paginationSchema, + memoriesListSchema, type ContainerTag, type DocumentMemoryEntry, type DocumentsApiResponse, type DocumentWithMemories, + type MemoriesList, + type MemoryEntry, + type MemoryEntryHistory, } from "../../shared/types" const MAX_CHARS = 200000 @@ -34,43 +37,10 @@ export interface DocumentsListResponse { pagination: SdkDocumentListResponse["pagination"] } -const memoryEntryHistorySchema = z.looseObject({ - id: z.string(), - memory: z.string(), - version: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - parentMemoryId: z.string().nullish(), - rootMemoryId: z.string().nullish(), - isLatest: z.boolean().optional(), - isForgotten: z.boolean().optional(), -}) - -export type MemoryEntryHistory = z.infer - -const memoryEntrySchema = z.looseObject({ - id: z.string(), - memory: z.string(), - version: z.number(), - isLatest: z.boolean(), - isForgotten: z.boolean(), - isStatic: z.boolean().optional(), - isInference: z.boolean().optional(), - createdAt: z.string(), - updatedAt: z.string(), - sourceCount: z.number().optional(), - documentIds: z.array(z.string()).optional(), - history: z.array(memoryEntryHistorySchema).optional(), -}) - -export type MemoryEntry = z.infer - -const memoryEntriesResponseSchema = z.object({ - memoryEntries: z.array(memoryEntrySchema), - pagination: paginationSchema, -}) - -export type MemoryEntriesResponse = z.infer +// Memory-entry shapes live in shared/types so the client parser and the +// listMemories output schema share one definition and can't drift. +export type { MemoryEntry, MemoryEntryHistory } +export type MemoryEntriesResponse = MemoriesList export type Memory = | { @@ -149,6 +119,19 @@ function objectProperty(value: unknown, key: string): unknown { : undefined } +// API error bodies are JSON like {"error": "..."} — unwrap them so users see +// the real reason instead of raw JSON or a generic fallback. +function extractApiErrorMessage(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw) return undefined + try { + const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown } + if (typeof parsed.error === "string" && parsed.error) return parsed.error + if (typeof parsed.message === "string" && parsed.message) + return parsed.message + } catch {} + return raw +} + export class SupermemoryClient { private client: Supermemory private containerTag: string @@ -371,7 +354,8 @@ export class SupermemoryClient { signal, }) if (!response.ok) { - throw Object.assign(new Error("Failed to fetch documents"), { + const message = extractApiErrorMessage(await response.text()) + throw Object.assign(new Error(message ?? ""), { status: response.status, }) } @@ -432,14 +416,13 @@ export class SupermemoryClient { }) if (!response.ok) { - const message = await response.text() - throw Object.assign( - new Error(message || "Failed to fetch memory entries"), - { status: response.status }, - ) + const message = extractApiErrorMessage(await response.text()) + throw Object.assign(new Error(message ?? ""), { + status: response.status, + }) } - return memoryEntriesResponseSchema.parse(await response.json()) + return memoriesListSchema.parse(await response.json()) } catch (error) { this.handleError(error) } @@ -466,8 +449,7 @@ export class SupermemoryClient { const status = objectProperty(error, "status") if (typeof status === "number") { - const rawMessage = objectProperty(error, "message") - const message = typeof rawMessage === "string" ? rawMessage : undefined + const message = extractApiErrorMessage(objectProperty(error, "message")) switch (status) { case 400: case 422: @@ -479,7 +461,7 @@ export class SupermemoryClient { case 403: throw new Error( message || - "Access forbidden. Your account may be restricted or blocked.", + "Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status.", ) case 404: throw new Error("Not found.") diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index f435ea3a..0fac82c7 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -2,7 +2,12 @@ import type { AuthInfo } from "@modelcontextprotocol/server" import { createMcpHandler } from "agents/mcp/server" import { Hono, type Context } from "hono" import { cors } from "hono/cors" -import { validateOAuthToken, type AuthUser } from "./auth" +import { + isApiKey, + validateApiKey, + validateOAuthToken, + type AuthUser, +} from "./auth" import { SupermemoryMCP } from "./legacy-protocol-state" import { createSupermemoryServer } from "./server" import type { ActorContext, ServerEnv } from "./types" @@ -176,7 +181,9 @@ async function handleMcpRequest( if (!token) return unauthorizedResponse(resourceMetadataUrl) - const authUser = await validateOAuthToken(token, apiUrl, mcpResource) + const authUser = isApiKey(token) + ? await validateApiKey(token, apiUrl) + : await validateOAuthToken(token, apiUrl, mcpResource) if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true) const actor: ActorContext = { diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 0156d9d1..6cb0bf66 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { containerTagAccessSchema, + memoriesListSchema, paginationSchema, sessionScopeSchema, } from "../../shared/types" @@ -42,33 +43,6 @@ const documentSummarySchema = z.object({ summary: z.string().nullable(), }) -const memoryHistorySchema = z.object({ - id: z.string(), - memory: z.string(), - version: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - parentMemoryId: z.string().nullish(), - rootMemoryId: z.string().nullish(), - isLatest: z.boolean().optional(), - isForgotten: z.boolean().optional(), -}) - -const memoryEntryOutputSchema = z.object({ - id: z.string(), - memory: z.string(), - version: z.number(), - isLatest: z.boolean(), - isForgotten: z.boolean(), - isStatic: z.boolean().optional(), - isInference: z.boolean().optional(), - createdAt: z.string(), - updatedAt: z.string(), - sourceCount: z.number().optional(), - documentIds: z.array(z.string()).optional(), - history: z.array(memoryHistorySchema).optional(), -}) - export const addMemoryOutputSchema = z.object({ action: z.enum(["save", "forget"]), success: z.boolean(), @@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({ export type ListDocumentsOutput = z.infer -export const listMemoriesOutputSchema = z.object({ - memoryEntries: z.array(memoryEntryOutputSchema), - pagination: paginationSchema, -}) +// Reuse the shared schema so the tool's output contract stays identical to what +// the client parses — the two can't drift. +export const listMemoriesOutputSchema = memoriesListSchema export type ListMemoriesOutput = z.infer diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index b8c4fe35..e7a34160 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({ email: z.string().optional(), name: z.string().optional(), }), + org: z.looseObject({ id: z.string().min(1) }).optional(), role: z.string().optional(), accessType: z.enum(["full", "restricted"]).optional(), containerTags: z.array(containerTagAccessSchema).nullable().optional(), @@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({ export type DocumentsApiResponse = z.infer +// Extracted memory entries from /v4/memories/list. Single source of truth for +// both the client parser and the listMemories tool output schema, so the two +// can't drift (a mismatch previously produced Ajv "must NOT have additional +// properties"). z.object strips unknown API fields on parse, keeping parsed data +// matched to the strict MCP output contract while tolerating new API fields. +export const memoryEntryHistorySchema = z.object({ + id: z.string(), + memory: z.string(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + parentMemoryId: z.string().nullish(), + rootMemoryId: z.string().nullish(), + isLatest: z.boolean().optional(), + isForgotten: z.boolean().optional(), +}) + +export type MemoryEntryHistory = z.infer + +export const memoryEntrySchema = z.object({ + id: z.string(), + memory: z.string(), + version: z.number(), + isLatest: z.boolean(), + isForgotten: z.boolean(), + isStatic: z.boolean().optional(), + isInference: z.boolean().optional(), + createdAt: z.string(), + updatedAt: z.string(), + sourceCount: z.number().optional(), + documentIds: z.array(z.string()).optional(), + history: z.array(memoryEntryHistorySchema).optional(), +}) + +export type MemoryEntry = z.infer + +export const memoriesListSchema = z.object({ + memoryEntries: z.array(memoryEntrySchema), + pagination: paginationSchema, +}) + +export type MemoriesList = z.infer + // ViewMessage — discriminated union returned by app tools as `structuredContent`. // The widget uses an exhaustive switch on `view` to dispatch to the correct view component. // Adding a new view here is a compile error in App.tsx until the case is handled. diff --git a/apps/memory-graph-playground/package.json b/apps/memory-graph-playground/package.json index 67e31a0b..debe23c7 100644 --- a/apps/memory-graph-playground/package.json +++ b/apps/memory-graph-playground/package.json @@ -7,6 +7,7 @@ "dev": "portless", "dev:app": "next dev --port ${PORT:-3004}", "build": "next build", + "check-types": "tsc --noEmit", "start": "next start" }, "dependencies": { diff --git a/apps/web/.env.example b/apps/web/.env.example index aaf5fab4..abd39cef 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai NEXT_PUBLIC_POSTHOG_KEY= EXA_API_KEY= -XAI_API_KEY= \ No newline at end of file +XAI_API_KEY= +NEXT_PUBLIC_AGENTID_AUTH_ENABLED= diff --git a/apps/web/app/(app)/configure/[section]/page.tsx b/apps/web/app/(app)/configure/[section]/page.tsx index 5acd2b3a..1d153db5 100644 --- a/apps/web/app/(app)/configure/[section]/page.tsx +++ b/apps/web/app/(app)/configure/[section]/page.tsx @@ -6,12 +6,22 @@ import { export default async function ConfigureSectionPage({ params, + searchParams, }: { params: Promise<{ section: string }> + searchParams: Promise> }) { const { section } = await params - // Default section is canonical at /configure. - if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure") + // Carry the query across, else deep links like ?mcpSetup= are dropped here. + if (section === DEFAULT_CONFIGURE_SECTION) { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(await searchParams)) { + if (typeof value === "string") query.set(key, value) + else if (Array.isArray(value)) for (const v of value) query.append(key, v) + } + const search = query.toString() + redirect(search ? `/configure?${search}` : "/configure") + } if (!isConfigureSection(section)) notFound() return null } diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 700e93a0..7d2d4d97 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -3,10 +3,12 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { PWAInstallPrompt } from "@/components/pwa-install-prompt" import { SettingsModalProvider } from "@/components/settings/settings-modal" +import { PromoCodeHost } from "@/hooks/use-promo-code" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( + {children} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 8500a236..442ded03 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -591,6 +591,79 @@ export default function LoginPage() { /> ) : null} + {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? ( +
+ + + AgentID + + + + + + + } + authProvider="AgentID" + className="w-full" + disabled={Boolean(loadingMessage)} + onClick={() => { + if (loadingMessage) return + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider: "agentid", + }) + setPendingLoginMethod("agentid") + signIn + .oauth2({ + callbackURL: getCallbackURL(), + providerId: "agentid", + }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + setIsLoading(false) + }) + }} + /> +
+ ) : null} MAX_ICON_BYTES) { + return new NextResponse(null, { status: 413 }) + } + if (!response.body) return new NextResponse(null, { status: 404 }) + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let bytes = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > MAX_ICON_BYTES) { + await reader.cancel() + return new NextResponse(null, { status: 413 }) + } + chunks.push(value) + } + const body = new Uint8Array(bytes) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new NextResponse(body, { + headers: { + "cache-control": + "public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000", + "content-type": contentType, + }, + }) +} diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 0f96e52c..febd2760 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context" import { useSession } from "@lib/auth" import { cn } from "@lib/utils" import { dmSans125ClassName } from "@/lib/fonts" -import { useCustomer } from "autumn-js/react" -import { ArrowRight, Loader, XCircle } from "lucide-react" +import { ArrowRight, XCircle } from "lucide-react" import Image from "next/image" import { useRouter, useSearchParams } from "next/navigation" -import { Suspense, useEffect, useState } from "react" +import { Suspense, useEffect, useMemo, useState } from "react" import { PENDING_CONNECT_URL_KEY } from "@/lib/constants" @@ -88,7 +87,7 @@ const PLUGIN_INFO: Record = { "Auto-capture of project decisions", "Context-aware suggestions", ], - icon: "/images/plugins/cursor.svg", + icon: "/images/plugins/cursor.png", }, codex: { name: "OpenAI Codex", @@ -103,11 +102,77 @@ const PLUGIN_INFO: Record = { }, } +const MULTI_PLUGIN_FEATURES = [ + "Share one persistent memory layer across selected coding agents.", + "Recall project context, coding decisions, and prior sessions.", + "Connect every selected plugin with one approval.", +] + +function isKnownPlugin(value: string): boolean { + return Object.hasOwn(PLUGIN_INFO, value) +} + function getPluginName(client: string): string { return PLUGIN_INFO[client]?.name ?? "External Tool" } -type Status = "loading" | "creating" | "success" | "error" | "upgrade" +function formatPluginNames(clients: string[]): string { + const names = clients.map((id) => getPluginName(id)) + if (names.length === 0) return "External Tool" + if (names.length === 1) return names[0] ?? "External Tool" + if (names.length === 2) { + return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}` + } + + return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}` +} + +function encodeBase64UrlJson(value: Record): string { + return btoa(JSON.stringify(value)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function PluginLogoStack({ clients }: { clients: string[] }) { + if (clients.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {clients.map((id, index) => { + const plugin = PLUGIN_INFO[id] + return ( +
+ {plugin ? ( + {plugin.name} + ) : ( + + )} +
+ ) + })} +
+ ) +} + +type Status = "loading" | "creating" | "success" | "error" const pageWrapperClass = "flex items-center justify-center min-h-screen bg-background p-4" @@ -121,16 +186,34 @@ function AuthConnectContent() { const router = useRouter() const { data: session, isPending } = useSession() const { org, organizations, isRestoring } = useAuth() - const autumn = useCustomer() const [status, setStatus] = useState("loading") const [error, setError] = useState(null) - const [isUpgrading, setIsUpgrading] = useState(false) const callback = params.get("callback") const client = params.get("client") - const validClient = client && client in PLUGIN_INFO ? client : null - const displayName = validClient ? getPluginName(validClient) : "External Tool" - const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null + const clientsParam = params.get("clients") + const hasClientList = params.has("clients") + const rawRequestedClients = useMemo( + () => + (clientsParam !== null ? clientsParam.split(",") : client ? [client] : []) + .map((value) => value.trim()) + .filter(Boolean), + [client, clientsParam], + ) + const requestedClients = useMemo( + () => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))), + [rawRequestedClients], + ) + const invalidClients = useMemo( + () => rawRequestedClients.filter((value) => !isKnownPlugin(value)), + [rawRequestedClients], + ) + const validClient = requestedClients[0] ?? null + const displayName = formatPluginNames(requestedClients) + const pluginInfo = + requestedClients.length === 1 && validClient + ? PLUGIN_INFO[validClient] + : null // Redirect new users (logged in but no organization) to onboarding. // Store the current connect URL so onboarding can redirect back here. @@ -166,6 +249,16 @@ function AuthConnectContent() { setError("Invalid callback URL.") return } + if (invalidClients.length > 0) { + setStatus("error") + setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) + return + } + if (requestedClients.length === 0) { + setStatus("error") + setError("Invalid or missing client.") + return + } if (!session || !org) { setStatus("error") setError( @@ -177,17 +270,13 @@ function AuthConnectContent() { try { setStatus("creating") const fetchParams = new URLSearchParams({ callback }) - if (validClient) fetchParams.set("client", validClient) + fetchParams.set("client", requestedClients[0] ?? "") const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - setStatus("upgrade") - return - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } @@ -198,7 +287,21 @@ function AuthConnectContent() { setStatus("success") const redirectUrl = new URL(callback) - redirectUrl.searchParams.set("apikey", data.key) + if (hasClientList) { + redirectUrl.searchParams.set( + "keys", + encodeBase64UrlJson( + Object.fromEntries( + requestedClients.map((requestedClient) => [ + requestedClient, + data.key, + ]), + ), + ), + ) + } else { + redirectUrl.searchParams.set("apikey", data.key) + } redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { @@ -208,23 +311,23 @@ function AuthConnectContent() { } } - async function handleUpgrade() { - try { - setIsUpgrading(true) - const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}` - await autumn.attach({ - planId: "api_pro", - successUrl: safeSuccessUrl, - }) - } catch (err) { - console.error("Upgrade failed:", err) - setIsUpgrading(false) - } - } - // Show a spinner while session/org data is loading or while we're about // to redirect to onboarding (prevents a brief flash of the connect card). const isAuthLoading = isPending || isRestoring || organizations === null + + useEffect(() => { + if (status !== "loading") return + if (rawRequestedClients.length === 0) { + setStatus("error") + setError("Invalid or missing client.") + return + } + if (invalidClients.length > 0) { + setStatus("error") + setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) + } + }, [invalidClients, rawRequestedClients.length, status]) + if (isAuthLoading || shouldRedirectToOnboarding) { return (
@@ -238,19 +341,7 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+

{pluginInfo?.description ?? - `Allow ${displayName} to access your Supermemory account.`} + (requestedClients.length > 1 + ? "Use one Supermemory account across these plugins." + : `Use your Supermemory account with ${displayName}.`)}

- {pluginInfo && ( -
    - {pluginInfo.features.map((feature) => ( +
      + {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map( + (feature) => (
    • - ))} -
    - )} + ), + )} +
- - - View all plans - -
-
-
- ) - } - if (status === "error") { return (
@@ -435,7 +430,7 @@ function AuthConnectContent() {
+ + + Add memory (C) + + + )} {canInvite && ( diff --git a/apps/web/components/company-brain-promo.tsx b/apps/web/components/company-brain-promo.tsx index 730eeb08..3bda29a8 100644 --- a/apps/web/components/company-brain-promo.tsx +++ b/apps/web/components/company-brain-promo.tsx @@ -45,42 +45,44 @@ export function CompanyBrainPromo() { return (
-
+
-
-

- Give your team a Company Brain -

-

- Lives in your Slack. Answers from your team's tools, and brings things - up before you ask. -

+
+
+

+ Give your team a Company Brain +

+

+ Lives in your Slack. Answers from your team's tools, and brings + things up before you ask. +

+
+
- diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 83f43c3e..eaa129ed 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -162,16 +162,19 @@ export function ConfigureView() {
-
-

- {active.label} -

-

- {active.description} -

+
+
+

+ {active.label} +

+

+ {active.description} +

+
+
+ {headerNotice ?
{headerNotice}
: null} diff --git a/apps/web/components/directory/connector-card.tsx b/apps/web/components/directory/connector-card.tsx new file mode 100644 index 00000000..dc735c17 --- /dev/null +++ b/apps/web/components/directory/connector-card.tsx @@ -0,0 +1,82 @@ +"use client" + +import { cn } from "@lib/utils" +import type { ReactNode } from "react" +import { dmSans125ClassName } from "@/lib/fonts" + +// Shared connector/integration card shell: icon, name, subtitle, optional +// top-right slot, and a footer split into a status side and an action side. +export function ConnectorCard({ + icon, + name, + subtitle, + topRight, + footerLeft, + footerRight, +}: { + icon: ReactNode + name: string + subtitle: string + topRight?: ReactNode + footerLeft: ReactNode + footerRight?: ReactNode +}) { + return ( +
+
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {topRight} +
+
+
{footerLeft}
+ {footerRight} +
+
+ ) +} + +export function ScopeChip({ + label, + connected, +}: { + label: string + connected: boolean +}) { + return ( + + + {label} + + ) +} diff --git a/apps/web/components/directory/section-rail.tsx b/apps/web/components/directory/section-rail.tsx new file mode 100644 index 00000000..080e5a24 --- /dev/null +++ b/apps/web/components/directory/section-rail.tsx @@ -0,0 +1,117 @@ +"use client" + +import { cn } from "@lib/utils" +import { ArrowLeft, ArrowRight } from "lucide-react" +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" +import { dmSans125ClassName } from "@/lib/fonts" + +export const sectionLabelClass = cn( + dmSans125ClassName(), + "text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]", +) + +// Horizontally scrollable card rail with a section heading — shared by the +// main integrations directory and the Company Brain connections directory. +// Arrows appear only when the content actually overflows. +export function SectionRail({ + label, + children, + headerSlot, + labelSlot, + scrollbar = "hidden", +}: { + label: string + children: ReactNode + headerSlot?: ReactNode + labelSlot?: ReactNode + scrollbar?: "hidden" | "visible" +}) { + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const [hasOverflow, setHasOverflow] = useState(false) + + const update = useCallback(() => { + const el = scrollRef.current + if (!el) return + setHasOverflow(el.scrollWidth > el.clientWidth + 4) + setCanScrollLeft(el.scrollLeft > 4) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) + }, []) + + useEffect(() => { + update() + const el = scrollRef.current + if (!el) return + el.addEventListener("scroll", update, { passive: true }) + el.addEventListener("scrollend", update) + const ro = new ResizeObserver(update) + ro.observe(el) + return () => { + el.removeEventListener("scroll", update) + el.removeEventListener("scrollend", update) + ro.disconnect() + } + }, [update]) + + const scrollBy = (dir: 1 | -1) => { + scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" }) + setTimeout(update, 450) + } + + const arrowClass = cn( + "flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity", + "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]", + "hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30", + ) + + return ( +
+
+
+

{label}

+ {labelSlot} +
+
+ {headerSlot} + {hasOverflow ? ( + <> + + + + ) : null} +
+
+
+ {children} +
+
+ ) +} + +// Standard card width inside a rail: full-width stacked on mobile, 2-up on +// small screens, 3-up on large. +export const railItemClass = + "w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]" diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index c30f84c4..20f76f19 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) => export function Header(props: HeaderProps) { const hasCompanyBrain = useHasCompanyBrain() if (hasCompanyBrain) { - return + return ( + + ) } return } diff --git a/apps/web/components/highlights-card.tsx b/apps/web/components/highlights-card.tsx index 38257d2e..6caaadf7 100644 --- a/apps/web/components/highlights-card.tsx +++ b/apps/web/components/highlights-card.tsx @@ -92,8 +92,8 @@ export function HighlightsCard({ if (isReplyOpen) replyInputRef.current?.focus() }, [isReplyOpen]) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes useEffect(() => { + setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0))) setIsReplyOpen(false) setReplyText("") setIsExpanded(false) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index a4a4ded6..5a37af45 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useCustomer } from "autumn-js/react" import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { SectionRail } from "@/components/directory/section-rail" import { $fetch } from "@lib/api" import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" @@ -71,8 +72,14 @@ import { isFreeTierPlugin, normalizePluginClientId, type InstallStep, + type PluginInfo, } from "@/lib/plugin-catalog" -import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" +import { + CopyButton, + INSET, + InstallSteps, + PillButton, +} from "./integrations/install-steps" import { ShortcutsConnectButtons, useShortcutsConnect, @@ -80,6 +87,7 @@ import { import { MCPSteps } from "./mcp-modal/mcp-detail-view" import { GranolaConnectModal } from "./granola-connect-modal" import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space" +import { usePromoCode } from "@/hooks/use-promo-code" type Connection = z.infer @@ -536,13 +544,13 @@ const SECTIONS: Array<{ action: { type: "external", href: POKE_RECIPE_URL }, }, { - kind: "client", - id: "shortcuts", - name: "Apple Shortcuts", - tagline: "Add memories from iPhone, iPad or Mac", - simpleTitle: "Save anything from your phone or Mac", - icon: , - action: { type: "view", viewMode: "shortcuts" as ViewParamValue }, + kind: "import", + id: "x-bookmarks", + name: "Import X bookmarks", + tagline: "Turn your X/Twitter bookmarks into memories", + simpleTitle: "Turn your X bookmarks into memory", + icon: X, + viewMode: "import" as ViewParamValue, }, { kind: "client", @@ -555,13 +563,13 @@ const SECTIONS: Array<{ dev: true, }, { - kind: "import", - id: "x-bookmarks", - name: "Import X bookmarks", - tagline: "Turn your X/Twitter bookmarks into memories", - simpleTitle: "Turn your X bookmarks into memory", - icon: X, - viewMode: "import" as ViewParamValue, + kind: "client", + id: "shortcuts", + name: "Apple Shortcuts", + tagline: "Add memories from iPhone, iPad or Mac", + simpleTitle: "Save anything from your phone or Mac", + icon: , + action: { type: "view", viewMode: "shortcuts" as ViewParamValue }, }, ], }, @@ -637,6 +645,206 @@ function IconBox({ ) } +const PLUGIN_COMMANDS: InstallStep[] = [ + { + code: "npx supermemory plugin", + copyLabel: "Install plugins", + title: "Install plugins", + description: + "Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.", + }, + { + code: "npx supermemory plugin login", + copyLabel: "Reconnect plugins", + title: "Reconnect plugins", + description: + "Run browser OAuth again for plugins that are already installed, without reinstalling them.", + }, + { + code: "npx supermemory plugin uninstall", + copyLabel: "Uninstall plugins", + title: "Uninstall plugins", + description: + "Remove selected plugin integrations while keeping your credentials and memories.", + }, +] + +const PLUGIN_COMMAND_CLIENTS = [ + "claude_code", + "cursor", + "codex", + "opencode", +] as const + +type PluginSetupTab = "agent" | "manual" + +const PLUGIN_CLI_TARGETS: Partial> = { + claude_code: "claude", + codex: "codex", + cursor: "cursor", + opencode: "opencode", +} + +function pluginAgentPrompt(plugin: PluginInfo): string { + const cliTarget = PLUGIN_CLI_TARGETS[plugin.id] + if (cliTarget) { + return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.` + } + + const docsInstruction = plugin.docsUrl + ? ` Follow the official setup instructions at ${plugin.docsUrl}.` + : " Follow its official setup instructions." + return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.` +} + +function PluginSetupMethodTabs({ + value, + onChange, +}: { + value: PluginSetupTab + onChange: (value: PluginSetupTab) => void +}) { + return ( +
+ {(["agent", "manual"] as const).map((tab) => ( + + ))} +
+ ) +} + +function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) { + const prompt = pluginAgentPrompt(plugin) + return ( +
+

+ {prompt} +

+ +
+ ) +} + +function PluginCommandsDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + return ( + + + + Supermemory plugin commands + +
+
+ {PLUGIN_COMMAND_CLIENTS.map((pluginId) => { + const plugin = PLUGIN_CATALOG[pluginId] + if (!plugin) return null + return ( + + + + ) + })} +
+
+

+ Plugin commands +

+

+ Install, reconnect, or remove integrations from one CLI. +

+
+ + + +
+
+
+ +
+
+
+

+ Run these commands from your terminal. +

+ + + +
+
+
+ ) +} + type InfoUseCase = { title: string description: string @@ -2067,6 +2275,7 @@ function ItemCard({ docsUrl, leftIndicator, statusSlot, + layoutClassName, }: { actionSlot: ReactNode infoActionSlot?: ReactNode @@ -2081,6 +2290,7 @@ function ItemCard({ docsUrl?: string leftIndicator?: ReactNode statusSlot?: ReactNode + layoutClassName?: string }) { const [infoOpen, setInfoOpen] = useState(false) return ( @@ -2098,6 +2308,9 @@ function ItemCard({ className={cn( "group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]", + id === "shortcuts" && + "max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center", + layoutClassName, )} > setInfoOpen(true)} /> @@ -2115,7 +2328,12 @@ function ItemCard({
{icon}
-
+
{leftIndicator} @@ -2139,7 +2357,13 @@ function ItemCard({ {tagline}

-
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]", + id === "shortcuts" && + "max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full", + )} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > @@ -2453,95 +2681,6 @@ function CategoryFilterToggle({ ) } -function SectionRail({ - label, - children, - headerSlot, -}: { - label: string - children: ReactNode - headerSlot?: ReactNode -}) { - const scrollRef = useRef(null) - const [canScrollLeft, setCanScrollLeft] = useState(false) - const [canScrollRight, setCanScrollRight] = useState(false) - - const update = useCallback(() => { - const el = scrollRef.current - if (!el) return - setCanScrollLeft(el.scrollLeft > 4) - setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4) - }, []) - - useEffect(() => { - update() - const el = scrollRef.current - if (!el) return - el.addEventListener("scroll", update, { passive: true }) - el.addEventListener("scrollend", update) - const ro = new ResizeObserver(update) - ro.observe(el) - return () => { - el.removeEventListener("scroll", update) - el.removeEventListener("scrollend", update) - ro.disconnect() - } - }, [update]) - - const scrollBy = (dir: 1 | -1) => { - scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" }) - setTimeout(update, 450) - } - - const arrowClass = cn( - "flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity", - "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]", - "hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30", - ) - - return ( -
-
-

- {label} -

-
- {headerSlot} - - -
-
-
- {children} -
-
- ) -} - export function IntegrationsView({ publicMode = false, onOpenDocument, @@ -2555,6 +2694,7 @@ export function IntegrationsView({ const { allProjects } = useContainerTags() const shortcutsConnect = useShortcutsConnect() const autumn = useCustomer({ queryOptions: { enabled: !publicMode } }) + const promoCode = usePromoCode() // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins // stay on hasProProduct. See useConnectorAccess. const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({ @@ -2566,18 +2706,21 @@ export function IntegrationsView({ const [connectingProvider, setConnectingProvider] = useState(null) const [granolaModalOpen, setGranolaModalOpen] = useState(false) + const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false) const [newKey, setNewKey] = useState<{ open: boolean key: string pluginId: string | null loading: boolean }>({ open: false, key: "", pluginId: null, loading: false }) + const [pluginSetupTab, setPluginSetupTab] = useState("agent") + const openPluginSetup = useCallback((pluginId: string) => { + setPluginSetupTab("agent") + setNewKey({ open: true, key: "", pluginId, loading: false }) + }, []) const [connectedPluginId, setConnectedPluginId] = useState( null, ) - const [finishSetupPluginId, setFinishSetupPluginId] = useState( - null, - ) const { data: pluginsData } = useQuery({ queryFn: async () => { @@ -2747,11 +2890,6 @@ export function IntegrationsView({ credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - throw new Error( - "Plugin access was denied. Check your plan or try again.", - ) - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } @@ -2761,12 +2899,7 @@ export function IntegrationsView({ }, onMutate: (pluginId) => setConnectingPlugin(pluginId), onError: (err) => { - // Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner. - setNewKey((s) => - s.loading - ? { open: false, key: "", pluginId: null, loading: false } - : s, - ) + setNewKey((s) => ({ ...s, loading: false })) toast.error("Failed to connect plugin", { description: err instanceof Error ? err.message : "Unknown error", }) @@ -2776,10 +2909,32 @@ export function IntegrationsView({ queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] }) }, onSuccess: (data, pluginId) => { - setNewKey({ open: true, key: data.key, pluginId, loading: false }) + setNewKey((s) => + s.open && s.pluginId === pluginId + ? { ...s, key: data.key, loading: false } + : s, + ) }, }) + const generatePluginKey = () => { + const pluginId = newKey.pluginId + if ( + !pluginId || + newKey.key || + newKey.loading || + createPluginKeyMutation.isPending + ) + return + setNewKey((s) => ({ ...s, loading: true })) + createPluginKeyMutation.mutate(pluginId) + } + + const selectPluginSetupTab = (tab: PluginSetupTab) => { + setPluginSetupTab(tab) + if (tab === "manual") generatePluginKey() + } + const addConnectionMutation = useMutation({ mutationFn: async (provider: ConnectorProvider) => { const response = await $fetch("@post/connections/:provider", { @@ -2830,8 +2985,10 @@ export function IntegrationsView({ try { const result = await autumn.attach({ planId: checkoutPlanId, + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/integrations`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return @@ -2842,7 +2999,7 @@ export function IntegrationsView({ toast.error("Failed to start checkout. Please try again.") } }, - [autumn], + [autumn, promoCode], ) const redirectToLogin = useCallback(() => { @@ -2909,10 +3066,7 @@ export function IntegrationsView({ void setConnectTarget(null) handleUpgrade("api_pro") } else { - // Open instantly; the key fills in on mint. The ?connect param stays the source - // of truth until the modal closes. - setNewKey({ open: true, key: "", pluginId: target, loading: true }) - createPluginKeyMutation.mutate(target) + openPluginSetup(target) } return } @@ -2947,8 +3101,8 @@ export function IntegrationsView({ redirectToLogin, setConnectTarget, setAddDoc, - createPluginKeyMutation, handleUpgrade, + openPluginSetup, ]) const closeMcpModal = () => { @@ -3263,7 +3417,7 @@ export function IntegrationsView({ handleUpgrade("api_pro") return } - createPluginKeyMutation.mutate("claude_code") + openPluginSetup("claude_code") }, }, { @@ -3341,7 +3495,7 @@ export function IntegrationsView({ return } trackCard(item) - createPluginKeyMutation.mutate(item.pluginId) + openPluginSetup(item.pluginId) }} disabled={!!connectingPlugin} className={cn( @@ -3362,12 +3516,7 @@ export function IntegrationsView({ { trackCard(item) - if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) { - if (connectingPlugin) return - createPluginKeyMutation.mutate(item.pluginId) - return - } - setFinishSetupPluginId(item.pluginId) + openPluginSetup(item.pluginId) }} /> ) @@ -3384,7 +3533,7 @@ export function IntegrationsView({ { trackCard(item) - createPluginKeyMutation.mutate(item.pluginId) + openPluginSetup(item.pluginId) }} disabled={!!connectingPlugin} > @@ -3554,7 +3703,7 @@ export function IntegrationsView({ return } trackCard(item) - createPluginKeyMutation.mutate(item.pluginId) + openPluginSetup(item.pluginId) }} disabled={!!connectingPlugin} > @@ -3629,7 +3778,7 @@ export function IntegrationsView({ } } - const renderItemCard = (item: Item) => ( + const renderItemCard = (item: Item, layoutClassName?: string) => ( ) @@ -3666,10 +3816,6 @@ export function IntegrationsView({ !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(connectedPluginId) - const finishSetupPlugin = finishSetupPluginId - ? PLUGIN_CATALOG[finishSetupPluginId] - : undefined - const finishSetupSteps = finishSetupPlugin?.installSteps ?? [] const pluginSteps = dialogPlugin?.installSteps ?? [] const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth @@ -3754,7 +3900,14 @@ export function IntegrationsView({

) : q || category !== "all" ? (
- {visibleItems.map((item) => renderItemCard(item))} + {visibleItems.map((item) => + renderItemCard( + item, + item.id === "shortcuts" + ? "sm:w-max sm:min-w-full" + : undefined, + ), + )}
) : (
@@ -3767,6 +3920,23 @@ export function IntegrationsView({ setPluginCommandsOpen(true)} + className={cn( + dmSans125ClassName(), + "inline-flex items-center gap-1.5 rounded-full text-[10px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4BA0FA]/60 sm:text-[11px]", + )} + > + Install plugins with one command + + + ) : null + } headerSlot={ cat === "ai-clients" && activeMcpKey ? (
+ + { @@ -3821,7 +3996,10 @@ export function IntegrationsView({ pluginId: open ? s.pluginId : null, loading: open ? s.loading : false, })) - if (!open) void setConnectTarget(null) + if (!open) { + setPluginSetupTab("agent") + void setConnectTarget(null) + } }} >

- {newKey.loading - ? "Generating your key…" - : "Copy your key and run these steps to finish."} + {pluginSetupTab === "agent" + ? "Copy this prompt into your coding agent." + : newKey.loading + ? "Generating your key…" + : "Follow these steps to finish manually."}

@@ -3889,17 +4069,30 @@ export function IntegrationsView({
- {newKey.loading ? ( + + {pluginSetupTab === "agent" && dialogPlugin ? ( + + ) : newKey.loading ? (
Generating your key…
- ) : ( + ) : newKey.key ? ( + ) : ( +
+

+ We couldn't generate the key for the manual setup. +

+ Try again +
)}
@@ -3913,6 +4106,7 @@ export function IntegrationsView({ pluginId: null, loading: false, }) + setPluginSetupTab("agent") void setConnectTarget(null) }} className={cn( @@ -4051,7 +4245,7 @@ export function IntegrationsView({ if (!connectedPluginId) return const pluginId = connectedPluginId setConnectedPluginId(null) - createPluginKeyMutation.mutate(pluginId) + openPluginSetup(pluginId) }} disabled={!!connectingPlugin} > @@ -4082,91 +4276,6 @@ export function IntegrationsView({ - { - if (!open) setFinishSetupPluginId(null) - }} - > - - - Finish setup {finishSetupPlugin?.name ?? "plugin"} - -
- {finishSetupPlugin && ( - - {finishSetupPlugin.name} - - )} -
-

- Finish setup {finishSetupPlugin?.name ?? "plugin"} -

-

- Complete install in the tool — this card turns active after the - first API call. -

-
- - - -
-
-
- {finishSetupSteps.length > 0 ? ( - - ) : ( -

- Open {finishSetupPlugin?.name ?? "the plugin"} and finish - authentication, then send a test memory. -

- )} -
-
-
- - - -
-
-
- { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index f924a8fb..cfcba481 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -30,6 +30,7 @@ import { type PluginInfo, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./install-steps" +import { usePromoCode } from "@/hooks/use-promo-code" interface ConnectedPlugin { id: string @@ -415,48 +416,11 @@ function PluginRow({ ) } -type TierFilter = "all" | "pro" | "free" - -const TIER_FILTERS: { value: TierFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "pro", label: "Pro" }, - { value: "free", label: "Free" }, -] - -function TierFilterToggle({ - value, - onChange, -}: { - value: TierFilter - onChange: (value: TierFilter) => void -}) { - return ( -
- {TIER_FILTERS.map((filter) => ( - - ))} -
- ) -} - export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const queryClient = useQueryClient() - const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( null, @@ -572,11 +536,6 @@ export function PluginsDetail() { credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - throw new Error( - "Plugin access was denied. Check your plan or try again.", - ) - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } @@ -613,8 +572,10 @@ export function PluginsDetail() { try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/integrations`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return @@ -635,17 +596,12 @@ export function PluginsDetail() { ) const visibleRows = useMemo(() => { - const filtered = catalogRows.filter((id) => { - if (tierFilter === "free") return isFreeTierPlugin(id) - if (tierFilter === "pro") return !isFreeTierPlugin(id) - return true - }) // Connected plugins float to the top (stable within each group). - return [...filtered].sort( + return [...catalogRows].sort( (a, b) => Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)), ) - }, [catalogRows, tierFilter, connectedPluginIds]) + }, [catalogRows, connectedPluginIds]) const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] @@ -684,12 +640,7 @@ export function PluginsDetail() { )} >
-
- Plugins - {catalogRows.length > 0 && ( - - )} -
+ Plugins
{visibleRows.map((pluginId) => { const plugin = PLUGIN_CATALOG[pluginId] diff --git a/apps/web/components/integrations/shortcuts-detail.tsx b/apps/web/components/integrations/shortcuts-detail.tsx index 833488eb..3737fd36 100644 --- a/apps/web/components/integrations/shortcuts-detail.tsx +++ b/apps/web/components/integrations/shortcuts-detail.tsx @@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({ }) { const { connect, isPending, pendingType } = controller return ( -
+
{ diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index e9a0673a..61b0cddd 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -3,6 +3,7 @@ import { LogoFull } from "@ui/assets/Logo" import { Button } from "@ui/components/button" import { Input } from "@ui/components/input" +import { useAuth } from "@lib/auth-context" import { cn } from "@lib/utils" import { ArrowRight, @@ -15,6 +16,7 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query" import { AnimatePresence, motion } from "motion/react" import { type ReactNode, useEffect, useRef, useState } from "react" +import { getBrainWorkspaceDomain } from "@/lib/billing-utils" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { type ResearchEvent, @@ -30,6 +32,9 @@ import { UserAvatar, } from "./step-about" import { ResearchActionRail } from "./research-action-rail" +import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial" +import { useTrialStatus } from "@/hooks/use-trial-status" +import { analytics } from "@/lib/analytics" import { type CompanyBrainConfirmResult, type CompanyBrainOrganizationChoice, @@ -52,7 +57,7 @@ interface CompanyBrainOnboardingProps { const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" -type Phase = "confirm" | "research" +type Phase = "confirm" | "trial" | "research" function normalizeDomain(input: string): string { const host = input @@ -82,13 +87,36 @@ export function CompanyBrainOnboarding({ onUsePersonal, }: CompanyBrainOnboardingProps) { const [phase, setPhase] = useState("confirm") + const { needsSetup } = useTrialStatus() + const resumedRef = useRef(false) + useEffect(() => { + if (resumedRef.current) return + const url = new URL(window.location.href) + if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return + resumedRef.current = true + url.searchParams.delete(CHECKOUT_RETURN_PARAM) + window.history.replaceState({}, "", `${url.pathname}${url.search}`) + setPhase("research") + }, []) + useEffect(() => { + if (resumedRef.current || !needsSetup || phase !== "confirm") return + resumedRef.current = true + setPhase("trial") + analytics.brainTrialCardViewed() + }, [needsSetup, phase]) + const { org } = useAuth() const [domain, setDomain] = useState(initialDomain) const [organizationChoices, setOrganizationChoices] = useState< CompanyBrainOrganizationChoice[] | null >(null) const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false) const firstName = name.trim().split(/\s+/)[0] ?? "" - const clean = normalizeDomain(domain) + // Returning from checkout remounts and reseeds local state from the email domain, + // so past the confirm step the org's stored domain is the one to trust. + const confirmedDomain = getBrainWorkspaceDomain(org?.metadata) + const clean = normalizeDomain( + phase === "confirm" ? domain : confirmedDomain || domain, + ) const queryClient = useQueryClient() const { status: researchStatus } = useResearchStatus(phase === "research") const researchDone = researchStatus === "done" @@ -107,7 +135,8 @@ export function CompanyBrainOnboarding({ } setOrganizationChoices(null) setServerSchedulesResearch(result.serverSchedulesResearch) - setPhase("research") + setPhase("trial") + analytics.brainTrialCardViewed() } // New-org signup schedules research after provisioning; if that hook is slow @@ -203,9 +232,9 @@ export function CompanyBrainOnboarding({
{/* Persistent card: full confirm card, then morphs into a slim docked header. */} @@ -215,13 +244,25 @@ export function CompanyBrainOnboarding({ style={cardSurfaceStyle} className={cn( "w-full mx-auto rounded-[22px] bg-[#1B1F24]", - phase === "confirm" - ? "max-w-xl p-6 md:p-8" - : "max-w-7xl px-5 py-3 xl:max-w-[1360px]", + phase === "research" + ? "max-w-7xl px-5 py-3 xl:max-w-[1360px]" + : phase === "trial" + ? "max-w-4xl p-6 md:p-7" + : "max-w-xl p-6 md:p-8", )} > - {phase === "confirm" ? ( + {phase === "trial" ? ( + + setPhase("research")} /> + + ) : phase === "confirm" ? (

- Starts your 14-day free trial. No credit card needed. + Included in your 14-day trial.

) diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 358597e6..861235e2 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react" import { toast } from "sonner" import { analytics } from "@/lib/analytics" import type { BrainMode } from "./types" +import { usePromoCode } from "@/hooks/use-promo-code" type SourceId = | "drive" @@ -97,7 +98,7 @@ type SourceId = | "raycast" type SourceState = "idle" | "connecting" | "connected" | "waitlist" type DriveScope = "selective" | "full" -type RequiredPlan = "pro" | "max" +type RequiredPlan = "pro" | "max" | "scale" const PROVIDER_TO_SOURCE: Record = { "google-drive": "drive", @@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial> = { const PLAN_LABELS: Record = { pro: "Pro", max: "Max", + scale: "Scale", } const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min" @@ -148,11 +150,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [ credits: "$20", productId: "api_pro", description: "For people building with AI memory", - features: [ - "Auto top-up when balance runs low", - "All plugins (Claude Code, Cursor, Hermes...)", - "Priority support", - ], + features: ["Auto top-up when balance runs low", "Priority support"], }, { id: "max", @@ -277,7 +275,12 @@ export function StepSources({ const [granolaOpen, setGranolaOpen] = useState(false) const [requestedPlan, setRequestedPlan] = useState("pro") const [requestedConnector, setRequestedConnector] = useState("This connector") - const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess() + const { + hasMax, + hasScale, + connectorAccess, + loading: planLoading, + } = useConnectorAccess() const { org, isRestoring } = useAuth() useEffect(() => { @@ -362,10 +365,12 @@ export function StepSources({ } }, [connectedParam]) - // company_brain unlocks pro connectors; max stays gated + // company_brain unlocks pro connectors; max and scale stay gated, and a + // higher tier satisfies a lower requirement. const isLocked = (plan?: RequiredPlan) => { if (!plan || planLoading) return false - if (plan === "max") return !hasMax + if (plan === "scale") return !hasScale + if (plan === "max") return !(hasMax || hasScale) return !connectorAccess } @@ -610,6 +615,7 @@ function OnboardingPlansModal({ requestedPlan: RequiredPlan }) { const autumn = useCustomer() + const promoCode = usePromoCode() const { currentPlan, isLoading } = useTokenUsage(autumn) const [upgradingPlan, setUpgradingPlan] = useState( null, @@ -628,8 +634,10 @@ function OnboardingPlansModal({ try { const result = await autumn.attach({ planId, + discounts: promoCode.getDiscounts(), successUrl: window.location.href, }) + promoCode.clear() if ((result as { paymentUrl?: string })?.paymentUrl) { window.location.href = (result as { paymentUrl: string }).paymentUrl return @@ -1185,14 +1193,14 @@ function MoreSourcesGrid({ icon={} state={values.connected.github ?? "idle"} ctaLabel="Connect" - locked={isLocked("max")} - requiredPlan="max" + locked={isLocked("scale")} + requiredPlan="scale" perks={[ "PRs and issues parsed", "READMEs and docs indexed", "Stays in sync with new activity", ]} - onConnect={guard("max", "GitHub", () => requestWaitlist("github"))} + onConnect={guard("scale", "GitHub", () => requestWaitlist("github"))} /> {mode === "personal" ? ( }, + { key: "gmail", r: 74, deg: 128, node: }, + { key: "notion", r: 74, deg: 236, node: }, + { key: "drive", r: 112, deg: 58, node: }, + { key: "granola", r: 112, deg: 172, node: }, + { key: "mcp", r: 112, deg: 296, node: }, +] + +const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]" +const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]" + +function BrainPanel() { + return ( +
+
+ ) +} + +function TimelineRow({ + date, + title, + value, + current, +}: { + date: string + title: string + value?: string + current?: boolean +}) { + return ( +
  • +
  • + ) +} + +export function StepTrial({ onActive }: { onActive: () => void }) { + const [starting, setStarting] = useState(false) + + const start = async () => { + if (starting) return + setStarting(true) + analytics.brainTrialCheckoutStarted() + try { + const res = await fetch(`${BACKEND}/brain/trial/start`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ successUrl: checkoutReturnUrl() }), + }) + const data = (await res.json()) as { + checkoutUrl?: string | null + status?: string + error?: string + } + if (res.status === 409 || data.error === "trial_unavailable") { + throw new Error( + "This workspace has already used its free trial. Upgrade from billing to continue.", + ) + } + if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.") + if (data.checkoutUrl) { + window.location.href = data.checkoutUrl + return + } + if (data.status === "already_active" || data.status === "attached") { + onActive() + return + } + throw new Error("Couldn't start the trial.") + } catch (error) { + console.error("Failed to start trial:", error) + toast.error( + error instanceof Error ? error.message : "Couldn't start the trial.", + ) + setStarting(false) + } + } + + return ( +
    +
    +
    +

    + Start your {TRIAL_DAYS}-day free trial +

    +

    + Add a payment method to start. You will not be charged today. We + will email you before your first payment. +

    +
    + +
      +
    + +
    + +

    + + Secured by Stripe · Cancel in one click +

    +
    +
    + + +
    + ) +} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index c9a06dd0..7b7398c6 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -394,11 +394,6 @@ export function SelectSpacesModal({ credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - throw new Error( - "Plugin access was denied. Check your plan or try again.", - ) - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index 99e9bf47..41d74634 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -23,6 +23,7 @@ import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" import * as DialogPrimitive from "@radix-ui/react-dialog" import { useMutation, useQuery } from "@tanstack/react-query" import { + Copy, LoaderIcon, ChevronDown, Users, @@ -458,10 +459,26 @@ export default function Account({ Organization + {org?.id ? ( + + ) : null} {isEditingOrgName ? (
    handleUpgrade("api_max")} + disabled={disabled} + className={cn( + dmSans125ClassName(), + PLAN_CARD_ACTION_CLASS, + "bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]", + )} + > + {disabled ? : null} + Activate Max + + ) + } + // Trial Scale: primary CTA is activate paid Scale (not a dead "current" state). if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) { return ( @@ -1471,6 +1512,20 @@ export default function Billing() { } /> ))} +
    + {isOnTrial ? ( +

    + Your trial runs on Scale. Moving to Max keeps the agent, + shared memory and unlimited seats, and drops the GitHub, S3 + and Web Crawler connectors, restricted access and container + tags, and User Insights. +

    + ) : null} +

    + Using more than about $400 of credits a month? Scale works out + cheaper than Max plus top-ups. +

    +
    ) : ( <> diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx index 74f8e7b0..9a338d1d 100644 --- a/apps/web/components/settings/company-brain-automations.tsx +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -19,7 +19,8 @@ import { Radar, Trash2, } from "lucide-react" -import { useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" +import { createPortal } from "react-dom" import { toast } from "sonner" import { Select, @@ -35,6 +36,8 @@ import { TooltipTrigger, } from "@ui/components/tooltip" import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { useOrgMemberRole } from "@/hooks/use-org-member-role" +import { configureSectionToPath } from "@/lib/configure-routes" import { dmSans125ClassName } from "@/lib/fonts" const BACKEND = @@ -301,6 +304,9 @@ function AutomationCard({ id, channels, ownerLabel, + personalOnlyApps = [], + isAdmin = false, + appCatalog = {}, onDone, onCancelNew, onCollapse, @@ -309,6 +315,9 @@ function AutomationCard({ id: string | null channels: Channel[] ownerLabel?: string + personalOnlyApps?: string[] + isAdmin?: boolean + appCatalog?: Record onDone: () => void onCancelNew?: () => void onCollapse?: () => void @@ -349,9 +358,18 @@ function AutomationCard({ const b = (await res.json().catch(() => ({}))) as { error?: string } throw new Error(b.error ?? "Couldn't save.") } + const b = (await res.json().catch(() => ({}))) as { + warnings?: { app: string }[] + } + return b.warnings ?? [] }, - onSuccess: () => { + onSuccess: (warnings) => { toast.success("Automation saved.") + if (warnings.length) + toast.warning( + `Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`, + { duration: 10000 }, + ) onDone() }, onError: (err) => @@ -619,6 +637,51 @@ function AutomationCard({ Cancel ) : null} + + {draft.deliverTo === "channel" && personalOnlyApps.length > 0 && ( + + + + {personalOnlyApps.map((app) => ( + + + {appCatalog[app]?.iconDomain ? ( + {appCatalog[app]?.name + ) : ( + + {app.slice(0, 1)} + + )} + + + {appCatalog[app]?.name ?? app} + + + ))} + + + + only connected to you ·{" "} + {isAdmin ? ( + <> + + Connect for workspace + {" "} + to use here + + ) : ( + "ask an admin to connect it for the workspace" + )} + + + )}
    {id ? ( @@ -851,8 +914,14 @@ function PresetCard({ export default function CompanyBrainAutomations() { const isCompanyBrain = useHasCompanyBrain() const { user, org } = useAuth() + const { isAdmin } = useOrgMemberRole(isCompanyBrain) const queryClient = useQueryClient() const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([]) + const [showAllTemplates, setShowAllTemplates] = useState(false) + const [actionSlot, setActionSlot] = useState(null) + useEffect(() => { + setActionSlot(document.getElementById("configure-section-actions")) + }, []) const [openId, setOpenId] = useState(null) const draftKey = useRef(0) const addDraft = (draft: Draft) => @@ -886,11 +955,15 @@ export default function CompanyBrainAutomations() { const res = await fetch(`${BACKEND}/brain/mcp-connections/`, { credentials: "include", }) - if (!res.ok) return [] as string[] + if (!res.ok) return [] as { serverSlug: string; userId: string | null }[] const body = (await res.json()) as { - connections?: { serverSlug: string }[] + connections?: { + serverSlug: string + userId: string | null + status: string + }[] } - return (body.connections ?? []).map((c) => c.serverSlug) + return (body.connections ?? []).filter((c) => c.status === "active") }, enabled: isCompanyBrain, }) @@ -899,7 +972,39 @@ export default function CompanyBrainAutomations() { const channels = channelsQuery.data ?? [] const automations = listQuery.data ?? [] - const presets = sortPresets(new Set(appsQuery.data ?? [])) + const catalogQuery = useQuery({ + queryKey: ["company-brain-automations", "catalog", "v2"], + queryFn: async () => { + const res = await fetch(`${BACKEND}/brain/mcp-connections/catalog`, { + credentials: "include", + }) + if (!res.ok) + return {} as Record + const body = (await res.json()) as { + catalog?: { slug: string; name?: string; iconDomain?: string }[] + } + return Object.fromEntries( + (body.catalog ?? []).map((e) => [ + e.slug, + { name: e.name ?? e.slug, iconDomain: e.iconDomain }, + ]), + ) + }, + enabled: isCompanyBrain, + }) + + const connections = appsQuery.data ?? [] + const presets = sortPresets(new Set(connections.map((c) => c.serverSlug))) + const sharedApps = new Set( + connections.filter((c) => c.userId === null).map((c) => c.serverSlug), + ) + const personalOnlyApps = [ + ...new Set( + connections + .filter((c) => c.userId !== null && !sharedApps.has(c.serverSlug)) + .map((c) => c.serverSlug), + ), + ] const nameFor = (userId: string | null): string | undefined => { if (!userId) return undefined if (userId === user?.id) return "You" @@ -913,10 +1018,34 @@ export default function CompanyBrainAutomations() { } const usedTitles = new Set(automations.map((a) => a.title)) const availablePresets = presets.filter((p) => !usedTitles.has(p.label)) + const shownPresets = showAllTemplates + ? availablePresets + : availablePresets.slice(0, 3) + const hiddenTemplateCount = availablePresets.length - shownPresets.length const hasList = automations.length > 0 || drafts.length > 0 + const newAutomationButton = ( + + ) + const newAutomationPortal = actionSlot ? ( + createPortal(newAutomationButton, actionSlot) + ) : ( +
    {newAutomationButton}
    + ) + return (
    + {newAutomationPortal}
    {automations.map((a) => openId === a.id ? ( @@ -925,6 +1054,9 @@ export default function CompanyBrainAutomations() { id={a.id} initial={toDraft(a)} channels={channels} + personalOnlyApps={personalOnlyApps} + isAdmin={isAdmin} + appCatalog={catalogQuery.data ?? {}} onDone={() => { setOpenId(null) refresh() @@ -953,6 +1085,9 @@ export default function CompanyBrainAutomations() { id={null} initial={draft} channels={channels} + personalOnlyApps={personalOnlyApps} + isAdmin={isAdmin} + appCatalog={catalogQuery.data ?? {}} onDone={() => { removeDraft(key) refresh() @@ -961,37 +1096,38 @@ export default function CompanyBrainAutomations() { /> ))} - {hasList ? ( -

    - Templates -

    - ) : null} +

    + {showAllTemplates ? "Templates" : "Ideas for your setup"} +

    - {availablePresets.map((p) => ( + {shownPresets.map((p) => ( addDraft(presetToDraft(p))} /> ))} +
    + {hiddenTemplateCount > 0 || showAllTemplates ? ( -
    + ) : null}
    ) diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index 55a01228..754d5b78 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -1,10 +1,11 @@ "use client" +import { useRouter } from "next/navigation" import { useOrgMemberRole } from "@/hooks/use-org-member-role" import { cn } from "@lib/utils" import * as DialogPrimitive from "@radix-ui/react-dialog" -import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { ChevronDown, Loader2, Plus, Search, XIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogContent, @@ -20,14 +21,33 @@ import { import { toast } from "sonner" import { dmSans125ClassName } from "@/lib/fonts" import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import type { McpDirectoryEntry } from "@/lib/mcp-directory" import { brainConnectorIcon, SlackMark } from "../brain-connector-icons" +import { ConnectorCard, ScopeChip } from "../directory/connector-card" +import { + railItemClass, + SectionRail, + sectionLabelClass, +} from "../directory/section-rail" import { PillButton } from "../integrations/install-steps" +import { + categoryLabel, + DirectoryEntryCard, + entrySlug, + isEntrySetUppable, + listableDirectoryEntries, + McpDirectoryGrid, + normalizeServerUrl, + useMcpDirectory, +} from "./mcp-directory-browser" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const MCP_BASE = `${BACKEND}/brain/mcp-connections` +const RECOMMENDED_DIRECTORY_COUNT = 9 + type AuthType = "oauth" | "static" | "none" type CatalogEntry = { slug: string @@ -58,6 +78,25 @@ function slugifyMcpName(value: string) { .slice(0, 63) } +function customConnectionName(slug: string) { + return titleCase(slug.replace(/-sm-dir-[a-z0-9]{6}$/, "").replace(/-/g, " ")) +} + +function directorySlugOf(entry: McpDirectoryEntry) { + return `${slugifyMcpName(entry.name).slice(0, 49)}-sm-dir-${stableDirectorySuffix( + entry.url ?? entry.note ?? entry.id, + )}` +} + +function stableDirectorySuffix(value: string) { + let hash = 0x811c9dc5 + for (const character of value) { + hash ^= character.codePointAt(0) ?? 0 + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(36).slice(0, 6).padStart(6, "0") +} + const pillLinkClass = cn( "relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5", "text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]", @@ -65,35 +104,18 @@ const pillLinkClass = cn( "cursor-pointer transition-opacity hover:opacity-80", ) -function ScopeChip({ - label, - connected, -}: { - label: string - connected: boolean -}) { - return ( - - - {label} - - ) -} - const menuItemClass = "gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" +const menuContentClass = cn( + dmSans125ClassName(), + "min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]", +) + +const menuContentStyle = { + background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", +} as const + const customInputClass = "h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" @@ -125,47 +147,27 @@ function AppCard({ const adminMenu = isAdmin && !personalOnly return ( -
    -
    -
    - {icon} -
    -
    -

    - {name} -

    -

    - {subtitle} -

    -
    -
    -
    -
    - {personalOnly || !anyConnected ? ( - - ) : ( - <> - - {showOrgChip ? ( - - ) : null} - - )} -
    - {adminMenu ? ( + + ) : ( + <> + + {showOrgChip ? ( + + ) : null} + + ) + } + footerRight={ + adminMenu ? (
    -
    + ) + } + /> ) } @@ -256,30 +250,12 @@ function SlackCard({ return () => clearTimeout(timer) }, [confirming]) return ( -
    -
    -
    - -
    -
    -

    - Slack -

    -

    - Messaging -

    -
    - {connected && status?.teamName ? ( + } + topRight={ + connected && status?.teamName ? ( {status.teamName} - ) : null} -
    -
    + ) : undefined + } + footerLeft={ - {isAdmin ? ( + } + footerRight={ + isAdmin ? (
    {connected ? (
    - ) : null} -
    -
    + ) : undefined + } + /> + ) +} + +// Compact icon for an installed integration — the user already knows what it +// is, so the full card lives only in Recommended/search. Clicking opens the +// same manage menu the cards use. +function InstalledTile({ + name, + icon, + children, +}: { + name: string + icon: React.ReactNode + children: React.ReactNode +}) { + return ( + + + + + +

    + {name} +

    + {children} +
    +
    ) } @@ -360,6 +379,8 @@ export default function CompanyBrainConnections() { const [rows, setRows] = useState([]) const [slackStatus, setSlackStatus] = useState(null) const [busy, setBusy] = useState(null) + const [query, setQuery] = useState("") + const [marketplaceCategory, setMarketplaceCategory] = useState("all") const [customOpen, setCustomOpen] = useState(false) const [customName, setCustomName] = useState("") const [customServerUrl, setCustomServerUrl] = useState("") @@ -369,8 +390,20 @@ export default function CompanyBrainConnections() { { name: string; value: string }[] >([]) const [customAdvancedOpen, setCustomAdvancedOpen] = useState(false) + const [customAuthMethod, setCustomAuthMethod] = useState<"oauth" | "api-key">( + "oauth", + ) + const [directoryEntry, setDirectoryEntry] = + useState(null) + const deepLinkHandled = useRef(false) + const router = useRouter() const { isAdmin } = useOrgMemberRole(isCompanyBrain) + const directory = useMcpDirectory() + const directoryEntries = useMemo( + () => listableDirectoryEntries(directory.entries), + [directory.entries], + ) const load = useCallback(async () => { const [catRes, connRes, slackRes] = await Promise.all([ @@ -481,22 +514,55 @@ export default function CompanyBrainConnections() { const resetCustomForm = () => { setCustomOpen(false) + setDirectoryEntry(null) setCustomName("") setCustomServerUrl("") setCustomToken("") setCustomHeaderName("") setCustomExtraHeaders([]) setCustomAdvancedOpen(false) + setCustomAuthMethod("oauth") } + const setUpDirectoryEntry = useCallback((entry: McpDirectoryEntry) => { + setDirectoryEntry(entry) + setCustomName(entry.name) + setCustomServerUrl(entry.url ?? "") + setCustomAdvancedOpen(false) + setCustomAuthMethod(entry.authMethods[0] ?? "oauth") + setCustomOpen(true) + }, []) + + // Deep link from Company Brain for apps it cannot authorize on their behalf. + useEffect(() => { + if (deepLinkHandled.current) return + const slug = new URLSearchParams(window.location.search).get("mcpSetup") + if (!slug) return + if (!directory.entries.length) return + deepLinkHandled.current = true + const entry = directory.entries.find((e) => directorySlugOf(e) === slug) + if (entry) setUpDirectoryEntry(entry) + else toast.error("That app is no longer in the MCP directory.") + // Router, not history: a replaceState here races the router and gets reverted. + router.replace(window.location.pathname, { scroll: false }) + }, [directory.entries, setUpDirectoryEntry, router]) + const connectCustom = async (event: React.FormEvent) => { event.preventDefault() - const slug = slugifyMcpName(customName) + const slug = directoryEntry + ? directorySlugOf(directoryEntry) + : slugifyMcpName(customName) const serverUrl = customServerUrl.trim() if (!slug) { toast.error("Enter a custom MCP name.") return } + if (!directoryEntry && /-sm-dir-[a-z0-9]{6}$/.test(slug)) { + toast.error( + "Choose a name that doesn't use the reserved directory suffix.", + ) + return + } if (!serverUrl) { toast.error("Enter an MCP URL.") return @@ -509,7 +575,11 @@ export default function CompanyBrainConnections() { const key = `custom:${slug}` setBusy(key) try { - const token = customToken.trim() + const token = customAuthMethod === "api-key" ? customToken.trim() : "" + if (customAuthMethod === "api-key" && !token) { + toast.error("Enter an API key.") + return + } if (token) { const rows = customExtraHeaders .map((h) => [h.name.trim(), h.value.trim()] as const) @@ -543,7 +613,7 @@ export default function CompanyBrainConnections() { toast.error(data.error ?? "Couldn't connect.") return } - toast.success(`${slug} connected.`) + toast.success(`${customName} connected.`) resetCustomForm() await load() return @@ -571,7 +641,7 @@ export default function CompanyBrainConnections() { window.open(data.authUrl, "_blank", "noopener") resetCustomForm() } else if (data.ok) { - toast.success(`${slug} connected.`) + toast.success(`${customName} connected.`) resetCustomForm() await load() } else { @@ -615,6 +685,112 @@ export default function CompanyBrainConnections() { } } + const loading = catalog === null + const apps = catalog ?? [] + const catalogSlugs = useMemo( + () => new Set(apps.map((entry) => entry.slug)), + [apps], + ) + const canClassifyCustomRows = catalogLoaded && apps.length > 0 + const customRows = canClassifyCustomRows + ? rows.filter( + (row) => + row.userId !== null && + row.status === "active" && + typeof row.serverUrl === "string" && + row.serverUrl.length > 0 && + !catalogSlugs.has(row.serverSlug), + ) + : [] + + const connectedUrls = useMemo( + () => + new Set( + rows + .filter( + (row) => + row.status === "active" && + typeof row.serverUrl === "string" && + row.serverUrl.length > 0, + ) + .map((row) => normalizeServerUrl(row.serverUrl ?? "")), + ), + [rows], + ) + + const isEntryConnected = useCallback( + (entry: McpDirectoryEntry) => { + const slug = entrySlug(entry) + if (catalogSlugs.has(slug)) { + return rows.some( + (row) => row.status === "active" && row.serverSlug === slug, + ) + } + return entry.url + ? connectedUrls.has(normalizeServerUrl(entry.url)) + : false + }, + [catalogSlugs, connectedUrls, rows], + ) + + const slackConnected = slackStatus?.connected ?? false + const isAppConnected = (slug: string) => + isConnected(slug, false) || isConnected(slug, true) + const installedApps = apps.filter((entry) => isAppConnected(entry.slug)) + const recommendedApps = apps.filter((entry) => !isAppConnected(entry.slug)) + const hasInstalled = + slackConnected || installedApps.length > 0 || customRows.length > 0 + + // Popular, connectable directory servers we don't already show as apps. + const recommendedDirectoryEntries = useMemo( + () => + directoryEntries + .filter( + (entry) => + isEntrySetUppable(entry) && + !catalogSlugs.has(entrySlug(entry)) && + !isEntryConnected(entry), + ) + .sort((a, b) => b.popularity - a.popularity) + .slice(0, RECOMMENDED_DIRECTORY_COUNT), + [catalogSlugs, directoryEntries, isEntryConnected], + ) + + // Top categories become the marketplace filter tags. + const marketplaceCategories = useMemo(() => { + const counts = new Map() + for (const entry of directoryEntries) { + for (const category of entry.categories) { + counts.set(category, (counts.get(category) ?? 0) + 1) + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([category]) => category) + }, [directoryEntries]) + + const marketplaceEntries = useMemo( + () => + marketplaceCategory === "all" + ? directoryEntries + : directoryEntries.filter((entry) => + entry.categories.includes(marketplaceCategory), + ), + [directoryEntries, marketplaceCategory], + ) + + const needle = query.trim().toLowerCase() + const searching = needle.length > 0 + const catalogMatches = searching + ? apps.filter( + (entry) => + entry.name.toLowerCase().includes(needle) || + entry.category.toLowerCase().includes(needle), + ) + : [] + const slackMatches = searching && "slack messaging".includes(needle) + if (!isCompanyBrain) { return (

    entry.slug)) - const canClassifyCustomRows = catalogLoaded && apps.length > 0 - const customRows = canClassifyCustomRows - ? rows.filter( - (row) => - row.userId !== null && - row.status === "active" && - typeof row.serverUrl === "string" && - row.serverUrl.length > 0 && - !catalogSlugs.has(row.serverSlug), - ) - : [] + const slackInstallHref = `${BACKEND}/brain/slack/oauth/install` + + const disconnectSlack = async () => { + try { + const res = await fetch(`${BACKEND}/brain/slack/workspace`, { + method: "DELETE", + credentials: "include", + }) + if (res.status === 403) { + toast.error("Only admins can disconnect Slack.") + return + } + if (!res.ok) { + toast.error("Couldn't disconnect Slack.") + return + } + } catch { + toast.error("Couldn't disconnect Slack.") + return + } + toast.success("Slack disconnected.") + await load().catch(() => undefined) + } + + const slackCard = ( + + ) + + const appCard = (entry: CatalogEntry) => ( + connect(entry, shared)} + onDisconnect={(shared) => disconnect(entry, shared)} + /> + ) + return (

    -
    - {loading ? ( - <> - - - - - ) : ( - <> - { - try { - const res = await fetch(`${BACKEND}/brain/slack/workspace`, { - method: "DELETE", - credentials: "include", - }) - if (res.status === 403) { - toast.error("Only admins can disconnect Slack.") - return - } - if (!res.ok) { - toast.error("Couldn't disconnect Slack.") - return - } - } catch { - toast.error("Couldn't disconnect Slack.") - return - } - toast.success("Slack disconnected.") - await load().catch(() => undefined) - }} - /> - {apps.map((entry) => ( - connect(entry, shared)} - onDisconnect={(shared) => disconnect(entry, shared)} - /> - ))} - {customRows.map((row) => ( - {}} - onDisconnect={() => - disconnect( - { - slug: row.serverSlug, - name: titleCase(row.serverSlug.replace(/-/g, " ")), - category: "Custom OAuth MCP", - authType: "oauth", - }, - false, - ) - } - /> - ))} - - - )} +
    + +
    + {loading ? ( +
    + + + +
    + ) : searching ? ( +
    + {slackMatches || catalogMatches.length > 0 ? ( +
    + {slackMatches ? slackCard : null} + {catalogMatches.map((entry) => ( +
    {appCard(entry)}
    + ))} +
    + ) : null} + 0} + /> +
    + ) : ( +
    + {hasInstalled ? ( +
    +

    Installed

    +
    + {slackConnected ? ( + } + > + {isAdmin ? ( + <> + + Reconnect + + { + if (window.confirm("Disconnect Slack?")) { + void disconnectSlack() + } + }} + > + Disconnect + + + ) : ( + + Managed by workspace admins + + )} + + ) : null} + {installedApps.map((entry) => { + const userConnected = isConnected(entry.slug, false) + const orgConnected = isConnected(entry.slug, true) + return ( + + {isAdmin ? ( + <> + + userConnected + ? disconnect(entry, false) + : connect(entry, false) + } + > + {userConnected + ? "Disconnect my account" + : "Connect my account"} + + + orgConnected + ? disconnect(entry, true) + : connect(entry, true) + } + > + {orgConnected + ? "Disconnect workspace" + : "Connect for workspace"} + + + ) : userConnected ? ( + disconnect(entry, false)} + > + Disconnect + + ) : ( + + Managed by workspace admins + + )} + + ) + })} + {customRows.map((row) => ( + + + disconnect( + { + slug: row.serverSlug, + name: customConnectionName(row.serverSlug), + category: "Custom MCP", + authType: "oauth", + }, + false, + ) + } + > + Disconnect + + + ))} +
    +
    + ) : null} + + {!slackConnected ? ( +
    {slackCard}
    + ) : null} + {recommendedApps.map((entry) => ( +
    + {appCard(entry)} +
    + ))} + {recommendedDirectoryEntries.map((entry) => ( +
    + +
    + ))} +
    +
    +
    +

    Marketplace

    + {marketplaceEntries.length > 0 ? ( + + {marketplaceEntries.length.toLocaleString()} servers + + ) : null} +
    + {marketplaceCategories.length > 0 ? ( +
    + {["all", ...marketplaceCategories].map((category) => ( + + ))} +
    + ) : null} + +
    +
    + )} + {/* Reset on every close path so the API key never lingers in state. */} + onOpenChange={(open: boolean) => open ? setCustomOpen(true) : resetCustomForm() } > @@ -755,11 +1099,14 @@ export default function CompanyBrainConnections() {
    - Add custom connector + {directoryEntry + ? `Set up ${directoryEntry.name}` + : "Add custom connector"}

    - Connect your Brain to any remote MCP server. Signs in with OAuth - unless you add an API key below. + {directoryEntry?.availability === "tenant" + ? "Enter your workspace-specific MCP URL, then choose how this server authenticates." + : "Confirm the remote MCP URL, then choose how this server authenticates."}

    - + {(["oauth", "api-key"] as const) + .filter( + (method) => + !directoryEntry || + directoryEntry.authMethods.includes(method), + ) + .map((method) => ( + + ))} +
    - {customAdvancedOpen && ( -
    - setCustomToken(event.target.value)} - type="password" - placeholder="API key (optional)" - className={customInputClass} + {customAuthMethod === "api-key" && ( + setCustomToken(event.target.value)} + type="password" + placeholder="API key" + required + className={customInputClass} + /> + )} + + {customAuthMethod === "api-key" && ( + + )} + + {customAuthMethod === "api-key" && customAdvancedOpen && ( +
    setCustomHeaderName(event.target.value)} diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index ccd098d6..96cd382d 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -38,6 +38,7 @@ import { getConnectionSubtitle, } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils" +import { usePromoCode } from "@/hooks/use-promo-code" type Connection = z.infer @@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) { export default function ConnectionsMCP() { const queryClient = useQueryClient() const autumn = useCustomer() + const promoCode = usePromoCode() const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) const router = useRouter() const [removeDialog, setRemoveDialog] = useState<{ @@ -552,8 +554,10 @@ export default function ConnectionsMCP() { try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#connections`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/settings/mcp-directory-browser.tsx b/apps/web/components/settings/mcp-directory-browser.tsx new file mode 100644 index 00000000..4fe323ee --- /dev/null +++ b/apps/web/components/settings/mcp-directory-browser.tsx @@ -0,0 +1,329 @@ +"use client" + +import { Loader2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import type { McpDirectoryEntry } from "@/lib/mcp-directory" +import { brainConnectorIcon } from "../brain-connector-icons" +import { ConnectorCard, ScopeChip } from "../directory/connector-card" +import { PillButton } from "../integrations/install-steps" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +let directoryCache: McpDirectoryEntry[] | null = null + +function isDirectoryEntry(value: unknown): value is McpDirectoryEntry { + if (!value || typeof value !== "object") return false + const entry = value as Partial + return ( + typeof entry.id === "string" && + typeof entry.name === "string" && + (entry.type === "remote" || entry.type === "local") && + (entry.url === null || typeof entry.url === "string") && + typeof entry.auth === "string" && + (entry.note === null || typeof entry.note === "string") && + Array.isArray(entry.categories) && + entry.categories.every((category) => typeof category === "string") && + typeof entry.popularity === "number" && + (entry.iconDomain === null || typeof entry.iconDomain === "string") && + ["custom", "unsupported"].includes(entry.setup ?? "") && + (entry.oauthCapability === null || + ["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) && + Array.isArray(entry.authMethods) && + entry.authMethods.every((method) => + ["oauth", "api-key"].includes(method), + ) && + ["fixed", "tenant", "unavailable", "local"].includes( + entry.availability ?? "", + ) + ) +} + +function parseDirectory(value: unknown) { + if (!value || typeof value !== "object") throw new Error("invalid catalog") + const entries = (value as { entries?: unknown }).entries + if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) { + throw new Error("invalid catalog") + } + return entries +} + +async function loadDirectory(signal: AbortSignal) { + if (directoryCache) return directoryCache + const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, { + signal, + cache: "default", + credentials: "include", + }) + if (!response.ok) throw new Error("catalog request failed") + directoryCache = parseDirectory(await response.json()) + return directoryCache +} + +export function useMcpDirectory() { + const [entries, setEntries] = useState( + () => directoryCache ?? [], + ) + const [error, setError] = useState(false) + + useEffect(() => { + const controller = new AbortController() + void loadDirectory(controller.signal) + .then((data) => { + setEntries(data) + setError(false) + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return + setError(true) + }) + return () => controller.abort() + }, []) + + return { entries, error } +} + +export function categoryLabel(value: string) { + return value + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} + +export function entrySlug(entry: McpDirectoryEntry) { + return entry.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63) +} + +// Mirrors the backend's URL normalization so connection rows match entries. +export function normalizeServerUrl(value: string) { + try { + const url = new URL(value) + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase() + } catch { + return value.toLowerCase() + } +} + +// An entry we can actually take the user through connecting. +export function isEntrySetUppable(entry: McpDirectoryEntry) { + return ( + entry.setup !== "unsupported" && + entry.authMethods.length > 0 && + (entry.availability === "fixed" || entry.availability === "tenant") + ) +} + +// Entries worth listing at all — servers with no reachable URL are dropped. +export function listableDirectoryEntries(entries: McpDirectoryEntry[]) { + return entries.filter((entry) => entry.availability !== "unavailable") +} + +export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) { + return [entry.name, entry.url, entry.note, ...entry.categories] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(needle)) +} + +function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) { + const [failed, setFailed] = useState(false) + if (!entry.iconDomain || failed) { + return brainConnectorIcon(entrySlug(entry), entry.name, "size-4") + } + return ( + setFailed(true)} + /> + ) +} + +export function DirectoryEntryCard({ + entry, + connected, + onSetUp, +}: { + entry: McpDirectoryEntry + connected: boolean + onSetUp: (entry: McpDirectoryEntry) => void +}) { + const canSetUp = !connected && isEntrySetUppable(entry) + const status = connected + ? "Connected" + : canSetUp + ? "Not connected" + : entry.availability === "local" + ? "Desktop only" + : "Coming soon" + return ( + } + name={entry.name} + subtitle={entrySubtitle(entry)} + footerLeft={} + footerRight={ + canSetUp ? ( + onSetUp(entry)}>Set up + ) : null + } + /> + ) +} + +function entrySubtitle(entry: McpDirectoryEntry) { + if (entry.categories.length > 0) { + return entry.categories.slice(0, 2).map(categoryLabel).join(" · ") + } + return entry.type === "local" ? "Desktop extension" : "MCP server" +} + +// One directory listing: a dense single-line row. The default state carries no +// status text — in a marketplace, "not connected" is implied. Only connection, +// or the reason there's no button, earns words. +export function DirectoryEntryRow({ + entry, + connected, + onSetUp, +}: { + entry: McpDirectoryEntry + connected: boolean + onSetUp: (entry: McpDirectoryEntry) => void +}) { + const canSetUp = !connected && isEntrySetUppable(entry) + return ( +
    +
    + +
    +
    +

    + {entry.name} +

    +

    + {entrySubtitle(entry)} +

    +
    + {connected ? ( + + + Connected + + ) : canSetUp ? ( + + ) : ( + + {entry.availability === "local" ? "Desktop only" : "Coming soon"} + + )} +
    + ) +} + +const GRID_PAGE_SIZE = 24 + +// Paged card grid over the MCP directory. With a query it renders matching +// servers; without one it renders the whole marketplace. +export function McpDirectoryGrid({ + query = "", + entries, + loadError, + excludeSlugs, + isEntryConnected, + onSetUp, + suppressEmpty, +}: { + query?: string + entries: McpDirectoryEntry[] + loadError: boolean + // entries already rendered elsewhere (e.g. the built-in app catalog) + excludeSlugs?: Set + isEntryConnected: (entry: McpDirectoryEntry) => boolean + onSetUp: (entry: McpDirectoryEntry) => void + // the caller rendered its own matches, so an empty grid isn't "no results" + suppressEmpty?: boolean +}) { + const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE) + const needle = query.trim().toLowerCase() + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query + useEffect(() => { + setVisibleCount(GRID_PAGE_SIZE) + }, [needle]) + + // Connected first, then connectable, then "coming soon"/desktop-only. + const matches = useMemo(() => { + const found = entries.filter( + (entry) => + !excludeSlugs?.has(entrySlug(entry)) && + (!needle || entryMatchesQuery(entry, needle)), + ) + return found.sort( + (a, b) => + Number(isEntryConnected(b)) - Number(isEntryConnected(a)) || + Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)), + ) + }, [entries, excludeSlugs, isEntryConnected, needle]) + + if (loadError) { + if (suppressEmpty) return null + return ( +
    + The MCP directory couldn't be loaded. Refresh to try again. +
    + ) + } + if (entries.length === 0) { + if (suppressEmpty) return null + return ( +
    + + Loading MCP directory +
    + ) + } + if (matches.length === 0) { + if (suppressEmpty) return null + return ( +
    + No integrations match “{query.trim()}”. +
    + ) + } + return ( +
    +
    + {matches.slice(0, visibleCount).map((entry) => ( + + ))} +
    + {visibleCount < matches.length ? ( + + ) : null} +
    + ) +} diff --git a/apps/web/components/slack-connect-card.tsx b/apps/web/components/slack-connect-card.tsx index 2fc6de2c..03cb8e29 100644 --- a/apps/web/components/slack-connect-card.tsx +++ b/apps/web/components/slack-connect-card.tsx @@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) { export function SlackConnectCard() { const isCompanyBrain = useHasCompanyBrain() const [status, setStatus] = useState(null) + const [trialActive, setTrialActive] = useState(true) const [loading, setLoading] = useState(true) useEffect(() => { @@ -58,10 +59,16 @@ export function SlackConnectCard() { let active = true ;(async () => { try { - const res = await fetch(`${BACKEND}/brain/slack/status`, { - credentials: "include", - }) - if (active && res.ok) setStatus((await res.json()) as SlackStatus) + const [slackRes, trialRes] = await Promise.all([ + fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }), + fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }), + ]) + if (!active) return + if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus) + if (trialRes.ok) { + const trial = (await trialRes.json()) as { active?: boolean } + setTrialActive(Boolean(trial.active)) + } } finally { if (active) setLoading(false) } @@ -92,7 +99,7 @@ export function SlackConnectCard() { Connected - ) : ( + ) : trialActive ? ( Add to Slack + ) : ( + + Finish setting up + )}
    ) diff --git a/apps/web/components/trial-setup-banner.tsx b/apps/web/components/trial-setup-banner.tsx new file mode 100644 index 00000000..b96e5c38 --- /dev/null +++ b/apps/web/components/trial-setup-banner.tsx @@ -0,0 +1,41 @@ +"use client" + +import { ArrowRight, CreditCard } from "lucide-react" +import Link from "next/link" +import { useTrialStatus } from "@/hooks/use-trial-status" + +export function TrialSetupBanner() { + const { needsSetup, data } = useTrialStatus() + if (!needsSetup) return null + + const endedTrial = data?.reason === "trial_ended" + + return ( +
    +
    + + + +
    +

    + {endedTrial + ? "Your Company Brain trial has ended" + : "Finish setting up Company Brain"} +

    +

    + {endedTrial + ? "Move to Max or Scale to switch the brain back on." + : "Add a card to start your 14-day trial. $0 today."} +

    +
    +
    + + {endedTrial ? "Upgrade" : "Add card"} + + +
    + ) +} diff --git a/apps/web/hooks/use-company-brain.ts b/apps/web/hooks/use-company-brain.ts index fb80f7be..fa2c9a2f 100644 --- a/apps/web/hooks/use-company-brain.ts +++ b/apps/web/hooks/use-company-brain.ts @@ -1,16 +1,8 @@ import { useAuth } from "@lib/auth-context" -import { - getBrainMode, - getCompanyBrainOverride, - hasCompanyBrain, -} from "@/lib/billing-utils" +import { isCompanyBrainOrg } from "@/lib/billing-utils" export function useHasCompanyBrain(): boolean { const { org } = useAuth() const metadata = org?.metadata as Record | string | undefined - // An explicit concierge override wins over the team-onboarding fallback. - const override = getCompanyBrainOverride(metadata) - if (override !== undefined) return override - // Team-brain orgs use brain spaces even before the add-on webhook lands. - return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team" + return isCompanyBrainOrg(metadata) } diff --git a/apps/web/hooks/use-connector-access.ts b/apps/web/hooks/use-connector-access.ts index 9f2599c3..d597ee1a 100644 --- a/apps/web/hooks/use-connector-access.ts +++ b/apps/web/hooks/use-connector-access.ts @@ -9,9 +9,12 @@ export function useConnectorAccess(opts?: { enabled?: boolean }) { const hasCompanyBrain = useHasCompanyBrain() const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro") const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max") + const hasScale = + enabled && hasActivePlan(autumn.data?.subscriptions, "api_scale") return { hasPro, hasMax, + hasScale, hasCompanyBrain, connectorAccess: hasPro || hasCompanyBrain, loading: enabled && autumn.isLoading, diff --git a/apps/web/hooks/use-promo-code.ts b/apps/web/hooks/use-promo-code.ts new file mode 100644 index 00000000..1bc8ca1c --- /dev/null +++ b/apps/web/hooks/use-promo-code.ts @@ -0,0 +1,82 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo } from "react" +import { toast } from "sonner" + +const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending" +const PROMO_TOAST_ID = "promo-code" + +function promoCodeKey(orgId: string): string { + return `sm.promoCode.org_${orgId}` +} + +function readOrgPromoCode(orgId?: string): string | null { + if (!orgId || typeof window === "undefined") return null + return window.localStorage.getItem(promoCodeKey(orgId)) +} + +export function usePromoCode() { + const { org } = useAuth() + const orgId = org?.id + + const getDiscounts = useCallback(() => { + const promotionCode = readOrgPromoCode(orgId) + return promotionCode ? [{ promotionCode }] : undefined + }, [orgId]) + + const clear = useCallback(() => { + if (!orgId) return + window.localStorage.removeItem(promoCodeKey(orgId)) + toast.dismiss(PROMO_TOAST_ID) + }, [orgId]) + + return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear]) +} + +export function PromoCodeCapture() { + useEffect(() => { + const url = new URL(window.location.href) + const code = url.searchParams.get("discountCode") + if (!code) return + + window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code) + url.searchParams.delete("discountCode") + window.history.replaceState({}, "", url.toString()) + }, []) + + return null +} + +export function PromoCodeHost() { + const { org } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!org?.id) return + + const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY) + if (pending) { + window.localStorage.setItem(promoCodeKey(org.id), pending) + window.localStorage.removeItem(PENDING_PROMO_CODE_KEY) + } + + const code = readOrgPromoCode(org.id) + if (!code) { + toast.dismiss(PROMO_TOAST_ID) + return + } + toast.success("Discount code active", { + id: PROMO_TOAST_ID, + description: `Code ${code} will apply at checkout.`, + duration: Number.POSITIVE_INFINITY, + action: { + label: "Upgrade", + onClick: () => router.push("/settings#billing"), + }, + }) + }, [org?.id, router]) + + return null +} diff --git a/apps/web/hooks/use-trial-status.ts b/apps/web/hooks/use-trial-status.ts new file mode 100644 index 00000000..78941199 --- /dev/null +++ b/apps/web/hooks/use-trial-status.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +export type TrialStatus = { + active: boolean + reason: string | null +} + +/** Distinguishes a named Company Brain org from one whose trial is actually live. */ +export function useTrialStatus() { + const isCompanyBrain = useHasCompanyBrain() + + const query = useQuery({ + queryKey: ["brain", "trial-status"], + queryFn: async (): Promise => { + const res = await fetch(`${BACKEND}/brain/trial/status`, { + credentials: "include", + }) + if (!res.ok) throw new Error("Failed to load trial status") + const data = (await res.json()) as { active?: boolean; reason?: string } + return { active: Boolean(data.active), reason: data.reason ?? null } + }, + enabled: isCompanyBrain, + staleTime: 30 * 1000, + }) + + return { + ...query, + needsSetup: isCompanyBrain && query.data ? !query.data.active : false, + } +} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 5ffda827..da1264f1 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -271,4 +271,9 @@ export const analytics = { }) => safeCapture("company_brain_promo_clicked", props), companyBrainPromoDismissed: () => safeCapture("company_brain_promo_dismissed"), + + brainTrialCardViewed: () => safeCapture("brain_trial_card_viewed"), + brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"), + brainTrialCheckoutAbandoned: () => + safeCapture("brain_trial_checkout_abandoned"), } diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index 3360e9c4..cf278998 100644 --- a/apps/web/lib/billing-utils.ts +++ b/apps/web/lib/billing-utils.ts @@ -112,6 +112,15 @@ export function getBrainMode( : null } +export function isCompanyBrainOrg( + metadataRaw: Record | string | null | undefined, +): boolean { + const override = getCompanyBrainOverride(metadataRaw) + if (override !== undefined) return override + if (hasCompanyBrain(metadataRaw)) return true + return getBrainMode(metadataRaw) === "team" +} + export type BrainTrialStatus = | "active" | "exhausted" @@ -185,18 +194,26 @@ export function getBrainTrialInfo( } /** - * Format a number with K/M suffix for display + * Format a number with K/M/B suffix for display * @example formatUsageNumber(1500000) => "1.5M" * @example formatUsageNumber(50000) => "50K" + * @example formatUsageNumber(999950) => "1.0M" */ export function formatUsageNumber(value: number): string { + const withSuffix = (n: number, suffix: string) => + n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}` + if (value >= 1_000_000) { const millions = value / 1_000_000 - return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M` + return millions >= 999.95 + ? withSuffix(value / 1_000_000_000, "B") + : withSuffix(millions, "M") } if (value >= 1_000) { const thousands = value / 1_000 - return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K` + return thousands >= 999.95 + ? withSuffix(value / 1_000_000, "M") + : withSuffix(thousands, "K") } return value.toString() } diff --git a/apps/web/lib/chat-stream-error.ts b/apps/web/lib/chat-stream-error.ts index 2e17135d..f31dc53b 100644 --- a/apps/web/lib/chat-stream-error.ts +++ b/apps/web/lib/chat-stream-error.ts @@ -1,9 +1,9 @@ import type { ModelId } from "@/lib/models" const OTHER_MODELS: ModelId[] = [ - "gpt-5.1", - "claude-sonnet-4.6", - "gemini-2.5-pro", + "gpt-5.6-terra", + "claude-sonnet-5", + "gemini-3.1-pro-preview", ] function flattenError(e: unknown): string { diff --git a/apps/web/lib/company-brain-entry.ts b/apps/web/lib/company-brain-entry.ts index 94ff5b33..5e0d516e 100644 --- a/apps/web/lib/company-brain-entry.ts +++ b/apps/web/lib/company-brain-entry.ts @@ -1,9 +1,4 @@ -import { - getBrainMode, - getBrainWorkspaceDomain, - getCompanyBrainOverride, - hasCompanyBrain, -} from "./billing-utils" +import { getBrainWorkspaceDomain, isCompanyBrainOrg } from "./billing-utils" export type BrainEntryOrganization = { id: string @@ -18,21 +13,12 @@ export type CompanyBrainEntryDecision = | { action: "choose"; organizations: BrainEntryOrganization[] } | { action: "create" } -export function isCompanyBrainOrganization( - organization: BrainEntryOrganization, -): boolean { - const override = getCompanyBrainOverride(organization.metadata) - if (override !== undefined) return override - return ( - hasCompanyBrain(organization.metadata) || - getBrainMode(organization.metadata) === "team" - ) -} - export function getCompanyBrainOrganizations( organizations: BrainEntryOrganization[], ): BrainEntryOrganization[] { - return organizations.filter(isCompanyBrainOrganization) + return organizations.filter((organization) => + isCompanyBrainOrg(organization.metadata), + ) } function normalizeDomain(domain: string): string { diff --git a/apps/web/lib/mcp-directory.ts b/apps/web/lib/mcp-directory.ts new file mode 100644 index 00000000..e1e3c3f5 --- /dev/null +++ b/apps/web/lib/mcp-directory.ts @@ -0,0 +1,21 @@ +export type McpDirectoryAvailability = + | "fixed" + | "tenant" + | "unavailable" + | "local" + +export type McpDirectoryEntry = { + id: string + name: string + type: "remote" | "local" + url: string | null + auth: string + note: string | null + categories: string[] + popularity: number + availability: McpDirectoryAvailability + iconDomain: string | null + setup: "custom" | "unsupported" + oauthCapability: "dcr" | "preregistered" | null + authMethods: Array<"oauth" | "api-key"> +} diff --git a/apps/web/lib/mcp-icon-domains.json b/apps/web/lib/mcp-icon-domains.json new file mode 100644 index 00000000..2989996d --- /dev/null +++ b/apps/web/lib/mcp-icon-domains.json @@ -0,0 +1,518 @@ +{ + "domains": [ + "10xgenomics.com", + "activecampaign.com", + "actively.ai", + "adisinsight-mcp.springer.com", + "adobe-creativity.adobe.io", + "adobeaemcloud.com", + "aep-ai-ama.adobe.io", + "affinity.co", + "aftership.com", + "agent.thoughtspot.app", + "agentmail.to", + "agents.riskanalytics.dnb.com", + "agenttools.wolfram.com", + "ahrefs.com", + "ai-connect.norton.com", + "ai-inc.mailchimp.com", + "ai-inc.quickbooks.intuit.com", + "ai-inc.turbotax.intuit.com", + "ai-tools.tillermoney.com", + "ai.chronograph.pe", + "ai.consilio.com", + "ai.thirdbridge.com", + "ai.todoist.net", + "ai.veltra.com", + "airbnb.com", + "airtable.com", + "ajo-mcp.adobe.io", + "alltrails.com", + "alma.food", + "alphavantage.co", + "alphaxiv.org", + "alpic.ai", + "amplitude.com", + "analytics.credit.morningstar.com", + "analytics.lseg.com", + "android.com", + "angellist.com", + "anthropic.mcp.creditkarma.com", + "api-ssl.bitly.com", + "apify.com", + "apigw.americanexpress.com", + "apollo.io", + "apollographql.com", + "app.airops.com", + "app.base44.com", + "app.brighthire.ai", + "app.carta.com", + "app.definely.com", + "app.eraser.io", + "app.files.com", + "app.flourish.studio", + "app.fyxer.com", + "app.grasp-ai.com", + "app.hanoverpark.com", + "app.ketryx.com", + "app.magicschool.ai", + "app.midpage.ai", + "app.synthesize.bio", + "app.tropicapp.io", + "app.unthread.io", + "appfolio.com", + "asana.com", + "ashbyhq.com", + "asset-management.mcp.cloudinary.com", + "atlassian.com", + "attention.tech", + "attio.com", + "audible.com", + "auraintelligence.com", + "autodesk.com", + "autorfp.ai", + "benchling.com", + "benevity.org", + "bigdata.com", + "bigquery.googleapis.com", + "bindings.mcp.cloudflare.com", + "blockscout.com", + "blueconic.com", + "boltz.bio", + "box.com", + "brandfetch.io", + "brave.com", + "braze.com", + "brevo.com", + "brex.com", + "briskteaching.com", + "calendar.google.com", + "calendly.com", + "callbacks.omniapp.co", + "canary-data.com", + "candid.org", + "canva.com", + "cargoai.co", + "cbinsights.com", + "chargebee.com", + "chartmogul.com", + "chatgpt.mermaid.ai", + "checkatrade.com", + "circleback.ai", + "civitatis-claude-app.civitatis.com", + "cja-mcp.adobe.io", + "clapi.guidepoint.io", + "clarify.ai", + "clarity-sfdr20-mcp.pro.clarity.ai", + "claude-mcp-api.ml.goodnotes.com", + "claude.mcp.kpler.com", + "claude.slidesgpt.com", + "claudecompanion.gateway.api.mcafee.com", + "clay.com", + "clerk.com", + "clickhouse.cloud", + "clickup.com", + "close.com", + "cloud.cdata.com", + "cloudimanage.com", + "cloze.com", + "cognitoforms.com", + "coindesk.com", + "columnapi.com", + "cometchat.com", + "commonroom.io", + "compute.googleapis.com", + "connect.squareup.com", + "connector.scholargateway.ai", + "consensus.app", + "contentsquare.com", + "context.era.app", + "context7.com", + "coralogix.com", + "coteach.ai", + "coupler.io", + "coursera.com", + "courtlistener.com", + "courtroom5.com", + "craft.do", + "crossbeam.com", + "crypto.com", + "customer.io", + "daloopa.com", + "dashboard.plaid.com", + "data-search.apigw.feverup.com", + "databricks.com", + "datacamp.com", + "datadoghq.com", + "datagrail.io", + "datahub.com", + "day.ai", + "deepl.com", + "demandapi-mcp.booking.com", + "descript.com", + "descrybe.com", + "developer.api.autodesk.com", + "developer.mcp.mastercard.com", + "devrev.ai", + "dhsprogram.com", + "dice.com", + "diffit.me", + "digits.com", + "directbooker.ai", + "docs.superhuman.com", + "docuseal.com", + "docusign.com", + "dovetail.com", + "dremio.com", + "drive.google.com", + "dropbox.com", + "dynatrace.com", + "econ-index.mcp.claude.com", + "elevenlabs.io", + "elicit.com", + "entendre.finance", + "eulerapp.com", + "everlaw.com", + "exa.ai", + "example-server.modelcontextprotocol.io", + "excalidraw.com", + "exp-app-mcp.prod.ep.viator.com", + "expedia.com", + "expo.dev", + "factset.com", + "fathom.ai", + "fellow.app", + "felt.com", + "fids-mcp.ice.com", + "fig-mcp.instacart.com", + "figma.com", + "financeanalytics.dnb.com", + "financialmodelingprep.com", + "fireflies.ai", + "firefox.com", + "fiscal.ai", + "fitch.group", + "floot.com", + "frontify-integrations.com", + "fullstory.com", + "funnel.io", + "g.runorion.com", + "g2.com", + "gainsight.com", + "gamma.app", + "gatewaymcp.verisk.com", + "genai-prod-ext.dominos.co.in", + "getaugust.ai", + "getguru.com", + "getmontecarlo.com", + "getunblocked.com", + "glean.com", + "global.datasite.com", + "glovoapp.com", + "gmail.com", + "gocardless.com", + "godaddy.com", + "gopigment.com", + "govcon.dev", + "govtribe.com", + "grain.com", + "granola.ai", + "grantedai.com", + "grasshopper-mcp.prd.narmitech.com", + "grounding.kensho.com", + "gusto.com", + "harmonic.ai", + "harness.io", + "harvey.ai", + "haveibeenpwned.com", + "hcls.mcp.claude.com", + "healthex.io", + "helium10.com", + "heygen.com", + "highspot.com", + "honeycomb.io", + "hrn-production.helix.com", + "hubspot.com", + "huggingface.co", + "ibisworld.com", + "ibkr.com", + "idiolect.app", + "ifttt.com", + "imedidata.com", + "incident.io", + "indeed.com", + "inductive.bio", + "inkbox.ai", + "insiderone.com", + "instrumentl.com", + "intapp.com", + "integrators.prod.api.tabsplatform.com", + "intercom.com", + "ipone.clarivate.com", + "ironcladapp.com", + "isometric.com", + "item.app", + "jam.dev", + "jentic.com", + "jotform.com", + "jupiterone.com", + "jusmundi.com", + "k.owkin.com", + "kfinance.kensho.com", + "kg.mcp.learningcommons.org", + "kindora-mcp.azurewebsites.net", + "kiwi.com", + "klaviyo.com", + "krisp.ai", + "kubernetes.io", + "lastminute.com", + "latch.bio", + "latticehq.com", + "lawve.ai", + "learn.microsoft.com", + "leaveadot.com", + "legal-mcp.thomsonreuters.com", + "legaldatahunter.com", + "legalzoom.com", + "letsbot.net", + "letsdeel.com", + "light.inc", + "lightfield.app", + "lilt.com", + "linear.app", + "listenlabs.ai", + "litmus.com", + "livestorm.co", + "localfalcon.com", + "lorikeetcx.ai", + "lovable.dev", + "lucid.app", + "luminpdf.com", + "lumonic.com", + "lunarcrush.ai", + "lusha.com", + "macaly.com", + "magicpatterns.com", + "mail.superhuman.com", + "mailerlite.com", + "make.com", + "manufact.com", + "marketplace-mcp.us-east-1.api.aws", + "matrixmcp.virtuoso.ai", + "mcp-app.turkishtechlab.com", + "mcp-demo.airwallex.com", + "mcp-gateway-external-pilot.spotify.net", + "mcp-pub.aiera.com", + "mcp-public.basecamp-research.com", + "mcp-server.egnyte.com", + "mcp-server.signnow.com", + "mcp-server.zomato.com", + "mcp-v1.tixel.com", + "mcp2.readwise.io", + "meetcampfire.com", + "melon.com", + "meltwater.com", + "mem.ai", + "mem0.ai", + "mercadolibre.com", + "mercury.com", + "metabase.com", + "metal.ai", + "metaview.ai", + "microsoft.com", + "mintlify.com", + "miro.com", + "mixpanel.com", + "monday.com", + "mongodb.com", + "moodys.com", + "morningstar.com", + "mospi.gov.in", + "motherduck.com", + "msci.com", + "mtnewswires.com", + "myisolved.com", + "n8n.io", + "netlify-mcp.netlify.app", + "netsuite.com", + "nimbleway.com", + "nlp.api.production.unwrap.ai", + "nooks.in", + "notion.com", + "omni.mulesoft.com", + "onesignal.com", + "ontra.ai", + "open-ai-app.stubhub.net", + "oreilly.com", + "otter.ai", + "ottotheagent.com", + "outreach.io", + "pagerduty.com", + "pandadoc.com", + "partner-mcp.ticketmaster.com", + "patlytics.ai", + "paypal.com", + "paytmpayments.com", + "peec.ai", + "pga.com", + "phished.io", + "phoenix.hginsights.com", + "pi.security", + "pinegap.ai", + "platform.opentargets.org", + "plaud.ai", + "playmcp.kakao.com", + "polaranalytics.com", + "pophive.org", + "posthog.com", + "postman.com", + "premium.mcp.pitchbook.com", + "privacy.com", + "process.st", + "prod.originhq.com", + "production.ai-mcp-extensibility-prd.tamg.cloud", + "projects.motionapp.com", + "pscale.dev", + "public-api.wordpress.com", + "pubmed.mcp.claude.com", + "qbo-connector.meridian.pilot.com", + "qonto.com", + "quartr.com", + "quicknode.com", + "quo.com", + "railway.com", + "rallyuxr.com", + "ramp-mcp-remote.ramp.com", + "ramp.com", + "rapid7.com", + "razorpay.com", + "react.dev", + "read.ai", + "reclaim.ai", + "reddit.com", + "relativity.com", + "remote.com", + "render.com", + "replit-mcp.com", + "resend.com", + "retool.com", + "revolut.com", + "rillet.com", + "roamresearch.com", + "roboflow.com", + "salesflare.com", + "salesloft.com", + "sanity.io", + "sap.com", + "scamguard.malwarebytes.com", + "scite.ai", + "seismic.com", + "semrush.com", + "send.co", + "sentry.dev", + "servicenow.com", + "services.biorender.com", + "services.functionhealth.com", + "services.oxfordeconomics.com", + "setup.shopify.com", + "shapes.co", + "shipbob.com", + "shippo.com", + "shutterstock.com", + "sigmacomputing.com", + "signeasy.com", + "similarweb.com", + "sketch.com", + "sketchup.com", + "slack.com", + "smartbear.com", + "smartling.com", + "smartsheet.com", + "snowflake.com", + "snowstorm-mcp.snomedtools.org", + "snyk.io", + "solveintelligence.com", + "sourcegraph.com", + "spinach.ai", + "splice.com", + "sprouts-mcp-server.kartikay-dhar.workers.dev", + "squareup.com", + "stackoverflow.com", + "staircase.ai", + "starburst.io", + "strava.com", + "stripe.com", + "stytch.dev", + "sumble.com", + "sumsub.com", + "supabase.com", + "super.com", + "supermetrics.com", + "surveymonkey.com", + "swagger.mcp.smartbear.com", + "sybill.ai", + "synapse.org", + "tableau.com", + "taskrabbit.com", + "tavily.com", + "taxact.com", + "teacher-tools.eedi.ai", + "teamtailor.com", + "techgc.co", + "tellme.embat.io", + "thumbtack.com", + "tickettailor.ai", + "ticktick.com", + "tigerdata.com", + "tines.com", + "tldraw-mcp-app.tldraw.workers.dev", + "tldv.io", + "tomtom.com", + "tray.io", + "trellis.law", + "trello.com", + "trivago.com", + "tryprofound.com", + "turquoise.health", + "twilio.com", + "uakozrqrztgrgwoywxkx.supabase.co", + "uber.com", + "ubereats.com", + "udemy.com", + "unsplash.com", + "use.kick.co", + "usepylon.com", + "v0.app", + "vast.blueskyapi.com", + "vendr.com", + "vercel.com", + "vibe.com", + "virtuoso.ai", + "voluum.com", + "webexapis.com", + "webflow.com", + "webull.com", + "whimsical.com", + "windsor.ai", + "wisdom-api.enterpret.com", + "wisprflow.ai", + "within.ai", + "wix.com", + "workable.com", + "workato.com", + "workfront.adobe.com", + "workos.com", + "wrike.com", + "wyndhamhotels.com", + "xactrestore-xactremodelserver-usw2-prod.propsol.io", + "xero.com", + "xweather.com", + "zapier.com", + "ziprecruiter.com", + "zocks.io", + "zoho.com", + "zoom.us", + "zoominfo.com", + "zscaler.com" + ] +} diff --git a/apps/web/lib/models.tsx b/apps/web/lib/models.tsx index 5d3479ef..fe0e7584 100644 --- a/apps/web/lib/models.tsx +++ b/apps/web/lib/models.tsx @@ -1,22 +1,22 @@ export const models = [ { - id: "grok-4.3", - name: "Grok 4.3", + id: "grok-4.5", + name: "Grok 4.5", description: "xAI's latest model", }, { - id: "gpt-5.1", - name: "GPT 5.1", + id: "gpt-5.6-terra", + name: "GPT 5.6", description: "OpenAI's latest model", }, { - id: "claude-sonnet-4.6", - name: "Claude Sonnet 4.6", + id: "claude-sonnet-5", + name: "Claude Sonnet 5", description: "Anthropic's advanced model", }, { - id: "gemini-2.5-pro", - name: "Gemini 3 Pro", + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro", description: "Google's most capable model", }, ] as const @@ -25,10 +25,10 @@ export type ModelId = (typeof models)[number]["id"] export type ReasoningEffort = "instant" | "thinking" export const modelNames: Record = { - "grok-4.3": { name: "Grok", version: "4.3" }, - "gpt-5.1": { name: "GPT", version: "5.1" }, - "claude-sonnet-4.6": { name: "Claude", version: "4.6" }, - "gemini-2.5-pro": { name: "Gemini", version: "3 Pro" }, + "grok-4.5": { name: "Grok", version: "4.5" }, + "gpt-5.6-terra": { name: "GPT", version: "5.6" }, + "claude-sonnet-5": { name: "Claude", version: "Sonnet 5" }, + "gemini-3.1-pro-preview": { name: "Gemini", version: "3.1 Pro" }, } export const reasoningOptions: Array<{ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 8febfc81..89587227 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -41,13 +41,14 @@ export default async function proxy(request: Request) { return NextResponse.next() } - // MCP setup page is public — no auth required - if (url.searchParams.get("view") === "mcp") { - return NextResponse.next() - } - - // Integrations index is public in guest mode; actions still require login. - if (url.pathname === "/" && url.searchParams.get("view") === "integrations") { + // Integrations index and MCP setup are public in guest mode; actions still + // require login. The ?view param is only meaningful at "/" (see + // lib/view-mode-context, which ignores it elsewhere), so scope it there — + // unscoped, ?view=mcp would let any path skip the /api/ gate below. + if ( + url.pathname === "/" && + ["integrations", "mcp"].includes(url.searchParams.get("view") ?? "") + ) { return NextResponse.next() } diff --git a/apps/web/package.json b/apps/web/package.json index cddbcd0a..63c4acee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "dev": "portless", "dev:app": "next dev --port ${PORT:-3000}", "build": "next build", + "check-types": "tsc --noEmit", "start": "next start", "lint": "biome check --write", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 63a7d0e5..780a171f 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,5 +1,8 @@ { "name": "@repo/hooks", "version": "0.0.0", - "private": true + "private": true, + "scripts": { + "check-types": "tsc --noEmit" + } } diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index ae44e6bd..af0e989b 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -3,6 +3,7 @@ import { anonymousClient, apiKeyClient, emailOTPClient, + genericOAuthClient, magicLinkClient, organizationClient, usernameClient, @@ -19,6 +20,7 @@ export const authClient = createAuthClient({ usernameClient(), magicLinkClient(), emailOTPClient(), + genericOAuthClient(), apiKeyClient(), adminClient(), organizationClient(), diff --git a/packages/lib/package.json b/packages/lib/package.json index 99b9d262..c83e27db 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + }, "exports": { "./*": "./*" }, diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts index 4f728f97..f98df074 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => { expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring) }) + it("does not treat an already-elapsed forgetAfter as expiring", () => { + const past = new Date(Date.now() - 60 * 1000).toISOString() + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ forgetAfter: past, createdAt: old }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault) + }) + it("returns recent color for memories created within 24 hours", () => { const recent = new Date(Date.now() - 1000).toISOString() const mem = makeMemory({ createdAt: recent }) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 9d0c2ef6..d165ac71 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -467,10 +467,10 @@ export function MemoryGraph({ n.x, n.y, containerSize.width, - containerSize.height, + graphFitHeight, ) }, - [nodes, containerSize.width, containerSize.height], + [nodes, containerSize.width, graphFitHeight], ) const navigateUp = useCallback(() => { diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 00a05d10..aed2d6f0 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -47,7 +47,7 @@ export function getMemoryBorderColor( if (mem.isForgotten) return colors.memBorderForgotten if (mem.forgetAfter) { const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() - if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring + if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring } const age = Date.now() - new Date(mem.createdAt).getTime() if (age < ONE_DAY_MS) return colors.memBorderRecent diff --git a/packages/openai-sdk-python/README.md b/packages/openai-sdk-python/README.md index 7e5b68aa..9b455b3b 100644 --- a/packages/openai-sdk-python/README.md +++ b/packages/openai-sdk-python/README.md @@ -229,6 +229,17 @@ openai_with_memory = with_supermemory( ## Manual Memory Tools +`SupermemoryTools` exposes seven OpenAI function-calling tools: + +- `search_memories` and `add_memory` +- `get_profile` +- `document_list`, `document_add`, and `document_delete` +- `memory_forget` + +The configured `project_id` or `container_tags` define the trusted scope. The +primary tag is used for profile, list, search, and forget operations, and the +model cannot select a different tag. + ### SupermemoryTools Class ```python @@ -245,8 +256,7 @@ tools = SupermemoryTools( # Search memories result = await tools.search_memories( information_to_get="user preferences", - limit=10, - include_full_docs=True + limit=10 ) # Add memory @@ -254,24 +264,48 @@ result = await tools.add_memory( memory="User prefers tea over coffee" ) -# Fetch specific memory -result = await tools.fetch_memory( - memory_id="memory-id-here" +# Get the configured user's profile +result = await tools.get_profile(query="favorite drinks") + +# List, add, or delete source documents +documents = await tools.document_list(limit=10, page=1) +document = await tools.document_add( + content="Meeting notes...", + title="Weekly meeting" +) +deleted = await tools.document_delete(document_id="document-id-here") + +# Soft-forget one extracted memory +forgotten = await tools.memory_forget( + memory_id="memory-entry-id-here", + reason="outdated" ) ``` +`include_full_docs` is retained as a deprecated Python argument for compatibility, +but v4 search returns relevant memories and chunks instead of full source documents. +It is no longer exposed in the OpenAI tool schema. + ### Individual Tools ```python from supermemory_openai import ( create_search_memories_tool, create_add_memory_tool, - create_fetch_memory_tool + create_get_profile_tool, + create_document_list_tool, + create_document_delete_tool, + create_document_add_tool, + create_memory_forget_tool, ) search_tool = create_search_memories_tool("your-api-key") add_tool = create_add_memory_tool("your-api-key") -fetch_tool = create_fetch_memory_tool("your-api-key") +profile_tool = create_get_profile_tool("your-api-key") +list_tool = create_document_list_tool("your-api-key") +delete_tool = create_document_delete_tool("your-api-key") +document_add_tool = create_document_add_tool("your-api-key") +forget_tool = create_memory_forget_tool("your-api-key") ``` ### Function Calling Integration @@ -343,6 +377,11 @@ SupermemoryTools( - `get_tool_definitions()` - Get OpenAI function definitions - `search_memories()` - Search user memories - `add_memory()` - Add new memory +- `get_profile()` - Get the configured user's profile +- `document_list()` - List source document metadata +- `document_add()` - Queue a source document for processing +- `document_delete()` - Delete an in-scope source document +- `memory_forget()` - Soft-forget one extracted memory - `execute_tool_call()` - Execute individual tool call ## Error Handling @@ -408,7 +447,7 @@ Optional for testing: ### Required - `openai>=1.102.0` - Official OpenAI Python SDK -- `supermemory>=3.1.0` - Supermemory client +- `supermemory>=3.50.0` - Supermemory client - `requests>=2.25.0` - HTTP requests (fallback) ### Optional diff --git a/packages/openai-sdk-python/pyproject.toml b/packages/openai-sdk-python/pyproject.toml index 3aa3d9d7..1d11cf85 100644 --- a/packages/openai-sdk-python/pyproject.toml +++ b/packages/openai-sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "supermemory-openai-sdk" -version = "1.0.6" +version = "1.0.7" description = "Memory tools for OpenAI function calling with supermemory" readme = "README.md" license = "MIT" @@ -25,7 +25,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "openai>=1.102.0", - "supermemory>=3.16.0", + "supermemory>=3.50.0", "typing-extensions>=4.0.0", "requests>=2.25.0", ] diff --git a/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py b/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py deleted file mode 100644 index 4afc4899..00000000 --- a/packages/openai-sdk-python/src/supermemory_openai/forget_memory.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Forget memory via DELETE /v4/memories (not exposed on supermemory SDK v3).""" - -from typing import Optional - -DEFAULT_BASE_URL = "https://api.supermemory.ai" - - -async def forget_memory_request( - api_key: str, - container_tag: str, - memory_id: Optional[str] = None, - memory_content: Optional[str] = None, - reason: Optional[str] = None, - base_url: str = DEFAULT_BASE_URL, -) -> None: - """Mark a memory as forgotten via the v4 memories endpoint.""" - payload: dict[str, str] = {"containerTag": container_tag} - if memory_id: - payload["id"] = memory_id - if memory_content: - payload["content"] = memory_content - if reason: - payload["reason"] = reason - - try: - import aiohttp - - async with aiohttp.ClientSession() as session: - async with session.delete( - f"{base_url}/v4/memories", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - json=payload, - ) as response: - if not response.ok: - error_text = await response.text() - raise RuntimeError( - f"Supermemory forget memory failed: {response.status} " - f"{response.reason}. {error_text}" - ) - except ImportError: - import requests - - response = requests.delete( - f"{base_url}/v4/memories", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - json=payload, - timeout=30, - ) - if not response.ok: - raise RuntimeError( - f"Supermemory forget memory failed: {response.status_code} " - f"{response.reason}. {response.text}" - ) diff --git a/packages/openai-sdk-python/src/supermemory_openai/middleware.py b/packages/openai-sdk-python/src/supermemory_openai/middleware.py index 1a079fa2..9cbad1b0 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/middleware.py +++ b/packages/openai-sdk-python/src/supermemory_openai/middleware.py @@ -222,15 +222,15 @@ async def add_memory_tool( ) -> None: """Add a new memory to the SuperMemory system.""" try: - add_params = { - "content": content, - "container_tag": container_tag, - } - if custom_id is not None: - add_params["custom_id"] = custom_id - # Handle both sync and async supermemory clients - result = client.add(**add_params) + if custom_id is None: + result = client.add(content=content, container_tag=container_tag) + else: + result = client.add( + content=content, + container_tag=container_tag, + custom_id=custom_id, + ) if inspect.isawaitable(result): response = await result else: diff --git a/packages/openai-sdk-python/src/supermemory_openai/tools.py b/packages/openai-sdk-python/src/supermemory_openai/tools.py index cdbd2ef7..bad75e8a 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/tools.py +++ b/packages/openai-sdk-python/src/supermemory_openai/tools.py @@ -1,7 +1,8 @@ """Supermemory tools for OpenAI function calling.""" import json -from typing import Any, Dict, List, Optional, TypedDict, Union +import warnings +from typing import Any, Dict, List, Optional, TypedDict import supermemory from openai.types.chat import ( @@ -10,28 +11,17 @@ from openai.types.chat import ( ChatCompletionToolMessageParam, ChatCompletionToolParam, ) -from supermemory.types import ( - AddResponse, - DocumentGetResponse, - SearchMemoriesResponse, -) -from supermemory.types.search_memories_response import Result +from openai.types.shared_params import FunctionDefinition +from supermemory.types import AddResponse, SearchMemoriesResponse -from .exceptions import ( - SupermemoryConfigurationError, - SupermemoryMemoryOperationError, - SupermemoryNetworkError, -) -from .forget_memory import DEFAULT_BASE_URL, forget_memory_request +from .exceptions import SupermemoryConfigurationError TOOL_DESCRIPTIONS = { "search_memories": ( - "Search (recall) stored memories for facts, preferences, history, and context about the user " - "or any topic. Use proactively before answering whenever memory could help — do not wait for " - "the user to explicitly ask you to search or recall. Search when the question touches personal " - "context, past conversations, preferences, projects, people, plans, or anything you may have " - "learned before. Results include memory/chunk IDs — use those IDs with memory_forget to remove " - "a specific learned fact." + "Search stored memories and source chunks for relevant facts, preferences, " + "history, and context. Use proactively whenever prior context could help. " + "Hybrid results may contain either a memory or a source chunk; only an ID " + "from a result containing a memory can be passed to memory_forget." ), "add_memory": ( "Add (remember) memories/details/information about the user or other facts or entities. " @@ -40,19 +30,19 @@ TOOL_DESCRIPTIONS = { ), "get_profile": ( "Get user profile containing static memories (permanent facts) and dynamic memories " - "(recent context). Optionally include search results by providing a query. " - "Profile and search result entries may include memory IDs useful for memory_forget." + "(recent context). Optionally include query-relevant search results. Static and dynamic " + "profile entries are text; only memory entries in search results have forgettable IDs." ), "document_list": ( "List stored source documents (conversations, URLs, files, pasted text) with pagination. " - "Returns document IDs for document_delete — not memory IDs for memory_forget. " - "Use to browse raw stored content before permanently removing a source." + "Returns document metadata and summaries, including IDs for document_delete. " + "It does not return full source content or memory IDs." ), "document_delete": ( - "Permanently delete a stored document and ALL memories extracted from it (hard delete). " - "Use document IDs from document_list. Use when the user wants to remove an entire " - "conversation, file, URL, or other source — not when correcting a single learned fact " - "(use memory_forget for that)." + "Permanently delete a stored source document and soft-forget memories extracted from it. " + "Use a document ID from document_list when the user wants to remove an entire source. " + "Deletion is refused for documents outside the configured scope, shared with another " + "scope, or still processing. Use memory_forget to remove one learned fact." ), "document_add": ( "Store a source document for asynchronous processing and automatic memory extraction. " @@ -67,10 +57,9 @@ TOOL_DESCRIPTIONS = { ), "memory_forget": ( "Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in " - "profile or search. Does NOT delete source documents. Provide memory_id (preferred — from " - "search_memories or get_profile) OR memory_content for an exact text match. Use when the " - "user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). " - "To remove an entire conversation or file, use document_delete instead." + "profile or search. This does not delete source documents. Provide a memory_id from a " + "search result containing a memory, or memory_content for an exact text match. Use " + "document_delete to remove an entire source." ), } @@ -79,19 +68,14 @@ PARAMETER_DESCRIPTIONS = { "What to look up in memory — keywords from the user's message, topic, entity names, or " "question phrasing. Search even when the user did not explicitly ask you to recall." ), - "include_full_docs": ( - "Whether to include the full document content in the response. " - "Defaults to true for better AI context." - ), "limit": "Maximum number of results to return", "memory": ( "The text content of the memory to add. This should be a single sentence or a short paragraph." ), - "container_tag": "Tag to filter/scope the operation (e.g., user ID, project ID)", "query": "Optional search query to include relevant search results", "page": "Page number to fetch, 1-based (default: 1)", "document_id": ( - "Document ID from document_list — permanently deletes the source document and all " + "Document ID from document_list. Permanently deletes the source and soft-forgets its " "extracted memories. Not a profile memory ID." ), "content": ( @@ -102,8 +86,8 @@ PARAMETER_DESCRIPTIONS = { "title": "Optional title for the document", "description": "Optional description for the document", "memory_id": ( - "Profile memory ID from search_memories or get_profile — soft-deletes one learned fact. " - "Not a document ID." + "Memory entry ID from a search_memories result containing a memory. Chunk and document " + "IDs are invalid." ), "memory_content": ( "Exact text of the profile memory to forget (alternative to memory_id). Must match " @@ -114,7 +98,6 @@ PARAMETER_DESCRIPTIONS = { DEFAULT_LIMIT = 10 DEFAULT_CHUNK_THRESHOLD = 0.6 -DEFAULT_INCLUDE_FULL_DOCS = True ALL_TOOL_NAMES = ( "search_memories", @@ -131,6 +114,8 @@ class SupermemoryToolsConfig(TypedDict, total=False): """Configuration for Supermemory tools. Only one of `project_id` or `container_tags` can be provided. + The first container tag is used for single-space operations. All configured + tags are applied to additions and define the allowed document-delete scope. """ base_url: Optional[str] @@ -138,15 +123,15 @@ class SupermemoryToolsConfig(TypedDict, total=False): project_id: Optional[str] -# Type aliases using inferred types from supermemory package -MemoryObject = Union[DocumentGetResponse, AddResponse] +# Type alias retained for compatibility with earlier releases. +MemoryObject = AddResponse class MemorySearchResult(TypedDict, total=False): """Result type for memory search operations.""" success: bool - results: Optional[List[Result]] + results: Optional[List[Dict[str, object]]] count: Optional[int] error: Optional[str] @@ -163,8 +148,8 @@ class ProfileResult(TypedDict, total=False): """Result type for profile operations.""" success: bool - profile: Optional[Dict[str, Any]] - search_results: Optional[Any] + profile: Optional[Dict[str, object]] + search_results: Optional[Dict[str, object]] error: Optional[str] @@ -172,8 +157,8 @@ class DocumentListResult(TypedDict, total=False): """Result type for document list operations.""" success: bool - documents: Optional[List[Any]] - pagination: Optional[Any] + documents: Optional[List[Dict[str, object]]] + pagination: Optional[Dict[str, object]] error: Optional[str] @@ -202,7 +187,7 @@ class MemoryForgetResult(TypedDict, total=False): # Function schemas for OpenAI function calling -MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { +MEMORY_TOOL_SCHEMAS: Dict[str, FunctionDefinition] = { "search_memories": { "name": "search_memories", "description": TOOL_DESCRIPTIONS["search_memories"], @@ -213,18 +198,16 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { "type": "string", "description": PARAMETER_DESCRIPTIONS["information_to_get"], }, - "include_full_docs": { - "type": "boolean", - "description": PARAMETER_DESCRIPTIONS["include_full_docs"], - "default": DEFAULT_INCLUDE_FULL_DOCS, - }, "limit": { - "type": "number", + "type": "integer", "description": PARAMETER_DESCRIPTIONS["limit"], "default": DEFAULT_LIMIT, + "minimum": 1, + "maximum": 100, }, }, "required": ["information_to_get"], + "additionalProperties": False, }, }, "add_memory": { @@ -239,6 +222,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { }, }, "required": ["memory"], + "additionalProperties": False, }, }, "get_profile": { @@ -247,16 +231,13 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { "parameters": { "type": "object", "properties": { - "container_tag": { - "type": "string", - "description": PARAMETER_DESCRIPTIONS["container_tag"], - }, "query": { "type": "string", "description": PARAMETER_DESCRIPTIONS["query"], }, }, "required": [], + "additionalProperties": False, }, }, "document_list": { @@ -265,21 +246,22 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { "parameters": { "type": "object", "properties": { - "container_tag": { - "type": "string", - "description": PARAMETER_DESCRIPTIONS["container_tag"], - }, "limit": { - "type": "number", + "type": "integer", "description": PARAMETER_DESCRIPTIONS["limit"], "default": DEFAULT_LIMIT, + "minimum": 1, + "maximum": 1100, }, "page": { - "type": "number", + "type": "integer", "description": PARAMETER_DESCRIPTIONS["page"], + "default": 1, + "minimum": 1, }, }, "required": [], + "additionalProperties": False, }, }, "document_delete": { @@ -294,6 +276,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { }, }, "required": ["document_id"], + "additionalProperties": False, }, }, "document_add": { @@ -316,6 +299,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { }, }, "required": ["content"], + "additionalProperties": False, }, }, "memory_forget": { @@ -324,10 +308,6 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { "parameters": { "type": "object", "properties": { - "container_tag": { - "type": "string", - "description": PARAMETER_DESCRIPTIONS["container_tag"], - }, "memory_id": { "type": "string", "description": PARAMETER_DESCRIPTIONS["memory_id"], @@ -342,20 +322,28 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { }, }, "required": [], + "additionalProperties": False, }, }, } def _resolve_container_tags(config: SupermemoryToolsConfig) -> List[str]: - if config.get("project_id") is not None and config.get("container_tags") is not None: + project_id = config.get("project_id") + configured_tags = config.get("container_tags") + + if project_id is not None and configured_tags is not None: raise SupermemoryConfigurationError( "Supermemory tools config accepts either project_id or container_tags, not both." ) - if config.get("project_id"): - return [f"sm_project_{config['project_id']}"] - if config.get("container_tags"): - return config["container_tags"] + if project_id: + return [f"sm_project_{project_id}"] + if configured_tags is not None: + if not configured_tags or any(not tag for tag in configured_tags): + raise SupermemoryConfigurationError( + "container_tags must contain at least one non-empty tag." + ) + return list(configured_tags) return ["sm_project_default"] @@ -363,8 +351,25 @@ def _tool_definition(name: str) -> ChatCompletionToolParam: return {"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} +def _model_to_dict(value: Any) -> Dict[str, object]: + """Normalize generated SDK models and already-plain response values.""" + if isinstance(value, dict): + return dict(value) + + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + + raise TypeError(f"Unsupported SDK response type: {type(value).__name__}") + + def _all_tool_definitions() -> List[ChatCompletionFunctionToolParam]: - return [{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} for name in ALL_TOOL_NAMES] + return [ + {"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} + for name in ALL_TOOL_NAMES + ] class SupermemoryTools: @@ -378,18 +383,18 @@ class SupermemoryTools: config: Optional configuration """ config = config or {} - self.api_key = api_key - self.base_url = config.get("base_url") or DEFAULT_BASE_URL - - client_kwargs = {"api_key": api_key} - if config.get("base_url"): - client_kwargs["base_url"] = config["base_url"] - - self.client = supermemory.AsyncSupermemory(**client_kwargs) + base_url = config.get("base_url") + if base_url: + self.client = supermemory.AsyncSupermemory( + api_key=api_key, + base_url=base_url, + ) + else: + self.client = supermemory.AsyncSupermemory(api_key=api_key) self.container_tags = _resolve_container_tags(config) - def _primary_container_tag(self, container_tag: Optional[str] = None) -> str: - return container_tag or self.container_tags[0] + def _primary_container_tag(self) -> str: + return self.container_tags[0] def get_tool_definitions(self) -> List[ChatCompletionFunctionToolParam]: """Get OpenAI function definitions for all memory tools.""" @@ -398,9 +403,7 @@ class SupermemoryTools: async def execute_tool_call(self, tool_call: ChatCompletionMessageToolCall) -> str: """Execute a tool call based on the function name and arguments.""" function_name = tool_call.function.name - args = json.loads(tool_call.function.arguments) - - handlers = { + handlers: Dict[str, Any] = { "search_memories": self.search_memories, "add_memory": self.add_memory, "get_profile": self.get_profile, @@ -417,6 +420,24 @@ class SupermemoryTools: "error": f"Unknown function: {function_name}", } else: + try: + args = json.loads(tool_call.function.arguments) + except (json.JSONDecodeError, TypeError): + return json.dumps({"success": False, "error": "Invalid tool arguments"}) + + if not isinstance(args, dict): + return json.dumps({"success": False, "error": "Invalid tool arguments"}) + + parameters = MEMORY_TOOL_SCHEMAS[function_name]["parameters"] or {} + properties = parameters.get("properties", {}) + required = parameters.get("required", []) + if not isinstance(properties, dict) or not isinstance(required, list): + return json.dumps({"success": False, "error": "Invalid tool arguments"}) + + required_names = {name for name in required if isinstance(name, str)} + if set(args) - set(properties) or required_names - set(args): + return json.dumps({"success": False, "error": "Invalid tool arguments"}) + result = await handler(**args) return json.dumps(result) @@ -424,14 +445,26 @@ class SupermemoryTools: async def search_memories( self, information_to_get: str, - include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS, + include_full_docs: Optional[bool] = None, limit: int = DEFAULT_LIMIT, ) -> MemorySearchResult: - """Search memories.""" + """Search memories. + + ``include_full_docs`` remains a deprecated Python-only argument for + source compatibility. V4 search cannot return full source documents. + """ + if include_full_docs is not None: + warnings.warn( + "include_full_docs is deprecated and ignored because v4 search " + "does not return full source documents", + DeprecationWarning, + stacklevel=2, + ) + try: response: SearchMemoriesResponse = await self.client.search.memories( q=information_to_get, - container_tags=self.container_tags, + container_tag=self._primary_container_tag(), limit=limit, threshold=DEFAULT_CHUNK_THRESHOLD, search_mode="hybrid", @@ -479,27 +512,28 @@ class SupermemoryTools: async def get_profile( self, - container_tag: Optional[str] = None, query: Optional[str] = None, ) -> ProfileResult: """Get user profile with optional query-scoped search results.""" try: - kwargs: Dict[str, Any] = { - "container_tag": self._primary_container_tag(container_tag), - } if query: - kwargs["q"] = query - - response = await self.client.profile(**kwargs) - profile = response.profile if hasattr(response, "profile") else None - search_results = ( - response.search_results if hasattr(response, "search_results") else None - ) + response = await self.client.profile( + container_tag=self._primary_container_tag(), + q=query, + ) + else: + response = await self.client.profile( + container_tag=self._primary_container_tag(), + ) return ProfileResult( success=True, - profile=profile if isinstance(profile, dict) else None, - search_results=search_results, + profile=_model_to_dict(response.profile), + search_results=( + _model_to_dict(response.search_results) + if response.search_results is not None + else None + ), ) except (OSError, ConnectionError) as network_error: return ProfileResult( @@ -514,27 +548,24 @@ class SupermemoryTools: async def document_list( self, - container_tag: Optional[str] = None, limit: Optional[int] = None, page: Optional[int] = None, ) -> DocumentListResult: """List stored documents.""" try: kwargs: Dict[str, Any] = { - "container_tags": [self._primary_container_tag(container_tag)], - "limit": limit or DEFAULT_LIMIT, + "container_tags": [self._primary_container_tag()], + "limit": DEFAULT_LIMIT if limit is None else limit, } if page is not None: kwargs["page"] = page response = await self.client.documents.list(**kwargs) - documents = response.memories if hasattr(response, "memories") else [] - pagination = response.pagination if hasattr(response, "pagination") else None return DocumentListResult( success=True, - documents=documents, - pagination=pagination, + documents=[_model_to_dict(document) for document in response.memories], + pagination=_model_to_dict(response.pagination), ) except (OSError, ConnectionError) as network_error: return DocumentListResult( @@ -550,7 +581,19 @@ class SupermemoryTools: async def document_delete(self, document_id: str) -> DocumentDeleteResult: """Delete a document by ID.""" try: - await self.client.documents.delete(document_id) + # The delete endpoint has no container-tag argument. Resolve custom IDs + # first and refuse documents whose complete tag set is not configured. + document = await self.client.documents.get(document_id) + document_tags = set(document.container_tags or []) + configured_tags = set(self.container_tags) + + if not document_tags or not document_tags.issubset(configured_tags): + return DocumentDeleteResult( + success=False, + error="Document is outside configured scope", + ) + + await self.client.documents.delete(document.id) return DocumentDeleteResult( success=True, message=f"Document {document_id} deleted successfully", @@ -605,7 +648,6 @@ class SupermemoryTools: async def memory_forget( self, - container_tag: Optional[str] = None, memory_id: Optional[str] = None, memory_content: Optional[str] = None, reason: Optional[str] = None, @@ -618,14 +660,17 @@ class SupermemoryTools: ) try: - await forget_memory_request( - api_key=self.api_key, - container_tag=self._primary_container_tag(container_tag), - memory_id=memory_id, - memory_content=memory_content, - reason=reason, - base_url=self.base_url, - ) + kwargs: Dict[str, Any] = { + "container_tag": self._primary_container_tag(), + } + if memory_id: + kwargs["id"] = memory_id + if memory_content: + kwargs["content"] = memory_content + if reason: + kwargs["reason"] = reason + + await self.client.memories.forget(**kwargs) return MemoryForgetResult( success=True, message="Memory forgotten successfully", @@ -691,7 +736,7 @@ class SearchMemoriesTool: async def execute( self, information_to_get: str, - include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS, + include_full_docs: Optional[bool] = None, limit: int = DEFAULT_LIMIT, ) -> MemorySearchResult: """Execute search memories.""" @@ -723,11 +768,10 @@ class GetProfileTool: async def execute( self, - container_tag: Optional[str] = None, query: Optional[str] = None, ) -> ProfileResult: """Execute get profile.""" - return await self.tools.get_profile(container_tag=container_tag, query=query) + return await self.tools.get_profile(query=query) class DocumentListTool: @@ -739,13 +783,11 @@ class DocumentListTool: async def execute( self, - container_tag: Optional[str] = None, limit: Optional[int] = None, page: Optional[int] = None, ) -> DocumentListResult: """Execute document list.""" return await self.tools.document_list( - container_tag=container_tag, limit=limit, page=page, ) @@ -793,14 +835,12 @@ class MemoryForgetTool: async def execute( self, - container_tag: Optional[str] = None, memory_id: Optional[str] = None, memory_content: Optional[str] = None, reason: Optional[str] = None, ) -> MemoryForgetResult: """Execute memory forget.""" return await self.tools.memory_forget( - container_tag=container_tag, memory_id=memory_id, memory_content=memory_content, reason=reason, diff --git a/packages/openai-sdk-python/tests/test_tools.py b/packages/openai-sdk-python/tests/test_tools.py index 3a5e34f9..4680a9a2 100644 --- a/packages/openai-sdk-python/tests/test_tools.py +++ b/packages/openai-sdk-python/tests/test_tools.py @@ -153,6 +153,10 @@ class TestToolDefinitions: assert search_tool is not None assert search_tool["type"] == "function" assert "information_to_get" in search_tool["function"]["parameters"]["required"] + assert ( + "include_full_docs" + not in search_tool["function"]["parameters"]["properties"] + ) # Check addMemory add_tool = next( @@ -200,25 +204,32 @@ class TestMemoryOperationsUnit: @pytest.mark.asyncio async def test_search_memories_uses_search_memories_hybrid(self): - """search_memories must call client.search.memories with hybrid mode.""" + """V4 search must use the primary singular tag and hybrid mode.""" from types import SimpleNamespace from unittest.mock import AsyncMock - tools = SupermemoryTools("test-key", {"container_tags": ["unit-tag"]}) + tools = SupermemoryTools( + "test-key", {"container_tags": ["primary-tag", "secondary-tag"]} + ) tools.client.search.memories = AsyncMock( return_value=SimpleNamespace( results=[SimpleNamespace(model_dump=lambda: {"memory": "likes tea"})] ) ) - result = await tools.search_memories("tea", limit=3) + with pytest.warns(DeprecationWarning, match="include_full_docs"): + result = await tools.search_memories( + "tea", include_full_docs=False, limit=3 + ) assert result["success"] is True assert result["count"] == 1 tools.client.search.memories.assert_awaited_once() kwargs = tools.client.search.memories.await_args.kwargs assert kwargs["q"] == "tea" - assert kwargs["container_tags"] == ["unit-tag"] + assert kwargs["container_tag"] == "primary-tag" + assert "container_tags" not in kwargs + assert "include_full_docs" not in kwargs assert kwargs["limit"] == 3 assert kwargs["search_mode"] == "hybrid" diff --git a/packages/openai-sdk-python/uv.lock b/packages/openai-sdk-python/uv.lock index 72db4dd2..05fcba22 100644 --- a/packages/openai-sdk-python/uv.lock +++ b/packages/openai-sdk-python/uv.lock @@ -377,7 +377,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -392,7 +392,7 @@ resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ @@ -422,7 +422,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1355,7 +1355,7 @@ wheels = [ [[package]] name = "supermemory" -version = "3.56.0" +version = "3.59.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1365,14 +1365,14 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/43/3a7619a697554555d37254bd1e63ff2bf1262bae8ab569eba57f823af3e4/supermemory-3.56.0.tar.gz", hash = "sha256:3cceb35465e79762c2213a56d2d17b38c554924ba19fd29e24ce09701f4b377d", size = 175386, upload-time = "2026-07-24T16:29:16.695Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/83/7db28873de639f4c4ac469266a2142d1f9def545a0cac2a023011df23601/supermemory-3.59.0.tar.gz", hash = "sha256:5efd5a5a087d552b0e00e739e4eeac21379d087bdfaa1c9f5995d11272b35bb3", size = 154326, upload-time = "2026-08-14T21:28:39.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/2a/0d8ac89c73f540caa38ced4df3b824abae11481243c60f6ef750103c973d/supermemory-3.56.0-py3-none-any.whl", hash = "sha256:334e5cd1a8743ed9b90aa25ad0e63266f6376b4d44d5bdfc5cc4f15fc19d72f9", size = 156297, upload-time = "2026-07-24T16:29:15.491Z" }, + { url = "https://files.pythonhosted.org/packages/af/73/0cf59b31317baf66b0bb9d1fa6506a54013e49fb35ffc9c12c3f3a189c5c/supermemory-3.59.0-py3-none-any.whl", hash = "sha256:3c66ae0fcb082241d8a600b4f8aaebe68159aee7cd24c2ce1aae533ea404bb2d", size = 142020, upload-time = "2026-08-14T21:28:38.842Z" }, ] [[package]] name = "supermemory-openai-sdk" -version = "1.0.6" +version = "1.0.7" source = { editable = "." } dependencies = [ { name = "openai" }, @@ -1402,7 +1402,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'async'", specifier = ">=3.8.0" }, { name = "openai", specifier = ">=1.102.0" }, { name = "requests", specifier = ">=2.25.0" }, - { name = "supermemory", specifier = ">=3.16.0" }, + { name = "supermemory", specifier = ">=3.50.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, ] provides-extras = ["async"] diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts index 50a3a529..8691c92a 100644 --- a/packages/tools/src/shared/forget-memory.ts +++ b/packages/tools/src/shared/forget-memory.ts @@ -1,4 +1,5 @@ const DEFAULT_BASE_URL = "https://api.supermemory.ai" +const FETCH_TIMEOUT_MS = 30_000 export interface ForgetMemoryParams { containerTag: string @@ -7,6 +8,10 @@ export interface ForgetMemoryParams { reason?: string } +export interface ForgetMemoryRequestOptions { + signal?: AbortSignal +} + /** * Marks a memory as forgotten via `DELETE /v4/memories`. * @@ -19,6 +24,7 @@ export async function forgetMemoryRequest( apiKey: string, params: ForgetMemoryParams, baseUrl: string = DEFAULT_BASE_URL, + options?: ForgetMemoryRequestOptions, ): Promise { const response = await fetch(`${baseUrl}/v4/memories`, { method: "DELETE", @@ -27,6 +33,7 @@ export async function forgetMemoryRequest( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(params), + signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 69f12594..136a19be 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -109,6 +109,22 @@ describe("memoryForget", () => { id: "mem_1", reason: "outdated", }) + expect(init.signal).toBeInstanceOf(AbortSignal) + }) + + it("uses a caller-provided signal instead of creating a timeout", async () => { + const fetchMock = stubFetch() + const controller = new AbortController() + + await forgetMemoryRequest( + API_KEY, + { containerTag: "user_1", id: "mem_1" }, + undefined, + { signal: controller.signal }, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(init.signal).toBe(controller.signal) }) it("throws a descriptive error on non-2xx responses", async () => { diff --git a/packages/ui/other/anonymous-auth.tsx b/packages/ui/other/anonymous-auth.tsx deleted file mode 100644 index 009902f9..00000000 --- a/packages/ui/other/anonymous-auth.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client" - -import { authClient } from "@lib/auth" -import { useRouter } from "next/navigation" -import { useEffect } from "react" - -export const AnonymousAuth = ({ - dashboardPath = "/dashboard", - loginPath = "/login", -}) => { - const router = useRouter() - - useEffect(() => { - const createAnonymousSession = async () => { - const session = await authClient.getSession() - - if (!session?.session) { - console.debug( - "[ANONYMOUS_AUTH] No session found, creating anonymous session...", - ) - - try { - // Create anonymous session - console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...") - const res = await authClient.signIn.anonymous() - - if (!res.token) { - throw new Error("Failed to get anonymous token") - } - - // Get the new session - console.debug( - "[ANONYMOUS_AUTH] Getting new session with anonymous token...", - ) - const newSession = await authClient.getSession() - - console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession) - - if (!newSession?.session || !newSession?.user) { - console.error( - "[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user", - ) - throw new Error("Failed to create anonymous session") - } - - // Get the user's organization - console.debug( - "[ANONYMOUS_AUTH] Fetching organizations for anonymous user...", - ) - const orgs = await authClient.organization.list() - - console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", { - count: orgs?.length || 0, - orgs: orgs?.map((o) => ({ - id: o.id, - name: o.name, - slug: o.slug, - })), - }) - - const org = orgs?.[0] - if (!org) { - console.error( - "[ANONYMOUS_AUTH] No organization found for anonymous user", - ) - throw new Error("Failed to get organization for anonymous user") - } - - // Redirect to the organization dashboard - console.debug( - `[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`, - ) - router.push(dashboardPath) - } catch (error) { - console.error( - "[ANONYMOUS_AUTH] Anonymous session creation error:", - error, - ) - console.error("[ANONYMOUS_AUTH] Error details:", { - message: error instanceof Error ? error.message : "Unknown error", - stack: error instanceof Error ? error.stack : undefined, - }) - router.push(loginPath) - } - } else if (session.session) { - // Session exists, handle organization routing - console.debug( - "[ANONYMOUS_AUTH] Session exists, checking organization...", - ) - - if (!session.session.activeOrganizationId) { - console.debug( - "[ANONYMOUS_AUTH] No active organization ID, fetching organizations...", - ) - const orgs = await authClient.organization.list() - - console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", { - count: orgs?.length || 0, - orgs: orgs?.map((o) => ({ - id: o.id, - name: o.name, - slug: o.slug, - })), - }) - - if (orgs?.[0]) { - console.debug( - `[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`, - ) - await authClient.organization.setActive({ - organizationId: orgs[0].id, - }) - console.debug( - `[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`, - ) - router.push(dashboardPath) - } - } else { - console.debug( - `[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`, - ) - console.debug( - "[ANONYMOUS_AUTH] Fetching full organization details...", - ) - const org = await authClient.organization.getFullOrganization({ - query: { - organizationId: session.session.activeOrganizationId, - }, - }) - - console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", { - id: org.id, - name: org.name, - slug: org.slug, - }) - - console.debug( - `[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`, - ) - router.push(dashboardPath) - } - } - } - - createAnonymousSession() - }, [router.push]) - - // Return null as this component only handles the redirect logic - return null -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a504670..8234997a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + }, "exports": { "./*": "./*" }, diff --git a/packages/validation/api.test.ts b/packages/validation/api.test.ts index 05c18934..e186af88 100644 --- a/packages/validation/api.test.ts +++ b/packages/validation/api.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test" import { readFileSync } from "node:fs" -import { SearchRequestSchema, Searchv4RequestSchema } from "./api" +import { + DocumentsWithMemoriesQuerySchema, + ListMemoriesQuerySchema, + SearchRequestSchema, + Searchv4RequestSchema, +} from "./api" describe("search threshold schemas", () => { it("do not contain redundant number transforms or unreachable range guards", () => { @@ -80,3 +85,70 @@ describe("search threshold schemas", () => { ).toBe(false) }) }) + +describe("pagination query schemas", () => { + it("preserve page/limit defaults", () => { + const listed = ListMemoriesQuerySchema.parse({}) + expect(listed.page).toBe(1) + expect(listed.limit).toBe(10) + + const docs = DocumentsWithMemoriesQuerySchema.parse({}) + expect(docs.page).toBe(1) + expect(docs.limit).toBe(10) + }) + + it.each([ + 1, 50, 1100, + ])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => { + expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit) + }) + + it("ListMemoriesQuerySchema accepts numeric string page/limit", () => { + const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" }) + expect(parsed.page).toBe(3) + expect(parsed.limit).toBe(25) + }) + + it.each([ + 0, -5, 2.5, + ])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => { + expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false) + }) + + it.each([ + 0, -1, 1.5, + ])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => { + expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false) + }) + + it("ListMemoriesQuerySchema still caps limit at 1100", () => { + expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe( + false, + ) + }) + + it.each([ + 0, -1, 2.5, + ])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => { + expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe( + false, + ) + }) + + it.each([ + 0, -10, 2.5, + ])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => { + expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe( + false, + ) + }) + + it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => { + const parsed = DocumentsWithMemoriesQuerySchema.parse({ + page: 2, + limit: 50, + }) + expect(parsed.page).toBe(2) + expect(parsed.limit).toBe(50) + }) +}) diff --git a/packages/validation/api.ts b/packages/validation/api.ts index f689dbf7..f066bfcd 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z .regex(/^\d+$/) .or(z.number()) .transform(Number) + .refine((value) => Number.isInteger(value) && value >= 1, { + message: "Limit must be a positive integer", + }) .refine((value) => value <= 1100, { message: "Limit cannot be greater than 1100", }) @@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z .regex(/^\d+$/) .or(z.number()) .transform(Number) + .refine((value) => Number.isInteger(value) && value >= 1, { + message: "Page must be a positive integer", + }) .default("1") .openapi({ description: "Page number to fetch", example: "1" }), sort: z @@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z export const DocumentsWithMemoriesQuerySchema = z .object({ - page: z.number().default(1).openapi({ + page: z.number().int().min(1).default(1).openapi({ description: "Page number to fetch", example: 1, }), - limit: z.number().default(10).openapi({ + limit: z.number().int().min(1).default(10).openapi({ description: "Number of items per page", example: 10, }), diff --git a/packages/validation/package.json b/packages/validation/package.json index ea9f5fc1..8ed19114 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -2,5 +2,8 @@ "name": "@repo/validation", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "scripts": { + "check-types": "tsc --noEmit" + } }