diff --git a/README.md b/README.md index 33a98c5f..36e862f6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ English · 简体中文

+

+ #1 on every major AI memory benchmark — LongMemEval, LoCoMo, and ConvoMem.
+ 95% Recall@15 with a 99.4% context reduction · ~50ms user profiles.
+ Read the research → +

+ --- Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory. @@ -356,10 +362,14 @@ Supermemory is state of the art across all major AI memory benchmarks: | Benchmark | What it measures | Result | |---|---|---| -| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **81.6% — #1** | +| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **#1** | | **[LoCoMo](https://github.com/snap-research/locomo)** | Fact recall across extended conversations (single-hop, multi-hop, temporal, adversarial) | **#1** | | **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | Personalization and preference learning | **#1** | +On LongMemEval, supermemory reaches **95% Recall@15 while adding only ~720 tokens of context — a 99.4% context reduction** (99.6% at @10, 99.8% at @5). Recall by category: Knowledge Updates 99%, Assistant recall 100%, User recall 97%, Multi-session 93%, Temporal Reasoning 91%, Preference 90%. + +We also built the **Supermemory Filesystem (SMFS)**, which uses **3.0× fewer tokens on Claude** (24M vs 72M) and **1.75× fewer on Codex** across the 110-question xAFS benchmark. See the full write-ups on our [research page](https://supermemory.ai/research). + We also built **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)** — an open-source framework for standardized, reproducible benchmarks of memory providers. Compare Supermemory, Mem0, Zep, and others head-to-head: ```bash diff --git a/apps/docs/connectors/gmail.mdx b/apps/docs/connectors/gmail.mdx index de974c27..2921bc8a 100644 --- a/apps/docs/connectors/gmail.mdx +++ b/apps/docs/connectors/gmail.mdx @@ -7,7 +7,7 @@ icon: "mail" Connect Gmail to automatically sync email threads into your supermemory knowledge base. Supports real-time updates via Google Cloud Pub/Sub webhooks and incremental synchronization. -**Scale Plan Required:** The Gmail connector is available on Scale and Enterprise plans only. +**Max Plan Required:** The Gmail connector is available on Max plan and above. ## Quick Setup diff --git a/apps/docs/integrations/claude-code.mdx b/apps/docs/integrations/claude-code.mdx index c9248cae..34532ae6 100644 --- a/apps/docs/integrations/claude-code.mdx +++ b/apps/docs/integrations/claude-code.mdx @@ -13,10 +13,6 @@ icon: "/images/claude-code-icon.svg" /> - -This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing). - - [supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects. diff --git a/apps/docs/integrations/opencode.mdx b/apps/docs/integrations/opencode.mdx index 9f47706c..bec9167b 100644 --- a/apps/docs/integrations/opencode.mdx +++ b/apps/docs/integrations/opencode.mdx @@ -5,10 +5,6 @@ description: "OpenCode Supermemory Plugin — persistent memory across coding se icon: "/images/opencode-logo.png" --- - -This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing). - - [OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects. diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 49718943..4b5af4a3 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -86,8 +86,6 @@ export default function BrainOnboardingPage() { () => workspaceDomainFromEmail(user?.email), [user?.email], ) - - // Team (Company Brain) onboarding is gated behind a private-beta flag. const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false const [mode, setMode] = useState(detectedMode) const [about, setAbout] = useState({ @@ -339,8 +337,6 @@ export default function BrainOnboardingPage() { setCreatingOrg(false) } }, [ensureOrg, goNext, forceCreate, organizations, router]) - - // Company Brain (team) onboarding is a single research surface, no stepper. const isCompanyBrain = allowTeam && mode === "team" const handleBrainConfirm = useCallback( diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 259584ee..402fd6fd 100644 --- a/apps/web/components/app-experience.tsx +++ b/apps/web/components/app-experience.tsx @@ -20,6 +20,7 @@ import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" import { IntegrationsView, DetailWrapper } from "@/components/integrations-view" +import { ConfigureView } from "@/components/configure-view" import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view" import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view" import { ChromeDetail } from "@/components/integrations/chrome-detail" @@ -698,6 +699,10 @@ export function AppExperience() { onOpenDocument={handleOpenDocument} /> + ) : viewMode === "configure" ? ( +
+ +
) : viewMode === "mcp" ? ( void setViewMode("integrations")} diff --git a/apps/web/components/brain-home/brain-home-view.tsx b/apps/web/components/brain-home/brain-home-view.tsx index 3be036fc..495bdcf0 100644 --- a/apps/web/components/brain-home/brain-home-view.tsx +++ b/apps/web/components/brain-home/brain-home-view.tsx @@ -219,7 +219,7 @@ function StatsRow({ + )} ))} ) } +function MobileStatLabel({ label }: { label: string }) { + const mobile = + label === "Connected sources" + ? "Sources" + : label === "Active members" + ? "Members" + : label + + return ( + <> + {mobile} + {label} + + ) +} + function RecentMemories({ docs, loading, diff --git a/apps/web/components/brain-home/connections-board.tsx b/apps/web/components/brain-home/connections-board.tsx index 7f39f183..479c343d 100644 --- a/apps/web/components/brain-home/connections-board.tsx +++ b/apps/web/components/brain-home/connections-board.tsx @@ -5,11 +5,11 @@ import { ArrowRight, Loader2 } from "lucide-react" import { useCallback, useEffect, useState } from "react" import { toast } from "sonner" import { dmSans125ClassName } from "@/lib/fonts" -import { useSettingsModal } from "@/components/settings/settings-modal" +import { useViewMode } from "@/lib/view-mode-context" import { brainConnectorIcon, SlackMark } from "../brain-connector-icons" -// Apps surfaced on the dashboard; the rest live behind "More" in settings. -const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const +// Preferred ordering for the dashboard; only unconnected apps are surfaced. +const FEATURED_SLUGS: readonly string[] = ["linear", "granola", "sentry"] // Example prompts on the right card — can include apps not shown on the left. const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const @@ -155,76 +155,91 @@ export function ConnectionsBoard() { } } - const { openSettings } = useSettingsModal() + const { setViewMode } = useViewMode() const apps = catalog ?? [] const loading = catalog === null - const featured = FEATURED_SLUGS.map((slug) => - apps.find((a) => a.slug === slug), - ).filter((a): a is CatalogEntry => Boolean(a)) + const unconnected = apps.filter((a) => !isConnected(a.slug)) + const featured = [ + ...FEATURED_SLUGS.map((slug) => + unconnected.find((a) => a.slug === slug), + ).filter((a): a is CatalogEntry => Boolean(a)), + ...unconnected.filter((a) => !FEATURED_SLUGS.includes(a.slug)), + ].slice(0, 3) + const overflow = unconnected.filter((a) => !featured.includes(a)) const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) => apps.find((a) => a.slug === slug), ).filter((a): a is CatalogEntry => Boolean(a)) - const remainingCount = Math.max(apps.length - featured.length, 0) const connectedCount = apps.filter((a) => isConnected(a.slug)).length + const showBoard = loading || unconnected.length > 0 return (
{slack && !slack.connected && }
-
-
-

- Connect your tools -

-

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

-
- -
- {loading ? ( - Array.from({ length: 3 }).map((_, i) => ( - - )) - ) : ( - <> - {featured.map((entry, i) => ( - connect(entry)} - showDivider={i < featured.length - 1 || remainingCount > 0} - /> - ))} - {remainingCount > 0 && ( - openSettings("company-brain")} - /> + {showBoard ? ( +
+
+

- )} -

-
+ > + Connect your tools +

+

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

+
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + )) + ) : ( + <> + {featured.map((entry, i) => ( + connect(entry)} + showDivider={ + i < featured.length - 1 || overflow.length > 0 + } + /> + ))} + {overflow.length > 0 && ( + a.name)} + onClick={() => void setViewMode("configure")} + /> + )} + + )} +
+
+ ) : null}
@@ -235,10 +250,12 @@ function AgentPreview({ apps, isConnected, connectedCount, + wide = false, }: { apps: CatalogEntry[] isConnected: (slug: string) => boolean connectedCount: number + wide?: boolean }) { const prompts = apps .filter((a) => AGENT_PROMPTS[a.slug]) @@ -252,7 +269,10 @@ function AgentPreview({ return (
@@ -359,7 +379,15 @@ function AppTile({ ) } -function MoreTile({ count, onClick }: { count: number; onClick: () => void }) { +function MoreTile({ + count, + names, + onClick, +}: { + count: number + names: string[] + onClick: () => void +}) { return (
@@ -411,7 +440,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) { function SlackBanner() { return (
-
-
+
+
- +

- Company Brain in Slack + Slack agent + Company Brain in Slack

-

- Install Supermemory so your team can{" "} - @supermemory in any - channel. +

+ + Ask @supermemory from + any channel. + + + Install Supermemory so your team can{" "} + @supermemory in any + channel. +

- - Add to Slack + + Add + Add to Slack
diff --git a/apps/web/components/company-brain-header.tsx b/apps/web/components/company-brain-header.tsx index 41ccb1c4..12662a12 100644 --- a/apps/web/components/company-brain-header.tsx +++ b/apps/web/components/company-brain-header.tsx @@ -19,11 +19,11 @@ import { ExternalLink, Home, LifeBuoy, - Link2, LayoutGrid, MenuIcon, SearchIcon, Settings, + Settings2, UserPlus, ChevronRight, Sun, @@ -35,7 +35,7 @@ import { DomainLogo } from "@/components/onboarding-brain/step-about" import { FeedbackModal } from "@/components/feedback-modal" import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" import { SlackMark } from "@/components/brain-connector-icons" -import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons" +import { GraphIcon } from "@/components/integration-icons" import { SpaceSelector } from "@/components/space-selector" import { UserProfileMenu } from "@/components/user-profile-menu" import { useTokenUsage } from "@/hooks/use-token-usage" @@ -121,7 +121,6 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { feedbackParam, ) const [, setInvite] = useQueryState("invite") - const [settingsTab] = useQueryState("settings") const { data: slackStatus } = useSlackStatus() const planByOrgId = new Map( @@ -140,10 +139,10 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { ?.role?.toLowerCase() const canInvite = memberRole === "owner" || memberRole === "admin" - const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain" + const isOverview = viewMode === "dashboard" const isGraph = viewMode === "graph" const isMemories = viewMode === "list" - const isConnections = settingsTab === "company-brain" + const isConfigure = viewMode === "configure" const slackConnected = slackStatus?.connected ?? false const selectOrg = useCallback( @@ -166,9 +165,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { void setViewMode("list") }, [setViewMode]) - const goConnections = useCallback(() => { - openSettings("company-brain") - }, [openSettings]) + const goConfigure = useCallback(() => { + void setViewMode("configure") + }, [setViewMode]) const goIntegrations = useCallback(() => { void setViewMode("integrations") @@ -184,16 +183,16 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { }, [setFeedbackOpen]) return ( -
-
+
+
)} -
+
{isMobile ? ( <> @@ -429,11 +430,11 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) { Memories - - Connections + + Configure {slackConnected ? ( diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx new file mode 100644 index 00000000..0b75762e --- /dev/null +++ b/apps/web/components/configure-view.tsx @@ -0,0 +1,130 @@ +"use client" + +import { cn } from "@lib/utils" +import { Blocks, CalendarClock, Cpu } from "lucide-react" +import { useState } from "react" +import CompanyBrainConnections from "@/components/settings/company-brain-connections" +import CompanyBrainModels from "@/components/settings/company-brain-models" +import Proactiveness from "@/components/settings/proactiveness" +import { ErrorBoundary } from "@/components/error-boundary" +import { dmSans125ClassName } from "@/lib/fonts" + +type ConfigureSection = "company-brain" | "models" | "automations" + +const SECTIONS: { + id: ConfigureSection + label: string + description: string + icon: typeof Blocks +}[] = [ + { + id: "company-brain", + label: "Integrations", + description: + "Connect the tools your brain works with. Your account covers your own actions and reads; workspace accounts are a shared fallback.", + icon: Blocks, + }, + { + id: "models", + label: "Models", + description: + "Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.", + icon: Cpu, + }, + { + id: "automations", + label: "Automations", + description: + "Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.", + icon: CalendarClock, + }, +] + +export function ConfigureView() { + const [activeSection, setActiveSection] = + useState("company-brain") + const active = SECTIONS.find((section) => section.id === activeSection) + if (!active) return null + + return ( +
+
+
+ + +
+
+

+ {active.label} +

+

+ {active.description} +

+
+ + + Something went wrong loading this section. +

+ } + > + {activeSection === "company-brain" ? ( + + ) : activeSection === "models" ? ( + + ) : ( + + )} +
+
+
+
+
+ ) +} diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 1a709f0c..64937900 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -43,12 +43,21 @@ const BACKEND = type Phase = "confirm" | "research" function normalizeDomain(input: string): string { - return input + const host = input .trim() .toLowerCase() .replace(/^https?:\/\//, "") .replace(/^www\./, "") .replace(/\/.*$/, "") + + // The confirmation card is often filled with a company name (for example, + // "Zomato") even though research needs a hostname. Preserve explicit TLDs, + // and make the common single-label case usable without a failed API round trip. + if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) { + return `${host}.com` + } + + return host } export function CompanyBrainOnboarding({ @@ -306,30 +315,35 @@ function DockedHeader({ }) { const brandName = workspaceNameFromDomain(domain) || domain return ( -
+
- - {brandName} - - - {done ? "Company Brain ready" : "Building your Company Brain…"} - +
+ + {brandName} + + + {done ? "Company Brain ready" : "Building your Company Brain…"} + +
{done ? ( ) : ( - + )}
) diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx index 9033d5e1..6804aacd 100644 --- a/apps/web/components/onboarding-brain/research-action-rail.tsx +++ b/apps/web/components/onboarding-brain/research-action-rail.tsx @@ -13,7 +13,7 @@ import { brainConnectorIcon, SlackMark, } from "@/components/brain-connector-icons" -import { useSettingsModal } from "@/components/settings/settings-modal" +import { useRouter } from "next/navigation" import { useResearchStatus } from "@/hooks/use-research-status" import { dmSans125ClassName } from "@/lib/fonts" import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about" @@ -83,7 +83,7 @@ export function ResearchActionRail({ onStatsChange?: (stats: ParallelSetupStats) => void }) { const { org } = useAuth() - const { openSettings } = useSettingsModal() + const router = useRouter() const { events } = useResearchStatus() const [catalog, setCatalog] = useState(null) const [rows, setRows] = useState([]) @@ -448,7 +448,7 @@ export function ResearchActionRail({ onConnect={connect} onBrowse={() => { pauseRotation() - openSettings("company-brain") + router.push("/?view=configure") }} /> )} diff --git a/apps/web/components/onboarding-brain/shell.tsx b/apps/web/components/onboarding-brain/shell.tsx index f2eb9f8a..9044ea08 100644 --- a/apps/web/components/onboarding-brain/shell.tsx +++ b/apps/web/components/onboarding-brain/shell.tsx @@ -19,7 +19,7 @@ export function BrainShell({ step, steps, children }: ShellProps) { return (
@@ -46,20 +46,25 @@ export function BrainShell({ step, steps, children }: ShellProps) { }} /> -
- - +
+ +
+ +
+
+ +
-
+
{!isLast && ( -
+
-
-
+
+
+

-
+
{mode === "personal" ? ( <> -
+
@@ -591,7 +591,7 @@ export function StepSources({
{moreOpen ? ( -
+
-

+

Paste multiple emails at once — we'll split them for you.

{count === 0 ? ( -
+

No invites yet.

@@ -242,11 +242,11 @@ export function StepTeam({
)}
-
+
{values.invites.map((inv) => (
@@ -263,7 +263,7 @@ export function StepTeam({ setRole(inv.email, r as "admin" | "member") } > - + @@ -295,12 +295,12 @@ export function StepTeam({ )} -
+
@@ -308,7 +308,7 @@ export function StepTeam({ variant="insideOut" onClick={onContinue} disabled={submitting} - className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]" + className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]" > {submitting ? ( <> diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx index 26d1fda3..74f8e7b0 100644 --- a/apps/web/components/settings/company-brain-automations.tsx +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -1,6 +1,5 @@ "use client" -import { authClient } from "@lib/auth" import { useAuth } from "@lib/auth-context" import { cn } from "@lib/utils" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" @@ -9,26 +8,19 @@ import { CalendarClock, ChevronDown, ChevronUp, - FilePlus2, FileText, GitPullRequest, Info, LifeBuoy, ListTodo, Loader2, - Lock, MessageCircleQuestion, Plus, + Radar, Trash2, } from "lucide-react" import { useRef, useState } from "react" import { toast } from "sonner" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@ui/components/dropdown-menu" import { Select, SelectContent, @@ -167,7 +159,7 @@ const PRESETS: Preset[] = [ { id: "standup", category: "team", - label: "Daily standup", + label: "Morning checkup", description: "Shipped work, decisions, blockers & open questions from the last 24h.", icon: CalendarClock, @@ -179,7 +171,7 @@ const PRESETS: Preset[] = [ { id: "weekly-recap", category: "team", - label: "Weekly team recap", + label: "Company progress", description: "Decisions, shipped work & unresolved threads from the past week.", icon: CalendarClock, @@ -236,6 +228,18 @@ const PRESETS: Preset[] = [ frequency: "daily", time: "09:00", }, + { + id: "competitor-check", + category: "product", + label: "Competitor check", + description: "What competitors shipped, announced, or changed this week.", + icon: Radar, + prompt: + "Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.", + frequency: "weekly", + weekday: 1, + time: "09:00", + }, { id: "release-notes", category: "product", @@ -273,38 +277,12 @@ function sortPresets(connected: Set): Preset[] { return [...PRESETS].sort((a, b) => rank(a) - rank(b)) } -// Up to 3 presets spanning distinct categories (connection-preferred order). -function galleryPresets(sorted: Preset[]): Preset[] { - const seen = new Set() - const out: Preset[] = [] - for (const p of sorted) { - if (seen.has(p.category)) continue - seen.add(p.category) - out.push(p) - if (out.length === 3) break - } - return out -} - function cadenceLabel(p: Preset): string { if (p.frequency === "weekly") return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}` return `Daily · ${p.time}` } -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ) -} - const controlClass = cn( dmSans125ClassName(), "h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50", @@ -318,9 +296,6 @@ const selectItemClass = "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white" const DM_VALUE = "__dm__" -const menuItemClass = - "cursor-pointer gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium text-white/85 hover:bg-white/[0.06] hover:text-white data-[highlighted]:bg-white/[0.06] data-[highlighted]:text-white focus:bg-white/[0.06] focus:text-white" - function AutomationCard({ initial, id, @@ -830,7 +805,7 @@ function AutomationRow({ ) } -function PresetTile({ +function PresetCard({ preset, onPick, }: { @@ -844,19 +819,31 @@ function PresetTile({ onClick={onPick} className={cn( dmSans125ClassName(), - "flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-[#14161A] px-3 py-2.5 text-left transition-colors hover:border-white/[0.12] hover:bg-[#171A1F]", + "flex min-w-0 cursor-pointer flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 text-left", + "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#171A1F]", )} - title={preset.description} > - - - - {preset.label} - - +
+
+ +
+
+

+ {preset.label} +

+

+ {preset.description} +

+
+
+
+ {cadenceLabel(preset)} - + + Use template + +
) } @@ -873,17 +860,6 @@ export default function CompanyBrainAutomations() { const removeDraft = (key: number) => setDrafts((d) => d.filter((x) => x.key !== key)) - const roleQuery = useQuery({ - queryKey: ["company-brain-automations", "role"], - queryFn: async () => - (await authClient.organization.getActiveMember()).data?.role ?? null, - staleTime: 60_000, - enabled: isCompanyBrain, - }) - const isAdmin = ["owner", "admin"].includes( - (roleQuery.data ?? "").toLowerCase(), - ) - const listQuery = useQuery({ queryKey: ["company-brain-automations", "list", org?.id], queryFn: async () => { @@ -935,71 +911,12 @@ export default function CompanyBrainAutomations() { queryKey: ["company-brain-automations", "list"], }) } - const showGallery = automations.length === 0 && drafts.length === 0 + const usedTitles = new Set(automations.map((a) => a.title)) + const availablePresets = presets.filter((p) => !usedTitles.has(p.label)) + const hasList = automations.length > 0 || drafts.length > 0 return (
-
-
- Channel automations - - - - - - addDraft(emptyDraft())} - className={menuItemClass} - > - - Blank automation - - {presets.map((p) => { - const Icon = p.icon - return ( - addDraft(presetToDraft(p))} - className={menuItemClass} - > - - {p.label} - - ) - })} - - -
- - - - Read-only scheduled summaries posted to a channel.{" "} - {isAdmin - ? "You manage all across the org." - : "You manage the ones you create."} - -
-
{automations.map((a) => openId === a.id ? ( @@ -1044,40 +961,37 @@ export default function CompanyBrainAutomations() { /> ))} - {showGallery ? ( -
-

- Start from a template: -

-
- {galleryPresets(presets).map((p) => ( - addDraft(presetToDraft(p))} - /> - ))} - -
-

- More templates in the New automation menu. -

-
+ {hasList ? ( +

+ Templates +

) : null} +
+ {availablePresets.map((p) => ( + addDraft(presetToDraft(p))} + /> + ))} + +
) diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index a6fb1ff8..ae1489a4 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -1,10 +1,22 @@ "use client" -import { authClient } from "@lib/auth" +import { useOrgMemberRole } from "@/hooks/use-org-member-role" import { cn } from "@lib/utils" -import { useQuery } from "@tanstack/react-query" -import { Loader2, Lock } from "lucide-react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react" import { useCallback, useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ui/components/dropdown-menu" import { toast } from "sonner" import { dmSans125ClassName } from "@/lib/fonts" import { useHasCompanyBrain } from "@/hooks/use-company-brain" @@ -33,7 +45,6 @@ type ConnRow = { userId: string | null } type SlackStatus = { connected: boolean; teamName: string | null } -type Scope = "org" | "user" function titleCase(s: string) { return s.replace(/\b\w/g, (c) => c.toUpperCase()) @@ -48,28 +59,20 @@ function slugifyMcpName(value: string) { .slice(0, 63) } -function SecondaryButton({ - children, - href, -}: { - children: React.ReactNode - href: string -}) { - return ( - - {children} - - ) -} +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]", + "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]", + "cursor-pointer transition-opacity hover:opacity-80", +) -function StatusDot({ connected }: { connected: boolean }) { +function ScopeChip({ + label, + connected, +}: { + label: string + connected: boolean +}) { return ( - {connected ? "Connected" : "Not connected"} + {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" + function AppCard({ name, subtitle, icon, - connected, - canConnect, - canDisconnect, - lockedHint, + userConnected, + orgConnected, + isAdmin, + personalOnly, busy, onConnect, onDisconnect, @@ -104,16 +110,20 @@ function AppCard({ name: string subtitle: string icon: React.ReactNode - connected: boolean - canConnect: boolean - canDisconnect: boolean - lockedHint?: string + userConnected: boolean + orgConnected: boolean + isAdmin: boolean + personalOnly?: boolean busy: boolean - onConnect: () => void - onDisconnect: () => void + onConnect: (shared: boolean) => void + onDisconnect: (shared: boolean) => void }) { + const anyConnected = userConnected || orgConnected + const showOrgChip = !personalOnly && (orgConnected || isAdmin) + const adminMenu = isAdmin && !personalOnly + return ( -
+
{icon} @@ -138,67 +148,154 @@ function AppCard({
- - {connected && canDisconnect ? ( - +
+ {personalOnly || !anyConnected ? ( + + ) : ( + <> + + {showOrgChip ? ( + + ) : null} + + )} +
+ {adminMenu ? ( + + + + + + + userConnected ? onDisconnect(false) : onConnect(false) + } + > + {userConnected ? "Disconnect my account" : "Connect my account"} + + + orgConnected ? onDisconnect(true) : onConnect(true) + } + > + {orgConnected + ? "Disconnect workspace" + : "Connect for workspace"} + + + + ) : userConnected ? ( + onDisconnect(false)} disabled={busy}> {busy && } Disconnect - ) : ( - !connected && - (canConnect ? ( - - {busy && } - Connect - - ) : lockedHint ? ( - - - {lockedHint} - - ) : null) + ) : personalOnly ? null : ( + onConnect(false)} disabled={busy}> + {busy && } + Connect + )}
) } -function ScopeToggle({ - scope, - onChange, +function SlackCard({ + status, + isAdmin, + installHref, }: { - scope: Scope - onChange: (s: Scope) => void + status: SlackStatus | null + isAdmin: boolean + installHref: string }) { - const items: { id: Scope; label: string }[] = [ - { id: "org", label: "Organization" }, - { id: "user", label: "Personal" }, - ] + const connected = status?.connected ?? false return ( -
- {items.map((it) => ( - - ))} +
+
+
+ +
+
+

+ Slack +

+

+ Messaging +

+
+
+
+ + {isAdmin ? ( + + {connected ? "Reconnect" : "Connect"} + + ) : null} +
) } function RowSkeleton() { return ( -
+
@@ -206,7 +303,7 @@ function RowSkeleton() {
-
+
) } @@ -219,19 +316,11 @@ export default function CompanyBrainConnections() { const [rows, setRows] = useState([]) const [slackStatus, setSlackStatus] = useState(null) const [busy, setBusy] = useState(null) - const [scope, setScope] = useState("user") + const [customOpen, setCustomOpen] = useState(false) const [customName, setCustomName] = useState("") const [customServerUrl, setCustomServerUrl] = useState("") - const roleQuery = useQuery({ - queryKey: ["company-brain-connections", "role"], - queryFn: async () => - (await authClient.organization.getActiveMember()).data?.role ?? null, - staleTime: 60_000, - enabled: isCompanyBrain, - }) - const role = (roleQuery.data ?? "").toLowerCase() - const isAdmin = role === "owner" || role === "admin" + const { isAdmin } = useOrgMemberRole(isCompanyBrain) const load = useCallback(async () => { const [catRes, connRes, slackRes] = await Promise.all([ @@ -388,8 +477,12 @@ export default function CompanyBrainConnections() { } if (data.authUrl) { window.open(data.authUrl, "_blank", "noopener") + setCustomOpen(false) + setCustomName("") + setCustomServerUrl("") } else if (data.ok) { toast.success(`${slug} connected.`) + setCustomOpen(false) setCustomName("") setCustomServerUrl("") await load() @@ -461,43 +554,9 @@ export default function CompanyBrainConnections() { !catalogSlugs.has(row.serverSlug), ) : [] - const shared = scope === "org" - const description = shared - ? "Connected by admins. Used for reads when you haven't connected your own." - : "Your personal accounts, used for your actions and your reads." - return (
-
- - {slackStatus?.connected && slackStatus.teamName ? ( -

- Slack · {slackStatus.teamName} -

- ) : null} - {isAdmin ? ( - - - Reconnect Slack - - ) : null} -
- -

- {description} -

- -
+
{loading ? ( <> @@ -506,100 +565,128 @@ export default function CompanyBrainConnections() { ) : ( <> - {!shared && isStaff ? ( -
-
-

- Custom MCP server -

-

- Add a personal OAuth MCP server by URL. -

-
-
- setCustomName(event.target.value)} - placeholder="Name" - className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" - /> - setCustomServerUrl(event.target.value)} - placeholder="https://example.com/mcp" - className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]" - /> -
- - {busy?.startsWith("custom:") && ( - - )} - Connect - -
-
-
- ) : null} - + {apps.map((entry) => ( connect(entry, shared)} - onDisconnect={() => disconnect(entry, shared)} + userConnected={isConnected(entry.slug, false)} + orgConnected={isConnected(entry.slug, true)} + isAdmin={isAdmin} + busy={busy?.startsWith(`${entry.slug}:`) ?? false} + onConnect={(shared) => connect(entry, shared)} + onDisconnect={(shared) => disconnect(entry, shared)} /> ))} - {!shared && - customRows.map((row) => ( - {}} - onDisconnect={() => - disconnect( - { - slug: row.serverSlug, - name: titleCase(row.serverSlug.replace(/-/g, " ")), - category: "Custom OAuth MCP", - authType: "oauth", - }, - false, - ) - } - /> - ))} + {customRows.map((row) => ( + {}} + onDisconnect={() => + disconnect( + { + slug: row.serverSlug, + name: titleCase(row.serverSlug.replace(/-/g, " ")), + category: "Custom OAuth MCP", + authType: "oauth", + }, + false, + ) + } + /> + ))} + {isStaff ? ( + + ) : null} )}
+ + + +
+ + + Custom MCP server + +

+ Add a personal OAuth MCP server by URL. +

+
+ + + Close + +
+ +
+ setCustomName(event.target.value)} + placeholder="Name" + className="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]" + /> + setCustomServerUrl(event.target.value)} + placeholder="https://example.com/mcp" + className="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]" + /> +
+ + {busy?.startsWith("custom:") && ( + + )} + Connect + +
+
+
+
) } diff --git a/apps/web/components/settings/company-brain-models.tsx b/apps/web/components/settings/company-brain-models.tsx new file mode 100644 index 00000000..37815ef2 --- /dev/null +++ b/apps/web/components/settings/company-brain-models.tsx @@ -0,0 +1,537 @@ +"use client" + +import { cn } from "@lib/utils" +import { Check, ChevronDown, Loader2, Lock } from "lucide-react" +import { useMemo, useState } from "react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { + type BrainModelConfig, + type BrainModelRole, + type BrainReasoningEffort, + type BrainReasoningKey, + useBrainModels, + useUpdateBrainModels, +} from "@/hooks/use-brain-models" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { useOrgMemberRole } from "@/hooks/use-org-member-role" +import { dmSans125ClassName } from "@/lib/fonts" +import { PillButton } from "../integrations/install-steps" + +const MODEL_LABELS: Record = { + "claude-sonnet-5": "Sonnet 5", + "claude-opus-4.8": "Opus 4.8", + "claude-sonnet-4.6": "Sonnet 4.6", + "claude-haiku-4.5": "Haiku 4.5", + "grok-4.5": "Grok 4.5", + "grok-4.3": "Grok 4.3", + "grok-4-fast": "Grok 4 Fast", + "gpt-5.6": "GPT-5.6", + "gpt-5.5": "GPT-5.5", +} + +const labelFor = (id: string) => MODEL_LABELS[id] ?? id + +// One-line personality tags so non-experts can tell models apart. +const MODEL_TAGS: Record = { + "claude-sonnet-5": "balanced", + "claude-opus-4.8": "most capable, slower", + "claude-sonnet-4.6": "balanced", + "claude-haiku-4.5": "fast and light", + "grok-4.5": "sharp all-rounder", + "grok-4.3": "capable", + "grok-4-fast": "fastest", + "gpt-5.6": "capable", + "gpt-5.5": "capable", +} + +const EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", +} + +const ROWS: { + role: BrainModelRole + effortKey: BrainReasoningKey + title: string + help: string + effortHelp: string +}[] = [ + { + role: "main", + effortKey: "mainEffort", + title: "Answers", + help: "Writes the replies your brain sends in Slack.", + effortHelp: "Deeper thinking gives better answers but takes longer.", + }, + { + role: "triage", + effortKey: "triageEffort", + title: "When to reply", + help: "Decides whether and how the brain responds to a message.", + effortHelp: + "Deeper thinking routes messages more carefully but takes longer.", + }, + { + role: "research", + effortKey: "researchEffort", + title: "Web research", + help: "Looks things up on the web when researching your company.", + effortHelp: "Deeper research per web search, at the cost of speed.", + }, +] + +type FullConfig = Required + +type PresetDef = { + id: string + label: string + description: string + build: ( + defaults: BrainModelConfig, + choices: { main: string[] } & Partial< + Record + >, + ) => FullConfig +} + +const pickEffort = ( + options: BrainReasoningEffort[] | undefined, + wanted: BrainReasoningEffort, + fallback: BrainReasoningEffort, +): BrainReasoningEffort => + !options || options.length === 0 || options.includes(wanted) + ? wanted + : fallback + +const PRESETS: PresetDef[] = [ + { + id: "fast", + label: "Fastest", + description: "Snappy replies, lighter on credits. Best for quick lookups.", + build: (defaults, choices) => ({ + main: choices.main.includes("grok-4-fast") + ? "grok-4-fast" + : defaults.main, + triage: defaults.triage, + research: defaults.research, + mainEffort: pickEffort(choices.mainEffort, "low", "low"), + triageEffort: pickEffort(choices.triageEffort, "low", "low"), + researchEffort: pickEffort(choices.researchEffort, "low", "low"), + }), + }, + { + id: "balanced", + label: "Balanced", + description: "Our recommended mix of speed and answer quality.", + build: (defaults) => ({ + main: defaults.main, + triage: defaults.triage, + research: defaults.research, + mainEffort: defaults.mainEffort ?? "high", + triageEffort: defaults.triageEffort ?? "low", + researchEffort: defaults.researchEffort ?? "high", + }), + }, + { + id: "thorough", + label: "Most thorough", + description: "Deepest answers and research. Slower, uses more credits.", + build: (defaults, choices) => ({ + main: defaults.main, + triage: defaults.triage, + research: defaults.research, + mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"), + triageEffort: pickEffort(choices.triageEffort, "medium", "low"), + researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"), + }), + }, +] + +const CONFIG_KEYS = [ + "main", + "triage", + "research", + "mainEffort", + "triageEffort", + "researchEffort", +] as const + +const extraHighIsBounded = (model: string): boolean => + model.startsWith("grok-") || model.startsWith("gpt-") + +const controlClass = cn( + dmSans125ClassName(), + "h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50", +) +const fieldLabel = cn( + dmSans125ClassName(), + "text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]", +) +const selectContentClass = cn( + dmSans125ClassName(), + "rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]", +) +const selectItemClass = + "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white" + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +export default function CompanyBrainModels({ + showHeading = true, +}: { + showHeading?: boolean +}) { + const isCompanyBrain = useHasCompanyBrain() + const { isAdmin } = useOrgMemberRole(isCompanyBrain) + + const modelsQuery = useBrainModels(isCompanyBrain) + const update = useUpdateBrainModels() + + const [draft, setDraft] = useState>({}) + const [advancedOpen, setAdvancedOpen] = useState(false) + + const resolved = modelsQuery.data?.resolved + const defaults = modelsQuery.data?.defaults + const choices = modelsQuery.data?.choices + + const valueFor = (role: BrainModelRole): string => + draft[role] ?? resolved?.[role] ?? "" + const effortFor = (key: BrainReasoningKey): BrainReasoningEffort | "" => + draft[key] ?? resolved?.[key] ?? defaults?.[key] ?? "" + + const dirty = useMemo(() => { + if (!resolved) return false + return ROWS.some(({ role, effortKey }) => { + const modelChanged = + draft[role] !== undefined && draft[role] !== resolved[role] + const effortChanged = + draft[effortKey] !== undefined && + draft[effortKey] !== resolved[effortKey] + return modelChanged || effortChanged + }) + }, [draft, resolved]) + + const presets = useMemo(() => { + if (!defaults || !choices) return [] + return PRESETS.map((p) => ({ ...p, config: p.build(defaults, choices) })) + }, [defaults, choices]) + + // Preset whose full config matches what's currently on screen (draft over saved). + const activePresetId = useMemo(() => { + if (!resolved) return null + const current: Record = {} + for (const key of CONFIG_KEYS) { + current[key] = draft[key] ?? resolved[key] ?? defaults?.[key] + } + return ( + presets.find((p) => + CONFIG_KEYS.every((key) => current[key] === p.config[key]), + )?.id ?? null + ) + }, [presets, draft, resolved, defaults]) + + if (!isCompanyBrain) return null + + const disabled = !isAdmin || modelsQuery.isLoading || update.isPending + + return ( +
+ {showHeading ? ( +
+ Models + + Choose which models Company Brain uses. Applies to this organization + only. + +
+ ) : null} + + {modelsQuery.isLoading ? ( +
+ + Loading models… +
+ ) : modelsQuery.isError ? ( +

+ Couldn't load models. +

+ ) : ( +
+
+ {presets.map((preset) => { + const isActive = preset.id === activePresetId + return ( + + ) + })} +
+ + + + {advancedOpen || activePresetId === null ? ( +
+ {ROWS.map(({ role, effortKey, title, help, effortHelp }) => { + const options = choices?.[role] ?? [] + const current = valueFor(role) + const effortOptions = choices?.[effortKey] ?? [] + const currentEffort = effortFor(effortKey) + const isDefault = + defaults?.[role] === current && + (effortOptions.length === 0 || + defaults?.[effortKey] === currentEffort) + return ( +
+
+
+

+ {title} +

+

+ {help} +

+
+ {isDefault ? ( + + Default + + ) : null} +
+ +
+ Model + +
+ + {effortOptions.length > 0 ? ( +
+ Thinking depth +
+ {effortOptions.map((effort) => { + const isOn = currentEffort === effort + return ( + + ) + })} +
+
+ Faster + Smarter +
+

+ {effortHelp} +

+ {currentEffort === "xhigh" && + extraHighIsBounded(current) ? ( +

+ Extra high maps to High for this model provider. +

+ ) : null} +
+ ) : null} +
+ ) + })} +
+ ) : null} + + {!isAdmin ? ( +
+ + Only organization admins can change these. +
+ ) : ( +
+ {dirty ? ( + + ) : null} + { + const patch: Partial = {} + for (const { role, effortKey } of ROWS) { + if (draft[role] && draft[role] !== resolved?.[role]) { + patch[role] = draft[role] + } + if ( + draft[effortKey] && + draft[effortKey] !== resolved?.[effortKey] + ) { + patch[effortKey] = draft[effortKey] + } + } + update.mutate(patch, { onSuccess: () => setDraft({}) }) + }} + > + {update.isPending ? ( + + ) : null} + Save + +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index fa1db533..62d1e468 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -10,9 +10,6 @@ import Account from "@/components/settings/account" import Billing from "@/components/settings/billing" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" -import CompanyBrainConnections from "@/components/settings/company-brain-connections" -import { ProactivenessIcon } from "@/components/settings/proactiveness-icon" -import Proactiveness from "@/components/settings/proactiveness" import Support from "@/components/settings/support" import { ErrorBoundary } from "@/components/error-boundary" import { useRouter } from "next/navigation" @@ -54,8 +51,6 @@ export const TABS = [ "billing", "integrations", "connections", - "company-brain", - "proactiveness", "support", ] as const export type SettingsTab = (typeof TABS)[number] @@ -92,18 +87,6 @@ const NAV_ITEMS: NavItem[] = [ description: "Drive, Notion, OneDrive, MCP", icon: , }, - { - id: "company-brain", - label: "Company Brain", - description: "Connect apps to your brain — org and personal", - icon: , - }, - { - id: "proactiveness", - label: "Proactiveness", - description: "Scheduled digests and unprompted actions", - icon: , - }, { id: "support", label: "Support & Help", @@ -166,7 +149,7 @@ export function SettingsContent({ const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth() const isCompanyBrain = useHasCompanyBrain() - // Company Brain orgs manage tools inside Company Brain; hide the generic tabs. + // Company Brain orgs manage tools in the Configure view; hide the generic tabs. const navItems = isCompanyBrain ? NAV_ITEMS.filter( (item) => item.id !== "integrations" && item.id !== "connections", @@ -498,8 +481,6 @@ export function SettingsContent({ {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } - {activeTab === "company-brain" && } - {activeTab === "proactiveness" && } {activeTab === "support" && }
diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index d4ba056f..b69701f2 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -15,10 +15,10 @@ import { useRouter } from "next/navigation" import { LogOut, Settings, + Settings2, RotateCcw, HelpCircle, LifeBuoy, - Building2, Sun, } from "lucide-react" import { cn } from "@lib/utils" @@ -26,7 +26,6 @@ import { dmSansClassName } from "@/lib/fonts" import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { useTokenUsage } from "@/hooks/use-token-usage" import { useSettingsModal } from "@/components/settings/settings-modal" -import { ProactivenessIcon } from "@/components/settings/proactiveness-icon" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { useViewMode } from "@/lib/view-mode-context" @@ -165,20 +164,13 @@ export function UserProfileMenu({ Settings - openSettings("company-brain")} - className="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" - > - - Company Brain - {isCompanyBrain ? ( openSettings("proactiveness")} + onClick={() => void setViewMode("configure")} className="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" > - - Proactiveness + + Configure ) : null} {isCompanyBrain ? ( diff --git a/apps/web/hooks/use-brain-models.ts b/apps/web/hooks/use-brain-models.ts new file mode 100644 index 00000000..95651d52 --- /dev/null +++ b/apps/web/hooks/use-brain-models.ts @@ -0,0 +1,68 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { useAuth } from "@lib/auth-context" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +const BASE = `${BACKEND}/brain/models` + +export type BrainModelRole = "main" | "triage" | "research" +export type BrainReasoningEffort = "low" | "medium" | "high" | "xhigh" +export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort" + +export type BrainModelConfig = Record & + Partial> + +export type BrainModelsResponse = { + resolved: BrainModelConfig + defaults: BrainModelConfig + choices: Record & + Partial> +} + +export function useBrainModels(enabled: boolean) { + const { org } = useAuth() + return useQuery({ + queryKey: ["brain", "models", org?.id], + queryFn: async (): Promise => { + const res = await fetch(`${BASE}/`, { credentials: "include" }) + if (!res.ok) throw new Error("Failed to load models") + return res.json() + }, + enabled, + staleTime: 60_000, + }) +} + +export function useUpdateBrainModels() { + const { org } = useAuth() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (patch: Partial) => { + const res = await fetch(`${BASE}/`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json", "X-App-Source": "nova" }, + body: JSON.stringify(patch), + }) + if (res.status === 403) + throw new Error("Only admins can change brain models.") + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error(b.message ?? b.error ?? "Failed to save models") + } + return res.json() + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["brain", "models", org?.id], + }) + toast.success("Brain models saved") + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to save models"), + }) +} diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts index c0b1c864..7f0e6384 100644 --- a/apps/web/hooks/use-document-mutations.ts +++ b/apps/web/hooks/use-document-mutations.ts @@ -390,6 +390,7 @@ export function useDocumentMutations({ urls: string[] project: string }): Promise<{ success: number; failed: number }> => { + const entityContext = await resolveEntityContext(project) let success = 0 let failed = 0 @@ -400,7 +401,7 @@ export function useDocumentMutations({ documents: chunk.map((url) => ({ content: url, containerTags: [project], - entityContext, + ...(entityContext !== undefined ? { entityContext } : {}), metadata: { sm_source: "consumer" }, })), }, diff --git a/apps/web/hooks/use-org-member-role.ts b/apps/web/hooks/use-org-member-role.ts new file mode 100644 index 00000000..66475e0c --- /dev/null +++ b/apps/web/hooks/use-org-member-role.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" + +// Shared active-member role for the current org. Single queryKey so the +// company-brain settings sections dedupe the getActiveMember call. +export function useOrgMemberRole(enabled = true) { + const { org } = useAuth() + const query = useQuery({ + queryKey: ["org", "member-role", org?.id], + queryFn: async () => + (await authClient.organization.getActiveMember()).data?.role ?? null, + staleTime: 60_000, + enabled: enabled && !!org?.id, + }) + const role = (query.data ?? "").toLowerCase() + const isAdmin = role === "owner" || role === "admin" + return { role, isAdmin, query } +} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 7ce5b9df..eebd1697 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -222,14 +222,7 @@ export const analytics = { // settings / spaces / docs analytics settingsTabChanged: (props: { - tab: - | "account" - | "billing" - | "integrations" - | "connections" - | "company-brain" - | "proactiveness" - | "support" + tab: "account" | "billing" | "integrations" | "connections" | "support" }) => safeCapture("settings_tab_changed", props), spaceCreated: () => safeCapture("space_created"), diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index 9afb6eab..2d9f1688 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -28,6 +28,7 @@ const viewLiterals = [ "graph", "list", "integrations", + "configure", "chat", "digests", // Integration sub-views — each card is its own view diff --git a/bun.lock b/bun.lock index eb3b53dd..0512d4a0 100644 --- a/bun.lock +++ b/bun.lock @@ -332,7 +332,7 @@ }, "packages/tools": { "name": "@supermemory/tools", - "version": "2.1.0", + "version": "2.1.1", "dependencies": { "@ai-sdk/anthropic": "^2.0.25", "@ai-sdk/openai": "^2.0.23", diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py b/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py index 9069630e..5359426e 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/context_provider.py @@ -9,7 +9,13 @@ following the same pattern as the built-in Mem0 integration. from typing import Any, Literal, Optional -from agent_framework import BaseContextProvider +try: + from agent_framework import BaseContextProvider +except ImportError: + # Renamed in agent-framework-core 1.0.0 stable; the interface is + # unchanged (source_id __init__, before_run/after_run hooks with + # identical keyword-only signatures). + from agent_framework import ContextProvider as BaseContextProvider from .connection import AgentSupermemory from .utils import ( diff --git a/packages/tools/package.json b/packages/tools/package.json index 053089e5..59289f3d 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.1.0", + "version": "2.1.1", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 80aea36d..c4f62f16 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -4,12 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // can be exercised deterministically without any network access. We only need // `search.execute` to return a single document with known multi-line content. const searchExecute = vi.fn() +const addMock = vi.fn() vi.mock("supermemory", () => { return { default: class MockSupermemory { search = { execute: searchExecute } - add = vi.fn() + add = addMock memories = { forget: vi.fn() } }, } @@ -83,3 +84,100 @@ describe("ClaudeMemoryTool view_range", () => { expect(result.content).not.toContain("line5") }) }) + +describe("ClaudeMemoryTool exact-file matching", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + searchExecute.mockReset() + addMock.mockReset() + tool = new ClaudeMemoryTool("test-api-key") + }) + + it("view finds the exact file even when a neighbour ranks first", async () => { + searchExecute.mockResolvedValue({ + results: [ + { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + { documentId: "memories_notes_txt", content: FILE_CONTENT }, + ], + }) + + const result = await tool.handleCommand({ + command: "view", + path: FILE_PATH, + }) + + expect(result.success).toBe(true) + expect(result.content).toContain("line1") + expect(result.content).not.toContain("backup stuff") + }) + + it("view reports not-found instead of returning a different file", async () => { + // Semantic search can surface a similarly-named file; that must not + // be served as the requested one. + searchExecute.mockResolvedValue({ + results: [ + { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + ], + }) + + const result = await tool.handleCommand({ + command: "view", + path: FILE_PATH, + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("File not found") + }) + + it("str_replace refuses to modify a different file than requested", async () => { + searchExecute.mockResolvedValue({ + results: [ + { documentId: "memories_notes_backup_txt", content: "backup stuff" }, + ], + }) + + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "backup", + new_str: "primary", + }) + + expect(result.success).toBe(false) + expect(addMock).not.toHaveBeenCalled() + }) +}) + +describe("ClaudeMemoryTool str_replace replacement literalness", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + searchExecute.mockReset() + addMock.mockReset() + searchExecute.mockResolvedValue({ + results: [{ documentId: "memories_notes_txt", content: FILE_CONTENT }], + }) + 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`, + }) + + 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(stored).not.toContain("line3") + }) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 6830b296..8c665701 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -262,29 +262,21 @@ export class ClaudeMemoryTool { viewRange?: [number, number], ): Promise { try { - const normalizedId = this.normalizePathToCustomId(filePath) - - const response = await this.client.search.execute({ - q: normalizedId, - containerTags: this.containerTags, - limit: 1, - includeFullDocs: true, - }) - - // Try to find exact match by customId - const exactMatch = response.results?.find( - (r) => r.documentId === normalizedId, - ) - const document = exactMatch || response.results?.[0] - - if (!document) { + // 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. + const readResult = await this.getFileDocument(filePath) + if (!readResult.success || !readResult.document) { return { success: false, - error: `File not found: ${filePath}`, + error: readResult.error || `File not found: ${filePath}`, } } - let content = document.content || "" + const document = readResult.document + + let content: string = document.raw || document.content || "" // Apply line range if specified if (viewRange) { @@ -393,8 +385,10 @@ export class ClaudeMemoryTool { } } - // Replace the string - const newContent = originalContent.replace(oldStr, newStr) + // Replace the string. The function replacer keeps `$` sequences + // in the replacement literal — a bare string here would expand + // patterns like $&, $', and $` and silently corrupt the file. + const newContent = originalContent.replace(oldStr, () => newStr) // Update the document const normalizedId = this.normalizePathToCustomId(filePath) @@ -590,11 +584,12 @@ export class ClaudeMemoryTool { includeFullDocs: true, }) - // Try to find exact match by customId first - const exactMatch = response.results?.find( + // 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 document = response.results?.find( (r) => r.documentId === normalizedId, ) - const document = exactMatch || response.results?.[0] if (!document) { return { diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 7fc4267e..33ac373a 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -399,7 +399,7 @@ const addMemoryTool = async ( * @param options.customId - Optional conversation ID to group messages for contextual memory generation * @param options.verbose - Enable detailed logging of memory operations (default: false) * @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile") - * @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "never") + * @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always") * @returns Object with `wrapClient` and `createClient` methods * @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set *