diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20842238..80600ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: quality-checks: name: Quality Checks runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/claude-auto-fix-ci.yml b/.github/workflows/claude-auto-fix-ci.yml index 993545c6..246e94c1 100644 --- a/.github/workflows/claude-auto-fix-ci.yml +++ b/.github/workflows/claude-auto-fix-ci.yml @@ -19,6 +19,7 @@ jobs: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.pull_requests[0] runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v5 @@ -41,18 +42,22 @@ jobs: - name: Get CI failure details id: failure_details uses: actions/github-script@v7 + env: + RUN_ID: ${{ github.event.workflow_run.id }} with: script: | + const runId = Number(process.env.RUN_ID); + const run = await github.rest.actions.getWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const jobs = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure'); diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9da46700..38dbdf9d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -4,14 +4,23 @@ on: pull_request: types: [opened, synchronize, ready_for_review, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: claude-review: + # Fork PRs run with a read-only token and no secrets/OIDC id-token, so the + # Claude action can never authenticate there and the job always fails. Skip + # it for forks so those PRs report a clean skipped check instead of a red X. if: | github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'graphite-app[bot]' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 17a26bc7..c37d662d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -13,11 +13,36 @@ on: jobs: claude: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ( + github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + (github.event.review.author_association == 'OWNER' || + github.event.review.author_association == 'MEMBER' || + github.event.review.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + (github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR') + ) runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read pull-requests: read diff --git a/.github/workflows/publish-agent-framework-python.yml b/.github/workflows/publish-agent-framework-python.yml index bb46d3d3..ef74637d 100644 --- a/.github/workflows/publish-agent-framework-python.yml +++ b/.github/workflows/publish-agent-framework-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/agent-framework-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-ai-sdk.yml b/.github/workflows/publish-ai-sdk.yml index 27817b20..1ac60cf1 100644 --- a/.github/workflows/publish-ai-sdk.yml +++ b/.github/workflows/publish-ai-sdk.yml @@ -7,9 +7,14 @@ on: paths: - "packages/ai-sdk/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-cartesia-sdk-python.yml b/.github/workflows/publish-cartesia-sdk-python.yml index 14155ac1..aa65eb4f 100644 --- a/.github/workflows/publish-cartesia-sdk-python.yml +++ b/.github/workflows/publish-cartesia-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/cartesia-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-memory-graph.yml b/.github/workflows/publish-memory-graph.yml index 66dcbf3c..6dff18ea 100644 --- a/.github/workflows/publish-memory-graph.yml +++ b/.github/workflows/publish-memory-graph.yml @@ -7,9 +7,14 @@ on: paths: - "packages/memory-graph/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-openai-sdk-python.yml b/.github/workflows/publish-openai-sdk-python.yml index 141477a8..e6066bcf 100644 --- a/.github/workflows/publish-openai-sdk-python.yml +++ b/.github/workflows/publish-openai-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/openai-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-pipecat-sdk-python.yml b/.github/workflows/publish-pipecat-sdk-python.yml index a0152ce3..f98ab1c8 100644 --- a/.github/workflows/publish-pipecat-sdk-python.yml +++ b/.github/workflows/publish-pipecat-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/pipecat-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-tools.yml b/.github/workflows/publish-tools.yml index 46a56329..8dfb8569 100644 --- a/.github/workflows/publish-tools.yml +++ b/.github/workflows/publish-tools.yml @@ -7,9 +7,14 @@ on: paths: - "packages/tools/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7c29ec..feb1ed41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed: # You'll need to add your API keys and database URLs ``` -4. **Change proxy for local development** - - Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`): - - ```ts - if (url.hostname === "localhost") { - return NextResponse.next(); - } - -5. **Start the Development Server** +4. **Start the Development Server** ```bash bun run dev:local diff --git a/README.md b/README.md index a4029582..f7e7fd18 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. @@ -351,11 +357,12 @@ const client = new Supermemory({ ``` - **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot. +- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud. - **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine. - **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move. - **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`. -Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, configuration, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise). +Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise). --- @@ -365,10 +372,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/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index e6dba3eb..ccf3dce0 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -21,11 +21,55 @@ import type { MemoryPayload, } from "../utils/types" +const PLATFORM_LABELS: Record = { + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", + t3: "T3 Chat", + twitter: "X / Twitter", +} + +function normalizePlatform(value?: string): string | undefined { + if (!value) return undefined + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") +} + +function inferPlatformFromActionSource( + actionSource: string, +): string | undefined { + const source = actionSource.toLowerCase() + if (source.includes("chatgpt")) return "chatgpt" + if (source.includes("claude")) return "claude" + if (source.includes("gemini")) return "gemini" + if (source.includes("t3")) return "t3" + if (source.includes("twitter") || source.includes("x_")) return "twitter" + return undefined +} + +function inferPlatformFromUrl(url?: string): string | undefined { + if (!url) return undefined + try { + const hostname = new URL(url).hostname + if (hostname === "chatgpt.com" || hostname === "chat.openai.com") { + return "chatgpt" + } + if (hostname === "claude.ai") return "claude" + if (hostname === "gemini.google.com") return "gemini" + if (hostname === "t3.chat") return "t3" + if (hostname === "x.com" || hostname === "twitter.com") return "twitter" + } catch { + return undefined + } +} + export default defineBackground(() => { let twitterImporter: TwitterImporter | null = null browser.runtime.onInstalled.addListener(async (details) => { - if (details.reason === "install") { + if (details.reason === "install" || details.reason === "update") { await trackEvent("extension_installed", { reason: details.reason, version: browser.runtime.getManifest().version, @@ -107,11 +151,33 @@ export default defineBackground(() => { content = data?.url || "" } + const platform = + normalizePlatform(data.sourcePlatform) || + inferPlatformFromUrl(data.url) || + inferPlatformFromActionSource(actionSource) + const platformLabel = platform + ? data.sourcePlatformLabel || PLATFORM_LABELS[platform] || platform + : undefined + const metadata: MemoryPayload["metadata"] = { sm_source: "consumer", + sm_origin: "browser_extension", + sm_origin_action: actionSource, website_url: data.url, } + if (platform) { + metadata.sm_origin_platform = platform + } + + if (platformLabel) { + metadata.sm_origin_platform_label = platformLabel + } + + if (data.sourceSurface) { + metadata.sm_origin_surface = data.sourceSurface + } + if (data.ogImage) { metadata.website_og_image = data.ogImage } @@ -148,7 +214,17 @@ export default defineBackground(() => { eventSource: string, ): Promise<{ success: boolean; data?: unknown; error?: string }> => { try { - const responseData = await searchMemories(data) + let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT + try { + const defaultProject = await getDefaultProject() + if (defaultProject?.containerTag) { + containerTag = defaultProject.containerTag + } + } catch (error) { + console.warn("Failed to get default project, using fallback:", error) + } + + const responseData = await searchMemories(data, containerTag) const response = responseData as { results?: Array<{ memory?: string }> } @@ -156,7 +232,6 @@ export default defineBackground(() => { response.results?.forEach((result, index) => { memories.push(`${index + 1}. ${result.memory} \n`) }) - console.log("Memories:", memories) await trackEvent(eventSource) return { success: true, data: memories } } catch (error) { @@ -236,12 +311,12 @@ export default defineBackground(() => { platform: string source: string } - console.log("=== PROMPT CAPTURED ===") - console.log(messageData) - console.log("========================") const memoryData: MemoryData = { content: messageData.prompt, + url: messageData.source, + sourcePlatform: messageData.platform, + sourceSurface: "prompt_capture", } const result = await saveMemoryToSupermemory( diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 7c3d28aa..444e3ac8 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -13,18 +13,37 @@ import { createChatGPTInputBarElement, DOMUtils, } from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" let chatGPTDebounceTimeout: NodeJS.Timeout | null = null let chatGPTRouteObserver: MutationObserver | null = null let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null let chatGPTObserverThrottle: NodeJS.Timeout | null = null +const CHATGPT_DEBUG = false +const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]" export function initializeChatGPT() { + debugChatGPT("initializeChatGPT called", { + host: window.location.hostname, + href: window.location.href, + }) + if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) { + debugChatGPT("not on ChatGPT domain, skipping") return } if (document.body.hasAttribute("data-chatgpt-initialized")) { + debugChatGPT("already initialized") return } @@ -39,6 +58,18 @@ export function initializeChatGPT() { setupChatGPTRouteChangeDetection() document.body.setAttribute("data-chatgpt-initialized", "true") + debugChatGPT("initialized listeners") +} + +function debugChatGPT(message: string, data?: unknown) { + if (!CHATGPT_DEBUG) return + + if (data === undefined) { + console.log(CHATGPT_LOG_PREFIX, message) + return + } + + console.log(CHATGPT_LOG_PREFIX, message, data) } function setupChatGPTRouteChangeDetection() { @@ -58,7 +89,7 @@ function setupChatGPTRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log("ChatGPT route changed, re-adding supermemory elements") + debugChatGPT("route changed, re-adding supermemory elements", currentUrl) setTimeout(() => { addSupermemoryButtonToMemoriesDialog() addSaveChatGPTElementBeforeComposerBtn() @@ -83,8 +114,10 @@ function setupChatGPTRouteChangeDetection() { if ( element.querySelector?.("#prompt-textarea") || element.querySelector?.("button.composer-btn") || + element.querySelector?.("button") || element.querySelector?.('[role="dialog"]') || element.matches?.("#prompt-textarea") || + element.matches?.("button") || element.id === "prompt-textarea" ) { shouldRecheck = true @@ -98,6 +131,7 @@ function setupChatGPTRouteChangeDetection() { chatGPTObserverThrottle = setTimeout(() => { try { chatGPTObserverThrottle = null + debugChatGPT("DOM changed near composer, rechecking UI") addSupermemoryButtonToMemoriesDialog() addSaveChatGPTElementBeforeComposerBtn() setupChatGPTAutoFetch() @@ -124,6 +158,8 @@ function setupChatGPTRouteChangeDetection() { async function getRelatedMemoriesForChatGPT(actionSource: string) { try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED const userQuery = document.getElementById("prompt-textarea")?.textContent || "" @@ -138,7 +174,15 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { return } - updateChatGPTIconFeedback("Searching memories...", iconElement) + if (isAutoSearch) { + const promptElement = document.getElementById("prompt-textarea") + if (promptElement) { + showLoadingSuggestion("chatgpt", promptElement) + } + setMemoryMarkerStatus(iconElement, "searching") + } else { + updateChatGPTIconFeedback("Searching memories...", iconElement) + } const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -159,24 +203,39 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { if (response?.success && response?.data) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { - promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - console.log( - "Prompt element dataset:", - promptElement.dataset.supermemories, + const memoryText = showMemorySuggestion( + "chatgpt", + promptElement, + response.data, ) + debugChatGPT("memory suggestion rendered", { + memoryLength: memoryText.length, + }) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = String(response.data) - updateChatGPTIconFeedback("Included Memories", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateChatGPTIconFeedback("Included Memories", iconElement) + } } else { console.warn( "ChatGPT prompt element not found after successful memory fetch", ) - updateChatGPTIconFeedback("Memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateChatGPTIconFeedback("Memories found", iconElement) + } } } else { console.warn("No memories found or API response invalid") - updateChatGPTIconFeedback("No memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateChatGPTIconFeedback("No memories found", iconElement) + } } } catch (error) { console.error("Error getting related memories:", error) @@ -185,7 +244,13 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { '[id*="sm-chatgpt-input-bar-element-before-composer"]', )[0] as HTMLElement if (icon) { - updateChatGPTIconFeedback("Error fetching memories", icon) + if ( + actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(icon, "error") + } else { + updateChatGPTIconFeedback("Error fetching memories", icon) + } } } catch (feedbackError) { console.error("Failed to update error feedback:", feedbackError) @@ -218,7 +283,7 @@ function addSupermemoryButtonToMemoriesDialog() { supermemoryButton.id = "supermemory-save-button" supermemoryButton.className = "btn relative btn-primary-outline mr-2" - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") supermemoryButton.innerHTML = `
@@ -278,11 +343,16 @@ async function saveMemoriesToSupermemory() { action: MESSAGE_TYPES.SAVE_MEMORY, data: { html: combinedContent, + sourcePlatform: "chatgpt", + sourceSurface: "memories_dialog", + url: window.location.href, }, actionSource: "chatgpt_memories_dialog", }) - console.log({ response }) + debugChatGPT("memory dialog saved", { + success: response.success, + }) if (response.success) { DOMUtils.showToast("success") @@ -300,272 +370,242 @@ function updateChatGPTIconFeedback( iconElement: HTMLElement, resetAfter = 0, ) { - if (!iconElement.dataset.originalHtml) { - iconElement.dataset.originalHtml = iconElement.innerHTML + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) + + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return } - const feedbackDiv = document.createElement("div") - feedbackDiv.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: #513EA9; - border-radius: 12px; - color: white; - font-size: 12px; - font-weight: 500; - cursor: ${message === "Included Memories" ? "pointer" : "default"}; - position: relative; - ` - - feedbackDiv.innerHTML = ` - - ${message} - ` - - if (message === "Included Memories" && iconElement.dataset.memoriesData) { - const popup = document.createElement("div") - popup.style.cssText = ` - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: #1a1a1a; - color: white; - padding: 0; - border-radius: 12px; - font-size: 13px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - max-width: 500px; - max-height: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 999999; - display: none; - border: 1px solid #333; - ` - - const header = document.createElement("div") - header.style.cssText = ` - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-bottom: 1px solid #333; - opacity: 0.8; - ` - header.innerHTML = ` - Included Memories - ` - - const content = document.createElement("div") - content.style.cssText = ` - padding: 0; - max-height: 300px; - overflow-y: auto; - ` - - const memoriesText = iconElement.dataset.memoriesData || "" - console.log("Memories text:", memoriesText) - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - console.log("Individual memories:", individualMemories) - - individualMemories.forEach((memory, index) => { - const memoryItem = document.createElement("div") - memoryItem.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 10px; - font-size: 13px; - line-height: 1.4; - ` - - const memoryText = document.createElement("div") - memoryText.style.cssText = ` - flex: 1; - color: #e5e5e5; - ` - memoryText.textContent = memory.trim() - - const removeBtn = document.createElement("button") - removeBtn.style.cssText = ` - background: transparent; - color: #9ca3af; - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; - height: fit-content; - display: flex; - align-items: center; - justify-content: center; - ` - removeBtn.innerHTML = `` - removeBtn.dataset.memoryIndex = index.toString() - - removeBtn.addEventListener("mouseenter", () => { - removeBtn.style.color = "#ef4444" - }) - removeBtn.addEventListener("mouseleave", () => { - removeBtn.style.color = "#9ca3af" - }) - - memoryItem.appendChild(memoryText) - memoryItem.appendChild(removeBtn) - content.appendChild(memoryItem) - }) - - popup.appendChild(header) - popup.appendChild(content) - document.body.appendChild(popup) - - feedbackDiv.addEventListener("mouseenter", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Click to see memories" - } - }) - - feedbackDiv.addEventListener("mouseleave", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Included Memories" - } - }) - - feedbackDiv.addEventListener("click", (e) => { - e.stopPropagation() - popup.style.display = "block" - }) - - document.addEventListener("click", (e) => { - if (!popup.contains(e.target as Node)) { - popup.style.display = "none" - } - }) - - 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 - - if (memoryItem) { - content.removeChild(memoryItem) - } - - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) - - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = updatedMemories - - const promptElement = document.getElementById("prompt-textarea") - if (promptElement) { - promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` - } - - content - .querySelectorAll("button[data-memory-index]") - .forEach((btn, newIndex) => { - const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() - }) - - if (currentMemories.length <= 1) { - if (promptElement?.dataset.supermemories) { - delete promptElement.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) + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return } - iconElement.innerHTML = "" - iconElement.appendChild(feedbackDiv) - - if (resetAfter > 0) { - setTimeout(() => { - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - }, resetAfter) - } + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) } function addSaveChatGPTElementBeforeComposerBtn() { - const composerButtons = document.querySelectorAll("button.composer-btn") + const promptInput = getChatGPTPromptInput() + if (!promptInput) { + debugChatGPT("prompt input not found", getChatGPTDomSnapshot()) + return + } - composerButtons.forEach((button) => { - if (button.hasAttribute("data-supermemory-icon-added-before")) { - return + const composer = findChatGPTComposerRoot(promptInput) + if (!composer?.querySelector) { + debugChatGPT("composer root not found", describeElement(promptInput)) + return + } + + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer"]`, + ), + ) + if (existingMarkers.length > 1) { + debugChatGPT("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() } + } else if (existingMarkers.length === 1) { + debugChatGPT("marker already exists") + return + } - const parent = button.parentElement - if (!parent) return - - const parentSiblings = parent.parentElement?.children - if (!parentSiblings) return - - let hasSpeechButtonSibling = false - for (const sibling of parentSiblings) { - if ( - sibling.getAttribute("data-testid") === - "composer-speech-button-container" - ) { - hasSpeechButtonSibling = true - break - } - } - - if (!hasSpeechButtonSibling) return - - const grandParent = parent.parentElement - if (!grandParent) return - - const existingIcon = grandParent.querySelector( - `#${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer`, - ) - if (existingIcon) { - button.setAttribute("data-supermemory-icon-added-before", "true") - return - } - - const saveChatGPTElement = createChatGPTInputBarElement(async () => { - await getRelatedMemoriesForChatGPT( - POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED, - ) - }) - - saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` - - button.setAttribute("data-supermemory-icon-added-before", "true") - - grandParent.insertBefore(saveChatGPTElement, parent) - - setupChatGPTAutoFetch() + const buttons = findChatGPTComposerButtons(promptInput, composer) + debugChatGPT("candidate ChatGPT buttons", { + input: describeElement(promptInput), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), }) + + const micButton = buttons.find((button) => isChatGPTMicButton(button)) + const voiceButton = buttons.find((button) => isChatGPTVoiceButton(button)) + const sendButton = buttons.find((button) => isChatGPTSendButton(button)) + const anchorButton = + micButton || voiceButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findChatGPTButtonSlot(anchorButton, composer) + const speechContainer = composer.querySelector( + '[data-testid="composer-speech-button-container"]', + ) as HTMLElement | null + const targetContainer = + anchorSlot?.parentElement || + speechContainer?.parentElement || + promptInput.parentElement + + if (!targetContainer) { + debugChatGPT("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(promptInput), + }) + return + } + + const saveChatGPTElement = createChatGPTInputBarElement(async () => { + await getRelatedMemoriesForChatGPT( + POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED, + ) + }) + + saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(saveChatGPTElement, anchorSlot) + debugChatGPT("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), + }) + } else { + targetContainer.appendChild(saveChatGPTElement) + debugChatGPT("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) + } + + setupChatGPTAutoFetch() +} + +function getChatGPTPromptInput(): HTMLElement | null { + return document.querySelector( + '#prompt-textarea, [data-testid="prompt-textarea"], div[contenteditable="true"]', + ) as HTMLElement | null +} + +function findChatGPTComposerRoot(input: HTMLElement): HTMLElement { + const form = input.closest("form") as HTMLElement | null + if (form) return form + + let current: HTMLElement | null = input + for (let depth = 0; current && depth < 8; depth += 1) { + if (current.querySelectorAll("button").length >= 2) { + return current + } + current = current.parentElement + } + + return input.parentElement || document.body +} + +function findChatGPTComposerButtons( + input: HTMLElement, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons + } + + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) + + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260 + + return verticallyNear && horizontallyNear + }) +} + +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.id, + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} + +function isChatGPTMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|dictate/i.test(buttonLabel(button)) +} + +function isChatGPTVoiceButton(button: HTMLButtonElement): boolean { + return /voice|audio|speech/i.test(buttonLabel(button)) +} + +function isChatGPTSendButton(button: HTMLButtonElement): boolean { + const label = buttonLabel(button) + return /composer-submit-button|send|submit/i.test(label) +} + +function findChatGPTButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current + } + + current = parent + } + + return current || button +} + +function describeElement(element: Element | null): string | null { + if (!element) return null + + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } + + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } + + return parts.join("") +} + +function getChatGPTDomSnapshot() { + return { + promptTextareas: document.querySelectorAll("#prompt-textarea").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + composerButtons: document.querySelectorAll("button.composer-btn").length, + speechContainers: document.querySelectorAll( + '[data-testid="composer-speech-button-container"]', + ).length, + } } async function setupChatGPTAutoFetch() { @@ -586,12 +626,29 @@ async function setupChatGPTAutoFetch() { promptTextarea.setAttribute("data-supermemory-auto-fetch", "true") const handleInput = () => { + const content = promptTextarea.textContent?.trim() || "" + syncAcceptedSupermemoryState(promptTextarea) + + if (content.length === 0) { + clearMemorySuggestion("chatgpt", promptTextarea) + document + .querySelectorAll( + '[id*="sm-chatgpt-input-bar-element-before-composer"]', + ) + .forEach((icon) => { + setMemoryMarkerStatus(icon as HTMLElement, "neutral") + }) + } + if (chatGPTDebounceTimeout) { clearTimeout(chatGPTDebounceTimeout) } chatGPTDebounceTimeout = setTimeout(async () => { - const content = promptTextarea.textContent?.trim() || "" + if (hasAcceptedSupermemoryContext(promptTextarea)) { + clearMemorySuggestion("chatgpt", promptTextarea) + return + } if (content.length > 2) { await getRelatedMemoriesForChatGPT( @@ -604,6 +661,7 @@ async function setupChatGPTAutoFetch() { icons.forEach((icon) => { const iconElement = icon as HTMLElement + setMemoryMarkerStatus(iconElement, "neutral") if (iconElement.dataset.originalHtml) { iconElement.innerHTML = iconElement.dataset.originalHtml delete iconElement.dataset.originalHtml @@ -612,7 +670,7 @@ async function setupChatGPTAutoFetch() { }) if (promptTextarea.dataset.supermemories) { - delete promptTextarea.dataset.supermemories + clearMemorySuggestion("chatgpt", promptTextarea) } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) @@ -631,7 +689,7 @@ function setupChatGPTPromptCapture() { const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false if (!autoCapture) { - console.log("Auto capture prompts is disabled, skipping prompt capture") + debugChatGPT("auto prompt capture disabled") return } const promptTextarea = document.getElementById("prompt-textarea") @@ -641,26 +699,18 @@ function setupChatGPTPromptCapture() { promptContent = promptTextarea.textContent || "" } - const storedMemories = promptTextarea?.dataset.supermemories - if ( - storedMemories && - promptTextarea && - !promptContent.includes("Supermemories of user") - ) { - promptTextarea.appendChild(document.createTextNode(storedMemories)) - promptContent = promptTextarea.textContent || "" - } - if (promptTextarea && promptContent.trim()) { - console.log(`ChatGPT prompt submitted via ${source}:`, promptContent) - + debugChatGPT("prompt submitted", { + source, + promptLength: promptContent.length, + }) try { await browser.runtime.sendMessage({ action: MESSAGE_TYPES.CAPTURE_PROMPT, data: { prompt: promptContent, platform: "chatgpt", - source: source, + source: window.location.href, }, }) } catch (error) { @@ -682,7 +732,7 @@ function setupChatGPTPromptCapture() { }) if (promptTextarea?.dataset.supermemories) { - delete promptTextarea.dataset.supermemories + clearMemorySuggestion("chatgpt", promptTextarea) } } @@ -705,6 +755,18 @@ function setupChatGPTPromptCapture() { async (event) => { const target = event.target as HTMLElement + if ( + (target.id === "prompt-textarea" || + target.closest("#prompt-textarea")) && + acceptMemorySuggestion( + event, + "chatgpt", + document.getElementById("prompt-textarea"), + ) + ) { + return + } + if ( target.id === "prompt-textarea" && event.key === "Enter" && diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 4fe41cbd..7bff4dfc 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -13,18 +13,37 @@ import { createClaudeInputBarElement, DOMUtils, } from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" let claudeDebounceTimeout: NodeJS.Timeout | null = null let claudeRouteObserver: MutationObserver | null = null let claudeUrlCheckInterval: NodeJS.Timeout | null = null let claudeObserverThrottle: NodeJS.Timeout | null = null +const CLAUDE_DEBUG = false +const CLAUDE_LOG_PREFIX = "[supermemory:claude]" export function initializeClaude() { + debugClaude("initializeClaude called", { + host: window.location.hostname, + href: window.location.href, + }) + if (!DOMUtils.isOnDomain(DOMAINS.CLAUDE)) { + debugClaude("not on Claude domain, skipping") return } if (document.body.hasAttribute("data-claude-initialized")) { + debugClaude("already initialized") return } @@ -39,6 +58,18 @@ export function initializeClaude() { setupClaudeRouteChangeDetection() document.body.setAttribute("data-claude-initialized", "true") + debugClaude("initialized listeners") +} + +function debugClaude(message: string, data?: unknown) { + if (!CLAUDE_DEBUG) return + + if (data === undefined) { + console.log(CLAUDE_LOG_PREFIX, message) + return + } + + console.log(CLAUDE_LOG_PREFIX, message, data) } function setupClaudeRouteChangeDetection() { @@ -58,7 +89,7 @@ function setupClaudeRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log("Claude route changed, re-adding supermemory icon") + debugClaude("route changed, re-adding supermemory icon", currentUrl) setTimeout(() => { addSupermemoryButtonToClaudeMemoryDialog() addSupermemoryIconToClaudeInput() @@ -84,9 +115,11 @@ function setupClaudeRouteChangeDetection() { element.querySelector?.('[role="dialog"]') || element.querySelector?.('div[contenteditable="true"]') || element.querySelector?.("textarea") || + element.querySelector?.("button") || element.matches?.('[role="dialog"]') || element.matches?.('div[contenteditable="true"]') || element.matches?.("textarea") || + element.matches?.("button") || element.textContent?.includes("Manage memory") ) { shouldRecheck = true @@ -100,6 +133,7 @@ function setupClaudeRouteChangeDetection() { claudeObserverThrottle = setTimeout(() => { try { claudeObserverThrottle = null + debugClaude("DOM changed near composer, rechecking UI") addSupermemoryButtonToClaudeMemoryDialog() addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() @@ -125,39 +159,207 @@ function setupClaudeRouteChangeDetection() { } function addSupermemoryIconToClaudeInput() { - const targetContainers = document.querySelectorAll( - ".relative.flex-1.flex.items-center.gap-2.shrink.min-w-0", + const input = getClaudePromptInput() + if (!input) { + debugClaude("prompt input not found", getClaudeDomSnapshot()) + return + } + + const composer = findComposerRoot(input) + if (!composer?.querySelector) { + debugClaude("composer root not found", describeElement(input)) + return + } + + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}"]`, + ), ) - - targetContainers.forEach((container) => { - if (container.hasAttribute("data-supermemory-icon-added")) { - return + if (existingMarkers.length > 1) { + debugClaude("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() } + } else if (existingMarkers.length === 1) { + debugClaude("marker already exists") + return + } - const existingIcon = container.querySelector( - `#${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}`, - ) - if (existingIcon) { - container.setAttribute("data-supermemory-icon-added", "true") - return - } - - const supermemoryIcon = createClaudeInputBarElement(async () => { - await getRelatedMemoriesForClaude( - POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED, - ) - }) - - supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` - - container.setAttribute("data-supermemory-icon-added", "true") - - container.insertBefore(supermemoryIcon, container.firstChild) + const buttons = findClaudeComposerButtons(input, composer) + debugClaude("candidate Claude buttons", { + input: describeElement(input), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), }) + + const micButton = buttons.find((button) => isClaudeMicButton(button)) + const voiceButton = buttons.find((button) => isClaudeVoiceButton(button)) + const sendButton = buttons.find((button) => isClaudeSendButton(button)) + const anchorButton = + micButton || voiceButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findClaudeButtonSlot(anchorButton, composer) + const targetContainer = anchorSlot?.parentElement || input.parentElement + + if (!targetContainer) { + debugClaude("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(input), + }) + return + } + + const supermemoryIcon = createClaudeInputBarElement(async () => { + await getRelatedMemoriesForClaude( + POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED, + ) + }) + + supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(supermemoryIcon, anchorSlot) + debugClaude("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), + }) + return + } + + targetContainer.appendChild(supermemoryIcon) + debugClaude("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) +} + +function getClaudePromptInput(): HTMLElement | null { + return document.querySelector( + '.ProseMirror[contenteditable="true"], div[contenteditable="true"], textarea', + ) as HTMLElement | null +} + +function findComposerRoot(input: HTMLElement): HTMLElement { + return ( + (input.closest("form") as HTMLElement | null) || + (input.closest('[data-testid*="composer"]') as HTMLElement | null) || + (input.closest('[class*="composer"]') as HTMLElement | null) || + (input.closest(".relative") as HTMLElement | null) || + input.parentElement || + document.body + ) +} + +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} + +function findClaudeComposerButtons( + input: HTMLElement, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons + } + + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) + + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260 + + return verticallyNear && horizontallyNear + }) +} + +function isClaudeMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|dictate/i.test(buttonLabel(button)) +} + +function isClaudeVoiceButton(button: HTMLButtonElement): boolean { + return /voice|audio|speech/i.test(buttonLabel(button)) +} + +function isClaudeSendButton(button: HTMLButtonElement): boolean { + return /send|submit/i.test(buttonLabel(button)) +} + +function findClaudeButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current + } + + current = parent + } + + return current || button +} + +function describeElement(element: Element | null): string | null { + if (!element) return null + + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } + + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } + + return parts.join("") +} + +function getClaudeDomSnapshot() { + return { + proseMirrors: document.querySelectorAll(".ProseMirror").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + } } async function getRelatedMemoriesForClaude(actionSource: string) { try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED let userQuery = "" const supermemoryContainer = document.querySelector( @@ -194,10 +396,12 @@ async function getRelatedMemoriesForClaude(actionSource: string) { } } - console.log("Claude query extracted:", userQuery) + debugClaude("query extracted", { + queryLength: userQuery.length, + }) if (!userQuery.trim()) { - console.log("No query text found for Claude") + debugClaude("memory search skipped because query is empty") return } @@ -210,7 +414,15 @@ async function getRelatedMemoriesForClaude(actionSource: string) { return } - updateClaudeIconFeedback("Searching memories...", iconElement) + if (isAutoSearch) { + const input = getClaudePromptInput() + if (input) { + showLoadingSuggestion("claude", input) + } + setMemoryMarkerStatus(iconElement, "searching") + } else { + updateClaudeIconFeedback("Searching memories...", iconElement) + } const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -228,7 +440,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) { timeoutPromise, ]) - console.log("Claude memories response:", response) + debugClaude("memory search response", { + success: response?.success, + }) if (response?.success && response?.data) { const textareaElement = document.querySelector( @@ -236,24 +450,39 @@ async function getRelatedMemoriesForClaude(actionSource: string) { ) as HTMLElement if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - console.log( - "Text element dataset:", - textareaElement.dataset.supermemories, + const memoryText = showMemorySuggestion( + "claude", + textareaElement, + response.data, ) + debugClaude("memory suggestion rendered", { + memoryLength: memoryText.length, + }) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = String(response.data) - updateClaudeIconFeedback("Included Memories", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateClaudeIconFeedback("Included Memories", iconElement) + } } else { console.warn( "Claude input area not found after successful memory fetch", ) - updateClaudeIconFeedback("Memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateClaudeIconFeedback("Memories found", iconElement) + } } } else { console.warn("No memories found or API response invalid for Claude") - updateClaudeIconFeedback("No memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateClaudeIconFeedback("No memories found", iconElement) + } } } catch (error) { console.error("Error getting related memories for Claude:", error) @@ -262,7 +491,13 @@ async function getRelatedMemoriesForClaude(actionSource: string) { '[id*="sm-claude-input-bar-element"]', ) as HTMLElement if (icon) { - updateClaudeIconFeedback("Error fetching memories", icon) + if ( + actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(icon, "error") + } else { + updateClaudeIconFeedback("Error fetching memories", icon) + } } } catch (feedbackError) { console.error("Failed to update Claude error feedback:", feedbackError) @@ -441,7 +676,9 @@ async function saveClaudeMemoriesToSupermemory(memoryDialog: HTMLElement) { actionSource: "claude_memories_dialog", }) - console.log({ response }) + debugClaude("memory dialog saved", { + success: response.success, + }) if (response.success) { DOMUtils.showToast("success") @@ -459,220 +696,27 @@ function updateClaudeIconFeedback( iconElement: HTMLElement, resetAfter = 0, ) { - if (!iconElement.dataset.originalHtml) { - iconElement.dataset.originalHtml = iconElement.innerHTML + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) + + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return } - const feedbackDiv = document.createElement("div") - feedbackDiv.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - background: #513EA9; - border-radius: 6px; - color: white; - font-size: 12px; - font-weight: 500; - cursor: ${message === "Included Memories" ? "pointer" : "default"}; - position: relative; - ` - - feedbackDiv.innerHTML = ` - - ${message} - ` - - if (message === "Included Memories" && iconElement.dataset.memoriesData) { - const popup = document.createElement("div") - popup.style.cssText = ` - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: #1a1a1a; - color: white; - padding: 0; - border-radius: 12px; - font-size: 13px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - max-width: 500px; - max-height: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 999999; - display: none; - border: 1px solid #333; - ` - - const header = document.createElement("div") - header.style.cssText = ` - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-bottom: 1px solid #333; - opacity: 0.8; - ` - header.innerHTML = ` - Included Memories - ` - - const content = document.createElement("div") - content.style.cssText = ` - padding: 0; - max-height: 300px; - overflow-y: auto; - ` - - const memoriesText = iconElement.dataset.memoriesData || "" - console.log("Memories text:", memoriesText) - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - console.log("Individual memories:", individualMemories) - - individualMemories.forEach((memory, index) => { - const memoryItem = document.createElement("div") - memoryItem.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 10px; - font-size: 13px; - line-height: 1.4; - ` - - const memoryText = document.createElement("div") - memoryText.style.cssText = ` - flex: 1; - color: #e5e5e5; - ` - memoryText.textContent = memory.trim() - - const removeBtn = document.createElement("button") - removeBtn.style.cssText = ` - background: transparent; - color: #9ca3af; - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; - height: fit-content; - display: flex; - align-items: center; - justify-content: center; - ` - removeBtn.innerHTML = `` - removeBtn.dataset.memoryIndex = index.toString() - - removeBtn.addEventListener("mouseenter", () => { - removeBtn.style.color = "#ef4444" - }) - removeBtn.addEventListener("mouseleave", () => { - removeBtn.style.color = "#9ca3af" - }) - - memoryItem.appendChild(memoryText) - memoryItem.appendChild(removeBtn) - content.appendChild(memoryItem) - }) - - popup.appendChild(header) - popup.appendChild(content) - document.body.appendChild(popup) - - feedbackDiv.addEventListener("mouseenter", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Click to see memories" - } - }) - - feedbackDiv.addEventListener("mouseleave", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Included Memories" - } - }) - - feedbackDiv.addEventListener("click", (e) => { - e.stopPropagation() - popup.style.display = "block" - }) - - document.addEventListener("click", (e) => { - if (!popup.contains(e.target as Node)) { - popup.style.display = "none" - } - }) - - 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 - - if (memoryItem) { - content.removeChild(memoryItem) - } - - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) - - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = updatedMemories - - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` - } - - content - .querySelectorAll("button[data-memory-index]") - .forEach((btn, newIndex) => { - const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() - }) - - 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) + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return } - iconElement.innerHTML = "" - iconElement.appendChild(feedbackDiv) - - if (resetAfter > 0) { - setTimeout(() => { - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - }, resetAfter) - } + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) } function setupClaudePromptCapture() { @@ -684,7 +728,7 @@ function setupClaudePromptCapture() { const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false if (!autoCapture) { - console.log("Auto capture prompts is disabled, skipping prompt capture") + debugClaude("auto prompt capture disabled") return } let promptContent = "" @@ -704,19 +748,11 @@ function setupClaudePromptCapture() { } } - const storedMemories = contentEditableDiv?.dataset.supermemories - if ( - storedMemories && - contentEditableDiv && - !promptContent.includes("Supermemories of user") - ) { - contentEditableDiv.appendChild(document.createTextNode(storedMemories)) - promptContent = - contentEditableDiv.textContent || contentEditableDiv.innerText || "" - } - if (promptContent.trim()) { - console.log(`Claude prompt submitted via ${source}:`, promptContent) + debugClaude("prompt submitted", { + source, + promptLength: promptContent.length, + }) try { await browser.runtime.sendMessage({ @@ -724,7 +760,7 @@ function setupClaudePromptCapture() { data: { prompt: promptContent, platform: "claude", - source: source, + source: window.location.href, }, }) } catch (error) { @@ -746,7 +782,7 @@ function setupClaudePromptCapture() { }) if (contentEditableDiv?.dataset.supermemories) { - delete contentEditableDiv.dataset.supermemories + clearMemorySuggestion("claude", contentEditableDiv) } } @@ -754,14 +790,16 @@ function setupClaudePromptCapture() { "click", async (event) => { const target = event.target as HTMLElement - const sendButton = - target.closest( - "button.inline-flex.items-center.justify-center.relative.shrink-0.can-focus.select-none", - ) || - target.closest('button[class*="bg-accent-main-000"]') || - target.closest('button[class*="rounded-lg"]') + if (target.closest('[data-supermemory-connected-indicator="true"]')) { + return + } - if (sendButton) { + const sendButton = target.closest("button") + + if ( + sendButton && + buttonLabel(sendButton as HTMLButtonElement).match(/send|submit/i) + ) { await captureClaudePromptContent("button click") } }, @@ -773,10 +811,18 @@ function setupClaudePromptCapture() { async (event) => { const target = event.target as HTMLElement + const activeInput = + (target.closest('div[contenteditable="true"]') as HTMLElement | null) || + (target.matches("textarea") ? (target as HTMLTextAreaElement) : null) + if (acceptMemorySuggestion(event, "claude", activeInput)) { + return + } + if ( (target.matches('div[contenteditable="true"]') || target.matches(".ProseMirror") || target.matches("textarea") || + target.closest('div[contenteditable="true"]') || target.closest(".ProseMirror")) && event.key === "Enter" && !event.shiftKey @@ -808,12 +854,27 @@ async function setupClaudeAutoFetch() { textareaElement.setAttribute("data-supermemory-auto-fetch", "true") const handleInput = () => { + const content = textareaElement.textContent?.trim() || "" + syncAcceptedSupermemoryState(textareaElement) + + if (content.length === 0) { + clearMemorySuggestion("claude", textareaElement) + document + .querySelectorAll('[id*="sm-claude-input-bar-element"]') + .forEach((icon) => { + setMemoryMarkerStatus(icon as HTMLElement, "neutral") + }) + } + if (claudeDebounceTimeout) { clearTimeout(claudeDebounceTimeout) } claudeDebounceTimeout = setTimeout(async () => { - const content = textareaElement.textContent?.trim() || "" + if (hasAcceptedSupermemoryContext(textareaElement)) { + clearMemorySuggestion("claude", textareaElement) + return + } if (content.length > 2) { await getRelatedMemoriesForClaude( @@ -826,6 +887,7 @@ async function setupClaudeAutoFetch() { icons.forEach((icon) => { const iconElement = icon as HTMLElement + setMemoryMarkerStatus(iconElement, "neutral") if (iconElement.dataset.originalHtml) { iconElement.innerHTML = iconElement.dataset.originalHtml delete iconElement.dataset.originalHtml @@ -834,7 +896,7 @@ async function setupClaudeAutoFetch() { }) if (textareaElement.dataset.supermemories) { - delete textareaElement.dataset.supermemories + clearMemorySuggestion("claude", textareaElement) } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts new file mode 100644 index 00000000..6ece78df --- /dev/null +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -0,0 +1,661 @@ +import { + DOMAINS, + ELEMENT_IDS, + MESSAGE_TYPES, + POSTHOG_EVENT_KEY, + UI_CONFIG, +} from "../../utils/constants" +import { + autoCapturePromptsEnabled, + autoSearchEnabled, +} from "../../utils/storage" +import { + createGeminiInputBarElement, + DOMUtils, +} from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" + +let geminiDebounceTimeout: NodeJS.Timeout | null = null +let geminiRouteObserver: MutationObserver | null = null +let geminiUrlCheckInterval: NodeJS.Timeout | null = null +let geminiObserverThrottle: NodeJS.Timeout | null = null +const GEMINI_DEBUG = false +const GEMINI_LOG_PREFIX = "[supermemory:gemini]" + +type GeminiInput = HTMLElement | HTMLTextAreaElement + +export function initializeGemini() { + debugGemini("initializeGemini called", { + host: window.location.hostname, + href: window.location.href, + }) + + if (!DOMUtils.isOnDomain(DOMAINS.GEMINI)) { + debugGemini("not on Gemini domain, skipping") + return + } + + if (document.body.hasAttribute("data-gemini-initialized")) { + debugGemini("already initialized") + return + } + + setTimeout(() => { + addSupermemoryIconToGeminiInput() + setupGeminiAutoFetch() + }, 2000) + + setupGeminiPromptCapture() + setupGeminiRouteChangeDetection() + + document.body.setAttribute("data-gemini-initialized", "true") + debugGemini("initialized listeners") +} + +function debugGemini(message: string, data?: unknown) { + if (!GEMINI_DEBUG) return + + if (data === undefined) { + console.log(GEMINI_LOG_PREFIX, message) + return + } + + console.log(GEMINI_LOG_PREFIX, message, data) +} + +function setupGeminiRouteChangeDetection() { + if (geminiRouteObserver) { + geminiRouteObserver.disconnect() + } + if (geminiUrlCheckInterval) { + clearInterval(geminiUrlCheckInterval) + } + if (geminiObserverThrottle) { + clearTimeout(geminiObserverThrottle) + geminiObserverThrottle = null + } + + let currentUrl = window.location.href + + const recheckGeminiUI = () => { + addSupermemoryIconToGeminiInput() + setupGeminiAutoFetch() + } + + const checkForRouteChange = () => { + if (window.location.href !== currentUrl) { + currentUrl = window.location.href + debugGemini("route changed, rechecking UI", currentUrl) + setTimeout(recheckGeminiUI, 1000) + } + } + + geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000) + + geminiRouteObserver = new MutationObserver((mutations) => { + if (geminiObserverThrottle) { + return + } + + const shouldRecheck = mutations.some((mutation) => + Array.from(mutation.addedNodes).some((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false + } + + const element = node as Element + return ( + element.matches?.("rich-textarea, textarea, button") || + element.matches?.('[contenteditable="true"]') || + !!element.querySelector?.( + 'rich-textarea, textarea, button, [contenteditable="true"]', + ) + ) + }), + ) + + if (shouldRecheck) { + geminiObserverThrottle = setTimeout(() => { + geminiObserverThrottle = null + debugGemini("DOM changed near Gemini composer, rechecking UI") + recheckGeminiUI() + }, 300) + } + }) + + try { + geminiRouteObserver.observe(document.body, { + childList: true, + subtree: true, + }) + } catch (error) { + console.error("Failed to set up Gemini route observer:", error) + if (geminiUrlCheckInterval) { + clearInterval(geminiUrlCheckInterval) + } + geminiUrlCheckInterval = setInterval(checkForRouteChange, 1000) + } +} + +function addSupermemoryIconToGeminiInput() { + const input = getGeminiPromptInput() + if (!input) { + debugGemini("prompt input not found", getGeminiDomSnapshot()) + return + } + + const composer = findGeminiComposerRoot(input) + if (!composer?.querySelector) { + debugGemini("composer root not found", describeElement(input)) + return + } + + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ), + ) + if (existingMarkers.length > 1) { + debugGemini("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() + } + } else if (existingMarkers.length === 1) { + debugGemini("marker already exists") + return + } + + const buttons = findGeminiComposerButtons(input, composer) + debugGemini("candidate Gemini buttons", { + input: describeElement(input), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), + }) + + const micButton = buttons.find((button) => isGeminiMicButton(button)) + const sendButton = buttons.find((button) => isGeminiSendButton(button)) + const anchorButton = micButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findGeminiButtonSlot(anchorButton, composer) + const targetContainer = + anchorSlot?.parentElement || + (input.closest("rich-textarea") as HTMLElement | null)?.parentElement || + input.parentElement + + if (!targetContainer) { + debugGemini("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(input), + }) + return + } + + const supermemoryIcon = createGeminiInputBarElement(async () => { + await getRelatedMemoriesForGemini( + POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_SEARCHED, + ) + }) + + supermemoryIcon.id = `${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(supermemoryIcon, anchorSlot) + debugGemini("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), + }) + return + } + + targetContainer.appendChild(supermemoryIcon) + debugGemini("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) +} + +function getGeminiPromptInput(): GeminiInput | null { + return document.querySelector( + 'rich-textarea .ql-editor[contenteditable="true"], rich-textarea [contenteditable="true"], .ql-editor[contenteditable="true"], div[contenteditable="true"], textarea', + ) as GeminiInput | null +} + +function findGeminiComposerRoot(input: GeminiInput): HTMLElement { + const form = input.closest("form") as HTMLElement | null + if (form) return form + + let current: HTMLElement | null = input + for (let depth = 0; current && depth < 8; depth += 1) { + if (current.querySelectorAll("button").length >= 2) { + return current + } + current = current.parentElement + } + + return input.parentElement || document.body +} + +function findGeminiComposerButtons( + input: GeminiInput, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons + } + + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) + + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 240 + + return verticallyNear && horizontallyNear + }) +} + +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.getAttribute("jsname"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} + +function isGeminiMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|voice|dictate|audio/i.test(buttonLabel(button)) +} + +function isGeminiSendButton(button: HTMLButtonElement): boolean { + const label = buttonLabel(button) + if (/send|submit/i.test(label)) { + return true + } + + return !!button.querySelector( + 'mat-icon[fonticon="send"], mat-icon[data-mat-icon-name="send"], [data-icon-name="send"]', + ) +} + +function findGeminiButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current + } + + current = parent + } + + return current || button +} + +function describeElement(element: Element | null): string | null { + if (!element) return null + + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } + + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } + + return parts.join("") +} + +function getGeminiDomSnapshot() { + return { + richTextareas: document.querySelectorAll("rich-textarea").length, + qlEditors: document.querySelectorAll(".ql-editor").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + } +} + +function getInputText(input: GeminiInput | null): string { + if (!input) return "" + if (input instanceof HTMLTextAreaElement) { + return input.value || "" + } + + return input.innerText || input.textContent || "" +} + +async function getRelatedMemoriesForGemini(actionSource: string) { + try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED + const input = getGeminiPromptInput() + const userQuery = getInputText(input).trim() + debugGemini("manual/auto memory search requested", { + actionSource, + hasInput: !!input, + queryLength: userQuery.length, + }) + + if (!userQuery) { + debugGemini("memory search skipped because query is empty") + return + } + + const iconElement = document.querySelector( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) as HTMLElement | null + + if (!iconElement) { + console.warn("Gemini icon element not found, cannot update feedback") + return + } + + if (input && isAutoSearch) { + showLoadingSuggestion("gemini", input) + } + setMemoryMarkerStatus(iconElement, "searching") + if (!isAutoSearch) { + updateGeminiIconFeedback("Searching memories...", iconElement) + } + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Memory search timeout")), + UI_CONFIG.API_REQUEST_TIMEOUT, + ), + ) + + const response = (await Promise.race([ + browser.runtime.sendMessage({ + action: MESSAGE_TYPES.GET_RELATED_MEMORIES, + data: userQuery, + actionSource, + }), + timeoutPromise, + ])) as { success?: boolean; data?: string } + + debugGemini("memory search response", response) + + if (response?.success && response?.data && input) { + const memoryText = showMemorySuggestion("gemini", input, response.data) + iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.supermemories = memoryText + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateGeminiIconFeedback("Included Memories", iconElement) + } + return + } + + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateGeminiIconFeedback("No memories found", iconElement, 1800) + } + } catch (error) { + console.error("Error getting related memories for Gemini:", error) + const iconElement = document.querySelector( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) as HTMLElement | null + if (iconElement) { + if ( + actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(iconElement, "error") + } else { + updateGeminiIconFeedback("Error fetching memories", iconElement, 1800) + } + } + } +} + +function updateGeminiIconFeedback( + message: string, + iconElement: HTMLElement, + resetAfter = 0, +) { + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) + + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return + } + + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return + } + + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) +} + +function setupGeminiPromptCapture() { + if (document.body.hasAttribute("data-gemini-prompt-capture-setup")) { + return + } + + document.body.setAttribute("data-gemini-prompt-capture-setup", "true") + + const captureGeminiPromptContent = async (source: string) => { + const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false + debugGemini("capture requested", { source, autoCapture }) + + if (!autoCapture) { + debugGemini("auto prompt capture disabled") + return + } + + const input = getGeminiPromptInput() + const promptContent = getInputText(input) + debugGemini("capture input state", { + hasInput: !!input, + promptLength: promptContent.length, + hasStoredMemories: !!input?.dataset.supermemories, + }) + + if (promptContent.trim()) { + try { + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.CAPTURE_PROMPT, + data: { + prompt: promptContent, + platform: "gemini", + source: window.location.href, + }, + }) + debugGemini("capture response", response) + } catch (error) { + console.error("Error sending Gemini prompt to background:", error) + } + } else { + debugGemini("capture skipped because prompt is empty") + } + + const icons = document.querySelectorAll( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) + + icons.forEach((icon) => { + const iconElement = icon as HTMLElement + iconElement.querySelector("[data-supermemory-status-badge]")?.remove() + delete iconElement.dataset.supermemoryStatus + delete iconElement.dataset.memoriesData + if (iconElement.dataset.originalHtml) { + iconElement.innerHTML = iconElement.dataset.originalHtml + delete iconElement.dataset.originalHtml + } + }) + + if (input?.dataset.supermemories) { + clearMemorySuggestion("gemini", input) + } + } + + document.addEventListener( + "click", + async (event) => { + const target = event.target as HTMLElement + if (target.closest('[data-supermemory-connected-indicator="true"]')) { + return + } + + const sendButton = target.closest("button") + if (sendButton && isGeminiSendButton(sendButton as HTMLButtonElement)) { + debugGemini("send button click detected", { + label: buttonLabel(sendButton as HTMLButtonElement), + element: describeElement(sendButton), + }) + await captureGeminiPromptContent("button click") + } + }, + true, + ) + + document.addEventListener( + "keydown", + async (event) => { + const target = event.target as HTMLElement + + const activeInput = + (target.closest('[contenteditable="true"]') as GeminiInput | null) || + (target.matches("textarea") ? (target as HTMLTextAreaElement) : null) + if (acceptMemorySuggestion(event, "gemini", activeInput)) { + return + } + + if ( + (target.matches("textarea") || + target.matches('[contenteditable="true"]') || + target.closest('[contenteditable="true"]')) && + event.key === "Enter" && + !event.shiftKey + ) { + debugGemini("Enter submit detected", { + target: describeElement(target), + }) + await captureGeminiPromptContent("Enter key") + } + }, + true, + ) +} + +async function setupGeminiAutoFetch() { + const autoSearch = (await autoSearchEnabled.getValue()) ?? false + debugGemini("setup auto fetch", { autoSearch }) + if (!autoSearch) { + return + } + + const input = getGeminiPromptInput() + if (!input || input.hasAttribute("data-supermemory-auto-fetch")) { + debugGemini("auto fetch skipped", { + hasInput: !!input, + alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"), + }) + return + } + + input.setAttribute("data-supermemory-auto-fetch", "true") + debugGemini("auto fetch attached", describeElement(input)) + + const handleInput = () => { + const content = getInputText(input).trim() + syncAcceptedSupermemoryState(input) + + if (content.length === 0) { + clearMemorySuggestion("gemini", input) + document + .querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`) + .forEach((icon) => { + setMemoryMarkerStatus(icon as HTMLElement, "neutral") + }) + } + + if (geminiDebounceTimeout) { + clearTimeout(geminiDebounceTimeout) + } + + geminiDebounceTimeout = setTimeout(async () => { + if (hasAcceptedSupermemoryContext(input)) { + clearMemorySuggestion("gemini", input) + return + } + + if (content.length > 2) { + await getRelatedMemoriesForGemini( + POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED, + ) + } else if (content.length === 0) { + const icons = document.querySelectorAll( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) + + icons.forEach((icon) => { + const iconElement = icon as HTMLElement + iconElement.querySelector("[data-supermemory-status-badge]")?.remove() + delete iconElement.dataset.supermemoryStatus + delete iconElement.dataset.memoriesData + if (iconElement.dataset.originalHtml) { + iconElement.innerHTML = iconElement.dataset.originalHtml + delete iconElement.dataset.originalHtml + } + }) + + if (input.dataset.supermemories) { + clearMemorySuggestion("gemini", input) + } + } + }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) + } + + input.addEventListener("input", handleInput) +} diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index 1c863530..776d9e9f 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -3,6 +3,7 @@ import { DOMUtils } from "../../utils/ui-components" import { initializeChatGPT } from "./chatgpt" import { initializeClaude } from "./claude" import { initializeGrok } from "./grok" +import { initializeGemini } from "./gemini" import { saveMemory, setupGlobalKeyboardShortcut, @@ -20,13 +21,13 @@ export default defineContentScript({ matches: [""], main() { // Setup global event listeners - browser.runtime.onMessage.addListener(async (message) => { + browser.runtime.onMessage.addListener((message) => { if (message.action === MESSAGE_TYPES.SHOW_TOAST) { DOMUtils.showToast(message.state) } else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) { - await saveMemory() + return saveMemory(message.actionSource || "content_script") } else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) { - await openImportModal() + return openImportModal() } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { updateTwitterImportUI(message) } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) { @@ -52,6 +53,9 @@ export default defineContentScript({ if (DOMUtils.isOnDomain(DOMAINS.GROK)) { initializeGrok() } + if (DOMUtils.isOnDomain(DOMAINS.GEMINI)) { + initializeGemini() + } if (DOMUtils.isOnDomain(DOMAINS.T3)) { initializeT3() } @@ -70,6 +74,7 @@ export default defineContentScript({ initializeChatGPT() initializeClaude() initializeGrok() + initializeGemini() initializeT3() initializeTwitter() diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts new file mode 100644 index 00000000..1722e71e --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -0,0 +1,409 @@ +type SuggestionInput = HTMLElement | HTMLTextAreaElement + +const SUGGESTION_ATTR = "data-supermemory-memory-suggestion" +const SUPERMEMORY_PREFIX = "Supermemories of user (only for the reference):" +const SUPERMEMORY_BLUE = "#1A88FF" + +export function buildSupermemoryText(memories: unknown): string { + const memoryText = Array.isArray(memories) + ? memories.join("").trim() + : String(memories || "").trim() + + return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}` +} + +export function showMemorySuggestion( + platform: string, + input: SuggestionInput, + memories: unknown, +): string { + const suggestionText = buildSupermemoryText(memories) + input.dataset.supermemories = suggestionText + delete input.dataset.supermemoriesInjected + + removeMemorySuggestion(platform) + + const anchor = getSuggestionAnchor(input) + if (!anchor) return suggestionText + + const previousPosition = window.getComputedStyle(anchor).position + if (previousPosition === "static") { + anchor.dataset.supermemoryPreviousPosition = "static" + anchor.style.position = "relative" + } + + const suggestion = createSuggestionContainer(platform, input, anchor) + suggestion.dataset.supermemorySuggestionState = "ready" + suggestion.style.gap = "8px" + suggestion.style.alignItems = "center" + + const text = document.createElement("span") + text.style.cssText = ` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + ` + text.textContent = suggestionText.trim() + + const tabKey = document.createElement("span") + tabKey.style.cssText = ` + display: inline-flex; + align-items: center; + justify-content: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + color: #FFFFFF; + font-size: 11px; + font-weight: 700; + line-height: 1; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset, 0 6px 18px rgba(26, 136, 255, 0.24); + flex-shrink: 0; + ` + tabKey.textContent = "Tab" + + suggestion.appendChild(text) + suggestion.appendChild(tabKey) + anchor.appendChild(suggestion) + + return suggestionText +} + +export function showLoadingSuggestion( + platform: string, + input: SuggestionInput, +) { + removeMemorySuggestion(platform) + + const anchor = getSuggestionAnchor(input) + if (!anchor) return + + const previousPosition = window.getComputedStyle(anchor).position + if (previousPosition === "static") { + anchor.dataset.supermemoryPreviousPosition = "static" + anchor.style.position = "relative" + } + + ensureSuggestionAnimationStyle() + + const suggestion = createSuggestionContainer(platform, input, anchor) + suggestion.dataset.supermemorySuggestionState = "loading" + suggestion.style.gap = "4px" + suggestion.setAttribute("aria-label", "supermemory searching memories") + + for (let index = 0; index < 3; index += 1) { + const dot = document.createElement("span") + dot.style.cssText = ` + width: 5px; + height: 5px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + animation: supermemorySuggestionDot 1s ease-in-out infinite; + animation-delay: ${index * 0.14}s; + ` + suggestion.appendChild(dot) + } + + anchor.appendChild(suggestion) +} + +function createSuggestionContainer( + platform: string, + input: SuggestionInput, + anchor: HTMLElement, +): HTMLDivElement { + const suggestion = document.createElement("div") + suggestion.setAttribute(SUGGESTION_ATTR, platform) + const position = getCaretPosition(input, anchor) + const verticalOffset = platform === "gemini" ? -10 : 0 + suggestion.style.cssText = ` + position: absolute; + left: ${position.left + 6}px; + top: ${position.top + verticalOffset}px; + max-width: min(540px, calc(100% - ${position.left + 220}px)); + display: inline-flex; + align-items: center; + height: 22px; + color: rgba(255, 255, 255, 0.34); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.35; + pointer-events: none; + z-index: 2147483646; + ` + return suggestion +} + +export function removeMemorySuggestion(platform: string) { + const elements = document.querySelectorAll( + `[${SUGGESTION_ATTR}="${platform}"]`, + ) + for (const element of elements) { + element.remove() + } +} + +export function acceptMemorySuggestion( + event: KeyboardEvent, + platform: string, + input: SuggestionInput | null, +): boolean { + if (event.key !== "Tab" || !input?.dataset.supermemories) { + return false + } + + event.preventDefault() + event.stopPropagation() + + const text = input.dataset.supermemories + appendTextToInput(input, text) + delete input.dataset.supermemories + input.dataset.supermemoriesInjected = "true" + removeMemorySuggestion(platform) + + return true +} + +export function hasAcceptedSupermemoryContext( + input: SuggestionInput | null, +): boolean { + if (!input) return false + const text = + input instanceof HTMLTextAreaElement + ? input.value + : input.innerText || input.textContent || "" + + return text.includes(SUPERMEMORY_PREFIX) +} + +export function syncAcceptedSupermemoryState(input: SuggestionInput | null) { + if (!input?.dataset.supermemoriesInjected) return + + if (!hasAcceptedSupermemoryContext(input)) { + delete input.dataset.supermemoriesInjected + } +} + +export function clearMemorySuggestion( + platform: string, + input: SuggestionInput | null, +) { + removeMemorySuggestion(platform) + if (input?.dataset.supermemories) { + delete input.dataset.supermemories + } + if (input?.dataset.supermemoriesInjected) { + delete input.dataset.supermemoriesInjected + } +} + +export function setMemoryMarkerStatus( + iconElement: HTMLElement | null, + status: "neutral" | "searching" | "found" | "none" | "error", +) { + if (!iconElement) return + + iconElement.querySelector("[data-supermemory-status-badge]")?.remove() + + if (status === "neutral" || status === "none") { + delete iconElement.dataset.supermemoryStatus + return + } + + iconElement.dataset.supermemoryStatus = status + const badge = document.createElement("span") + badge.dataset.supermemoryStatusBadge = "true" + badge.style.cssText = ` + position: absolute; + top: 3px; + right: 3px; + width: ${status === "searching" ? "7px" : "8px"}; + height: ${status === "searching" ? "7px" : "8px"}; + border-radius: 999px; + background: ${status === "found" ? "#36F3D7" : status === "searching" ? SUPERMEMORY_BLUE : status === "error" ? "#EF4444" : "rgba(255, 255, 255, 0.55)"}; + border: 1px solid rgba(5, 7, 10, 0.9); + box-shadow: ${status === "found" ? "0 0 0 2px rgba(54, 243, 215, 0.18)" : "none"}; + pointer-events: none; + ` + iconElement.appendChild(badge) +} + +export function showMarkerPopover( + iconElement: HTMLElement, + message: string, + memories?: string, + resetAfter = 0, +) { + iconElement.querySelector("[data-supermemory-marker-popover]")?.remove() + ensureSuggestionAnimationStyle() + + const popover = document.createElement("div") + popover.dataset.supermemoryMarkerPopover = "true" + popover.style.cssText = ` + position: absolute; + right: 0; + bottom: calc(100% + 10px); + min-width: 168px; + max-width: 280px; + padding: 10px; + border-radius: 12px; + background: rgba(10, 14, 20, 0.96); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #FAFAFA; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + line-height: 1.35; + text-align: left; + z-index: 2147483647; + pointer-events: auto; + ` + + const title = document.createElement("div") + title.style.cssText = ` + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: ${memories ? "8px" : "0"}; + ` + + if (message.toLowerCase().includes("searching")) { + const dots = document.createElement("span") + dots.style.cssText = "display: inline-flex; gap: 3px; align-items: center;" + for (let index = 0; index < 3; index += 1) { + const dot = document.createElement("span") + dot.style.cssText = ` + width: 4px; + height: 4px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + animation: supermemorySuggestionDot 1s ease-in-out infinite; + animation-delay: ${index * 0.14}s; + ` + dots.appendChild(dot) + } + title.appendChild(dots) + } + + const titleText = document.createElement("span") + titleText.textContent = + message === "Included Memories" ? "Included memories" : message + title.appendChild(titleText) + popover.appendChild(title) + + if (memories) { + const list = document.createElement("div") + list.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + max-height: 160px; + overflow-y: auto; + color: rgba(255, 255, 255, 0.76); + ` + + memories + .split(/[,\n]/) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0 && memory !== ",") + .slice(0, 5) + .forEach((memory) => { + const item = document.createElement("div") + item.textContent = memory + list.appendChild(item) + }) + + popover.appendChild(list) + } + + iconElement.appendChild(popover) + + if (resetAfter > 0) { + setTimeout(() => { + popover.remove() + }, resetAfter) + } +} + +function ensureSuggestionAnimationStyle() { + if (document.getElementById("supermemory-suggestion-animation-style")) { + return + } + + const style = document.createElement("style") + style.id = "supermemory-suggestion-animation-style" + style.textContent = ` + @keyframes supermemorySuggestionDot { + 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); } + 40% { opacity: 1; transform: translateY(-1px); } + } + ` + document.head.appendChild(style) +} + +function getSuggestionAnchor(input: SuggestionInput): HTMLElement | null { + return ( + (input.closest("form") as HTMLElement | null) || + (input.closest('[role="textbox"]') as HTMLElement | null)?.parentElement || + input.parentElement + ) +} + +function getCaretPosition(input: SuggestionInput, anchor: HTMLElement) { + const anchorRect = anchor.getBoundingClientRect() + + if (!(input instanceof HTMLTextAreaElement)) { + const selection = window.getSelection() + if (selection?.rangeCount) { + const range = selection.getRangeAt(0).cloneRange() + if (input.contains(range.startContainer)) { + range.collapse(true) + let rect = range.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) { + const marker = document.createElement("span") + marker.textContent = "\u200b" + range.insertNode(marker) + rect = marker.getBoundingClientRect() + marker.remove() + } + + if (rect.width || rect.height) { + return { + left: Math.max(18, rect.right - anchorRect.left + 4), + top: Math.max(10, rect.top - anchorRect.top), + } + } + } + } + } + + const inputRect = input.getBoundingClientRect() + return { + left: Math.max(18, inputRect.left - anchorRect.left + 18), + top: Math.max(10, inputRect.top - anchorRect.top + 8), + } +} + +function appendTextToInput(input: SuggestionInput, text: string) { + if (input instanceof HTMLTextAreaElement) { + input.value = `${input.value}${text}` + input.dispatchEvent(new Event("input", { bubbles: true })) + return + } + + input.focus() + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(input) + range.collapse(false) + range.insertNode(document.createTextNode(text)) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + input.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText" }), + ) +} diff --git a/apps/browser-extension/entrypoints/content/shared.ts b/apps/browser-extension/entrypoints/content/shared.ts index 68d117a1..12647908 100644 --- a/apps/browser-extension/entrypoints/content/shared.ts +++ b/apps/browser-extension/entrypoints/content/shared.ts @@ -1,9 +1,12 @@ import { MESSAGE_TYPES } from "../../utils/constants" import { bearerToken, userData } from "../../utils/storage" +import type { APIResponse } from "../../utils/types" import { DOMUtils } from "../../utils/ui-components" import { default as TurndownService } from "turndown" -export async function saveMemory() { +export async function saveMemory( + actionSource = "content_script", +): Promise { try { DOMUtils.showToast("loading") @@ -64,21 +67,28 @@ export async function saveMemory() { data.markdown = markdown } - const response = await browser.runtime.sendMessage({ + const response = (await browser.runtime.sendMessage({ action: MESSAGE_TYPES.SAVE_MEMORY, data, - actionSource: "context_menu", - }) + actionSource, + })) as APIResponse - console.log("Response from enxtension:", response) - if (response.success) { + if (response?.success) { DOMUtils.showToast("success") - } else { - DOMUtils.showToast("error") + return response + } + DOMUtils.showToast("error") + return { + success: false, + error: response?.error || "Failed to save memory", } } catch (error) { console.error("Error saving memory:", error) DOMUtils.showToast("error") + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } } } @@ -90,7 +100,7 @@ export function setupGlobalKeyboardShortcut() { event.key === "m" ) { event.preventDefault() - await saveMemory() + await saveMemory("keyboard_shortcut") } }) } @@ -110,9 +120,6 @@ export function setupStorageListener() { window.location.hostname === "app.supermemory.ai" ) ) { - console.log( - "Bearer token and user data is only allowed to be used on localhost or supermemory.ai", - ) return } diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index c7bdb09a..66a11235 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -26,7 +26,6 @@ export function initializeT3() { } setTimeout(() => { - console.log("Adding supermemory icon to T3 input") addSupermemoryIconToT3Input() setupT3AutoFetch() }, 2000) @@ -55,7 +54,6 @@ function setupT3RouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log("T3 route changed, re-adding supermemory icon") setTimeout(() => { addSupermemoryIconToT3Input() setupT3AutoFetch() @@ -183,10 +181,7 @@ async function getRelatedMemoriesForT3(actionSource: string) { } } - console.log("T3 query extracted:", userQuery) - if (!userQuery.trim()) { - console.log("No query text found for T3") return } @@ -217,8 +212,6 @@ async function getRelatedMemoriesForT3(actionSource: string) { timeoutPromise, ]) - console.log("T3 memories response:", response) - if (response?.success && response?.data) { let textareaElement = null const supermemoryContainer = document.querySelector( @@ -337,12 +330,10 @@ function updateT3IconFeedback( ` const memoriesText = iconElement.dataset.memoriesData || "" - console.log("Memories text:", memoriesText) const individualMemories = memoriesText .split(/[,\n]/) .map((memory) => memory.trim()) .filter((memory) => memory.length > 0 && memory !== ",") - console.log("Individual memories:", individualMemories) individualMemories.forEach((memory, index) => { const memoryItem = document.createElement("div") @@ -493,11 +484,10 @@ function setupT3PromptCapture() { } document.body.setAttribute("data-t3-prompt-capture-setup", "true") - const captureT3PromptContent = async (source: string) => { + const captureT3PromptContent = async (_source: string) => { const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false if (!autoCapture) { - console.log("Auto capture prompts is disabled, skipping prompt capture") return } let promptContent = "" @@ -538,15 +528,13 @@ function setupT3PromptCapture() { } if (promptContent.trim()) { - console.log(`T3 prompt submitted via ${source}:`, promptContent) - try { await browser.runtime.sendMessage({ action: MESSAGE_TYPES.CAPTURE_PROMPT, data: { prompt: promptContent, platform: "t3", - source: source, + source: window.location.href, }, }) } catch (error) { diff --git a/apps/browser-extension/entrypoints/content/twitter.ts b/apps/browser-extension/entrypoints/content/twitter.ts index ffa138af..875f4bab 100644 --- a/apps/browser-extension/entrypoints/content/twitter.ts +++ b/apps/browser-extension/entrypoints/content/twitter.ts @@ -253,7 +253,7 @@ async function showOnboardingToast() { header.style.cssText = "display: flex; align-items: flex-start; gap: 12px; position: relative;" - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") const icon = document.createElement("img") icon.src = iconUrl icon.alt = "Supermemory" @@ -512,7 +512,7 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) { animation: smSlideInUp 0.3s ease-out; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") const icon = document.createElement("img") icon.src = iconUrl icon.alt = "Supermemory" diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index bcc2a910..ace10a03 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -2,7 +2,12 @@ import { useQueryClient } from "@tanstack/react-query" import { useEffect, useState } from "react" import "./App.css" import { validateAuthToken } from "../../utils/api" -import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants" +import { + getSupermemoryLoginUrl, + MESSAGE_TYPES, + STORAGE_KEYS, + UI_CONFIG, +} from "../../utils/constants" import { useDefaultProject, useProjects, @@ -253,6 +258,7 @@ function App() { const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] = useState(false) const [authInvalidated, setAuthInvalidated] = useState(false) + const [saveError, setSaveError] = useState(null) const queryClient = useQueryClient() const { data: projects = [], isLoading: loadingProjects } = useProjects({ @@ -375,29 +381,70 @@ function App() { const handleSaveCurrentPage = async () => { setSaving(true) + setSaveError(null) try { const tabs = await chrome.tabs.query({ active: true, currentWindow: true, }) - if (tabs.length > 0 && tabs[0].id) { - const response = await chrome.tabs.sendMessage(tabs[0].id, { - action: MESSAGE_TYPES.SAVE_MEMORY, - actionSource: "popup", - }) + const tab = tabs[0] + let response: { success?: boolean; error?: string } | undefined - if (response?.success) { - await chrome.tabs.sendMessage(tabs[0].id, { - action: MESSAGE_TYPES.SHOW_TOAST, - state: "success", + if (tab?.id) { + try { + response = await chrome.tabs.sendMessage(tab.id, { + action: MESSAGE_TYPES.SAVE_MEMORY, + actionSource: "popup", }) + } catch (contentScriptError) { + console.warn("Content script save failed:", contentScriptError) + } + } + + if (response && !response.success) { + throw new Error(response.error || "Failed to save current page") + } + + if (!response) { + const fallbackUrl = tab?.url || currentUrl + const fallbackTitle = tab?.title || currentTitle || "Current Page" + + if (!fallbackUrl) { + throw new Error("No active page URL found") + } + + response = await chrome.runtime.sendMessage({ + action: MESSAGE_TYPES.SAVE_MEMORY, + actionSource: "popup_fallback", + data: { + url: fallbackUrl, + title: fallbackTitle, + content: `${fallbackTitle}\n\n${fallbackUrl}`, + }, + }) + } + + if (response?.success) { + if (tab?.id) { + await chrome.tabs + .sendMessage(tab.id, { + action: MESSAGE_TYPES.SHOW_TOAST, + state: "success", + }) + .catch(() => undefined) } window.close() + return } + + throw new Error(response?.error || "Failed to save current page") } catch (error) { console.error("Failed to save current page:", error) + setSaveError( + error instanceof Error ? error.message : "Could not save page", + ) try { const tabs = await chrome.tabs.query({ @@ -413,8 +460,6 @@ function App() { } catch (toastError) { console.error("Failed to show error toast:", toastError) } - - window.close() } finally { setSaving(false) } @@ -592,7 +637,7 @@ function App() { > supermemory
@@ -600,11 +645,9 @@ function App() { Your - supermemory + + supermemory + @@ -658,7 +701,7 @@ function App() { > supermemory @@ -672,11 +715,9 @@ function App() { return name.endsWith("s") ? `${name}'` : `${name}'s` })()} - supermemory + + supermemory + {userSignedIn && ( @@ -931,6 +972,11 @@ function App() { {saving ? "Saving..." : "Add to supermemory"} + {saveError && ( +

+ {saveError} +

+ )} ) : activeTab === "imports" ? ( @@ -1269,13 +1315,13 @@ function App() { @@ -1300,9 +1346,7 @@ function App() { className="w-full py-3 px-6 bg-[#2d3f5c] text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-[#3d5270] disabled:bg-neutral-600 disabled:cursor-not-allowed" onClick={() => { chrome.tabs.create({ - url: import.meta.env.PROD - ? "https://app.supermemory.ai/login" - : "http://localhost:3000/login", + url: getSupermemoryLoginUrl(), }) }} type="button" diff --git a/apps/browser-extension/entrypoints/popup/index.html b/apps/browser-extension/entrypoints/popup/index.html index ed4cb949..d0cca15e 100644 --- a/apps/browser-extension/entrypoints/popup/index.html +++ b/apps/browser-extension/entrypoints/popup/index.html @@ -3,7 +3,7 @@ - Default Popup Title + supermemory diff --git a/apps/browser-extension/entrypoints/welcome/Welcome.tsx b/apps/browser-extension/entrypoints/welcome/Welcome.tsx index 9463eba4..aa982a0e 100644 --- a/apps/browser-extension/entrypoints/welcome/Welcome.tsx +++ b/apps/browser-extension/entrypoints/welcome/Welcome.tsx @@ -1,105 +1,114 @@ +import { getSupermemoryLoginUrl } from "../../utils/constants" + +const featureCards = [ + { + number: "01", + title: "Save any page", + description: "Articles, docs, and references from the browser.", + }, + { + number: "02", + title: "Import X bookmarks", + description: "Bring saved posts into your memory library.", + }, + { + number: "03", + title: "Capture AI chats", + description: "Save useful conversations from ChatGPT, Claude, and Gemini.", + }, + { + number: "04", + title: "Use context anywhere", + description: "Search and reuse memories when you need them.", + }, +] + function Welcome() { return ( -
-
- {/* Header */} -
- supermemory -

- Your AI second brain for saving and organizing everything that - matters. Supermemory learns and remembers everything you save, your - preferences, and understands you. -

-
+
+
+
+
- {/* Features Section */} -
-

- What can you do with supermemory ? -

+
+
+
+ + + supermemory + +
+
-
-
-
💾
-

- Save Any Page -

-

- Instantly save web pages, articles, and content to your personal - knowledge base -

-
+
+
+

+ Your browser now has{" "} + supermemory. +

-
-
🐦
-

- Import Twitter/X Bookmarks -

-

- Bring all your saved tweets and bookmarks into one organized - place -

-
- -
-
🤖
-

- Import ChatGPT Memories -

-

- Keep your important AI conversations and insights accessible -

-
- -
-
🔍
-

- Your context, everywhere. -

-

- You can connect chatbots with MCP, chat with your personal - assistant, and more. -

+
+ +
-
- {/* Actions */} -
- -
+
+ {featureCards.map((feature) => ( +
+

+ {feature.number} +

+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
- {/* Footer */} -
-

- Learn more at{" "} - - supermemory.ai - -

-
-
+
+ supermemory stores your extension session locally in Chrome. +
+
) } diff --git a/apps/browser-extension/entrypoints/welcome/index.html b/apps/browser-extension/entrypoints/welcome/index.html index 92bb26e0..30ac3214 100644 --- a/apps/browser-extension/entrypoints/welcome/index.html +++ b/apps/browser-extension/entrypoints/welcome/index.html @@ -2,7 +2,7 @@ - + Welcome to supermemory @@ -10,4 +10,4 @@
- \ No newline at end of file + diff --git a/apps/browser-extension/public/new_logo.png b/apps/browser-extension/public/new_logo.png new file mode 100644 index 00000000..dbabef62 Binary files /dev/null and b/apps/browser-extension/public/new_logo.png differ diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json index 621fa129..781af494 100644 --- a/apps/browser-extension/tsconfig.json +++ b/apps/browser-extension/tsconfig.json @@ -4,5 +4,6 @@ "allowImportingTsExtensions": true, "jsx": "react-jsx", "types": ["chrome"] - } + }, + "exclude": ["**/*.test.ts"] } diff --git a/apps/browser-extension/utils/api.ts b/apps/browser-extension/utils/api.ts index dd42d078..59cef3d7 100644 --- a/apps/browser-extension/utils/api.ts +++ b/apps/browser-extension/utils/api.ts @@ -3,6 +3,7 @@ */ import { API_ENDPOINTS } from "./constants" import { bearerToken, defaultProject, userData } from "./storage" +import { buildSearchMemoriesBody } from "./search-request" import { AuthenticationError, type MemoryPayload, @@ -145,14 +146,14 @@ export async function saveMemory(payload: MemoryPayload): Promise { /** * Search memories using Supermemory API */ -export async function searchMemories(query: string): Promise { +export async function searchMemories( + query: string, + containerTag?: string, +): Promise { try { const response = await makeAuthenticatedRequest("/v4/search", { method: "POST", - body: JSON.stringify({ - q: query, - include: { relatedMemories: true }, - }), + body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)), }) return response } catch (error) { diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts index a7083420..c5fe8347 100644 --- a/apps/browser-extension/utils/constants.ts +++ b/apps/browser-extension/utils/constants.ts @@ -10,6 +10,17 @@ export const API_ENDPOINTS = { : "http://localhost:3000", } as const +export function getSupermemoryLoginUrl(): string { + const baseUrl = API_ENDPOINTS.SUPERMEMORY_WEB + const loginUrl = new URL("/login", baseUrl) + const redirectUrl = new URL("/", baseUrl) + + redirectUrl.searchParams.set("extension-auth-success", "true") + loginUrl.searchParams.set("redirect", redirectUrl.toString()) + + return loginUrl.toString() +} + /** * DOM Element IDs */ @@ -22,6 +33,7 @@ export const ELEMENT_IDS = { SAVE_TWEET_ELEMENT: "sm-save-tweet-element", CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element", CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element", + GEMINI_INPUT_BAR_ELEMENT: "sm-gemini-input-bar-element", T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element", PROJECT_SELECTION_MODAL: "sm-project-selection-modal", } as const @@ -59,6 +71,7 @@ export const DOMAINS = { CHATGPT: ["chatgpt.com", "chat.openai.com"], CLAUDE: ["claude.ai"], GROK: ["grok.com", "x.ai"], + GEMINI: ["gemini.google.com"], T3: ["t3.chat"], SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"], } as const @@ -95,6 +108,8 @@ export const POSTHOG_EVENT_KEY = { T3_CHAT_MEMORIES_AUTO_SEARCHED: "t3_chat_memories_auto_searched", CLAUDE_CHAT_MEMORIES_SEARCHED: "claude_chat_memories_searched", CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched", + GEMINI_CHAT_MEMORIES_SEARCHED: "gemini_chat_memories_searched", + GEMINI_CHAT_MEMORIES_AUTO_SEARCHED: "gemini_chat_memories_auto_searched", CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched", CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched", } as const diff --git a/apps/browser-extension/utils/route-detection.ts b/apps/browser-extension/utils/route-detection.ts index a8a4714f..c28e5caf 100644 --- a/apps/browser-extension/utils/route-detection.ts +++ b/apps/browser-extension/utils/route-detection.ts @@ -39,7 +39,6 @@ export function createRouteDetection( const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log(`${config.platform} route changed, re-initializing`) setTimeout(config.reinitCallback, 1000) } } diff --git a/apps/browser-extension/utils/search-request.test.ts b/apps/browser-extension/utils/search-request.test.ts new file mode 100644 index 00000000..228d0060 --- /dev/null +++ b/apps/browser-extension/utils/search-request.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test" +import { buildSearchMemoriesBody } from "./search-request" + +describe("buildSearchMemoriesBody", () => { + it("builds the default related-memory search body", () => { + expect(buildSearchMemoriesBody("deploy notes")).toEqual({ + q: "deploy notes", + include: { relatedMemories: true }, + }) + }) + + it("includes the container tag when provided", () => { + expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({ + q: "deploy notes", + include: { relatedMemories: true }, + containerTag: "sm_project_docs", + }) + }) +}) diff --git a/apps/browser-extension/utils/search-request.ts b/apps/browser-extension/utils/search-request.ts new file mode 100644 index 00000000..95c547a1 --- /dev/null +++ b/apps/browser-extension/utils/search-request.ts @@ -0,0 +1,14 @@ +export function buildSearchMemoriesBody( + query: string, + containerTag?: string, +): { + q: string + include: { relatedMemories: boolean } + containerTag?: string +} { + return { + q: query, + include: { relatedMemories: true }, + ...(containerTag ? { containerTag } : {}), + } +} diff --git a/apps/browser-extension/utils/twitter-auth.ts b/apps/browser-extension/utils/twitter-auth.ts index 8a791fa2..d8b862a3 100644 --- a/apps/browser-extension/utils/twitter-auth.ts +++ b/apps/browser-extension/utils/twitter-auth.ts @@ -51,7 +51,6 @@ export async function captureTwitterTokens( if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) { const tokensAlreadyLogged = await getTokensLogged() if (!tokensAlreadyLogged) { - console.log("Twitter auth tokens captured successfully") await setTokensLogged() } diff --git a/apps/browser-extension/utils/twitter-import.ts b/apps/browser-extension/utils/twitter-import.ts index afc2691e..3c463370 100644 --- a/apps/browser-extension/utils/twitter-import.ts +++ b/apps/browser-extension/utils/twitter-import.ts @@ -186,8 +186,6 @@ export class TwitterImporter { if (documents.length > 0) { await saveAllTweets(documents) } - console.log("Tweets saved") - console.log("Documents:", documents) } catch (error) { console.error("Error saving tweets batch:", error) await this.config.onError(error as Error) @@ -201,9 +199,6 @@ export class TwitterImporter { [] const nextCursor = extractNextCursor(instructions) - console.log("Next cursor:", nextCursor) - console.log("Tweets length:", tweets.length) - if (nextCursor && tweets.length > 0 && !this.config.isFolderImport) { await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting await this.batchImportAll(nextCursor, importedCount, uniqueGroupId) diff --git a/apps/browser-extension/utils/types.ts b/apps/browser-extension/utils/types.ts index 8cec2241..e17f4ff9 100644 --- a/apps/browser-extension/utils/types.ts +++ b/apps/browser-extension/utils/types.ts @@ -38,6 +38,9 @@ export interface MemoryData { url?: string ogImage?: string title?: string + sourcePlatform?: string + sourcePlatformLabel?: string + sourceSurface?: string } /** diff --git a/apps/browser-extension/utils/ui-components.ts b/apps/browser-extension/utils/ui-components.ts index 99f96cbb..2ed57690 100644 --- a/apps/browser-extension/utils/ui-components.ts +++ b/apps/browser-extension/utils/ui-components.ts @@ -117,7 +117,7 @@ export function createToast(state: ToastState): HTMLElement { break case "success": { - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") icon.innerHTML = `Success` textElement.textContent = "Added to Memory" break @@ -184,7 +184,7 @@ export function createTwitterImportButton(onClick: () => void): HTMLElement { font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") button.style.backgroundImage = `url("${iconUrl}")` button.style.backgroundRepeat = "no-repeat" @@ -232,7 +232,7 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement { z-index: 1000; ` - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` Save to Memory @@ -261,31 +261,72 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement { * @returns HTMLElement - The save button element */ export function createChatGPTInputBarElement(onClick: () => void): HTMLElement { - const iconButton = document.createElement("div") + return createConnectedIndicator(onClick) +} + +export function createConnectedIndicator(onClick: () => void): HTMLElement { + const iconButton = document.createElement("button") + iconButton.type = "button" + iconButton.setAttribute("aria-label", "supermemory connected") + iconButton.dataset.supermemoryConnectedIndicator = "true" iconButton.style.cssText = ` display: inline-flex; align-items: center; justify-content: center; - width: auto; - height: 24px; + width: 32px; + height: 32px; + min-width: 32px; cursor: pointer; - transition: opacity 0.2s ease; + transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease; border-radius: 50%; + border: none; + background: transparent; + padding: 0; + position: relative; + flex-shrink: 0; ` - // Use appropriate icon based on theme - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` - Save to Memory + ` + const tooltip = document.createElement("div") + tooltip.textContent = "supermemory connected" + tooltip.style.cssText = ` + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(2px); + background: #0A0E14; + color: #FAFAFA; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 6px 8px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.16s ease, transform 0.16s ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + z-index: 2147483647; + ` + iconButton.appendChild(tooltip) + iconButton.addEventListener("mouseenter", () => { - iconButton.style.opacity = "0.8" + iconButton.style.backgroundColor = "rgba(255, 255, 255, 0.08)" + tooltip.style.opacity = "1" + tooltip.style.transform = "translateX(-50%) translateY(0)" }) iconButton.addEventListener("mouseleave", () => { - iconButton.style.opacity = "1" + iconButton.style.backgroundColor = "transparent" + tooltip.style.opacity = "0" + tooltip.style.transform = "translateX(-50%) translateY(2px)" }) iconButton.addEventListener("click", (event) => { @@ -303,42 +344,11 @@ export function createChatGPTInputBarElement(onClick: () => void): HTMLElement { * @returns HTMLElement - The save button element */ export function createClaudeInputBarElement(onClick: () => void): HTMLElement { - const iconButton = document.createElement("div") - iconButton.style.cssText = ` - display: inline-flex; - align-items: center; - justify-content: center; - width: auto; - height: 32px; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 6px; - background: transparent; - ` + return createConnectedIndicator(onClick) +} - const iconFileName = "/icon-16.png" - const iconUrl = browser.runtime.getURL(iconFileName) - iconButton.innerHTML = ` - Get Related Memories from supermemory - ` - - iconButton.addEventListener("mouseenter", () => { - iconButton.style.backgroundColor = "rgba(0, 0, 0, 0.05)" - iconButton.style.borderColor = "rgba(0, 0, 0, 0.2)" - }) - - iconButton.addEventListener("mouseleave", () => { - iconButton.style.backgroundColor = "transparent" - iconButton.style.borderColor = "rgba(0, 0, 0, 0.1)" - }) - - iconButton.addEventListener("click", (event) => { - event.stopPropagation() - event.preventDefault() - onClick() - }) - - return iconButton +export function createGeminiInputBarElement(onClick: () => void): HTMLElement { + return createConnectedIndicator(onClick) } /** @@ -360,7 +370,7 @@ export function createT3InputBarElement(onClick: () => void): HTMLElement { background: transparent; ` - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` Get Related Memories from supermemory @@ -433,7 +443,7 @@ export function createProjectSelectionModal( margin-bottom: 20px; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") header.innerHTML = `

@@ -702,7 +712,7 @@ export const DOMUtils = { if (icon && text) { if (state === "success") { - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") icon.innerHTML = `Success` icon.style.animation = "" text.textContent = "Added to Memory" diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index 2f334297..ea1ca4a0 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ manifest: { name: "supermemory", homepage_url: "https://supermemory.ai", - version: "6.1.4", + version: "6.1.3", permissions: ["storage", "activeTab", "webRequest", "tabs"], host_permissions: [ "*://x.com/*", @@ -42,11 +42,14 @@ export default defineConfig({ "*://*.grok.com/*", "*://x.ai/*", "*://*.x.ai/*", + "*://claude.ai/*", + "*://gemini.google.com/*", + "*://t3.chat/*", "https://*.posthog.com/*", ], web_accessible_resources: [ { - resources: ["icon-16.png", "fonts/*.ttf"], + resources: ["new_logo.png", "fonts/*.ttf"], matches: [""], }, ], diff --git a/apps/docs/connectors/gmail.mdx b/apps/docs/connectors/gmail.mdx index 744081d2..73e5063e 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/docs.json b/apps/docs/docs.json index 11d53085..fd992163 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -180,6 +180,7 @@ "self-hosting/overview", "self-hosting/quickstart", "self-hosting/configuration", + "self-hosting/embeddings", "self-hosting/providers", "self-hosting/local-vs-enterprise" ] diff --git a/apps/docs/integrations/ai-sdk.mdx b/apps/docs/integrations/ai-sdk.mdx index bdf73fd3..eede8429 100644 --- a/apps/docs/integrations/ai-sdk.mdx +++ b/apps/docs/integrations/ai-sdk.mdx @@ -140,6 +140,18 @@ const model = withSupermemory(openai("gpt-5"), { }) ``` +### Persisting Tool Calls (default: off) + +By default, saved conversations include only user and assistant text — tool calls and tool results are dropped, since tool payloads are often large and low-signal and would pollute memory extraction. To persist the full tool round trip (tool calls with their arguments, plus tool results, in their original order), set `includeToolCalls: true`: + +```typescript +const model = withSupermemory(openai("gpt-5"), { + containerTag: "user-123", + customId: "conv-1", + includeToolCalls: true, +}) +``` + --- ## Memory Tools diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx index 280a1333..09478722 100644 --- a/apps/docs/self-hosting/configuration.mdx +++ b/apps/docs/self-hosting/configuration.mdx @@ -5,7 +5,7 @@ description: "Every environment variable the self-hosted server understands." icon: "settings" --- -The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it. +The self-hosted server aims for **zero configuration** — the only required input is one LLM provider key, which the first-boot wizard collects interactively (or set via env var for non-interactive deployments). Embeddings default to local English; you can pick another provider in the optional wizard step or via env. Everything else below is opt-in. The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager. @@ -18,7 +18,7 @@ The installer writes API keys to `~/.supermemory/env`, which is loaded on every ## LLM providers -In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**: +In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own LLM for the intelligent steps — summaries, contextual chunking, and memory extraction. Embeddings default to a local model (no API key) and can optionally use OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). Configure **at least one** LLM provider: | Variable | Provider | |---|---| @@ -61,43 +61,20 @@ OPENAI_MODEL=gpt-oss:20b Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`. -## Embedding models +## Embeddings -By default, embeddings run on the bundled local model (`Xenova/bge-base-en-v1.5`, 768-dim, English-only) — no network, no key. Swap in a remote provider if you need multilingual coverage or want to match embeddings you already generate elsewhere: +By default, vectors are computed locally with `Xenova/bge-base-en-v1.5` (768d) — no embedding API key. On interactive first boot you can pick a different provider after the LLM key step; for Docker/CI set env vars instead. + +Full provider table, multilingual guidance, remote examples (OpenAI / Gemini / Ollama), and the re-ingestion / dimension-lock warning: **[Embeddings (self-hosted)](/self-hosting/embeddings)**. | Variable | Purpose | Default | |---|---|---| -| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local` \| `openai` \| `openai-compatible` \| `google` | `local` | -| `SUPERMEMORY_EMBEDDING_MODEL` | Model id — HF id for `local`, provider model id for remote | `Xenova/bge-base-en-v1.5` | -| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Output dimensionality. Required for models not in the known-dimension list (e.g. `text-embedding-3-large`) | inferred where possible | -| `SUPERMEMORY_EMBEDDING_API_KEY` | Remote provider key | falls back to `OPENAI_API_KEY` / `GEMINI_API_KEY` | -| `SUPERMEMORY_EMBEDDING_BASE_URL` | Remote endpoint URL | `openai-compatible` also falls back to `OPENAI_BASE_URL`; plain `openai` does not | +| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local`, `openai`, `gemini`, or OpenAI-compatible remote | `local` | +| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` | +| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match model and stored data | `768` | +| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs | unset | -```bash -# Multilingual local model — swap the HF id, dimensions are inferred -SUPERMEMORY_EMBEDDING_MODEL=Xenova/multilingual-e5-large - -# OpenAI embeddings -SUPERMEMORY_EMBEDDING_PROVIDER=openai -SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small - -# Any OpenAI-compatible embedding endpoint (e.g. Ollama) -SUPERMEMORY_EMBEDDING_PROVIDER=openai-compatible -SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1 -SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text - -# Gemini embeddings -SUPERMEMORY_EMBEDDING_PROVIDER=google -SUPERMEMORY_EMBEDDING_MODEL=gemini-embedding-001 -``` - - -Changing the model or dimensionality after you've already ingested data requires re-ingestion — the store enforces one embedding dimension at a time, and a boot-time check fails fast if the configured dimension doesn't match what's already there. Only reshape a fresh, empty store. - - -pgvector's HNSW index caps out at 2000 dimensions. `text-embedding-3-large` (3072-dim natively) needs `SUPERMEMORY_EMBEDDING_DIMENSIONS` set to something ≤2000 to fit — the `text-embedding-3-*` family and Gemini support this kind of output reduction; models like `text-embedding-ada-002` don't and must run at their native size. - -## Embedding performance +### Embedding performance Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom (remote embedding providers ignore these — there's no local worker pool to tune): @@ -164,8 +141,13 @@ Any other environment variables you may find referenced in the codebase are plat # Persistent data location SUPERMEMORY_DATA_DIR=/var/lib/supermemory -# One LLM provider +# One LLM provider (required for extraction) OPENAI_API_KEY=sk-... + +# Optional — omit to keep local Xenova/bge-base-en-v1.5 (768d) +# SUPERMEMORY_EMBEDDING_PROVIDER=openai +# SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small +# SUPERMEMORY_EMBEDDING_DIMENSIONS=1536 ``` -That's enough for full ingestion, memory extraction, and hybrid search. +That's enough for full ingestion, memory extraction, and hybrid search with the default local embeddings. diff --git a/apps/docs/self-hosting/embeddings.mdx b/apps/docs/self-hosting/embeddings.mdx new file mode 100644 index 00000000..95385ff0 --- /dev/null +++ b/apps/docs/self-hosting/embeddings.mdx @@ -0,0 +1,135 @@ +--- +title: "Embeddings (self-hosted)" +sidebarTitle: "Embeddings" +description: "Local and remote embedding providers for Supermemory local — defaults, env vars, multilingual options, and dimension lock." +icon: "waypoints" +--- + +Self-hosted Supermemory uses the **same embedding provider stack** as the hosted platform: local ONNX models, OpenAI, Gemini, or any OpenAI-compatible embeddings endpoint (including Ollama). LLM keys power extraction and summarization; embeddings are configured separately. + +## Defaults + +| | | +|---|---| +| Provider | `local` | +| Model | `Xenova/bge-base-en-v1.5` | +| Dimensions | `768` | +| API key | None — runs on your machine | + +Press Enter at the optional first-boot picker to keep this default. Nothing is sent off-box to embed. + + +The default local model is **English-only**. Non-English content can ingest successfully while dense semantic recall stays weak. See [Multilingual](#multilingual). + + +## First-time setup (interactive) + +On first boot with a TTY, Supermemory asks for an LLM API key (required), then optionally which embedding model to use. + +1. Choose or paste an LLM provider key (OpenAI, Anthropic, Gemini, Groq, or OpenAI-compatible). +2. Optionally pick an embedding provider/model. **Press Enter to keep the local English model.** +3. Choices are saved encrypted under your data directory (`$SUPERMEMORY_DATA_DIR`, typically `./.supermemory` / `~/.supermemory`). + +Boot order is intentional: LLM keys load first so remote embedding options can reuse them (for example OpenAI or Gemini embeddings with the same key). + + +**First boot (terminal):** Supermemory asks for an LLM API key (required), then optionally which embedding model to use. Press Enter to keep the local English model. Choices are saved encrypted under your data directory. + + +## Configuration (env) + +For Docker, CI, or any non-interactive deploy, set env vars — there is **no interactive prompt without a TTY**. + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_EMBEDDING_PROVIDER` | Embedding backend: `local`, `openai`, `gemini`, or an OpenAI-compatible remote (`ollama` / custom base URL) | `local` | +| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` (local) | +| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match the model and any already-stored data | `768` (local default) | +| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs (Ollama, vLLM, etc.) | unset | +| `OPENAI_API_KEY` | Used when provider is `openai` (or compatible) if not otherwise supplied | unset | +| `GEMINI_API_KEY` | Used when provider is `gemini` | unset | + +Local worker tuning (throughput only — does not change model or dimensions): + +| Variable | Purpose | Default | +|---|---|---| +| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` | +| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` | +| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` | +| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` | +| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset | + +Ingestion memory headroom is controlled by `SUPERMEMORY_EMBEDDING_RAM_LIMIT` — see [Memory limits & ingestion queue](/self-hosting/configuration#memory-limits--ingestion-queue). + + +**Docker / production:** Set at least one LLM key and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `SUPERMEMORY_EMBEDDING_MODEL` / `SUPERMEMORY_EMBEDDING_DIMENSIONS` (and base URL or API key as needed). There is no interactive prompt without a TTY. + + +## Multilingual + +The default `Xenova/bge-base-en-v1.5` model is trained for English. For German, Dutch, and other non-English corpora, dense recall can fail even when hybrid keyword search still finds rare tokens. + +For multilingual or non-English deployments, switch **before** large backfills: + +```bash +# Example: local multilingual (set dimensions to match the model) +SUPERMEMORY_EMBEDDING_PROVIDER=local +SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-m3 +SUPERMEMORY_EMBEDDING_DIMENSIONS=1024 +``` + +Or use a remote multilingual embedding API (OpenAI, Gemini, or Ollama with a multilingual embed model). Set provider, model, and dimensions together. Changing them later requires a fresh data directory or full re-ingestion — see below. + +## Remote providers + +### Local (default) + +```bash +# Explicit local default — no embedding API key +SUPERMEMORY_EMBEDDING_PROVIDER=local +SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5 +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +### OpenAI + +```bash +OPENAI_API_KEY=sk-... +SUPERMEMORY_EMBEDDING_PROVIDER=openai +SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small +SUPERMEMORY_EMBEDDING_DIMENSIONS=1536 +``` + +### Gemini + +```bash +GEMINI_API_KEY=... +SUPERMEMORY_EMBEDDING_PROVIDER=gemini +SUPERMEMORY_EMBEDDING_MODEL=text-embedding-004 +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +### Ollama (OpenAI-compatible) + +```bash +SUPERMEMORY_EMBEDDING_PROVIDER=openai +SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1 +OPENAI_API_KEY=ollama +SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text +SUPERMEMORY_EMBEDDING_DIMENSIONS=768 +``` + +Use the dimension published for your chosen model. A mismatch with vectors already in the store fails boot. + +## Changing models later + + +**Not supported in place.** Embeddings from different models (or different dimensions) are not comparable. Start from a fresh data directory or re-ingest all content so vectors stay in one space. If configured dimensions disagree with stored data, the server **refuses to boot**. + + +**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable. + +## Related + +- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits +- [Quickstart](/self-hosting/quickstart) — install and first memory diff --git a/apps/docs/self-hosting/overview.mdx b/apps/docs/self-hosting/overview.mdx index cfd8d16f..8fc9fe23 100644 --- a/apps/docs/self-hosting/overview.mdx +++ b/apps/docs/self-hosting/overview.mdx @@ -24,7 +24,7 @@ No Docker. No database to provision. No config files. It boots in seconds with e Run the binary with nothing set and you get a complete memory system: - **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings. -- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded. +- **Built-in local embeddings** — default `Xenova/bge-base-en-v1.5` (768d) on your machine, no API key. Same provider stack as cloud if you opt into OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). - **An API key, generated for you** — printed on first boot, ready to paste into any SDK. - **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works. @@ -64,7 +64,7 @@ Self-hosted is free, open source, and great for local development, air-gapped en |---|---|---| | Full Memory API | ✅ | ✅ | | Hybrid semantic search | ✅ | ✅ | -| Local embeddings | ✅ | Managed | +| Embeddings | Local default (or OpenAI / Gemini / Ollama) | Same provider stack, managed | | File ingestion (PDFs, images) | ✅ | ✅ | | [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ | | [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ | @@ -75,11 +75,14 @@ If you outgrow a single machine — or want connectors, MCP, and the best-tuned ## Next steps - + Install, run, and store your first memory in under two minutes Every environment variable: LLM providers, storage, auth, tuning + + Local default, remote providers, multilingual, dimension lock + diff --git a/apps/docs/self-hosting/quickstart.mdx b/apps/docs/self-hosting/quickstart.mdx index 9ea39140..cb86ee33 100644 --- a/apps/docs/self-hosting/quickstart.mdx +++ b/apps/docs/self-hosting/quickstart.mdx @@ -47,9 +47,13 @@ First boot sets everything up — the embedded Supermemory graph engine, local e Save that API key — it's your bearer token for every request. -In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models). +In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. After the LLM key, you can optionally pick an embedding model (press Enter to keep local `Xenova/bge-base-en-v1.5`). See [all providers](/self-hosting/configuration#llm-providers), [embeddings](/self-hosting/embeddings), and [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models). + +**Docker / non-interactive:** set an LLM key via env and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `MODEL` / `DIMENSIONS`. There is no wizard without a TTY. + + ## Add your first memory @@ -141,10 +145,13 @@ By default, all state lives in a single directory you can back up or move: ## Next steps - + LLM providers, local models, performance tuning + + Local default, OpenAI / Gemini / Ollama, multilingual + The full API — it all works against your local server diff --git a/apps/mcp/README.md b/apps/mcp/README.md index d29c1f08..934ada87 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -106,6 +106,24 @@ Search memories and get user profile. | `includeProfile` | boolean | No | Include user profile summary. Default: `true` | | `containerTag` | string | No | Project tag to scope the search | +### `listMemories` + +Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search. + +```json +{ + "page": 1, + "limit": 10, + "containerTag": "optional-project-tag" +} +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `page` | integer | No | Page number (1-based). Default: `1` | +| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` | +| `containerTag` | string | No | Project tag to scope the listing | + ### `whoAmI` Get the current logged-in user's information. @@ -186,6 +204,7 @@ bun run test:e2e | `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip | | `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` | | `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args | +| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation | | `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection | | `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt | diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index e18375cc..88343249 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -4,11 +4,34 @@ import { API_KEY, callTool, connect, textOf, type Session } from "./helpers" const EXPECTED_TOOLS = [ "memory", "recall", + "listMemories", "listProjects", "whoAmI", "memory-graph", ] +const READ_ONLY_TOOL_NAMES = [ + "recall", + "listMemories", + "listProjects", + "whoAmI", + "memory-graph", +] + +const READ_ONLY_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} + +const MEMORY_TOOL_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +} + describe.skipIf(!API_KEY)("MCP — discovery & identity", () => { let s: Session @@ -25,6 +48,20 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => { for (const t of EXPECTED_TOOLS) expect(names).toContain(t) }) + it("marks read-only tools as non-destructive", async () => { + const { tools } = await s.client.listTools() + for (const name of READ_ONLY_TOOL_NAMES) { + const tool = tools.find((t) => t.name === name) + expect(tool?.annotations).toMatchObject(READ_ONLY_ANNOTATIONS) + } + }) + + it("marks memory as mutating", async () => { + const { tools } = await s.client.listTools() + const memory = tools.find((t) => t.name === "memory") + expect(memory?.annotations).toMatchObject(MEMORY_TOOL_ANNOTATIONS) + }) + it("lists profile & projects resources", async () => { const { resources } = await s.client.listResources() const uris = resources.map((r) => r.uri) diff --git a/apps/mcp/e2e/list-memories.test.ts b/apps/mcp/e2e/list-memories.test.ts new file mode 100644 index 00000000..4bec0751 --- /dev/null +++ b/apps/mcp/e2e/list-memories.test.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + API_KEY, + callTool, + connect, + type Session, + sleep, + textOf, +} from "./helpers" + +// listMemories reads extracted memory entries, which appear only after the +// async ingestion pipeline finishes — poll like recallUntil does. +async function listUntil( + s: Session, + needle: string, + { tries = 18, delayMs = 5000 } = {}, +): Promise { + for (let i = 0; i < tries; i++) { + // The marker document is the newest, so page 1 is enough. + const res = await callTool(s.client, "listMemories", { limit: 20 }) + const txt = textOf(res) + if (txt.includes(needle)) return txt + await sleep(delayMs) + } + return null +} + +describe.skipIf(!API_KEY)("MCP — listMemories", () => { + let s: Session + const created: string[] = [] + + beforeAll(async () => { + s = await connect() + }) + afterAll(async () => { + for (const content of created) { + await callTool(s.client, "memory", { + content, + action: "forget", + }).catch(() => {}) + } + await s?.close() + }) + + it("lists a saved memory without dumping document content", async () => { + const marker = `lm-${randomUUID()}` + const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.` + created.push(content) + + const save = await callTool(s.client, "memory", { content, action: "save" }) + expect(save.isError).toBeFalsy() + + const listing = await listUntil(s, marker) + expect( + listing, + `listMemories never returned marker ${marker}`, + ).not.toBeNull() + // Header shape: "N memories across M documents (page X of Y, ...)" + expect(listing).toMatch(/memor(y|ies) across \d+ document/) + }, 120_000) + + it("paginates with a bounded page size", async () => { + const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 }) + expect(res.isError).toBeFalsy() + const txt = textOf(res) + // With the memory saved above there is at least one document. + expect(txt).toMatch(/page 1 of \d+/) + }, 30_000) + + it("rejects an out-of-range limit", async () => { + const res = await callTool(s.client, "listMemories", { limit: 500 }) + // Zod schema caps limit at 50 — the SDK surfaces this as a tool error. + expect(res.isError).toBeTruthy() + }, 30_000) +}) diff --git a/apps/mcp/src/format.test.ts b/apps/mcp/src/format.test.ts new file mode 100644 index 00000000..3512b20e --- /dev/null +++ b/apps/mcp/src/format.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest" +import type { DocumentsApiResponse } from "./client" +import { formatMemoriesList } from "./format" + +function makeResponse( + overrides: Partial = {}, +): DocumentsApiResponse { + return { + documents: [], + pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 }, + ...overrides, + } +} + +function makeEntry(memory: string, extra: Record = {}) { + return { + id: `mem_${memory.slice(0, 8)}`, + memory, + spaceId: "space_1", + createdAt: "2026-06-10T12:00:00Z", + updatedAt: "2026-06-10T12:00:00Z", + ...extra, + } +} + +describe("formatMemoriesList", () => { + it("reports an empty store", () => { + expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.") + }) + + it("reports an out-of-range page distinctly from an empty store", () => { + const result = formatMemoriesList( + makeResponse({ + pagination: { + currentPage: 3, + limit: 10, + totalItems: 12, + totalPages: 2, + }, + }), + ) + expect(result).toBe("No documents on page 3 (2 pages total).") + }) + + it("groups memories under their source document with title, type, and date", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Preferences", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("User prefers dark mode"), + makeEntry("User works in TypeScript"), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain( + "2 memories across 1 document (page 1 of 1, 1 documents total), newest first.", + ) + expect(result).toContain('"Preferences" (text, 2026-06-12)') + expect(result).toContain("- User prefers dark mode") + expect(result).toContain("- User works in TypeScript") + expect(result).not.toContain("More available") + }) + + it("excludes forgotten and superseded memory entries", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Facts", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("Current fact"), + makeEntry("Forgotten fact", { isForgotten: true }), + makeEntry("Old version of a fact", { isLatest: false }), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain("- Current fact") + expect(result).not.toContain("Forgotten fact") + expect(result).not.toContain("Old version of a fact") + expect(result).toContain("1 memory across 1 document") + }) + + it("marks documents whose extraction has not produced memories yet", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Still processing", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain( + '"Still processing" (text, 2026-06-12) — no extracted memories yet', + ) + }) + + it("falls back to (untitled) for documents without a title", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: null, + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [makeEntry("Some fact")], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain('"(untitled)" (text, 2026-06-12)') + }) + + it("flattens multi-line memories and truncates oversized ones", () => { + const longMemory = `start ${"x".repeat(600)}` + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Big", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [ + makeEntry("line one\nline two\ttabbed"), + makeEntry(longMemory), + ], + }, + ], + pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 }, + }), + ) + + expect(result).toContain("- line one line two tabbed") + expect(result).toContain("… [truncated]") + const truncatedLine = result + .split("\n") + .find((line) => line.includes("[truncated]")) + expect(truncatedLine).toBeDefined() + expect((truncatedLine as string).length).toBeLessThan(600) + }) + + it("points at the next page when more documents exist", () => { + const result = formatMemoriesList( + makeResponse({ + documents: [ + { + id: "doc_1", + title: "Page one doc", + type: "text", + createdAt: "2026-06-12T08:00:00Z", + updatedAt: "2026-06-12T08:00:00Z", + memoryEntries: [makeEntry("A fact")], + }, + ], + pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 }, + }), + ) + + expect(result).toContain("page 1 of 3, 3 documents total") + expect(result).toContain("More available — call listMemories with page: 2.") + }) +}) diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts index cbd074cf..43c427f0 100644 --- a/apps/mcp/src/format.ts +++ b/apps/mcp/src/format.ts @@ -1,3 +1,56 @@ +import type { DocumentsApiResponse } from "./client" + +// Listing must stay lightweight: memory entries are extracted facts (short +// strings), never raw document content, so responses fit comfortably in +// client output limits even at the maximum page size. +const MAX_LIST_MEMORY_CHARS = 500 + +export function formatMemoriesList(response: DocumentsApiResponse): string { + const { documents, pagination } = response + const day = (s: string | null | undefined) => s?.slice(0, 10) ?? "" + + if (documents.length === 0) { + return pagination.currentPage > 1 + ? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).` + : "No memories stored yet." + } + + let memoryCount = 0 + const blocks = documents.map((doc) => { + const activeEntries = doc.memoryEntries.filter( + (entry) => entry.isForgotten !== true && entry.isLatest !== false, + ) + const title = doc.title?.trim() || "(untitled)" + const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})` + + if (activeEntries.length === 0) { + return `${header} — no extracted memories yet` + } + + memoryCount += activeEntries.length + const lines = activeEntries.map((entry) => { + const text = entry.memory.replace(/\s+/g, " ").trim() + return `- ${ + text.length > MAX_LIST_MEMORY_CHARS + ? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} … [truncated]` + : text + }` + }) + return [header, ...lines].join("\n") + }) + + const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.` + + const parts = [header, "", blocks.join("\n\n")] + if (pagination.currentPage < pagination.totalPages) { + parts.push( + "", + `More available — call listMemories with page: ${pagination.currentPage + 1}.`, + ) + } + return parts.join("\n") +} + export function formatMemories( response: { results?: Array>; total?: number }, opts: { diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index de54deff..86012e68 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -6,7 +6,7 @@ import { RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server" import { SupermemoryClient } from "./client" -import { formatMemories } from "./format" +import { formatMemories, formatMemoriesList } from "./format" import { initPosthog, posthog } from "./posthog" import { z } from "zod" import mcpAppHtml from "../dist/mcp-app.html" @@ -29,6 +29,20 @@ const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000 const MAX_RECALL_CHARS = 200000 +const READ_ONLY_TOOL_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const + +const MEMORY_TOOL_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +} as const + export class SupermemoryMCP extends McpAgent { private clientInfo: { name: string; version?: string } | null = null private cachedContainerTags: string[] = [] @@ -92,6 +106,27 @@ export class SupermemoryMCP extends McpAgent { ...(hasRootContainerTag ? {} : containerTagField), }) + const listMemoriesSchema = z.object({ + page: z + .number() + .int() + .min(1) + .optional() + .default(1) + .describe("Page number (1-based)"), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .default(10) + .describe( + "Documents per page; each document groups its extracted memories (default 10, max 50)", + ), + ...(hasRootContainerTag ? {} : containerTagField), + }) + const contextPromptSchema = z.object({ includeRecent: z .boolean() @@ -104,6 +139,7 @@ export class SupermemoryMCP extends McpAgent { type ContextPromptArgs = z.infer type MemoryArgs = z.infer type RecallArgs = z.infer + type ListMemoriesArgs = z.infer // Register memory tool this.server.registerTool( @@ -112,6 +148,7 @@ export class SupermemoryMCP extends McpAgent { description: "DO NOT USE ANY OTHER MEMORY TOOL ONLY USE THIS ONE. Save or forget information about the user. Use 'save' when user shares preferences, facts, or asks to remember something. Use 'forget' when information is outdated or user requests removal.", inputSchema: memorySchema, + annotations: MEMORY_TOOL_ANNOTATIONS, }, // @ts-expect-error - zod type inference issue with MCP SDK (args: MemoryArgs) => this.handleMemory(args), @@ -124,11 +161,25 @@ export class SupermemoryMCP extends McpAgent { description: "DO NOT USE ANY OTHER RECALL TOOL ONLY USE THIS ONE. Search the user's memories. Returns relevant memories plus their profile summary.", inputSchema: recallSchema, + annotations: READ_ONLY_TOOL_ANNOTATIONS, }, // @ts-expect-error - zod type inference issue with MCP SDK (args: RecallArgs) => this.handleRecall(args), ) + // Register listMemories tool + this.server.registerTool( + "listMemories", + { + description: + "Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file — e.g. before forgetting stale memories or to power a 'list everything' view. For finding memories relevant to a topic, use 'recall' instead.", + inputSchema: listMemoriesSchema, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, + // @ts-expect-error - zod type inference issue with MCP SDK + (args: ListMemoriesArgs) => this.handleListMemories(args), + ) + // Register profile resource this.server.registerResource( "User Profile", @@ -204,6 +255,7 @@ export class SupermemoryMCP extends McpAgent { "Force refresh from the server (default: false; uses cache with TTL)", ), }), + annotations: READ_ONLY_TOOL_ANNOTATIONS, }, // @ts-expect-error - zod type inference issue with MCP SDK async (args: { refresh?: boolean }) => { @@ -258,6 +310,7 @@ export class SupermemoryMCP extends McpAgent { { description: "Get the current logged-in user's information", inputSchema: z.object({}), + annotations: READ_ONLY_TOOL_ANNOTATIONS, }, // @ts-expect-error - zod type inference issue with MCP SDK async () => { @@ -308,6 +361,7 @@ export class SupermemoryMCP extends McpAgent { description: "Visualize the user's memory graph as an interactive force-directed graph showing documents, memories, and their relationships.", inputSchema: memoryGraphSchema, + annotations: READ_ONLY_TOOL_ANNOTATIONS, _meta: { ui: { resourceUri: memoryGraphResourceUri } }, }, // @ts-expect-error - zod type inference issue with MCP SDK @@ -371,6 +425,7 @@ export class SupermemoryMCP extends McpAgent { page: z.number().optional().default(1), limit: z.number().optional().default(10), }), + annotations: READ_ONLY_TOOL_ANNOTATIONS, _meta: { ui: { resourceUri: memoryGraphResourceUri, @@ -726,6 +781,46 @@ export class SupermemoryMCP extends McpAgent { } } + private async handleListMemories(args: { + page?: number + limit?: number + containerTag?: string + }) { + const { page = 1, limit = 10, containerTag } = args + const effectiveContainerTag = containerTag || this.props?.containerTag + + try { + const client = this.getClient(effectiveContainerTag) + const result = await client.getDocuments( + effectiveContainerTag ? [effectiveContainerTag] : undefined, + page, + limit, + ) + + return { + content: [ + { + type: "text" as const, + text: formatMemoriesList(result), + }, + ], + } + } catch (error) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred" + console.error("List memories operation failed:", error) + return { + content: [ + { + type: "text" as const, + text: `Error listing memories: ${message}`, + }, + ], + isError: true, + } + } + } + private async getClientInfo(): Promise< { name: string; version?: string } | undefined > { diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts index 8289ccd9..b22e6386 100644 --- a/apps/mcp/vitest.config.ts +++ b/apps/mcp/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { - include: ["e2e/**/*.test.ts"], + include: ["e2e/**/*.test.ts", "src/**/*.test.ts"], testTimeout: 90_000, hookTimeout: 30_000, }, diff --git a/apps/web/app/(app)/brain/page.tsx b/apps/web/app/(app)/brain/page.tsx new file mode 100644 index 00000000..95541281 --- /dev/null +++ b/apps/web/app/(app)/brain/page.tsx @@ -0,0 +1,137 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2 } from "lucide-react" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" +import { cn } from "@lib/utils" +import { analytics } from "@/lib/analytics" +import { dmSansClassName } from "@/lib/fonts" +import { + detectModeFromEmail, + generateOrgSlug, + workspaceDomainFromEmail, + workspaceNameFromDomain, + workspaceNameFromEmail, + type BrainMetadata, +} from "@/components/onboarding-brain/types" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +// No forms: sign up → org auto-created → Slack install. +// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits). +export default function BrainEntryPage() { + const router = useRouter() + const { user, org, organizations, setActiveOrg, refetchOrganizations } = + useAuth() + const { email = null } = user ?? {} + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + const startedRef = useRef(false) + + const run = useCallback(async () => { + if (organizations && organizations.length > 0) { + const active = + org ?? organizations.find((o) => o.slug) ?? organizations[0] + if (!org && active?.slug) await setActiveOrg(active.slug) + const status = await fetch(`${BACKEND}/brain/slack/status`, { + credentials: "include", + headers: { "X-App-Source": "nova" }, + }) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null) + if (status?.connected) { + router.replace("/") + return + } + window.location.href = `${BACKEND}/brain/slack/oauth/install` + return + } + + // Personal email → shell org; the Slack workspace resolves identity later. + const domain = + detectModeFromEmail(email) === "team" + ? workspaceDomainFromEmail(email) + : null + const name = + (domain + ? workspaceNameFromDomain(domain) + : workspaceNameFromEmail(email)) || "Company Brain" + const metadata: BrainMetadata & { signupSource: string } = { + signupSource: "consumer", + brainOnboardingVersion: "v1", + brainMode: "team", + brainWorkspaceName: name, + brainWorkspaceDomain: domain, + // Always the shared Team Brain; the CB UI never selects a slug space. + brainContainerTag: SHARED_TEAM_BRAIN_TAG, + } + const result = await authClient.organization.create({ + name, + slug: generateOrgSlug(name), + metadata, + }) + if (result.error || !result.data?.slug) { + throw new Error(result.error?.message || "Could not create workspace.") + } + await setActiveOrg(result.data.slug) + await refetchOrganizations() + analytics.onboardingWorkspaceCreated({ + mode: "team", + has_about: false, + has_domain: Boolean(domain), + }) + window.location.href = `${BACKEND}/brain/slack/oauth/install` + }, [email, org, organizations, setActiveOrg, refetchOrganizations, router]) + + // Sole caller of run(): the guard is only released on failure, so a dep change + // mid-flight can't kick off a second org creation. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry + useEffect(() => { + if (!user || organizations === null || startedRef.current) return + startedRef.current = true + run().catch((e) => { + startedRef.current = false + console.error("Brain entry failed:", e) + setError(e instanceof Error ? e.message : "Something went wrong.") + }) + }, [user, organizations, run, attempt]) + + return ( +
+ {error ? ( + <> +

+ Couldn't set up your Company Brain +

+

{error}

+ + + ) : ( + <> + +

+ Setting up your Company Brain… +

+ + )} +
+ ) +} 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/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index c7723724..5f9bf8cc 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -18,7 +18,7 @@ import { motion } from "motion/react" import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { Logo } from "@ui/assets/Logo" -import { resolveAuthRedirectUrl } from "@/lib/url-helpers" +import { getBackendUrl, resolveAuthRedirectUrl } from "@/lib/url-helpers" import { Loader2 } from "lucide-react" function isMcpOAuthAuthorizeContext(sp: Pick): boolean { @@ -276,7 +276,7 @@ export default function LoginPage() { const token = formData.get("token") as string const callbackURL = getCallbackURL() router.push( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, + `${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, ) } diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index 7753e6b5..e23b055c 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -13,29 +13,137 @@ function isValidUrl(urlString: string): boolean { } } +function isPrivateIPv4Octets(a: number, b: number): boolean { + // 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback), + // 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16 + if (a === 0) return true + if (a === 10) return true + if (a === 127) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true + return false +} + +// Parse an IPv6 hostname into its eight 16-bit groups. The WHATWG URL parser +// already canonicalizes literals, but it leaves the brackets on (e.g. "[::1]") +// and performs no SSRF filtering. Handles "::" compression and embedded IPv4 +// suffixes (e.g. ::ffff:1.2.3.4). Returns null when not an IPv6 literal. +function parseIPv6Groups(input: string): number[] | null { + let host = input + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1) + const zone = host.indexOf("%") // strip scope/zone id, e.g. fe80::1%eth0 + if (zone !== -1) host = host.slice(0, zone) + if (!host.includes(":")) return null + + // Convert an embedded IPv4 tail (e.g. ::ffff:1.2.3.4) into two hex groups. + const dot = host.indexOf(".") + if (dot !== -1) { + const colon = host.lastIndexOf(":", dot) + if (colon === -1) return null + const octets = host.slice(colon + 1).split(".") + if (octets.length !== 4) return null + const bytes: number[] = [] + for (const octet of octets) { + if (!/^\d{1,3}$/.test(octet)) return null + const n = Number(octet) + if (n > 255) return null + bytes.push(n) + } + const hi = (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)).toString(16) + const lo = (((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0)).toString(16) + host = `${host.slice(0, colon + 1)}${hi}:${lo}` + } + + const parseSide = (side: string): number[] | null => { + if (side === "") return [] + const groups: number[] = [] + for (const group of side.split(":")) { + if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null + groups.push(Number.parseInt(group, 16)) + } + return groups + } + + const halves = host.split("::") + if (halves.length > 2) return null + + if (halves.length === 2) { + const head = parseSide(halves[0] ?? "") + const tail = parseSide(halves[1] ?? "") + if (!head || !tail) return null + const missing = 8 - head.length - tail.length + if (missing < 1) return null + return [...head, ...new Array(missing).fill(0), ...tail] + } + + const all = parseSide(host) + if (all?.length !== 8) return null + return all +} + +function isPrivateIPv6(input: string): boolean { + const g = parseIPv6Groups(input) + if (!g) return false + + // Unspecified (::) and loopback (::1) + if (g.every((x) => x === 0)) return true + if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true + + // Link-local fe80::/10 + if (((g[0] ?? 0) & 0xffc0) === 0xfe80) return true + + // Unique local address fc00::/7 (fc00–fdff) + if ((((g[0] ?? 0) >> 8) & 0xfe) === 0xfc) return true + + // IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d): + // re-check the embedded IPv4 against the private ranges so loopback/metadata + // can't be reached via an IPv6 wrapper. + const firstFiveZero = g.slice(0, 5).every((x) => x === 0) + const ipv4Mapped = firstFiveZero && g[5] === 0xffff + const ipv4Compatible = + firstFiveZero && g[5] === 0 && (g[6] !== 0 || g[7] !== 0) + if (ipv4Mapped || ipv4Compatible) { + const a = ((g[6] ?? 0) >> 8) & 0xff + const b = (g[6] ?? 0) & 0xff + return isPrivateIPv4Octets(a, b) + } + + return false +} + +// NOTE: This blocks private/loopback/metadata IP *literals* only. It cannot stop +// DNS rebinding (a public hostname that resolves to a private address), which +// would require resolving + pinning the address before connecting — not available +// in the edge fetch runtime here. function isPrivateHost(hostname: string): boolean { const lowerHost = hostname.toLowerCase() - if ( - lowerHost === "localhost" || - lowerHost === "127.0.0.1" || - lowerHost === "0.0.0.0" || - lowerHost === "::1" || - lowerHost === "::" || - lowerHost.startsWith("127.") || - lowerHost.startsWith("0.0.0.0") - ) { + // Hostname-based loopback (RFC 6761 reserves localhost and *.localhost) + if (lowerHost === "localhost" || lowerHost.endsWith(".localhost")) { return true } - const privateIpPatterns = [ - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[01])\./, - /^192\.168\./, - /^169\.254\./, // Link-local / Metadata service - ] + // IPv6 literals arrive bracketed (e.g. "[::1]"), so the previous + // string-equality checks never matched them. + if (lowerHost.includes(":")) { + return isPrivateIPv6(lowerHost) + } - return privateIpPatterns.some((pattern) => pattern.test(hostname)) + // IPv4. The WHATWG URL parser canonicalizes alternate encodings + // (decimal/hex/octal, e.g. http://2130706433 -> 127.0.0.1) to dotted-quad, + // so matching the dotted form here also blocks those encodings. + const octets = lowerHost.split(".") + if ( + octets.length === 4 && + octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) + ) { + const [a, b] = octets.map(Number) as [number, number, number, number] + return isPrivateIPv4Octets(a, b) + } + + return false } // File extensions that are not HTML and can't be scraped for OG data diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 63daad88..28b93b18 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" @@ -28,6 +29,7 @@ import { RaycastDetail } from "@/components/integrations/raycast-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" +import { SlackHandoff } from "@/components/onboarding-brain/slack-handoff" import { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" import { DocumentsCommandPalette } from "@/components/documents-command-palette" @@ -67,6 +69,7 @@ import { } from "@/lib/search-params" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" import { getToolDocumentSpace } from "@/lib/plugin-space" +import { getBackendUrl } from "@/lib/url-helpers" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -134,17 +137,25 @@ export function AppExperience() { const { viewMode, setViewMode } = useViewMode() useLegacyViewRedirect() const isCompanyBrain = useHasCompanyBrain() + const backendUrl = getBackendUrl() - // Slack OAuth redirects back here with ?slack=connected — toast then clean up. + // ?slack=connected: CB orgs get the handoff takeover, everyone else a toast. + const [slackHandoff, setSlackHandoff] = useState<{ + team: string | null + } | null>(null) useEffect(() => { const sp = new URLSearchParams(window.location.search) if (sp.get("slack") !== "connected") return const team = sp.get("team") - toast.success( - team - ? `Supermemory added to ${team} on Slack` - : "Supermemory added to your Slack", - ) + if (isCompanyBrain) { + setSlackHandoff({ team }) + } else { + toast.success( + team + ? `Supermemory added to ${team} on Slack` + : "Supermemory added to your Slack", + ) + } sp.delete("slack") sp.delete("team") const qs = sp.toString() @@ -153,7 +164,7 @@ export function AppExperience() { "", window.location.pathname + (qs ? `?${qs}` : ""), ) - }, []) + }, [isCompanyBrain]) const queryClient = useQueryClient() const [highlightsForceAt, setHighlightsForceAt] = useState(0) @@ -331,7 +342,7 @@ export function AppExperience() { queryFn: async (): Promise => { const spaceId = selectedProject || "sm_project_default" const forceRefresh = highlightsForceAt > 0 - const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` + const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}` if (!forceRefresh) { const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) @@ -345,22 +356,19 @@ export function AppExperience() { } } - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - spaceId, - highlightsCount: 3, - questionsCount: 4, - includeHighlights: true, - includeQuestions: true, - forceRefresh, - }), - }, - ) + const response = await fetch(`${backendUrl}/v3/space-highlights`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + spaceId, + highlightsCount: 3, + questionsCount: 4, + includeHighlights: true, + includeQuestions: true, + forceRefresh, + }), + }) if (!response.ok) { throw new Error("Failed to fetch space highlights") @@ -404,10 +412,9 @@ export function AppExperience() { if (stored) return JSON.parse(stored) as MemoryOfDay } catch {} - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`, - { credentials: "include" }, - ) + const response = await fetch(`${backendUrl}/v3/memory-of-day`, { + credentials: "include", + }) if (!response.ok) return null const data = (await response.json()) as MemoryOfDay | null if (data) { @@ -617,6 +624,12 @@ export function AppExperience() { return ( + {slackHandoff && ( + setSlackHandoff(null)} + /> + )}
+ ) : 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/chat/index.tsx b/apps/web/components/chat/index.tsx index aae5920b..c78df7f7 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -1111,7 +1111,7 @@ export function ChatSidebar({ const params = new URLSearchParams({ projectId: chatProject }) if (historyScope === "all") params.set("scope", "all") const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`, + `${chatApiBase}/chat/threads?${params.toString()}`, { credentials: "include" }, ) if (response.ok) { @@ -1123,7 +1123,7 @@ export function ChatSidebar({ } finally { setIsLoadingThreads(false) } - }, [chatProject, historyScope]) + }, [chatApiBase, chatProject, historyScope]) useEffect(() => { if (!isHistoryOpen) return @@ -1134,10 +1134,9 @@ export function ChatSidebar({ const loadThread = useCallback( async (id: string) => { try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`, - { credentials: "include" }, - ) + const response = await fetch(`${chatApiBase}/chat/threads/${id}`, { + credentials: "include", + }) if (response.ok) { const data = await response.json() const uiMessages = data.messages.map( @@ -1179,7 +1178,7 @@ export function ChatSidebar({ console.error("Failed to load thread:", error) } }, - [setThreadId], + [chatApiBase, setThreadId], ) // Auto-restore thread from URL on mount (e.g. reload or direct link) @@ -1197,7 +1196,7 @@ export function ChatSidebar({ async (threadId: string) => { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, + `${chatApiBase}/chat/threads/${threadId}`, { method: "DELETE", credentials: "include" }, ) if (response.ok) { @@ -1213,7 +1212,7 @@ export function ChatSidebar({ setConfirmingDeleteId(null) } }, - [currentChatId, handleNewChat], + [chatApiBase, currentChatId, handleNewChat], ) const formatRelativeTime = (isoString: string): string => { 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/document-icon.tsx b/apps/web/components/document-icon.tsx index 00e36341..8088b44e 100644 --- a/apps/web/components/document-icon.tsx +++ b/apps/web/components/document-icon.tsx @@ -17,6 +17,7 @@ import { } from "@ui/assets/icons" import { Globe, FileText, FileCode, Image } from "lucide-react" import { cn } from "@lib/utils" +import { isYouTubeUrl } from "@/lib/url-helpers" function MCPIcon({ className }: { className?: string }) { return ( @@ -206,7 +207,7 @@ export function DocumentIcon({ return } - if (url?.includes("youtube.com") || url?.includes("youtu.be")) { + if (isYouTubeUrl(url)) { return } diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index 8e1c9a11..2232d159 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -3,7 +3,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import dynamic from "next/dynamic" -import { isTwitterUrl } from "@/lib/url-helpers" +import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers" import { ImagePreview } from "./image-preview" import { TweetContent } from "./tweet" import { NotionDoc } from "./notion-doc" @@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType { if (document.type === "google_doc") return "google_doc" if (document.type === "google_sheet") return "google_sheet" if (document.type === "google_slide") return "google_slide" - if (document.url?.includes("youtube.com")) return "youtube" + if (isYouTubeUrl(document.url)) return "youtube" if (document.type === "webpage") return "webpage" return null diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx index 675edff4..9b825833 100644 --- a/apps/web/components/ensure-workspace.tsx +++ b/apps/web/components/ensure-workspace.tsx @@ -28,7 +28,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) { (pathname === "/" && ["integrations", "mcp"].includes(searchParams.get("view") ?? "")) const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending - const isOnboarding = pathname.startsWith("/onboarding") + // /brain is the Slack-first Company Brain entry: it creates the org itself. + const isOnboarding = + pathname.startsWith("/onboarding") || + pathname === "/brain" || + pathname.startsWith("/brain/") useEffect(() => { if (isGuestPublicAppPage) return diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index be363274..132dd3fa 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -101,6 +101,34 @@ type OgData = { image?: string } +const EXTENSION_PLATFORM_LABELS: Record = { + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", + t3: "T3 Chat", + twitter: "X / Twitter", +} + +function getExtensionSourceLabel( + document: DocumentWithMemories, +): string | null { + const metadata = document.metadata + if (!metadata || typeof metadata !== "object") return null + + const label = metadata.sm_origin_platform_label + if (typeof label === "string" && label.trim()) { + return label.trim() + } + + const platform = metadata.sm_origin_platform + if (typeof platform === "string" && platform.trim()) { + const normalized = platform.trim().toLowerCase() + return EXTENSION_PLATFORM_LABELS[normalized] || platform.trim() + } + + return null +} + const ogCache = new Map() const ogInflight = new Map>() const ogFailures = new Map() @@ -1229,6 +1257,7 @@ const DocumentCard = memo( pluginDocument?.kind === "claude-code-doc" ? claudeCodeTokenBadge(document) : null + const sourceLabel = getExtensionSourceLabel(document) const date = new Date( document.createdAt, ).toLocaleDateString("en-US", { @@ -1236,7 +1265,9 @@ const DocumentCard = memo( day: "numeric", year: "numeric", }) - return badge ? `${badge} · ${date}` : date + return [sourceLabel, badge, date] + .filter(Boolean) + .join(" - ") })()}

diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 1a709f0c..5f26f00b 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({ @@ -67,6 +76,11 @@ export function CompanyBrainOnboarding({ const queryClient = useQueryClient() const { status: researchStatus } = useResearchStatus(phase === "research") const researchDone = researchStatus === "done" + // One-shot retry: re-kick research once on a terminal error. + const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">( + "idle", + ) + const [retryUi, setRetryUi] = useState(null) const handleConfirm = async () => { if (!clean || submitting) return @@ -111,6 +125,48 @@ export function CompanyBrainOnboarding({ return () => window.clearTimeout(timer) }, [phase, serverSchedulesResearch, clean, queryClient]) + // "error" lingers a render after re-kicking, so only arm the second-error + // branch once the re-run is observed running. + useEffect(() => { + if (phase !== "research" || !clean) return + const stage = retryStage.current + if (researchStatus === "error" && stage === "idle") { + retryStage.current = "started" + setRetryUi("retrying") + void (async () => { + // A failed restart never reaches queued/running, and polling is off on + // error — without this the UI would say "retrying" forever. + const ok = await fetch(`${BACKEND}/brain/research/start`, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + "X-App-Source": "nova", + }, + body: JSON.stringify({ domain: clean }), + }) + .then((res) => res.ok) + .catch(() => false) + if (!ok) { + retryStage.current = "exhausted" + setRetryUi("exhausted") + return + } + queryClient.invalidateQueries({ queryKey: ["brain-research-status"] }) + })() + } else if ( + (researchStatus === "queued" || researchStatus === "running") && + stage === "started" + ) { + retryStage.current = "rerunning" + } else if (researchStatus === "error" && stage === "rerunning") { + retryStage.current = "exhausted" + setRetryUi("exhausted") + } else if (researchStatus === "done") { + setRetryUi(null) + } + }, [phase, clean, researchStatus, queryClient]) + return (
@@ -298,54 +356,76 @@ function ConfirmBody({ function DockedHeader({ domain, done, + retrying, + exhausted, onContinue, }: { domain: string done: boolean + retrying: boolean + exhausted: boolean onContinue: () => void }) { const brandName = workspaceNameFromDomain(domain) || domain + const showSpinner = !done && !exhausted + const statusLabel = done + ? "Company Brain ready" + : exhausted + ? "Couldn't finish — you can continue" + : retrying + ? "Retrying research…" + : "Building your Company Brain…" + const statusColor = done + ? "text-[#5CD68A]" + : exhausted + ? "text-[#E5A45A]" + : "text-[#737373]" return ( -
+
- - {brandName} - - - {done ? "Company Brain ready" : "Building your Company Brain…"} - - {done ? ( - - ) : ( - - )} + {showSpinner && ( + + )} + {statusLabel} + +
+ {/* Never gated on research; the admin can move on while it keeps working. */} +
) } function ResearchTranscript() { const { status, events } = useResearchStatus() - const running = status !== "done" + const running = status !== "done" && status !== "error" const scrollRef = useRef(null) // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events 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 && ( -
+
void +}) { + return ( +
+ +
+ +
+

+ {teamName + ? `Company Brain is live in ${teamName}` + : "Company Brain is live in your Slack"} +

+

+ We sent you a DM to get started. Ask it anything about your company — + it answers where your team already works. +

+ + Open Slack + + + +
+
+ ) +} diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 5c63d1e2..65cec780 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -474,9 +474,9 @@ export function StepSources({ } return ( -
-
-
+
+
+

-
+
{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/billing.tsx b/apps/web/components/settings/billing.tsx index 93909fc2..e94ef416 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -2,6 +2,8 @@ import { dmSans125ClassName } from "@/lib/fonts" import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { getBrainTrialInfo } from "@/lib/billing-utils" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" import { getCanceledSubscription } from "@lib/queries" @@ -201,6 +203,42 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [ }, ] +// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max). +const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [ + { + id: "scale", + name: "Scale", + price: "$399", + period: "/mo", + credits: "$600", + productId: "api_scale", + description: "Company Brain for your team, with production usage", + mostPopular: true, + features: [ + "Company Brain Slack agent & shared memory", + "$600 monthly usage credits when paid", + "Auto top-up & spend caps", + "Team connectors & dedicated support", + ], + }, + { + id: "enterprise", + name: "Enterprise", + price: "Custom", + period: "", + credits: "Unlimited", + productId: "api_enterprise", + description: "Custom deployments with dedicated engineering", + includesFrom: "Scale", + features: [ + "Custom metering & billing", + "Custom integrations & SSO", + "Forward-deployed engineer", + ], + isContactSales: true, + }, +] + const PLAN_RANK: Record = { free: 0, pro: 1, @@ -478,6 +516,11 @@ export default function Billing() { const { user, org } = useAuth() const autumn = useCustomer() const posthog = usePostHog() + const isCompanyBrain = useHasCompanyBrain() + const brainTrial = useMemo( + () => getBrainTrialInfo(org?.metadata as Record | string), + [org?.metadata], + ) const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) const [isResuming, setIsResuming] = useState(false) @@ -511,20 +554,57 @@ export default function Billing() { planUsagePct, currentPlan, hasPaidPlan, + isTrialing: autumnTrialing, + trialEndsAtMs: autumnTrialEndsAtMs, isLoading: isCheckingStatus, daysRemaining, } = useTokenUsage(autumn) + const brainTrialStillOpen = + brainTrial.status === "active" && + (brainTrial.endsAtMs == null || brainTrial.endsAtMs > Date.now()) + const isBrainTrialEnded = + isCompanyBrain && + (brainTrial.status === "expired" || + brainTrial.status === "exhausted" || + (brainTrial.status === "active" && + brainTrial.endsAtMs != null && + brainTrial.endsAtMs <= Date.now())) + const isOnTrial = + !isBrainTrialEnded && + (autumnTrialing || (isCompanyBrain && brainTrialStillOpen)) + const trialEndsAtMs = brainTrial.endsAtMs ?? autumnTrialEndsAtMs ?? null + const trialDaysLeft = + brainTrial.daysRemaining ?? + (trialEndsAtMs != null + ? Math.max( + 0, + Math.ceil((trialEndsAtMs - Date.now()) / (1000 * 60 * 60 * 24)), + ) + : null) + const trialEndsLabel = + trialEndsAtMs != null + ? new Date(trialEndsAtMs).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : null + const trialCredits = brainTrial.credits ?? (isOnTrial ? 200 : null) + const showPlanUsage = hasPaidPlan || isOnTrial || isCompanyBrain + // Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2). + // Company Brain orgs only list Scale + Enterprise — no carousel. const didAutoOpenPlanPage = useRef(false) useEffect(() => { + if (isCompanyBrain) return if (didAutoOpenPlanPage.current || isCheckingStatus) return didAutoOpenPlanPage.current = true if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) { setIsPlanCarouselActive(true) setPlanPage(1) } - }, [isCheckingStatus, currentPlan]) + }, [isCheckingStatus, currentPlan, isCompanyBrain]) const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID] const creditRemaining = @@ -834,6 +914,25 @@ export default function Billing() { ) } + // Trial Scale: primary CTA is activate paid Scale (not a dead "current" state). + if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) { + return ( + + ) + } + if (isCurrentPlan) { return ( ) } @@ -914,31 +1015,43 @@ export default function Billing() {
-
+

- {hasPaidPlan - ? `${planDisplayNames[currentPlan]} plan` - : "Free plan"} + {isOnTrial || + isBrainTrialEnded || + (isCompanyBrain && currentPlan === "scale") + ? "Scale plan" + : hasPaidPlan + ? `${planDisplayNames[currentPlan]} plan` + : "Free plan"}

{isPlanCanceling ? "Cancelling" - : hasPaidPlan - ? "Active" - : "Free"} + : isBrainTrialEnded + ? brainTrial.status === "exhausted" + ? "Credits used up" + : "Trial ended" + : isOnTrial + ? "Free trial" + : hasPaidPlan + ? "Active" + : "Free"}

{isPlanCanceling - ? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}. You'll move to Free after that.` - : hasPaidPlan - ? "Expanded memory, connections, and usage for this workspace." - : "Upgrade when you need more workspace usage and integrations."} + ? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}.${isCompanyBrain ? "" : " You'll move to Free after that."}` + : isOnTrial + ? [ + trialEndsLabel + ? `Ends ${trialEndsLabel}${trialDaysLeft != null ? ` · ${trialDaysLeft} day${trialDaysLeft !== 1 ? "s" : ""} left` : ""}` + : null, + trialCredits != null + ? `$${trialCredits} trial credits` + : null, + ] + .filter(Boolean) + .join(" · ") + : isBrainTrialEnded + ? "Trial ended. Activate Scale to restore access." + : hasPaidPlan + ? "Expanded memory, connections, and usage for this workspace." + : "Upgrade when you need more workspace usage and integrations."}

+ {isBrainTrialEnded ? ( +
+ +
+ ) : null}
@@ -1216,51 +1362,64 @@ export default function Billing() {
-
-
+ {showPlanUsage ? ( +
+
+

+ {isOnTrial ? "Trial credit usage" : "Plan usage"} +

+

+ {formatUsd(usdSpent)} + {usdIncluded > 0 ? ( + + {" "} + / {formatUsd(usdIncluded)} + + ) : null} + + {planUsagePct < 1 && planUsagePct > 0 + ? "< 1" + : Math.round(planUsagePct)} + % used + +

+
+
+
80 + ? "#C73B1B" + : "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)", + }} + /> +

- Plan usage -

-

- {planUsagePct < 1 && planUsagePct > 0 - ? "< 1" - : Math.round(planUsagePct)} - % used + {isOnTrial + ? "Credits apply for the trial period" + : daysRemaining !== null + ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` + : "Usage resets with your billing cycle"}

-
-
80 - ? "#C73B1B" - : "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)", - }} - /> -
-

- {daysRemaining !== null - ? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}` - : "Usage resets with your billing cycle"} -

-
+ ) : null}
@@ -1268,7 +1427,7 @@ export default function Billing() {
+ {isCompanyBrain ? ( +
+ {COMPANY_BRAIN_PLAN_CARDS.map((plan) => ( + + ))}
+ ) : ( + <> +
+
+
+ {PLAN_CARDS.map((plan) => ( + + ))} +
+
+ {ADVANCED_PLAN_CARDS.map((plan) => ( + + ))} +
+
+
+ {isPlanCarouselActive ? null : ( +
+ +
+ )} + )}
@@ -1567,16 +1752,16 @@ export default function Billing() { - {hasPaidPlan ? ( + {hasPaidPlan || isOnTrial ? (
Credits - -
+ {isOnTrial ? ( +

- Top-up credits + Trial credits

- {creditRemaining > 0 - ? `${formatUsd(creditRemaining)} available` - : "No top-up credits yet"} + {formatUsd(creditRemaining)} remaining + {usdIncluded > 0 ? ( + + {" "} + of {formatUsd(usdIncluded)} + + ) : trialCredits != null ? ( + + {" "} + of {formatUsd(trialCredits)} + + ) : null}

- Optional add-on that{" "} + Company Brain trials include{" "} - rolls over + ${trialCredits ?? 200} {" "} - month-to-month, separate from your monthly usage above. + in usage credits. Paid Scale includes $600/mo. Top-ups are + available after you activate.

- -
-
+ + ) : ( + +
+
+ +
+

+ Top-up credits +

+

+ {creditRemaining > 0 + ? `${formatUsd(creditRemaining)} available` + : "No top-up credits yet"} +

+

+ Optional add-on that{" "} + + rolls over + {" "} + month-to-month, separate from your monthly usage above. +

+
+
+ +
+
+ )}
) : null} diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx new file mode 100644 index 00000000..74f8e7b0 --- /dev/null +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -0,0 +1,998 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import type { LucideIcon } from "lucide-react" +import { + CalendarClock, + ChevronDown, + ChevronUp, + FileText, + GitPullRequest, + Info, + LifeBuoy, + ListTodo, + Loader2, + MessageCircleQuestion, + Plus, + Radar, + Trash2, +} from "lucide-react" +import { useRef, useState } from "react" +import { toast } from "sonner" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@ui/components/tooltip" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { dmSans125ClassName } from "@/lib/fonts" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" +const BASE = `${BACKEND}/brain/automations` + +type Automation = { + id: string + enabled: boolean + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + cron: string + timezone: string | null + createdBy: string | null +} +type Channel = { id: string; name: string; isPrivate: boolean } +type Frequency = "daily" | "weekly" + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +const DEFAULT_PROMPT = + "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap." + +// Local day/time -> UTC cron; the Date roundtrip carries any day rollover. +function toUtcCron( + time: string, + frequency: Frequency, + weekday: number, +): string | null { + const [hh, mm] = time.split(":").map(Number) + if ( + hh === undefined || + mm === undefined || + Number.isNaN(hh) || + Number.isNaN(mm) + ) + return null + const d = new Date() + d.setHours(hh, mm, 0, 0) + if (frequency === "weekly") + d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7)) + const m = d.getUTCMinutes() + const h = d.getUTCHours() + return frequency === "daily" + ? `${m} ${h} * * *` + : `${m} ${h} * * ${d.getUTCDay()}` +} + +function fromUtcCron( + cron: string, +): { frequency: Frequency; weekday: number; time: string } | null { + const parts = cron.trim().split(/\s+/) + if (parts.length !== 5) return null + const [min, hr, , , dow] = parts + const mm = Number(min) + const hh = Number(hr) + if (Number.isNaN(mm) || Number.isNaN(hh)) return null + const d = new Date() + d.setUTCHours(hh, mm, 0, 0) + const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` + if (dow === "*") return { frequency: "daily", weekday: 1, time } + const targetDow = Number(dow) + if (Number.isNaN(targetDow)) return null + d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7)) + return { frequency: "weekly", weekday: d.getDay(), time } +} + +type Draft = { + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + frequency: Frequency + weekday: number + time: string + enabled: boolean +} + +function toDraft(a: Automation): Draft { + const parsed = fromUtcCron(a.cron) + return { + title: a.title, + channelId: a.channelId, + deliverTo: a.deliverTo === "dm" ? "dm" : "channel", + prompt: a.prompt, + frequency: parsed?.frequency ?? "daily", + weekday: parsed?.weekday ?? 1, + time: parsed?.time ?? "09:00", + enabled: a.enabled, + } +} + +const emptyDraft = (): Draft => ({ + title: "", + channelId: "", + deliverTo: "channel", + prompt: DEFAULT_PROMPT, + frequency: "daily", + weekday: 1, + time: "09:00", + enabled: true, +}) + +type Category = "team" | "engineering" | "support" | "product" + +type Preset = { + id: string + label: string + description: string + icon: LucideIcon + category: Category + requiresApps?: string[] + prompt: string + frequency: Frequency + weekday?: number + time: string +} + +const PRESETS: Preset[] = [ + { + id: "standup", + category: "team", + label: "Morning checkup", + description: + "Shipped work, decisions, blockers & open questions from the last 24h.", + icon: CalendarClock, + prompt: + "Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.", + frequency: "daily", + time: "09:00", + }, + { + id: "weekly-recap", + category: "team", + label: "Company progress", + description: + "Decisions, shipped work & unresolved threads from the past week.", + icon: CalendarClock, + prompt: + "Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.", + frequency: "weekly", + weekday: 1, + time: "09:00", + }, + { + id: "unanswered", + category: "team", + label: "Unanswered questions", + description: "Questions in this channel from the last 24h with no reply.", + icon: MessageCircleQuestion, + prompt: + "Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.", + frequency: "daily", + time: "16:00", + }, + { + id: "prs-review", + category: "engineering", + label: "PRs awaiting review", + description: "Open PRs waiting on review; flags stale ones.", + icon: GitPullRequest, + requiresApps: ["github"], + prompt: + "List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.", + frequency: "daily", + time: "09:30", + }, + { + id: "issue-triage", + category: "engineering", + label: "Issue triage", + description: "New or unassigned issues that need a response.", + icon: ListTodo, + requiresApps: ["github", "linear"], + prompt: + "Summarize new or unassigned issues from the last 24 hours that need triage or a response.", + frequency: "daily", + time: "09:00", + }, + { + id: "customer-signal", + category: "support", + label: "Customer signal", + description: "Recent customer issues & feedback and their status.", + icon: LifeBuoy, + requiresApps: ["plain", "linear"], + prompt: + "Recap customer issues and feedback raised recently across our tools and channels, with their current status.", + 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", + label: "Release notes draft", + description: "Draft notes from PRs merged since the last digest.", + icon: FileText, + requiresApps: ["github"], + prompt: + "Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.", + frequency: "weekly", + weekday: 5, + time: "16:00", + }, +] + +function presetToDraft(p: Preset): Draft { + return { + title: p.label, + channelId: "", + deliverTo: "channel", + prompt: p.prompt, + frequency: p.frequency, + weekday: p.weekday ?? 1, + time: p.time, + enabled: true, + } +} + +// Connected-app presets first, universal next, unconnected-app presets last. +function sortPresets(connected: Set): Preset[] { + const rank = (p: Preset) => { + if (!p.requiresApps) return 1 + return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2 + } + return [...PRESETS].sort((a, b) => rank(a) - rank(b)) +} + +function cadenceLabel(p: Preset): string { + if (p.frequency === "weekly") + return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}` + return `Daily · ${p.time}` +} + +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", +) +const fieldLabel = cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]") +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" +const DM_VALUE = "__dm__" + +function AutomationCard({ + initial, + id, + channels, + ownerLabel, + onDone, + onCancelNew, + onCollapse, +}: { + initial: Draft + id: string | null + channels: Channel[] + ownerLabel?: string + onDone: () => void + onCancelNew?: () => void + onCollapse?: () => void +}) { + const [draft, setDraft] = useState(initial) + const set = (k: K, v: Draft[K]) => + setDraft((d) => ({ ...d, [k]: v })) + + const body = () => { + if (!draft.title.trim()) throw new Error("Give the automation a name.") + if (draft.deliverTo === "channel" && !draft.channelId) + throw new Error("Pick a channel to post to.") + const cron = toUtcCron(draft.time, draft.frequency, draft.weekday) + if (!cron) throw new Error("Pick a valid time.") + return { + title: draft.title.trim(), + deliverTo: draft.deliverTo, + channelId: draft.deliverTo === "dm" ? null : draft.channelId, + prompt: draft.prompt.trim() || DEFAULT_PROMPT, + cron, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + enabled: draft.enabled, + } + } + + const save = useMutation({ + mutationFn: async () => { + const payload = body() + const res = await fetch(id ? `${BASE}/${id}` : `${BASE}/`, { + method: id ? "PUT" : "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + if (res.status === 403) + throw new Error("You can only manage automations you created.") + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(b.error ?? "Couldn't save.") + } + }, + onSuccess: () => { + toast.success("Automation saved.") + onDone() + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't save."), + }) + + const trigger = useMutation({ + mutationFn: async () => { + if (!id) throw new Error("Save the automation first.") + const res = await fetch(`${BASE}/${id}/run-now`, { + method: "POST", + credentials: "include", + }) + const b = (await res.json().catch(() => ({}))) as { + ok?: boolean + reason?: string + error?: string + } + if (!res.ok || b.ok === false) + throw new Error(b.reason ?? b.error ?? "Couldn't run.") + }, + onSuccess: () => toast.success("Automation triggered — check the channel."), + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't run."), + }) + + const remove = useMutation({ + mutationFn: async () => { + if (!id) return + const res = await fetch(`${BASE}/${id}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) throw new Error("Couldn't delete.") + }, + onSuccess: () => { + toast.success("Automation removed.") + onDone() + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Couldn't delete."), + }) + + const busy = save.isPending || trigger.isPending || remove.isPending + const disabled = busy + + return ( +
+
+
+ set("title", e.target.value)} + /> + {ownerLabel ? ( + + {ownerLabel} + + ) : null} +
+ {onCollapse ? ( + + ) : null} + +
+ +
+