From be267c2fc8330789a18673a5631495f2ddd004ca Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:02:50 +0000 Subject: [PATCH 01/37] feat(web): automation connection warnings and calmer automations page (#1396) Inline notice with app icons when a channel automation can't use personal-only connections (footer, next to Save), post-save warning toast from the API, templates capped to 3 connection-relevant ideas with a show-all toggle, and New automation promoted to a primary button on the heading row. Pairs with mono #2724; degrades gracefully without it. Fixes ENG-1151 --- apps/web/components/configure-view.tsx | 23 ++- .../settings/company-brain-automations.tsx | 182 +++++++++++++++--- 2 files changed, 172 insertions(+), 33 deletions(-) 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} +

+
+
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}
) From 59b148e5b2d4f5b4e27c9a6351fb3a0224ed76f2 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:10:46 +0000 Subject: [PATCH 02/37] feat(web): sell Company Brain on Max as well as Scale (#1440) Company Brain workspaces could only buy Scale at $399/mo, which is roughly eight times what the median team uses. Adds the $100/mo Max card to the Company Brain plan picker, notes what a Scale trial loses on the way down, and flags that Scale is cheaper above about $400/mo of credits. --- .../onboarding-brain/step-sources.tsx | 22 +++++-- apps/web/components/settings/billing.tsx | 65 +++++++++++++++++-- apps/web/hooks/use-connector-access.ts | 3 + 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 358597e6..5535392d 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -97,7 +97,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 +116,7 @@ const SOURCE_LABEL: Partial> = { const PLAN_LABELS: Record = { pro: "Pro", max: "Max", + scale: "Scale", } const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min" @@ -277,7 +278,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 +368,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 } @@ -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" ? ( 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 +1508,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/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, From a7efd817ec22a97c935d89c811121327feb8080e Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 19:35:45 +0530 Subject: [PATCH 03/37] fix(validation): reject non-positive page/limit in pagination query schemas (#1271) --- packages/validation/api.test.ts | 74 ++++++++++++++++++++++++++++++++- packages/validation/api.ts | 10 ++++- 2 files changed, 81 insertions(+), 3 deletions(-) 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, }), From f163c932cfac28ab04103ccef8f3de73f220c99d Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 20:55:01 +0530 Subject: [PATCH 04/37] fix(web): keep highlights card active index in range on refresh (#1334) Co-authored-by: Vedant Mahajan --- apps/web/components/highlights-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 74b2201eebe9ff44f37bc985682888dceb509cd2 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 20:55:53 +0530 Subject: [PATCH 05/37] fix(memory-graph): stop painting expired memories as expiring (#1335) Co-authored-by: Vedant Mahajan --- .../memory-graph/src/__tests__/graph-data-utils.test.ts | 7 +++++++ packages/memory-graph/src/hooks/use-graph-data.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/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 From 14bcc92c31b86dc9b8b74c1aadacd45fc6308d6b Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 20:56:23 +0530 Subject: [PATCH 06/37] fix(memory-graph): center arrow-key navigation in the visible graph area (#1337) --- packages/memory-graph/src/components/memory-graph.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(() => { From 47152afc1d512d4247b31bdda05ea4efc19f6c96 Mon Sep 17 00:00:00 2001 From: pawan Date: Wed, 12 Aug 2026 20:56:54 +0530 Subject: [PATCH 07/37] fix(openai-sdk): cap supermemory to <3.5 so a fresh install imports (#1236) --- packages/openai-sdk-python/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openai-sdk-python/pyproject.toml b/packages/openai-sdk-python/pyproject.toml index 557e38b0..4fcdf981 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.4" +version = "1.0.5" description = "Memory tools for OpenAI function calling with supermemory" readme = "README.md" license = "MIT" @@ -26,7 +26,7 @@ classifiers = [ requires-python = ">=3.8.1" dependencies = [ "openai>=1.102.0", - "supermemory>=3.1.0", + "supermemory>=3.1.0,<3.5.0", "typing-extensions>=4.0.0", "requests>=2.25.0", ] From 00e57fb9c219f353654b65017288de78d783b9ec Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 20:57:33 +0530 Subject: [PATCH 08/37] fix(web): stop formatUsageNumber rendering 1000.0K at unit boundaries (#1340) Co-authored-by: Vedant Mahajan --- apps/web/lib/billing-utils.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index 3360e9c4..c2f7cfaa 100644 --- a/apps/web/lib/billing-utils.ts +++ b/apps/web/lib/billing-utils.ts @@ -185,18 +185,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() } From b7a6ea9a5f1f287a2e323b6125769b6aadf28959 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 12 Aug 2026 20:58:06 +0530 Subject: [PATCH 09/37] fix(extension): stop fragmenting Included Memories that contain commas or newlines (#1339) Co-authored-by: Vedant Mahajan --- .../entrypoints/content/chatgpt.ts | 5 ++- .../entrypoints/content/claude.ts | 5 ++- .../entrypoints/content/gemini.ts | 5 ++- .../entrypoints/content/memory-suggestion.ts | 38 +++++++++++++++++-- .../entrypoints/content/t3.ts | 28 ++++++++------ 5 files changed, 63 insertions(+), 18 deletions(-) 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..18861fe3 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -12,6 +12,39 @@ 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 showMemorySuggestion( platform: string, input: SuggestionInput, @@ -305,10 +338,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..39a05f03 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -10,6 +10,10 @@ import { autoCapturePromptsEnabled, } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" +import { + parseMemoriesFromDataset, + serializeMemoriesForDataset, +} from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null @@ -233,7 +237,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { if (textareaElement) { textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) updateT3IconFeedback("Included Memories", iconElement) } else { @@ -329,11 +335,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") @@ -421,15 +425,17 @@ function updateT3IconFeedback( content.removeChild(memoryItem) } - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const currentMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) currentMemories.splice(index, 1) + // Injected prompt keeps its existing joined-text form; the popup's + // own data is stored as JSON so comma-bearing memories stay intact. const updatedMemories = currentMemories.join(" ,") - iconElement.dataset.memoriesData = updatedMemories + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(currentMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || From c70c142fc78bb748010489ec37f3b06994ee93e3 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Wed, 12 Aug 2026 13:12:54 -0700 Subject: [PATCH 10/37] fix(web): open Slack install in new window (#1460) --- apps/web/components/onboarding-brain/research-action-rail.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx index ce79d015..c951f94f 100644 --- a/apps/web/components/onboarding-brain/research-action-rail.tsx +++ b/apps/web/components/onboarding-brain/research-action-rail.tsx @@ -525,6 +525,8 @@ function SlackStepBody({
Date: Thu, 13 Aug 2026 06:58:47 +0000 Subject: [PATCH 11/37] feat(web): take a card before the Company Brain trial starts (#1459) Onboarding now opens a trial step that collects a card through Stripe checkout before the brain is enabled, with a timeline showing today's $0, the day-12 reminder, and the day-14 charge. - Only leaves the card step once the API confirms the trial is live - Brain home shows a setup banner and dims what the trial unlocks - Recovers orgs that abandoned checkout instead of stranding them - Adds the organization ID to account settings, copyable from the label --- .../components/brain-home/brain-home-view.tsx | 21 +- .../brain-home/connections-board.tsx | 47 +++- apps/web/components/dashboard-view.tsx | 2 + .../company-brain-onboarding.tsx | 51 +++- .../onboarding-brain/research-action-rail.tsx | 2 +- .../onboarding-brain/step-trial.tsx | 239 ++++++++++++++++++ apps/web/components/settings/account.tsx | 19 +- apps/web/components/slack-connect-card.tsx | 24 +- apps/web/components/trial-setup-banner.tsx | 41 +++ apps/web/hooks/use-trial-status.ts | 34 +++ apps/web/lib/analytics.ts | 5 + 11 files changed, 451 insertions(+), 34 deletions(-) create mode 100644 apps/web/components/onboarding-brain/step-trial.tsx create mode 100644 apps/web/components/trial-setup-banner.tsx create mode 100644 apps/web/hooks/use-trial-status.ts diff --git a/apps/web/components/brain-home/brain-home-view.tsx b/apps/web/components/brain-home/brain-home-view.tsx index 06a00ba6..3501c5e8 100644 --- a/apps/web/components/brain-home/brain-home-view.tsx +++ b/apps/web/components/brain-home/brain-home-view.tsx @@ -8,6 +8,8 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react" import { useQueryState } from "nuqs" import { useSettingsModal } from "@/components/settings/settings-modal" import { useBrainTrial } from "@/hooks/use-brain-trial" +import { TrialSetupBanner } from "@/components/trial-setup-banner" +import { useTrialStatus } from "@/hooks/use-trial-status" import { dmSans125ClassName } from "@/lib/fonts" import { useViewMode } from "@/lib/view-mode-context" import { @@ -170,6 +172,7 @@ export function BrainHomeView() { const o = useBrainOverview() const trial = useBrainTrial() const board = useConnectionsBoard() + const { needsSetup } = useTrialStatus() // Rows with no reported state (older orgs, pre-Slack) don't count or render. const milestones = [ ...(o.researchStatus != null ? [o.researchStatus === "done"] : []), @@ -186,6 +189,7 @@ export function BrainHomeView() { return (
+ - {board.slack && !board.slack.connected && } + {board.slack && !board.slack.connected && !needsSetup && }
{board.showBoard && } @@ -486,7 +490,7 @@ function BrainTimeline({ canInvite: boolean toolsCardVisible: boolean }) { - const trial = useBrainTrial() + const { needsSetup } = useTrialStatus() const { openSettings } = useSettingsModal() const { setViewMode } = useViewMode() const [, setInvite] = useQueryState("invite") @@ -532,12 +536,13 @@ function BrainTimeline({ title: slackConnected ? "Slack connected" : "Connect Slack", hint: slackConnected ? undefined - : trial.state === "trialing" - ? "Ask your brain from any channel." - : "Starts your 14-day free trial. No credit card needed.", - action: slackConnected - ? undefined - : { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` }, + : needsSetup + ? "Starts with your trial." + : "Ask your brain from any channel.", + action: + slackConnected || needsSetup + ? undefined + : { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` }, }, ...(rollout != null ? [ diff --git a/apps/web/components/brain-home/connections-board.tsx b/apps/web/components/brain-home/connections-board.tsx index 04ab056c..3c79256e 100644 --- a/apps/web/components/brain-home/connections-board.tsx +++ b/apps/web/components/brain-home/connections-board.tsx @@ -4,6 +4,7 @@ import { cn } from "@lib/utils" import { ArrowRight, Loader2 } from "lucide-react" import { useCallback, useEffect, useState } from "react" import { toast } from "sonner" +import { useTrialStatus } from "@/hooks/use-trial-status" import { dmSans125ClassName } from "@/lib/fonts" import { useViewMode } from "@/lib/view-mode-context" import { brainConnectorIcon, SlackMark } from "../brain-connector-icons" @@ -192,6 +193,7 @@ export const CONNECT_TOOLS_CARD_ID = "connect-tools" export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) { const { setViewMode } = useViewMode() const { loading, featured, overflow, busy, isConnected, connect } = board + const { needsSetup } = useTrialStatus() return (

- Give your Slack agent live access to the apps your team already uses. + {needsSetup + ? "Starts with your trial." + : "Give your Slack agent live access to the apps your team already uses."}

-
+
{loading ? ( Array.from({ length: 3 }).map((_, i) => ( @@ -248,6 +258,7 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) { export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) { const { previewApps, isConnected, connectedCount } = board + const { needsSetup } = useTrialStatus() const prompts = previewApps .filter((a) => AGENT_PROMPTS[a.slug]) .slice(0, 6) @@ -275,12 +286,19 @@ export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {

- {connectedCount > 0 - ? "Things your agent can answer now:" - : "Connect a tool and your agent can answer:"} + {needsSetup + ? "Starts with your trial." + : connectedCount > 0 + ? "Things your agent can answer now:" + : "Connect a tool and your agent can answer:"}

-
+
{prompts.map((p, i) => ( diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 93559ace..12c76a86 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -32,6 +32,7 @@ import { StaticGraphPreview } from "@/components/memory-graph/graph-card" import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip" import { ChromeIcon, RaycastIcon } from "@/components/integration-icons" import { SlackConnectCard } from "@/components/slack-connect-card" +import { TrialSetupBanner } from "@/components/trial-setup-banner" import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons" import { analytics } from "@/lib/analytics" import type { IntegrationParamValue } from "@/lib/search-params" @@ -1344,6 +1345,7 @@ export function DashboardView({ )} >
+ {headerNotice ?
{headerNotice}
: null} diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index e9a0673a..3d69acb7 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -30,6 +30,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 +55,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,6 +85,23 @@ 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 [domain, setDomain] = useState(initialDomain) const [organizationChoices, setOrganizationChoices] = useState< CompanyBrainOrganizationChoice[] | null @@ -107,7 +127,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 +224,9 @@ export function CompanyBrainOnboarding({
{/* Persistent card: full confirm card, then morphs into a slim docked header. */} @@ -215,13 +236,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-trial.tsx b/apps/web/components/onboarding-brain/step-trial.tsx new file mode 100644 index 00000000..48bc1823 --- /dev/null +++ b/apps/web/components/onboarding-brain/step-trial.tsx @@ -0,0 +1,239 @@ +"use client" + +import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons" +import { GradientLogo } from "@ui/assets/Logo" +import { Button } from "@ui/components/button" +import { cn } from "@lib/utils" +import { ArrowRight, Loader2, ShieldCheck } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { SlackMark } from "@/components/brain-connector-icons" +import { analytics } from "@/lib/analytics" +import { dmSans125ClassName } from "@/lib/fonts" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +export const CHECKOUT_RETURN_PARAM = "brainTrial" + +const TRIAL_DAYS = 14 +/** The only reminder that lands before the charge; 15 and 17 are post-trial. */ +const REMINDER_DAY = 12 +const MONTHLY_PRICE = "$100" + +function checkoutReturnUrl(): string { + const url = new URL(window.location.href) + url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete") + return url.toString() +} + +function dayOffset(days: number): string { + const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000) + return at.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +} + +const ORBIT = [ + { key: "slack", r: 74, deg: 0, node: }, + { 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 trial +

    +

    + We take a card now so Company Brain keeps working when the trial + ends. Cancel any time before then and you won't be charged. +

    +
    + +
      +
    + +
    + +

    + + Secured by Stripe · Cancel in one click +

    +
    +
    + + +
    + ) +} 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 ? (
    (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-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"), } From fcf49855cef2e6da138898bd042af76f800ac41a Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 13 Aug 2026 17:08:50 +0530 Subject: [PATCH 12/37] Upgrade Nova model picker to current runtime models (#1404) --- .../components/chat/home-chat-composer.tsx | 4 ++-- apps/web/components/chat/index.tsx | 4 ++-- apps/web/components/chat/model-selector.tsx | 3 +-- apps/web/lib/chat-stream-error.ts | 6 ++--- apps/web/lib/models.tsx | 24 +++++++++---------- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 7f9d28af..d3328d73 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -36,9 +36,9 @@ export function HomeChatComposer({ const [attachmentDrafts, setAttachmentDrafts] = useState< ChatAttachmentDraft[] >([]) - const [selectedModel, setSelectedModel] = useState("grok-4.3") + const [selectedModel, setSelectedModel] = useState("grok-4.5") const [reasoningEffort, setReasoningEffort] = useState( - getDefaultReasoningEffort("grok-4.3"), + getDefaultReasoningEffort("grok-4.5"), ) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index c78df7f7..70f01d06 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -206,11 +206,11 @@ export function ChatSidebar({ >([]) const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false) const [selectedModel, setSelectedModel] = useState( - initialSelectedModel ?? "grok-4.3", + initialSelectedModel ?? "grok-4.5", ) const [reasoningEffort, setReasoningEffort] = useState( initialReasoningEffort ?? - getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"), + getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"), ) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel diff --git a/apps/web/components/chat/model-selector.tsx b/apps/web/components/chat/model-selector.tsx index ca9935ee..b65cbad7 100644 --- a/apps/web/components/chat/model-selector.tsx +++ b/apps/web/components/chat/model-selector.tsx @@ -23,8 +23,7 @@ export default function ChatModelSelector({ minimal = false, dropdownDirection = "up", }: ChatModelSelectorProps = {}) { - const [internalModel, setInternalModel] = - useState("claude-sonnet-4.6") + const [internalModel, setInternalModel] = useState("claude-sonnet-5") const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) 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/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<{ From 1356affbd16d7d88ef6dd223936be36f6fc2cabe Mon Sep 17 00:00:00 2001 From: James Yang Date: Thu, 13 Aug 2026 08:25:16 -0400 Subject: [PATCH 13/37] fix(extension): finish Included Memories leftovers on T3 (#1257) (#1421) Co-authored-by: abhay-codes07 Co-authored-by: Vedant Mahajan --- .../entrypoints/content/memory-suggestion.ts | 7 ++ .../entrypoints/content/t3.ts | 95 +++++++++++-------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 18861fe3..27b65b08 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -45,6 +45,13 @@ export function parseMemoriesFromDataset( .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, diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 39a05f03..bddd83ed 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -11,7 +11,9 @@ import { } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" import { + buildSupermemoryText, parseMemoriesFromDataset, + renumberIncludedMemories, serializeMemoriesForDataset, } from "./memory-suggestion" @@ -19,6 +21,19 @@ 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)) { @@ -57,6 +72,7 @@ function setupT3RouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { + disposeT3IncludedPopup() currentUrl = window.location.href setTimeout(() => { addSupermemoryIconToT3Input() @@ -235,7 +251,9 @@ 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 = serializeMemoriesForDataset( response.data, @@ -274,6 +292,8 @@ function updateT3IconFeedback( iconElement.dataset.originalHtml = iconElement.innerHTML } + disposeT3IncludedPopup() + const feedbackDiv = document.createElement("div") feedbackDiv.style.cssText = ` display: flex; @@ -409,68 +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 = parseMemoriesFromDataset( + const remainingMemories = parseMemoriesFromDataset( iconElement.dataset.memoriesData, ) - currentMemories.splice(index, 1) - - // Injected prompt keeps its existing joined-text form; the popup's - // own data is stored as JSON so comma-bearing memories stay intact. - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = - serializeMemoriesForDataset(currentMemories) + 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 = "" @@ -562,6 +579,7 @@ function setupT3PromptCapture() { if (textareaElement?.dataset.supermemories) { delete textareaElement.dataset.supermemories } + disposeT3IncludedPopup() } const handleT3SendButtonClick = async (event: Event) => { @@ -717,6 +735,7 @@ async function setupT3AutoFetch() { if (textareaElement.dataset.supermemories) { delete textareaElement.dataset.supermemories } + disposeT3IncludedPopup() } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) } From 9d64e0f9504d958f79d9fe482ac2e6397ee2f112 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Singh <152812718+abhinavkr26104@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:55:50 +0530 Subject: [PATCH 14/37] fix: add type checks for TypeScript workspaces (#1447) --- apps/browser-extension/package.json | 1 + apps/memory-graph-playground/package.json | 1 + apps/web/package.json | 1 + packages/hooks/package.json | 5 ++++- packages/lib/package.json | 3 +++ packages/ui/package.json | 3 +++ packages/validation/package.json | 5 ++++- 7 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index f40426a8..43f3a56a 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -9,6 +9,7 @@ "dev:firefox": "wxt -b firefox", "build": "wxt build", "build:firefox": "wxt build -b firefox", + "check-types": "bun run compile", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "compile": "tsc --noEmit", 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/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/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/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/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" + } } From 7f448d55d83a9a5db467d9b3f77f6a72de055fcd Mon Sep 17 00:00:00 2001 From: Sarath Donepudi <68329935+dupenodi@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:56:49 +0530 Subject: [PATCH 15/37] docs: document pinned install for supermemory-server (#1238) Co-authored-by: Vedant Mahajan --- apps/docs/self-hosting/quickstart.mdx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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 From 82dae50ef458139823b3bfd3ebaaaac90ffd8a7c Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Singh <152812718+abhinavkr26104@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:11:00 +0530 Subject: [PATCH 16/37] fix(tools): bound memory forget requests (#1451) --- packages/tools/src/shared/forget-memory.ts | 7 +++++++ packages/tools/src/tool-operations.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) 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 () => { From eac070048b7066e3d4a29ff4dd17fb9bed8606a3 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Thu, 13 Aug 2026 22:04:01 -0700 Subject: [PATCH 17/37] feat(web): add memory button to company brain navbar (#1468) Co-authored-by: Claude Opus 4.8 --- apps/web/components/company-brain-header.tsx | 42 +++++++++++++++++++- apps/web/components/header.tsx | 7 +++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/web/components/company-brain-header.tsx b/apps/web/components/company-brain-header.tsx index 99cd1116..e7c9258a 100644 --- a/apps/web/components/company-brain-header.tsx +++ b/apps/web/components/company-brain-header.tsx @@ -21,6 +21,7 @@ import { LifeBuoy, LayoutGrid, MenuIcon, + Plus, SearchIcon, Settings, Settings2, @@ -55,6 +56,7 @@ const BACKEND = type SlackStatus = { connected: boolean; teamName: string | null } interface CompanyBrainHeaderProps { + onAddMemory?: () => void onOpenSearch?: () => void } @@ -108,7 +110,10 @@ function useSlackStatus() { }) } -export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { +export function CompanyBrainHeader({ + onAddMemory, + onOpenSearch, +}: CompanyBrainHeaderProps) { const { user, org, organizations, setActiveOrg } = useAuth() const autumn = useCustomer() const { currentPlan } = useTokenUsage(autumn) @@ -412,6 +417,18 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", }} > + {onAddMemory && ( + <> + + + Add memory + + + + )} + {onAddMemory && ( + + + + + + Add memory (C) + + + )} {canInvite && ( 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 } From 2e85722cf41e5e3e211e8cc542a852458fed5813 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:36:31 +0000 Subject: [PATCH 18/37] Clarify Company Brain trial copy (#1469) Make the trial terms and payment timing clear, and simplify the call to action. --- apps/web/components/onboarding-brain/step-trial.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/components/onboarding-brain/step-trial.tsx b/apps/web/components/onboarding-brain/step-trial.tsx index 48bc1823..5dd1ce64 100644 --- a/apps/web/components/onboarding-brain/step-trial.tsx +++ b/apps/web/components/onboarding-brain/step-trial.tsx @@ -177,11 +177,11 @@ export function StepTrial({ onActive }: { onActive: () => void }) { "text-[22px] leading-tight font-medium text-[#fafafa]", )} > - Start your {TRIAL_DAYS}-day trial + Start your {TRIAL_DAYS}-day free trial

    - We take a card now so Company Brain keeps working when the trial - ends. Cancel any time before then and you won't be charged. + Add a payment method to start. You will not be charged today. We + will email you before your first payment.

    @@ -221,7 +221,7 @@ export function StepTrial({ onActive }: { onActive: () => void }) { ) : ( <> - Add card and start trial + Start free trial )} From 9cbddcec564c60a8d1b79ffa5b90f7d12335caef Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:46:21 +0000 Subject: [PATCH 19/37] docs: historical backfill guide (#1474) Adds a focused guide for backfilling dated documents with `documentDate` and the batch ingestion API. - includes TypeScript and Python batch examples plus optional completion polling - links the guide from the docs navigation and ingestion entry points Validated with `bunx mintlify@latest validate` and `bunx mintlify@latest broken-links`. --- apps/docs/docs.json | 5 + apps/docs/ingestion/add-memories.mdx | 1 + .../batch-ingest-historical-data.mdx | 145 ++++++++++++++++++ apps/docs/using-supermemory.mdx | 1 + 4 files changed, 152 insertions(+) create mode 100644 apps/docs/ingestion/batch-ingest-historical-data.mdx 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/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 + From 5ecbc263450def05fea29c4adcd30aa85b5af31c Mon Sep 17 00:00:00 2001 From: Dhravya Date: Fri, 14 Aug 2026 22:36:52 +0000 Subject: [PATCH 20/37] fix(mcp): surface real API error messages instead of 'restricted or blocked' (#1406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why? Plain **T-1554**: a user with a **read-only** MCP OAuth grant got 403s on memory listing, and the client rendered them as *"Access forbidden. Your account may be restricted or blocked."* The API's actual error body said `{"error": "This API key has read-only access"}` — but `handleError` discarded it, so the user (and support) chased a nonexistent account ban. Two masking layers: 1. `handleError` used the raw error `message`, which for our raw-fetch endpoints was a hardcoded string ("Failed to fetch documents") or unparsed JSON, and fell back to the scary "restricted or blocked" text when empty. 2. `getDocuments` didn't read the response body at all. ## What? - New `extractApiErrorMessage()` unwraps JSON error bodies (`{"error": ...}` / `{"message": ...}`) so the API's real reason reaches the user. - `getDocuments` and `listMemoryEntries` now pass the (unwrapped) response body through with the status, letting `handleError` apply status-aware fallbacks when the body is empty. - Reworded the empty-body 403 fallback to point at the common cause first: *"Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status."* Companion API-side fix (read-only grants couldn't call semantically-read POST list endpoints at all): supermemoryai/mono#2772. ## Testing - Added tests: a 403 with a JSON error body surfaces the API's message; an empty-body 403 gets the scope-aware fallback. `vitest run src/server/client/index.test.ts` — 3 passed. - `tsc --noEmit -p tsconfig.json` clean. (The `check-types` script also runs `tsconfig.widget.json`, which fails on origin/main with a pre-existing `UseAppOptions.strict` error, unrelated.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Low Risk** > User-facing error text only in the MCP client; no auth or API behavior changes. > > **Overview** > **MCP client errors now show what the API actually returned** instead of hardcoded strings or misleading “restricted or blocked” text. > > Adds `extractApiErrorMessage()` to parse JSON bodies (`error` / `message` fields) from failed responses. **`getDocuments`** and **`listMemoryEntries`** read the response body on non-OK status and attach the unwrapped message (with status) for **`handleError`**, which also uses the helper on error messages. When a 403 has no body message, the fallback now points users toward **read-only or scoped OAuth** rather than an account ban. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1f492470cf4b619e58eea6d45af1dfa0b8cad0c4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- apps/mcp/src/server/client/index.ts | 30 ++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index cc45d438..ad9695a4 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -149,6 +149,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 +384,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,11 +446,10 @@ 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()) @@ -466,8 +479,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 +491,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.") From e651045ac50470aa10df5cc8ff7ad2a9b72b00cf Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 15 Aug 2026 18:41:46 +0530 Subject: [PATCH 21/37] Remove paid plugin UI (#1403) --- apps/web/app/auth/connect/page.tsx | 295 +++++++++--------- apps/web/components/integrations-view.tsx | 5 - .../integrations/plugins-detail.tsx | 59 +--- .../onboarding-brain/step-sources.tsx | 6 +- apps/web/components/select-spaces-modal.tsx | 5 - apps/web/components/settings/billing.tsx | 2 +- 6 files changed, 150 insertions(+), 222 deletions(-) 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() {
    - ))} -
    - ) -} - export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() const queryClient = useQueryClient() - const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( null, @@ -572,11 +534,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 } @@ -635,17 +592,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 +636,7 @@ export function PluginsDetail() { )} >
    -
    - Plugins - {catalogRows.length > 0 && ( - - )} -
    + Plugins
    {visibleRows.map((pluginId) => { const plugin = PLUGIN_CATALOG[pluginId] diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 5535392d..d3733991 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -149,11 +149,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", 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/billing.tsx b/apps/web/components/settings/billing.tsx index b46da2c2..b8e366a4 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -137,6 +137,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [ features: [ "Pay-as-you-go after $5 runs out", "Full search and memory access", + "All plugins (Claude Code, Cursor, Hermes...)", "Email support", ], }, @@ -151,7 +152,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [ features: [ "Auto top-up when balance runs low", "Google Drive, Notion, OneDrive & Granola connectors", - "All plugins (Claude Code, Cursor, Hermes...)", "Priority support", ], }, From d14b209f7ca1714568b8e81503090eef276134df Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Sun, 16 Aug 2026 13:43:53 -0700 Subject: [PATCH 22/37] feat(web): support discount code checkout (#1523) --- apps/web/app/(app)/layout.tsx | 2 + apps/web/app/layout.tsx | 2 + .../components/add-document/connections.tsx | 4 + apps/web/components/add-document/index.tsx | 6 ++ apps/web/components/integrations-view.tsx | 6 +- .../integrations/plugins-detail.tsx | 4 + .../onboarding-brain/step-sources.tsx | 4 + apps/web/components/settings/billing.tsx | 4 + .../components/settings/connections-mcp.tsx | 4 + apps/web/hooks/use-promo-code.ts | 82 +++++++++++++++++++ 10 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 apps/web/hooks/use-promo-code.ts 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/layout.tsx b/apps/web/app/layout.tsx index 5de4eb43..2ba044fd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -11,6 +11,7 @@ import { Suspense } from "react" import { Toaster } from "@ui/components/sonner" import { NuqsAdapter } from "nuqs/adapters/next/app" import { ThemeProvider } from "@/lib/theme-provider" +import { PromoCodeCapture } from "@/hooks/use-promo-code" const font = Space_Grotesk({ subsets: ["latin"], @@ -95,6 +96,7 @@ export default function RootLayout({ includeCredentials={true} headers={{ "X-App-Source": "nova" }} > + diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 56339a08..8b24a6bd 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -42,6 +42,7 @@ import { getConnectionSubtitle, } from "@/components/settings/sync-utils" import type { ImportProvider } from "@/components/settings/sync-utils" +import { usePromoCode } from "@/hooks/use-promo-code" type GDriveSyncScope = "scoped" | "full" @@ -309,6 +310,7 @@ interface ConnectContentProps { export function ConnectContent({ selectedProject }: ConnectContentProps) { const queryClient = useQueryClient() const autumn = useCustomer() + const promoCode = usePromoCode() const { connectorAccess } = useConnectorAccess() const [connectingProvider, setConnectingProvider] = useState(null) @@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { try { const result = await autumn.attach({ planId, + discounts: promoCode.getDiscounts(), successUrl: window.location.href, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index 1c231f7a..6ab77d9a 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils" import { SpaceSelector } from "../space-selector" import { useIsMobile } from "@hooks/use-mobile" import { addDocumentParam } from "@/lib/search-params" +import { usePromoCode } from "@/hooks/use-promo-code" type TabType = "note" | "link" | "file" | "connect" @@ -153,6 +154,7 @@ export function AddDocument({ }) const autumn = useCustomer() + const promoCode = usePromoCode() const { tokensUsed, searchesUsed, @@ -342,8 +344,10 @@ export function AddDocument({ try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return @@ -442,8 +446,10 @@ export function AddDocument({ try { const result = await autumn.attach({ planId: "api_pro", + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#account`, }) + promoCode.clear() if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") return diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 12031cd9..900f4ca6 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -80,6 +80,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 @@ -2555,6 +2556,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({ @@ -2825,8 +2827,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 @@ -2837,7 +2841,7 @@ export function IntegrationsView({ toast.error("Failed to start checkout. Please try again.") } }, - [autumn], + [autumn, promoCode], ) const redirectToLogin = useCallback(() => { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index cb50ecaf..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 @@ -418,6 +419,7 @@ function PluginRow({ export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const queryClient = useQueryClient() const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( @@ -570,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 diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index d3733991..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" @@ -614,6 +615,7 @@ function OnboardingPlansModal({ requestedPlan: RequiredPlan }) { const autumn = useCustomer() + const promoCode = usePromoCode() const { currentPlan, isLoading } = useTokenUsage(autumn) const [upgradingPlan, setUpgradingPlan] = useState( null, @@ -632,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 diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index b8e366a4..d69474b9 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -36,6 +36,7 @@ import { } from "lucide-react" import { useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" +import { usePromoCode } from "@/hooks/use-promo-code" const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" @@ -531,6 +532,7 @@ export default function Billing() { const queryClient = useQueryClient() const { user, org } = useAuth() const autumn = useCustomer() + const promoCode = usePromoCode() const posthog = usePostHog() const isCompanyBrain = useHasCompanyBrain() const brainTrial = useMemo( @@ -698,8 +700,10 @@ export default function Billing() { try { const result = await autumn.attach({ planId, + discounts: promoCode.getDiscounts(), successUrl: `${window.location.origin}/settings#billing`, }) + promoCode.clear() if ((result as { paymentUrl?: string })?.paymentUrl) { window.location.href = (result as { paymentUrl: string }).paymentUrl return 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/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 +} From 5d2b5855fe492a3682a1cde4a255e2db0c4db595 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Sun, 16 Aug 2026 23:20:38 +0000 Subject: [PATCH 23/37] feat(auth): AgentID sign-in button on the web login page (#1467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Adds a "Continue with AgentID" button to the web app's login page, matching the existing Google/GitHub buttons (same `ExternalAuthButton` pattern, PostHog `login_attempt` capture, last-used badge). - `packages/lib/auth.ts`: adds the `genericOAuthClient` plugin — generic OAuth providers sign in via `signIn.oauth2({ providerId })`, not `signIn.social`. - `apps/web/app/(auth)/login/page.tsx`: the button, gated the same way as the other social buttons — always shown on cloud (`NEXT_PUBLIC_HOST_ID === "supermemory"`), opt-in elsewhere via `NEXT_PUBLIC_AGENTID_AUTH_ENABLED` (added to `.env.example`). ## Why? Companion to supermemoryai/mono#2908, which registers an `agentid` generic OAuth provider (OIDC against auth.agentid.com) on the API so agents can authenticate with their AgentID identity. The consumer app talks to the same better-auth server, so it gets the same sign-in option. mono#2916 additionally auto-invites the agent's verified human owner to the agent's workspace. Requires mono#2908 to be deployed for the button to work; until then the API rejects the unknown provider and the page shows its normal error state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Touches authentication entry points and OAuth client configuration; risk is moderate because it extends login surface area but follows existing social sign-in patterns and is feature-flagged. > > **Overview** > Adds **Continue with AgentID** on the web login page, using the same `ExternalAuthButton` flow as Google/GitHub (PostHog `login_attempt`, last-used badge, loading/error handling). > > The button calls **`signIn.oauth2({ providerId: "agentid" })`** instead of `signIn.social`, enabled by registering **`genericOAuthClient`** on the shared better-auth client in `packages/lib/auth.ts`. > > Visibility matches other social providers: shown on cloud when `NEXT_PUBLIC_HOST_ID === "supermemory"`, or elsewhere when **`NEXT_PUBLIC_AGENTID_AUTH_ENABLED`** is set (documented in `.env.example`). Depends on the API registering the `agentid` generic OAuth provider. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 90a32786a380d804b4833a0dbefd620072952b27. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- apps/web/.env.example | 3 +- apps/web/app/(auth)/login/page.tsx | 73 ++++++++++++++++++++++++++++++ packages/lib/auth.ts | 2 + 3 files changed, 77 insertions(+), 1 deletion(-) 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/(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}
    Date: Mon, 17 Aug 2026 18:47:36 +0000 Subject: [PATCH 24/37] Fix integrations layout and mobile promo responsiveness (#1481) ## Summary - Reorder Apps & extensions so Import X bookmarks appears in the top row and Apple Shortcuts uses the open space below. - Keep both Apple Shortcut actions inline on larger screens while allowing the card to grow only as much as needed. - Rework the Company Brain promo on phones so its logo, copy, close control, and CTA remain readable and aligned. --- apps/web/components/company-brain-promo.tsx | 52 ++++++++------- apps/web/components/integrations-view.tsx | 66 +++++++++++++------ .../integrations/shortcuts-detail.tsx | 2 +- 3 files changed, 75 insertions(+), 45 deletions(-) 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/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 900f4ca6..19a3b9b0 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -537,13 +537,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", @@ -556,13 +556,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 }, }, ], }, @@ -2068,6 +2068,7 @@ function ItemCard({ docsUrl, leftIndicator, statusSlot, + layoutClassName, }: { actionSlot: ReactNode infoActionSlot?: ReactNode @@ -2082,6 +2083,7 @@ function ItemCard({ docsUrl?: string leftIndicator?: ReactNode statusSlot?: ReactNode + layoutClassName?: string }) { const [infoOpen, setInfoOpen] = useState(false) return ( @@ -2099,6 +2101,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)} /> @@ -2116,7 +2121,12 @@ function ItemCard({
    {icon}
    -
    +
    {leftIndicator} @@ -2140,7 +2150,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()} > @@ -3628,7 +3648,7 @@ export function IntegrationsView({ } } - const renderItemCard = (item: Item) => ( + const renderItemCard = (item: Item, layoutClassName?: string) => ( ) @@ -3753,7 +3774,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, + ), + )}
    ) : (
    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 ( -
    +
    { From 61030190d978c6b7f8b26d71ff98aab72f596c8b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:19:56 +0000 Subject: [PATCH 25/37] chore: update bun.lock Co-Authored-By: Claude Opus 4.5 --- bun.lock | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index d55c8237..3eb49d31 100644 --- a/bun.lock +++ b/bun.lock @@ -343,7 +343,7 @@ "ai": "^5.0.29", "lru-cache": "^11.2.6", "openai": "^4.104.0", - "supermemory": "^3.0.0-alpha.26", + "supermemory": "^4.25.4", "zod": "^4.1.5", }, "devDependencies": { @@ -5536,9 +5536,11 @@ "@supermemory/tools/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.65.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw=="], + "@supermemory/tools/supermemory": ["supermemory@4.25.4", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-97ME3rlmu7OmsXJTb9OgXOD+3VUv4Wej0ZX9xezG+LKkMwrzi4xeeAZaOJFcr0oI/QQjcHG2WOzm+und1e7MFA=="], + "@supermemory/tools/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@supermemory/tools/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@supermemory/tools/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], From c0eb81c88758808b4ffaad67a9c3e4d97bc969bf Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:13:12 +0000 Subject: [PATCH 26/37] refactor(brain): one isCompanyBrainOrg helper in the web app (#1535) Two hand-rolled copies of the add-on/brainMode rule replaced by a single shared helper, and the one-line isCompanyBrainOrganization wrapper dropped. No behaviour change. --- apps/web/hooks/use-company-brain.ts | 12 ++---------- apps/web/lib/billing-utils.ts | 9 +++++++++ apps/web/lib/company-brain-entry.ts | 22 ++++------------------ 3 files changed, 15 insertions(+), 28 deletions(-) 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/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index c2f7cfaa..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" 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 { From 149589ae7e6b72a5be57ddb052a41f3eee0e7614 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:13:12 +0000 Subject: [PATCH 27/37] fix(brain): keep the confirmed company domain after checkout return (#1536) Returning from Stripe remounts onboarding and reseeds the domain from the user's email, so the header showed the wrong company and a research retry would re-run on the wrong domain. Past the confirm step, read the org's stored brainWorkspaceDomain instead. --- .../onboarding-brain/company-brain-onboarding.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 3d69acb7..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, @@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({ 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" From 18a2dfbe3929c872684fd2b407fc0058c427baa3 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Wed, 19 Aug 2026 02:27:27 +0000 Subject: [PATCH 28/37] feat(mcp): accept Supermemory API keys as Bearer auth (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Stack Context Single-auth story for the Claude Code supermemory plugin rework: the plugin's hooks and its MCP surface share one credential (`sm_` API key from the existing browser connect flow). That requires `mcp.supermemory.ai` to accept plain API keys, which it currently rejects (OAuth JWT only). ## What? - `validateApiKey()` in `server/auth`: `sm_`-prefixed Bearer tokens validate via the existing `fetchSession()` (`GET /v3/session`) and map to the same `AuthUser` shape as OAuth tokens (`userId` ← `user.id`, `organizationId` ← `org.id`, the key itself as `bearerToken` for downstream API calls). Successful lookups cached per isolate for 60s. - `handleMcpRequest` routes by token shape: `sm_` keys → session validation, everything else → OAuth JWT verification (unchanged). - `sessionInfoSchema` now types the `org.id` field the session endpoint already returns. ## Why? MCP clients that already hold an API key (Claude Code plugin hooks, CLI, scripts) can connect without an OAuth dance or a second consent. OAuth behavior is untouched — the existing "rejects opaque API keys" test on the OAuth validator still passes; keys just get their own path. Malformed keys are rejected without an API round-trip. Tests: 4 new cases (valid key → AuthUser, cache hit → single fetch, 401 → null, malformed → no request). `vitest run src/server/auth` 13/13, `tsc --noEmit` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Adds a new authentication path on the MCP entrypoint with in-memory key caching (60s TTL), so revoked keys may remain valid briefly within an isolate; OAuth behavior is unchanged. > > **Overview** > MCP Bearer auth now accepts **`sm_` Supermemory API keys** in addition to OAuth JWTs, so clients that already hold an API key can connect without OAuth. > > **`validateApiKey`** treats keys matching `sm_` plus at least 17 non-space characters as API keys: it calls **`GET /v3/session`** with the key as Bearer, maps **`user.id`** and **`org.id`** into the same **`AuthUser`** shape as OAuth (key kept as **`bearerToken`** for downstream API calls), and caches successful results per isolate for **60s** (up to 1000 entries, full clear on overflow). Malformed keys are rejected locally with no HTTP call; session **401** yields unauthenticated. > > **`handleMcpRequest`** branches on token shape: API keys go through session validation; other tokens still use JWT verification unchanged. > > **`sessionInfoSchema`** now includes optional **`org.id`** typing for session responses used when resolving organization context from API keys. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e54fb11bf1598d07a807eb2b0b63a347aaa58fb6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- apps/mcp/src/server/auth/index.test.ts | 62 +++++++++++++++++++++++++- apps/mcp/src/server/auth/index.ts | 45 +++++++++++++++++++ apps/mcp/src/server/index.ts | 11 ++++- apps/mcp/src/shared/types.ts | 1 + 4 files changed, 116 insertions(+), 3 deletions(-) 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/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/shared/types.ts b/apps/mcp/src/shared/types.ts index b8c4fe35..838b13fb 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(), From 7d59070ad612203dd6d4e0691fa20e873a9eefc1 Mon Sep 17 00:00:00 2001 From: Rajarshi Datta <138959719+rajarshidattapy@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:33:58 +0530 Subject: [PATCH 29/37] fix(web): scope the ?view=mcp guest exemption to / (#1553) --- apps/web/middleware.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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() } From 20410a6862a014467c99d6721304cef6907459f0 Mon Sep 17 00:00:00 2001 From: Rajarshi Datta <138959719+rajarshidattapy@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:22:17 +0530 Subject: [PATCH 30/37] fix(ui): remove the unused, broken AnonymousAuth component (#1555) --- packages/ui/other/anonymous-auth.tsx | 150 --------------------------- 1 file changed, 150 deletions(-) delete mode 100644 packages/ui/other/anonymous-auth.tsx 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 -} From 7b1175cb1a461ebd19be9fd59c52f7aafb569a8e Mon Sep 17 00:00:00 2001 From: Aditya Mishra Date: Wed, 19 Aug 2026 19:23:52 +0530 Subject: [PATCH 31/37] fix(browser-extension): run wxt prepare before type checking (#1557) --- apps/browser-extension/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index 43f3a56a..0db9933d 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -9,10 +9,9 @@ "dev:firefox": "wxt -b firefox", "build": "wxt build", "build:firefox": "wxt build -b firefox", - "check-types": "bun run compile", + "check-types": "wxt prepare && tsc --noEmit", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", - "compile": "tsc --noEmit", "postinstall": "wxt prepare" }, "dependencies": { From 818a83a3817ddc58ad312e2c6c16c4a1ae03c7c8 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Wed, 19 Aug 2026 14:05:52 -0700 Subject: [PATCH 32/37] fix(mcp): strip API extras from listMemories entries (#1539) Co-authored-by: Claude Opus 4.8 --- apps/mcp/src/server/client/index.ts | 48 ++++----------------- apps/mcp/src/server/tools/output-schemas.ts | 35 ++------------- apps/mcp/src/shared/types.ts | 43 ++++++++++++++++++ 3 files changed, 56 insertions(+), 70 deletions(-) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index ad9695a4..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 = | { @@ -452,7 +422,7 @@ export class SupermemoryClient { }) } - return memoryEntriesResponseSchema.parse(await response.json()) + return memoriesListSchema.parse(await response.json()) } catch (error) { this.handleError(error) } 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 838b13fb..e7a34160 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -117,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. From dda56e766e9bdd0ad6e6c8144690ddd0a73271ab Mon Sep 17 00:00:00 2001 From: ishaanxgupta <124028055+ishaanxgupta@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:07:31 +0000 Subject: [PATCH 33/37] Add plugin CLI command guide to integrations (#1534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add an “Install plugins with one command” action beside the Plugins section image image --- apps/web/components/integrations-view.tsx | 437 +++++++++++++++------- 1 file changed, 306 insertions(+), 131 deletions(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 19a3b9b0..b821af9a 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -71,8 +71,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, @@ -638,6 +644,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 @@ -2478,10 +2684,12 @@ function SectionRail({ label, children, headerSlot, + labelSlot, }: { label: string children: ReactNode headerSlot?: ReactNode + labelSlot?: ReactNode }) { const scrollRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) @@ -2523,14 +2731,17 @@ function SectionRail({ return (
    -

    - {label} -

    +
    +

    + {label} +

    + {labelSlot} +
    {headerSlot} + ) : null + } headerSlot={ cat === "ai-clients" && activeMcpKey ? (
    + + { @@ -3848,7 +4089,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."}

    @@ -3916,17 +4162,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 +
    )}
    @@ -3940,6 +4199,7 @@ export function IntegrationsView({ pluginId: null, loading: false, }) + setPluginSetupTab("agent") void setConnectTarget(null) }} className={cn( @@ -4078,7 +4338,7 @@ export function IntegrationsView({ if (!connectedPluginId) return const pluginId = connectedPluginId setConnectedPluginId(null) - createPluginKeyMutation.mutate(pluginId) + openPluginSetup(pluginId) }} disabled={!!connectingPlugin} > @@ -4109,91 +4369,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. -

    - )} -
    -
    -
    - - - -
    -
    -
    - { From 34876664810a43a55954a0a83571662a3bd333b8 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Thu, 20 Aug 2026 22:58:39 +0000 Subject: [PATCH 34/37] feat(web): add MCP connector directory (#1461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full 654-entry MCP directory without bundling records into client JavaScript, with explicit capability status and connector branding that degrades safely when no authoritative logo is available. ## Changes - Lazy-load and validate the searchable, filterable, progressively rendered MCP catalog. - Render same-origin proxied provider icons for 543 entries, with a reviewed domain allowlist and deterministic fallback marks for 111 unresolved or unbranded entries. - Record OAuth discovery capability separately from end-to-end support; all directory setup actions remain suppressed until their authentication flow is verified. - Add a reproducible OAuth metadata probe with HTTPS/private-network protections, stable URL keys, authorization-server scanning, and catalog fingerprint validation. - Add Google Drive branding for the curated built-in connector. ## Testing - **Passed:** Deterministic generation and catalog assertions. ```bash PATH="$HOME/.bun/bin:$PATH" python3 apps/web/scripts/generate-mcp-directory.py --output cmp apps/web/public/mcp-directory.json ``` Verified 654 entries, 254 DCR discoveries, 27 preregistered OAuth discoveries, 373 unclassified entries, and zero directory setup actions. - **Passed:** Stale OAuth metadata fingerprint is rejected by the generator. - **Passed:** Touched-file Biome checks and `git diff --check`. - **Passed:** Icon proxy returned 200 for an allowlisted domain and 400 for an unknown valid-looking domain. - **Passed:** Authenticated desktop/mobile browser inspection and conservative capability labels. - **Passed:** Public preview returned HTTP 200 and rendered the real app. Authentication cookies do not transfer to the public hostname, so the public screenshot shows login. - **Partial:** Repository-wide TypeScript checks remain blocked by unrelated existing errors outside the touched MCP files. - **Partial:** 111 entries intentionally retain deterministic fallback marks; endpoint-derived domains may not always be the canonical brand logo. - **Blocked:** Google rejected the local HTTP OAuth callback, so live Google Drive consent, callback, persistence, tool discovery, disconnect, and reconnect were not completed. Public preview: https://ar8ruchhbi65.preview.us1.vorflux.com/configure/tools --- **Attached Images** *[288.csv]* *[mcp-directory-final.json]* ![mcp-directory-branding-desktop.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA0.3_UzR_OP9Jk228FYbrAPTXyqybRBlqwn5Uv4tksf_Y0.png) ![mcp-directory-branding-mobile.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA1.b5G6nsOBVm2s6DlEFWFiMFCcULAkV0MCCGZ8XVsA5js.png) ![mcp-directory-public-preview.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo4MDA2.ZrBAeBi62JX1xavAtaDLQ0fuixgBjN7x1NrqIxtdmKw.png) --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/1cd0aab9-2a45-4818-aa13-f9bfe032ddba) - Requested by: Dhravya Shah (dhravya@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. --- > [!NOTE] > **Medium Risk** > Changes how users pick MCP URLs and auth (OAuth vs API key) before hitting existing connect endpoints; no new backend auth logic in this diff, but misconfiguration or trusting bad URLs remains a user-risk surface. > > **Overview** > Adds a **browseable MCP directory** on the Company Brain connectors page: the catalog is **not bundled in JS**—it loads from static **`/mcp-directory.json`** only after the user opens the directory (with validation, caching, and abort handling). > > The new **`McpDirectoryBrowser`** supports search, category/availability filters, and progressive “show more” rendering. Supported remote entries route into the existing custom MCP flow via **Set up**, which pre-fills name/URL and opens the connector dialog with context-specific copy. > > The custom connector dialog now uses an explicit **OAuth vs API key** toggle; API key fields only appear for API-key mode, and directory-backed connections get **stable slugs** (`-dir-` suffix) so names display cleanly on connected cards. **Middleware** excludes `mcp-directory.json` from the auth matcher so the asset can be fetched publicly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b59bae84a065503aa903bbdcf4d8680c39d2bb1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../app/(app)/configure/[section]/page.tsx | 14 +- apps/web/app/api/mcp-icon/route.ts | 58 ++ apps/web/components/brain-connector-icons.tsx | 4 +- .../components/directory/connector-card.tsx | 82 ++ .../web/components/directory/section-rail.tsx | 117 +++ apps/web/components/integrations-view.tsx | 95 +- .../settings/company-brain-connections.tsx | 872 +++++++++++++----- .../settings/mcp-directory-browser.tsx | 329 +++++++ apps/web/lib/mcp-directory.ts | 21 + apps/web/lib/mcp-icon-domains.json | 518 +++++++++++ 10 files changed, 1769 insertions(+), 341 deletions(-) create mode 100644 apps/web/app/api/mcp-icon/route.ts create mode 100644 apps/web/components/directory/connector-card.tsx create mode 100644 apps/web/components/directory/section-rail.tsx create mode 100644 apps/web/components/settings/mcp-directory-browser.tsx create mode 100644 apps/web/lib/mcp-directory.ts create mode 100644 apps/web/lib/mcp-icon-domains.json 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/api/mcp-icon/route.ts b/apps/web/app/api/mcp-icon/route.ts new file mode 100644 index 00000000..8f5ddc53 --- /dev/null +++ b/apps/web/app/api/mcp-icon/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server" +import iconDomains from "@/lib/mcp-icon-domains.json" + +const DOMAIN_RE = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i +const MAX_ICON_BYTES = 256 * 1024 + +const ALLOWED_DOMAINS = new Set(iconDomains.domains) + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams + .get("domain") + ?.trim() + .toLowerCase() + if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) { + return new NextResponse(null, { status: 400 }) + } + + const response = await fetch( + `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`, + { next: { revalidate: 60 * 60 * 24 * 7 } }, + ) + const contentType = response.headers.get("content-type") ?? "" + if (!response.ok || !contentType.startsWith("image/")) { + return new NextResponse(null, { status: 404 }) + } + const contentLength = Number(response.headers.get("content-length") ?? 0) + if (contentLength > 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/components/brain-connector-icons.tsx b/apps/web/components/brain-connector-icons.tsx index b93ed41a..4bbf6a04 100644 --- a/apps/web/components/brain-connector-icons.tsx +++ b/apps/web/components/brain-connector-icons.tsx @@ -1,5 +1,5 @@ import { cn } from "@lib/utils" -import { Gmail, Granola, Notion } from "@ui/assets/icons" +import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons" import { dmSans125ClassName } from "@/lib/fonts" export function SlackMark({ className }: { className?: string }) { @@ -99,6 +99,8 @@ export function brainConnectorIcon( className = "size-[18px]", ): React.ReactNode { switch (slug) { + case "google-drive": + return case "gmail": return case "github": 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/integrations-view.tsx b/apps/web/components/integrations-view.tsx index b821af9a..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" @@ -2680,100 +2681,6 @@ function CategoryFilterToggle({ ) } -function SectionRail({ - label, - children, - headerSlot, - labelSlot, -}: { - label: string - children: ReactNode - headerSlot?: ReactNode - labelSlot?: 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} -

    - {labelSlot} -
    -
    - {headerSlot} - - -
    -
    -
    - {children} -
    -
    - ) -} - export function IntegrationsView({ publicMode = false, onOpenDocument, 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/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/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" + ] +} From 183e9fba9375fb1d7681f4aaddd0c06a084f5edc Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:24:38 +0530 Subject: [PATCH 35/37] fix(openai-sdk-python): harden v4 migration --- packages/openai-sdk-python/README.md | 7 ++-- packages/openai-sdk-python/pyproject.toml | 5 ++- .../src/supermemory_openai/middleware.py | 16 ++++----- .../src/supermemory_openai/tools.py | 33 ++++++++++--------- .../openai-sdk-python/tests/test_tools.py | 19 ++++++++--- packages/openai-sdk-python/uv.lock | 8 ++--- 6 files changed, 52 insertions(+), 36 deletions(-) diff --git a/packages/openai-sdk-python/README.md b/packages/openai-sdk-python/README.md index f9d96510..b030a9fa 100644 --- a/packages/openai-sdk-python/README.md +++ b/packages/openai-sdk-python/README.md @@ -245,8 +245,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 @@ -260,6 +259,10 @@ result = await tools.fetch_memory( ) ``` +`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 diff --git a/packages/openai-sdk-python/pyproject.toml b/packages/openai-sdk-python/pyproject.toml index 9e991881..210b2ae8 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.5" +version = "1.0.6" description = "Memory tools for OpenAI function calling with supermemory" readme = "README.md" license = "MIT" @@ -15,7 +15,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -62,7 +61,7 @@ multi_line_output = 3 line_length = 88 [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true 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 60274615..d294bf58 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 Dict, List, Optional, TypedDict, Union +import warnings +from typing import Dict, List, Optional, TypedDict import supermemory from openai.types.chat import ( @@ -10,7 +11,6 @@ from openai.types.chat import ( ChatCompletionToolMessageParam, ) from supermemory.types import AddResponse, SearchMemoriesResponse -from supermemory.types.search_memories_response import Result from .exceptions import ( SupermemoryConfigurationError, @@ -23,6 +23,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 the primary v4 search scope; all configured tags + are applied when adding a memory. """ base_url: Optional[str] @@ -38,7 +40,7 @@ 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] @@ -65,14 +67,6 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = { "type": "string", "description": "Terms to search for in the user's memories", }, - "include_full_docs": { - "type": "boolean", - "description": ( - "Whether to include the full document content in the response. " - "Defaults to true for better AI context." - ), - "default": True, - }, "limit": { "type": "number", "description": "Maximum number of results to return", @@ -169,23 +163,32 @@ class SupermemoryTools: async def search_memories( self, information_to_get: str, - include_full_docs: bool = True, + include_full_docs: Optional[bool] = None, limit: int = 10, ) -> MemorySearchResult: """Search memories. Args: information_to_get: Terms to search for - include_full_docs: Whether to include full document content + include_full_docs: Deprecated compatibility argument. V4 search + returns relevant memories and chunks, not full source documents. limit: Maximum number of results Returns: MemorySearchResult """ + 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.container_tags[0], limit=limit, threshold=0.6, search_mode="hybrid", @@ -317,7 +320,7 @@ class SearchMemoriesTool: async def execute( self, information_to_get: str, - include_full_docs: bool = True, + include_full_docs: Optional[bool] = None, limit: int = 10, ) -> MemorySearchResult: """Execute search memories.""" diff --git a/packages/openai-sdk-python/tests/test_tools.py b/packages/openai-sdk-python/tests/test_tools.py index 5bc95920..029c4610 100644 --- a/packages/openai-sdk-python/tests/test_tools.py +++ b/packages/openai-sdk-python/tests/test_tools.py @@ -159,6 +159,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( @@ -206,25 +210,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 6c457622..b4e297e5 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 = [ @@ -1372,7 +1372,7 @@ wheels = [ [[package]] name = "supermemory-openai-sdk" -version = "1.0.4" +version = "1.0.6" source = { editable = "." } dependencies = [ { name = "openai" }, From d21d66138085382143c2f14c5d41ac17df8d8c73 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:13:25 +0530 Subject: [PATCH 36/37] fix(tools): harden seven-tool parity --- .github/workflows/ci.yml | 17 ++ bun.lock | 2 +- packages/tools/package.json | 4 +- packages/tools/src/ai-sdk.ts | 33 ++- packages/tools/src/claude-memory.test.ts | 99 ++++--- packages/tools/src/claude-memory.ts | 321 ++++++++++++++++----- packages/tools/src/openai/tools.ts | 34 ++- packages/tools/src/tool-operations.test.ts | 51 +++- packages/tools/src/tools-shared.ts | 180 +++++++++++- packages/tools/src/voltagent/middleware.ts | 61 ++-- packages/tools/src/voltagent/types.ts | 5 +- 11 files changed, 614 insertions(+), 193 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..62670c9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,22 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Detect Tools package changes + id: tools-changes + run: | + if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Run Tools unit tests + if: steps.tools-changes.outputs.changed == 'true' + run: bun run --cwd packages/tools test:unit + + - name: Build Tools package + if: steps.tools-changes.outputs.changed == 'true' + run: bun run --cwd packages/tools build + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/bun.lock b/bun.lock index 3eb49d31..2084333c 100644 --- a/bun.lock +++ b/bun.lock @@ -336,7 +336,7 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "2.1.1", + "version": "2.2.0", "dependencies": { "@ai-sdk/anthropic": "^2.0.25", "@ai-sdk/openai": "^2.0.23", diff --git a/packages/tools/package.json b/packages/tools/package.json index a4d0034f..74f2f5bc 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,14 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.1.1", + "version": "2.2.0", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index b7e437db..1b710808 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -5,6 +5,7 @@ import { DEFAULT_VALUES, PARAMETER_DESCRIPTIONS, TOOL_DESCRIPTIONS, + deleteDocumentByIdentifier, getContainerTags, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" @@ -56,12 +57,12 @@ export const searchMemoriesTool = ( limit = DEFAULT_VALUES.limit, }) => { try { - const response = await client.search({ + const response = await client.search.documents({ q: informationToGet, - ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), + containerTags, limit, - threshold: DEFAULT_VALUES.chunkThreshold, - searchMode: "hybrid", + chunkThreshold: DEFAULT_VALUES.chunkThreshold, + includeFullDocs, }) return { @@ -196,10 +197,12 @@ export const documentListTool = ( }), execute: async ({ containerTag, limit, page }) => { try { - const tag = containerTag || containerTags[0] + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags const response = await client.documents.list({ - containerTags: [tag], + containerTags: scopeTags, limit: limit || DEFAULT_VALUES.limit, ...(page !== undefined && { page }), }) @@ -227,15 +230,29 @@ export const documentDeleteTool = ( apiKey, ...(config?.baseUrl ? { baseURL: config.baseUrl } : {}), }) + const containerTags = getContainerTags(config) + const strict = config?.strict ?? false return tool({ description: TOOL_DESCRIPTIONS.documentDelete, inputSchema: z.object({ documentId: z.string().describe(PARAMETER_DESCRIPTIONS.documentId), + containerTag: strict + ? z + .string() + .nullable() + .describe(PARAMETER_DESCRIPTIONS.documentContainerTag) + : z + .string() + .optional() + .describe(PARAMETER_DESCRIPTIONS.documentContainerTag), }), - execute: async ({ documentId }) => { + execute: async ({ documentId, containerTag }) => { try { - await client.documents.delete(documentId) + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags + await deleteDocumentByIdentifier(client, documentId, scopeTags) return { success: true, diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7d16493f..afe16dcf 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path -// can be exercised deterministically without any network access. We only need -// `client.search()` to return a single document with known multi-line content. -const searchMock = vi.fn() +// Mock the Supermemory SDK so the Claude memory tool's document-backed file +// operations can be exercised deterministically without any network access. +const documentsListMock = vi.fn() +const documentsGetMock = vi.fn() +const documentsDeleteBulkMock = vi.fn() const addMock = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = searchMock add = addMock memories = { forget: vi.fn() } - documents = { delete: vi.fn() } + documents = { + list: documentsListMock, + get: documentsGetMock, + deleteBulk: documentsDeleteBulkMock, + } }, } }) @@ -22,20 +26,58 @@ import { ClaudeMemoryTool } from "./claude-memory" const FILE_PATH = "/memories/notes.txt" // 5 distinct lines so an off-by-one at either end is observable. const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5" +const FILE_DOCUMENT = { + id: "document-notes", + customId: "memories_notes_txt", + filePath: FILE_PATH, + content: FILE_CONTENT, +} +const NEIGHBOUR_DOCUMENT = { + id: "document-notes-backup", + customId: "memories_notes_backup_txt", + filePath: "/memories/notes.backup.txt", + content: "backup stuff", +} + +function mockDocuments(documents: typeof FILE_DOCUMENT[]) { + documentsListMock.mockResolvedValue({ + memories: documents.map((document) => ({ + id: document.id, + customId: document.customId, + containerTags: ["claude_memory"], + metadata: { + claude_memory_type: "file", + file_path: document.filePath, + }, + })), + pagination: { totalPages: 1 }, + }) + documentsGetMock.mockImplementation(async (id: string) => { + const document = documents.find((candidate) => candidate.id === id) + if (!document) throw new Error(`Document not found: ${id}`) + return { + id: document.id, + customId: document.customId, + containerTags: ["sm_project_default", "claude_memory"], + metadata: { + claude_memory_type: "file", + file_path: document.filePath, + }, + content: document.content, + } + }) +} function mockDocument(content: string) { - // `readFile` matches by `id === normalizePathToCustomId(path)`. - // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt" - searchMock.mockResolvedValue({ - results: [{ id: "memories_notes_txt", chunk: content }], - }) + mockDocuments([{ ...FILE_DOCUMENT, content }]) } describe("ClaudeMemoryTool view_range", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) @@ -90,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() addMock.mockReset() tool = new ClaudeMemoryTool("test-api-key") }) - it("view finds the exact file even when a neighbour ranks first", async () => { - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - { id: "memories_notes_txt", chunk: FILE_CONTENT }, - ], - }) + it("view finds the exact file even when a neighbour is listed first", async () => { + mockDocuments([NEIGHBOUR_DOCUMENT, FILE_DOCUMENT]) const result = await tool.handleCommand({ command: "view", @@ -114,13 +152,9 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("view reports not-found instead of returning a different file", async () => { - // Semantic search can surface a similarly-named file; that must not + // The document list can contain a similarly-named file; that must not // be served as the requested one. - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - ], - }) + mockDocuments([NEIGHBOUR_DOCUMENT]) const result = await tool.handleCommand({ command: "view", @@ -132,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) it("str_replace refuses to modify a different file than requested", async () => { - searchMock.mockResolvedValue({ - results: [ - { id: "memories_notes_backup_txt", chunk: "backup stuff" }, - ], - }) + mockDocuments([NEIGHBOUR_DOCUMENT]) const result = await tool.handleCommand({ command: "str_replace", @@ -154,11 +184,10 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool beforeEach(() => { - searchMock.mockReset() + documentsListMock.mockReset() + documentsGetMock.mockReset() addMock.mockReset() - searchMock.mockResolvedValue({ - results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }], - }) + mockDocument(FILE_CONTENT) tool = new ClaudeMemoryTool("test-api-key") }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index ad05c108..3792e9f8 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -1,5 +1,5 @@ import Supermemory from "supermemory" -import { getContainerTags } from "./tools-shared" +import { deleteDocumentById, getContainerTags } from "./tools-shared" import type { SupermemoryToolsConfig } from "./types" // Claude Memory Tool Types @@ -37,6 +37,14 @@ export interface MemoryToolResult { is_error: boolean } +type ClaudeFileMetadata = Record + +interface ClaudeFileDocument { + documentId: string + content: string + metadata: ClaudeFileMetadata +} + /** * Claude Memory Tool - Client-side implementation * Maps Claude's memory tool commands to supermemory document operations @@ -44,6 +52,7 @@ export interface MemoryToolResult { export class ClaudeMemoryTool { private client: Supermemory private containerTags: string[] + private scopeContainerTags: [string, ...string[]] private memoryContainerPrefix: string /** @@ -68,6 +77,7 @@ export class ClaudeMemoryTool { // Get base container tags and add memory-specific tag const baseContainerTags = getContainerTags(config) + this.scopeContainerTags = baseContainerTags this.containerTags = [...baseContainerTags, this.memoryContainerPrefix] } @@ -193,44 +203,89 @@ export class ClaudeMemoryTool { */ private async listDirectory(dirPath: string): Promise { try { - // Search for all memory files - const response = await this.client.search({ - q: "*", // Search for all - ...(this.containerTags[0] - ? { containerTag: this.containerTags[0] } - : {}), - limit: 100, // Get many files (max allowed) - searchMode: "hybrid", - }) + // Document search returns ranked chunks, not a complete inventory. Walk + // every page of the document-list endpoint so files cannot disappear + // from a directory merely because they did not rank in a search page. + const documents: Supermemory.DocumentListResponse.Memory[] = [] + let page = 1 - if (!response.results) { - return { - success: true, - content: `Directory: ${dirPath}\n(empty)`, - } + while (true) { + const response = await this.client.documents.list({ + containerTags: this.scopeContainerTags, + filters: { + AND: [ + { key: "claude_memory_type", value: "file" }, + { + key: "file_path", + value: dirPath, + filterType: "string_contains", + }, + ], + }, + includeContent: false, + limit: 100, + page, + }) + + documents.push(...response.memories) + + if (page >= response.pagination.totalPages) break + page += 1 } // Filter files that match the directory path and extract relative paths const files: string[] = [] const dirs = new Set() + const candidates: Array<{ + document: Supermemory.DocumentListResponse.Memory + filePath: string + }> = [] - for (const result of response.results) { - // Get the file path from metadata (since customId is normalized) - const filePath = result.metadata?.file_path as string - if (!filePath || !filePath.startsWith(dirPath)) continue + for (const document of documents) { + if (!this.isDocumentInConfiguredScope(document)) continue - // Get relative path from directory - const relativePath = filePath.substring(dirPath.length) - if (!relativePath) continue + const filePath = this.getDocumentFilePath(document) + if (!filePath || !filePath.startsWith(dirPath)) { + continue + } + candidates.push({ document, filePath }) + } - // If path contains /, it's in a subdirectory - const slashIndex = relativePath.indexOf("/") - if (slashIndex > 0) { - // It's a subdirectory - dirs.add(`${relativePath.substring(0, slashIndex)}/`) - } else if (relativePath !== "") { - // It's a file in this directory - files.push(relativePath) + // Full GETs are required to verify hidden project tags. Keep them bounded + // so large directories do not become a long serial chain or a burst of + // unbounded requests. + const verificationBatchSize = 8 + for ( + let index = 0; + index < candidates.length; + index += verificationBatchSize + ) { + const batch = candidates.slice(index, index + verificationBatchSize) + const verified = await Promise.all( + batch.map(async (candidate) => + (await this.isDirectoryDocumentInExactScope(candidate.document)) + ? candidate + : undefined, + ), + ) + + for (const candidate of verified) { + if (!candidate) continue + const { filePath } = candidate + + // Get relative path from directory + const relativePath = filePath.substring(dirPath.length) + if (!relativePath) continue + + // If path contains /, it's in a subdirectory + const slashIndex = relativePath.indexOf("/") + if (slashIndex > 0) { + // It's a subdirectory + dirs.add(`${relativePath.substring(0, slashIndex)}/`) + } else if (relativePath !== "") { + // It's a file in this directory + files.push(relativePath) + } } } @@ -264,10 +319,8 @@ export class ClaudeMemoryTool { viewRange?: [number, number], ): Promise { try { - // Same lookup as every mutating command: limit 5 so the exact - // customId match is findable among semantic near-neighbours. - // With the old limit of 1, a similarly-named file ranking first - // made this return the wrong file's contents as a success. + // Resolve the exact document inside the configured scope so reads and + // mutations use the complete stored file, not one ranked search chunk. const readResult = await this.getFileDocument(filePath) if (!readResult.success || !readResult.document) { return { @@ -278,7 +331,7 @@ export class ClaudeMemoryTool { const document = readResult.document - let content: string = document.raw || document.content || "" + let content = document.content // Apply line range if specified if (viewRange) { @@ -376,8 +429,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content // Check if old_str exists in the content if (!originalContent.includes(oldStr)) { @@ -435,8 +487,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content const lines = originalContent.split("\n") // Validate line number @@ -490,9 +541,7 @@ export class ClaudeMemoryTool { } } - const documentId = - readResult.document.documentId ?? this.normalizePathToCustomId(filePath) - await this.client.documents.delete(documentId) + await deleteDocumentById(this.client, readResult.document.documentId) return { success: true, @@ -531,8 +580,7 @@ export class ClaudeMemoryTool { } } - const originalContent = - readResult.document.raw || readResult.document.content || "" + const originalContent = readResult.document.content const newNormalizedId = this.normalizePathToCustomId(newPath) // Create new document with new path @@ -552,8 +600,7 @@ export class ClaudeMemoryTool { // customId — the add above already replaced the content. const oldNormalizedId = this.normalizePathToCustomId(oldPath) if (oldNormalizedId !== newNormalizedId) { - const oldDocumentId = readResult.document.documentId ?? oldNormalizedId - await this.client.documents.delete(oldDocumentId) + await deleteDocumentById(this.client, readResult.document.documentId) } return { @@ -573,48 +620,124 @@ export class ClaudeMemoryTool { */ private async getFileDocument(filePath: string): Promise<{ success: boolean - document?: any + document?: ClaudeFileDocument error?: string }> { try { const normalizedId = this.normalizePathToCustomId(filePath) + let page = 1 + const candidates = new Map< + string, + Supermemory.DocumentListResponse.Memory + >() - const response = await this.client.search({ - q: normalizedId, - ...(this.containerTags[0] - ? { containerTag: this.containerTags[0] } - : {}), - limit: 5, - searchMode: "hybrid", - }) + // customId values are only unique within an exact container-tag set in + // Mono. Resolve the matching document inside this tool's configured + // scope before fetching by internal ID; a direct get(customId) can pick + // another project/user's same-named file. + while (true) { + const response = await this.client.documents.list({ + containerTags: this.scopeContainerTags, + filters: { + AND: [ + { key: "claude_memory_type", value: "file" }, + { key: "file_path", value: filePath }, + ], + }, + includeContent: false, + limit: 100, + page, + }) - // Only accept the exact customId match. Falling back to the top - // semantic hit would let callers read — and worse, modify or - // delete — a different file than the one they asked for. - const match = response.results?.find( - (r) => - r.id === normalizedId || - r.documents?.some((d) => d.id === normalizedId), - ) + for (const document of response.memories) { + if ( + document.customId === normalizedId && + this.getDocumentFilePath(document) === filePath && + this.isDocumentInConfiguredScope(document) + ) { + candidates.set(document.id, document) + } + } - if (!match) { + if (page >= response.pagination.totalPages) break + page += 1 + } + + const exactMatches: Array<{ + candidate: Supermemory.DocumentListResponse.Memory + document: Supermemory.DocumentGetResponse + }> = [] + let hasUnverifiedCandidate = false + for (const candidate of candidates.values()) { + let document: Supermemory.DocumentGetResponse + try { + document = await this.client.documents.get(candidate.id) + } catch (error) { + if (error instanceof Supermemory.NotFoundError) continue + throw error + } + + if (document.id !== candidate.id) { + hasUnverifiedCandidate = true + continue + } + if ( + document.customId !== normalizedId || + this.getDocumentFilePath(document) !== filePath || + !this.hasExactContainerTags(document.containerTags) + ) { + continue + } + + exactMatches.push({ candidate, document }) + } + + if (exactMatches.length === 0) { return { success: false, error: `File not found: ${filePath}`, } } + if (exactMatches.length > 1) { + return { + success: false, + error: `File path is ambiguous in the configured container scope: ${filePath}`, + } + } + if (hasUnverifiedCandidate) { + return { + success: false, + error: `File path could not be resolved unambiguously in the configured container scope: ${filePath}`, + } + } - const content = match.chunk || match.memory || "" - const documentId = match.documents?.[0]?.id ?? match.id + const match = exactMatches[0] + if (!match) { + return { success: false, error: `File not found: ${filePath}` } + } + const { candidate, document } = match + const content = + typeof document.content === "string" + ? document.content + : typeof document.raw === "string" + ? document.raw + : undefined + if (content === undefined) { + return { + success: false, + error: `File content unavailable: ${filePath}`, + } + } + const metadata = + document.metadata && + typeof document.metadata === "object" && + !Array.isArray(document.metadata) + ? (document.metadata as ClaudeFileMetadata) + : {} return { success: true, - document: { - documentId, - content, - raw: content, - metadata: match.metadata, - }, + document: { documentId: candidate.id, content, metadata }, } } catch (error) { return { @@ -624,6 +747,60 @@ export class ClaudeMemoryTool { } } + private getDocumentFilePath(document: { + metadata: unknown + }): string | undefined { + const metadata = document.metadata + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined + } + const metadataRecord = metadata as Record + + return typeof metadataRecord.file_path === "string" + ? metadataRecord.file_path + : undefined + } + + private isDocumentInConfiguredScope( + document: Supermemory.DocumentListResponse.Memory, + ): boolean { + const documentTags = document.containerTags ?? [] + const expectedTags = this.containerTags.filter( + (tag) => !tag.startsWith("sm_project_"), + ) + + return ( + documentTags.length === expectedTags.length && + documentTags.every((tag, index) => tag === expectedTags[index]) + ) + } + + private async isDirectoryDocumentInExactScope( + document: Supermemory.DocumentListResponse.Memory, + ): Promise { + try { + // Mono strips internal project tags from every list response, so only a + // full get can prove that no hidden tags change this document's scope. + const fullDocument = await this.client.documents.get(document.id) + return ( + fullDocument.id === document.id && + this.hasExactContainerTags(fullDocument.containerTags) + ) + } catch (error) { + if (!(error instanceof Supermemory.NotFoundError)) throw error + // A document can disappear between list and get. Skip stale entries + // instead of failing the entire directory view. + return false + } + } + + private hasExactContainerTags(containerTags?: string[]): boolean { + return ( + containerTags?.length === this.containerTags.length && + containerTags.every((tag, index) => tag === this.containerTags[index]) + ) + } + /** * Validate that path starts with /memories for security */ diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 49b5c88a..06a26935 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -4,6 +4,7 @@ import { DEFAULT_VALUES, PARAMETER_DESCRIPTIONS, TOOL_DESCRIPTIONS, + deleteDocumentByIdentifier, getContainerTags, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" @@ -14,7 +15,9 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited>["results"] + results?: Awaited< + ReturnType + >["results"] count?: number error?: string } @@ -31,7 +34,7 @@ export interface ProfileResult { static: string[] dynamic: string[] } - searchResults?: Awaited> + searchResults?: Awaited>["searchResults"] error?: string } @@ -159,6 +162,10 @@ export const memoryToolSchemas = { type: "string", description: PARAMETER_DESCRIPTIONS.documentId, }, + containerTag: { + type: "string", + description: PARAMETER_DESCRIPTIONS.documentContainerTag, + }, }, required: ["documentId"], }, @@ -248,12 +255,12 @@ export function createSearchMemoriesFunction( limit?: number }): Promise { try { - const response = await client.search({ + const response = await client.search.documents({ q: informationToGet, - ...(containerTags[0] ? { containerTag: containerTags[0] } : {}), + containerTags, limit, - threshold: DEFAULT_VALUES.chunkThreshold, - searchMode: "hybrid", + chunkThreshold: DEFAULT_VALUES.chunkThreshold, + includeFullDocs, }) return { @@ -363,10 +370,12 @@ export function createDocumentListFunction( page?: number }): Promise { try { - const tag = containerTag || containerTags[0] + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags const response = await client.documents.list({ - containerTags: [tag], + containerTags: scopeTags, limit: limit || DEFAULT_VALUES.limit, ...(page !== undefined && { page }), }) @@ -392,15 +401,20 @@ export function createDocumentDeleteFunction( apiKey: string, config?: SupermemoryToolsConfig, ) { - const { client } = createClient(apiKey, config) + const { client, containerTags } = createClient(apiKey, config) return async function documentDelete({ documentId, + containerTag, }: { documentId: string + containerTag?: string }): Promise { try { - await client.documents.delete(documentId) + const scopeTags: [string, ...string[]] = containerTag + ? [containerTag] + : containerTags + await deleteDocumentByIdentifier(client, documentId, scopeTags) return { success: true, diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8ec46fa0..c6152021 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -2,18 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool // executions can be verified deterministically without network access. -const documentsDelete = vi.fn() +const documentsDeleteBulk = vi.fn() +const documentsGet = vi.fn() const documentsList = vi.fn() -const searchMock = vi.fn() const clientAdd = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { - search = searchMock add = clientAdd documents = { - delete: documentsDelete, + deleteBulk: documentsDeleteBulk, + get: documentsGet, list: documentsList, add: vi.fn(), } @@ -35,12 +35,20 @@ function executeTool(tool: unknown, args: Record) { } beforeEach(() => { - documentsDelete.mockReset().mockResolvedValue(undefined) + documentsDeleteBulk.mockReset().mockResolvedValue({ + success: true, + deletedCount: 1, + errors: [], + }) + documentsGet.mockReset().mockResolvedValue({ + id: "doc_123", + customId: "doc_123", + containerTags: ["sm_project_default"], + }) documentsList.mockReset().mockResolvedValue({ memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) - searchMock.mockReset() clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) vi.unstubAllGlobals() }) @@ -53,7 +61,8 @@ describe("documentDelete", () => { } expect(result.success).toBe(true) - expect(documentsDelete).toHaveBeenCalledWith("doc_123") + expect(documentsGet).toHaveBeenCalledWith("doc_123") + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] }) }) }) @@ -177,16 +186,30 @@ describe("memoryForget", () => { describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const CUSTOM_ID = "memories_prefs_txt" + const DOCUMENT_ID = "doc_file_1" function mockFileDocument(content: string) { - searchMock.mockResolvedValue({ - results: [ + const metadata = { + claude_memory_type: "file", + file_path: FILE_PATH, + } + documentsList.mockResolvedValue({ + memories: [ { - id: CUSTOM_ID, - chunk: content, - metadata: { file_path: FILE_PATH }, + id: DOCUMENT_ID, + customId: CUSTOM_ID, + containerTags: ["claude_memory"], + metadata, }, ], + pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, + }) + documentsGet.mockResolvedValue({ + id: DOCUMENT_ID, + customId: CUSTOM_ID, + containerTags: ["sm_project_default", "claude_memory"], + content, + metadata, }) } @@ -247,7 +270,7 @@ describe("ClaudeMemoryTool", () => { }) expect(result.success).toBe(true) - expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) it("rename removes the old document after creating the new one", async () => { @@ -264,6 +287,6 @@ describe("ClaudeMemoryTool", () => { expect(clientAdd).toHaveBeenCalledWith( expect.objectContaining({ customId: "memories_renamed_txt" }), ) - expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) }) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index cbb1c1a7..dffc7093 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -2,48 +2,51 @@ * Shared constants and descriptions for Supermemory tools */ +import type Supermemory from "supermemory" import type { MemoryMode } from "./shared/types" // Tool descriptions export const TOOL_DESCRIPTIONS = { searchMemories: - "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 memoryForget to remove a specific learned fact.", + "Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.", addMemory: "Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.", getProfile: - "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 memoryForget.", + "Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.", documentList: - "List stored source documents (conversations, URLs, files, pasted text) with pagination. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.", + "List stored source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.", documentDelete: - "Permanently delete a stored document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).", + "Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.", documentAdd: "Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.", memoryForget: - "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 memoryId (preferred — from searchMemories or getProfile) OR memoryContent 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 documentDelete instead.", + "Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.", } as const // Parameter descriptions export const PARAMETER_DESCRIPTIONS = { informationToGet: - "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.", + "What to look up in stored context — keywords from the user's message, topic, entity names, or question phrasing.", includeFullDocs: "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.", containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)", + documentContainerTag: + "Optional one-tag scope override. When deleting a document returned by documentList with a containerTag override, pass the same value here. In strict mode, pass null to use the configured union.", query: "Optional search query to include relevant search results", page: "Page number to fetch, 1-based (default: 1)", documentId: - "Document ID from documentList — permanently deletes the source document and all extracted memories. Not a profile memory ID.", + "Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.", content: "Document body to store — plain text, a conversation transcript, a long pasted blob, or a URL to a webpage/PDF/image/video. Content is queued and memories are extracted automatically after background processing; do not split into addMemory calls.", title: "Optional title for the document", description: "Optional description for the document", memoryId: - "Profile memory ID from searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.", + "Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.", memoryContent: - "Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, search first and use memoryId.", + "Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.", reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)", } as const @@ -57,7 +60,7 @@ export const DEFAULT_VALUES = { // Container tag constants export const CONTAINER_TAG_CONSTANTS = { projectPrefix: "sm_project_", - defaultTags: ["sm_project_default"] as string[], + defaultTags: ["sm_project_default"] as const, } as const /** @@ -66,16 +69,167 @@ export const CONTAINER_TAG_CONSTANTS = { export function getContainerTags(config?: { projectId?: string containerTags?: string[] -}): string[] { +}): [string, ...string[]] { if (config?.projectId !== undefined && config.containerTags !== undefined) { throw new Error( "Supermemory tools config accepts either projectId or containerTags, not both.", ) } - if (config?.projectId) { + if (config?.projectId !== undefined) { + if (config.projectId.trim() === "") { + throw new Error("Supermemory tools config requires a non-empty projectId.") + } return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`] } - return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags + if (config?.containerTags !== undefined) { + const [firstTag, ...remainingTags] = config.containerTags + if ( + firstTag === undefined || + config.containerTags.some((tag) => tag.trim() === "") + ) { + throw new Error( + "Supermemory tools config requires at least one non-empty containerTag.", + ) + } + return [firstTag, ...remainingTags] + } + return [...CONTAINER_TAG_CONSTANTS.defaultTags] +} + +/** Delete exactly one document by its internal ID. */ +export async function deleteDocumentById( + client: Supermemory, + documentId: string, +): Promise { + const response = await client.documents.deleteBulk({ ids: [documentId] }) + if (response.success && response.deletedCount === 1) return + + const detail = response.errors?.find((error) => error.id === documentId)?.error + throw new Error( + detail + ? `Failed to delete document ${documentId}: ${detail}` + : `Failed to delete document ${documentId}: expected one deletion, received ${response.deletedCount}`, + ) +} + +/** + * Resolve an internal ID or customId inside the effective container-tag union, + * then delete the exact internal document ID. Internal IDs take precedence over + * customId matches. + */ +export async function deleteDocumentByIdentifier( + client: Supermemory, + documentIdentifier: string, + containerTags: readonly [string, ...string[]], +): Promise { + const directMatch = await getDocumentIfFound(client, documentIdentifier) + if ( + directMatch?.id === documentIdentifier && + hasContainerTagOverlap(directMatch.containerTags, containerTags) + ) { + await deleteDocumentById(client, directMatch.id) + return + } + + const candidateIds = new Set() + let hasInternalIdCandidate = false + let page = 1 + while (true) { + const response = await client.documents.list({ + containerTags: [...containerTags], + includeContent: false, + limit: 100, + page, + }) + for (const document of response.memories) { + if (document.id === documentIdentifier) { + hasInternalIdCandidate = true + } + if ( + document.id === documentIdentifier || + document.customId === documentIdentifier + ) { + candidateIds.add(document.id) + } + } + if (page >= response.pagination.totalPages) break + page += 1 + } + + let exactIdMatch: string | undefined + let hasUnverifiedCandidate = false + const customIdMatches: string[] = [] + for (const candidateId of candidateIds) { + const document = await getDocumentIfFound(client, candidateId) + if (document?.id !== candidateId) { + hasUnverifiedCandidate = true + continue + } + if (!hasContainerTagOverlap(document.containerTags, containerTags)) { + continue + } + if (document.id === documentIdentifier) { + exactIdMatch = document.id + break + } + if (document.customId === documentIdentifier) { + customIdMatches.push(document.id) + } else { + hasUnverifiedCandidate = true + } + } + + if (exactIdMatch) { + await deleteDocumentById(client, exactIdMatch) + return + } + if (hasInternalIdCandidate) { + throw new Error( + `Document ID ${documentIdentifier} could not be verified safely in the configured container scope.`, + ) + } + if (hasUnverifiedCandidate) { + throw new Error( + `Document identifier ${documentIdentifier} could not be resolved unambiguously in the configured container scope.`, + ) + } + if (customIdMatches.length === 1) { + await deleteDocumentById(client, customIdMatches[0] as string) + return + } + if (customIdMatches.length > 1) { + throw new Error( + `Document customId ${documentIdentifier} is ambiguous in the configured container scope.`, + ) + } + throw new Error( + `Document ${documentIdentifier} was not found in the configured container scope.`, + ) +} + +async function getDocumentIfFound(client: Supermemory, documentId: string) { + try { + return await client.documents.get(documentId) + } catch (error) { + if (isNotFoundError(error)) return undefined + throw error + } +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "status" in error && + error.status === 404 + ) +} + +function hasContainerTagOverlap( + actual: string[] | undefined, + expected: readonly string[], +): boolean { + return actual?.some((tag) => expected.includes(tag)) ?? false } /** diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index bf771726..7e788c3d 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -18,7 +18,11 @@ import { type Logger, type MemoryMode, } from "../shared" -import type { SupermemoryVoltAgent, VoltAgentMessage } from "./types" +import type { + SearchFilters, + SupermemoryVoltAgent, + VoltAgentMessage, +} from "./types" /** * Context for Supermemory middleware operations. @@ -47,7 +51,7 @@ export interface SupermemoryMiddlewareContext { limit?: number rerank?: boolean rewriteQuery?: boolean - filters?: { OR: Array } | { AND: Array } + filters?: SearchFilters include?: { chunks?: boolean documents?: boolean @@ -258,23 +262,7 @@ export const enhanceMessagesWithMemories = async ( if (useAdvancedSearch && ctx.mode !== "profile") { ctx.logger.info("Using advanced search with custom parameters") - const searchParams: { - q: string - containerTag: string - threshold?: number - limit?: number - rerank?: boolean - rewriteQuery?: boolean - filters?: { OR: Array } | { AND: Array } - include?: { - chunks?: boolean - documents?: boolean - forgottenMemories?: boolean - relatedMemories?: boolean - summaries?: boolean - } - searchMode?: "memories" | "documents" | "hybrid" - } = { + const searchParams: Supermemory.SearchParams = { q: queryText, containerTag: ctx.containerTag, } @@ -288,31 +276,32 @@ export const enhanceMessagesWithMemories = async ( if (ctx.include !== undefined) searchParams.include = ctx.include if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode - const response = await ctx.client.search.memories(searchParams) + const response = await ctx.client.search(searchParams) // Hybrid search returns both memory entries (`memory` field) and - // document chunks (`chunk` field). Handle both. - type SearchResult = { - memory?: string - chunk?: string - metadata?: Record - } - const formattedMemories = response.results - .map((result: SearchResult) => { - const text = result.memory || result.chunk - return text ? `- ${text}` : null - }) - .filter(Boolean) + // document chunks (`chunk` field). Normalize both for prompt templates. + const searchResults = response.results.flatMap((result) => { + const memory = result.memory ?? result.chunk + if (!memory) { + return [] + } + + return [ + { + memory, + ...(result.metadata ? { metadata: result.metadata } : {}), + }, + ] + }) + const formattedMemories = searchResults + .map((result) => `- ${result.memory}`) .join("\n") memories = ctx.promptTemplate ? ctx.promptTemplate({ userMemories: "", generalSearchMemories: formattedMemories, - searchResults: response.results as Array<{ - memory: string - metadata?: Record - }>, + searchResults, }) : `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}` } else { diff --git a/packages/tools/src/voltagent/types.ts b/packages/tools/src/voltagent/types.ts index cc5350ea..e6524ebc 100644 --- a/packages/tools/src/voltagent/types.ts +++ b/packages/tools/src/voltagent/types.ts @@ -5,6 +5,7 @@ * Supermemory by providing hooks that inject memories before LLM calls. */ +import type Supermemory from "supermemory" import type { PromptTemplate, MemoryMode, @@ -58,7 +59,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { /** * Advanced filters to apply to the search using AND/OR logic. - * Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] } + * Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] } * * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. */ @@ -99,7 +100,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { /** * Advanced search filters using AND/OR logic */ -export type SearchFilters = { OR: Array } | { AND: Array } +export type SearchFilters = NonNullable /** * Options for including additional data in search results From 7153801f11dfdb50aeed6c4e0840ce79ccc7f805 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:46:13 +0000 Subject: [PATCH 37/37] fix(tools): resolve lint and format errors - Replace `as any` with typed assertion in claude-memory.ts - Apply Biome formatting fixes Co-Authored-By: Claude Opus 4.5 --- packages/tools/src/claude-memory.test.ts | 34 +++++++++++++----------- packages/tools/src/claude-memory.ts | 2 +- packages/tools/src/openai/tools.ts | 4 +-- packages/tools/src/tools-shared.ts | 11 +++++--- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index afe16dcf..7337441a 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -39,7 +39,7 @@ const NEIGHBOUR_DOCUMENT = { content: "backup stuff", } -function mockDocuments(documents: typeof FILE_DOCUMENT[]) { +function mockDocuments(documents: (typeof FILE_DOCUMENT)[]) { documentsListMock.mockResolvedValue({ memories: documents.map((document) => ({ id: document.id, @@ -191,20 +191,22 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { tool = new ClaudeMemoryTool("test-api-key") }) - it.each(["$&", "$'", "$`", "$$"])( - "stores %s literally instead of expanding it as a replacement pattern", - async (dollarSequence) => { - const result = await tool.handleCommand({ - command: "str_replace", - path: FILE_PATH, - old_str: "line3", - new_str: `price is ${dollarSequence} today`, - }) + it.each([ + "$&", + "$'", + "$`", + "$$", + ])("stores %s literally instead of expanding it as a replacement pattern", async (dollarSequence) => { + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "line3", + new_str: `price is ${dollarSequence} today`, + }) - expect(result.success).toBe(true) - expect(addMock).toHaveBeenCalledTimes(1) - const stored = addMock.mock.calls[0]?.[0]?.content as string - expect(stored).toContain(`price is ${dollarSequence} today`) - }, - ) + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toContain(`price is ${dollarSequence} today`) + }) }) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 3792e9f8..867c4d82 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -150,7 +150,7 @@ export class ClaudeMemoryTool { default: return { success: false, - error: `Unknown command: ${(command as any).command}`, + error: `Unknown command: ${(command as { command: string }).command}`, } } } catch (error) { diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 06a26935..22257727 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -15,9 +15,7 @@ import type { SupermemoryToolsConfig } from "../types" */ export interface MemorySearchResult { success: boolean - results?: Awaited< - ReturnType - >["results"] + results?: Awaited>["results"] count?: number error?: string } diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index dffc7093..c0a3b540 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -47,7 +47,8 @@ export const PARAMETER_DESCRIPTIONS = { "Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.", memoryContent: "Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.", - reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)", + reason: + "Optional reason recorded when forgetting (e.g. outdated, user correction)", } as const // Default values @@ -77,7 +78,9 @@ export function getContainerTags(config?: { } if (config?.projectId !== undefined) { if (config.projectId.trim() === "") { - throw new Error("Supermemory tools config requires a non-empty projectId.") + throw new Error( + "Supermemory tools config requires a non-empty projectId.", + ) } return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`] } @@ -104,7 +107,9 @@ export async function deleteDocumentById( const response = await client.documents.deleteBulk({ ids: [documentId] }) if (response.success && response.deletedCount === 1) return - const detail = response.errors?.find((error) => error.id === documentId)?.error + const detail = response.errors?.find( + (error) => error.id === documentId, + )?.error throw new Error( detail ? `Failed to delete document ${documentId}: ${detail}`