aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2025-08-30 14:56:38 -0700
committerDhravya Shah <[email protected]>2025-08-30 14:56:38 -0700
commitdedd2fe612ec4beb6c222b54f271ec55ea31e8ad (patch)
tree0b1560b101c7958601e88c701b1c95c34cb09839 /apps
parenttemp disable waitlist for user testing (diff)
parentMerge pull request #395 from supermemoryai/mahesh/browser-extension (diff)
downloadsupermemory-dedd2fe612ec4beb6c222b54f271ec55ea31e8ad.tar.xz
supermemory-dedd2fe612ec4beb6c222b54f271ec55ea31e8ad.zip
Merge branch 'main' of https://github.com/supermemoryai/supermemory
Diffstat (limited to 'apps')
-rw-r--r--apps/browser-extension/.gitignore26
-rw-r--r--apps/browser-extension/README.md1
-rw-r--r--apps/browser-extension/entrypoints/background.ts201
-rw-r--r--apps/browser-extension/entrypoints/content.ts463
-rw-r--r--apps/browser-extension/entrypoints/popup/App.css42
-rw-r--r--apps/browser-extension/entrypoints/popup/App.tsx404
-rw-r--r--apps/browser-extension/entrypoints/popup/index.html13
-rw-r--r--apps/browser-extension/entrypoints/popup/main.tsx17
-rw-r--r--apps/browser-extension/entrypoints/popup/style.css27
-rw-r--r--apps/browser-extension/entrypoints/welcome/Welcome.tsx105
-rw-r--r--apps/browser-extension/entrypoints/welcome/index.html13
-rw-r--r--apps/browser-extension/entrypoints/welcome/main.tsx17
-rw-r--r--apps/browser-extension/entrypoints/welcome/welcome.css49
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-Bold.ttfbin0 -> 86516 bytes
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-Light.ttfbin0 -> 86612 bytes
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-Medium.ttfbin0 -> 86612 bytes
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-Regular.ttfbin0 -> 86588 bytes
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttfbin0 -> 86572 bytes
-rw-r--r--apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttfbin0 -> 134112 bytes
-rw-r--r--apps/browser-extension/package.json32
-rw-r--r--apps/browser-extension/public/icon-128.pngbin0 -> 229436 bytes
-rw-r--r--apps/browser-extension/public/icon-16.pngbin0 -> 33814 bytes
-rw-r--r--apps/browser-extension/public/icon-48.pngbin0 -> 110647 bytes
-rw-r--r--apps/browser-extension/tsconfig.json8
-rw-r--r--apps/browser-extension/utils/api.ts156
-rw-r--r--apps/browser-extension/utils/constants.ts81
-rw-r--r--apps/browser-extension/utils/query-client.ts24
-rw-r--r--apps/browser-extension/utils/query-hooks.ts64
-rw-r--r--apps/browser-extension/utils/twitter-auth.ts101
-rw-r--r--apps/browser-extension/utils/twitter-import.ts192
-rw-r--r--apps/browser-extension/utils/twitter-utils.ts377
-rw-r--r--apps/browser-extension/utils/types.ts149
-rw-r--r--apps/browser-extension/utils/ui-components.ts450
-rw-r--r--apps/browser-extension/wxt.config.ts40
-rw-r--r--apps/web/app/page.tsx15
35 files changed, 3066 insertions, 1 deletions
diff --git a/apps/browser-extension/.gitignore b/apps/browser-extension/.gitignore
new file mode 100644
index 00000000..a2569538
--- /dev/null
+++ b/apps/browser-extension/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.output
+stats.html
+stats-*.json
+.wxt
+web-ext.config.ts
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md
new file mode 100644
index 00000000..4fb6e6e8
--- /dev/null
+++ b/apps/browser-extension/README.md
@@ -0,0 +1 @@
+## supermemory Browser Extension \ No newline at end of file
diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts
new file mode 100644
index 00000000..e3474743
--- /dev/null
+++ b/apps/browser-extension/entrypoints/background.ts
@@ -0,0 +1,201 @@
+import { getDefaultProject, saveMemory, searchMemories } from "../utils/api"
+import {
+ CONTAINER_TAGS,
+ CONTEXT_MENU_IDS,
+ MESSAGE_TYPES,
+} from "../utils/constants"
+import { captureTwitterTokens } from "../utils/twitter-auth"
+import {
+ type TwitterImportConfig,
+ TwitterImporter,
+} from "../utils/twitter-import"
+import type {
+ ExtensionMessage,
+ MemoryData,
+ MemoryPayload,
+} from "../utils/types"
+
+export default defineBackground(() => {
+ let twitterImporter: TwitterImporter | null = null
+
+ browser.runtime.onInstalled.addListener((details) => {
+ browser.contextMenus.create({
+ id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY,
+ title: "Save to supermemory",
+ contexts: ["selection", "page", "link"],
+ })
+
+ // Open welcome tab on first install
+ if (details.reason === "install") {
+ browser.tabs.create({
+ url: browser.runtime.getURL("/welcome.html"),
+ })
+ }
+ })
+
+ // Intercept Twitter requests to capture authentication headers.
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ (details) => {
+ captureTwitterTokens(details)
+ return {}
+ },
+ { urls: ["*://x.com/*", "*://twitter.com/*"] },
+ ["requestHeaders", "extraHeaders"],
+ )
+
+ // Handle context menu clicks.
+ browser.contextMenus.onClicked.addListener(async (info, tab) => {
+ if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) {
+ if (tab?.id) {
+ try {
+ await browser.tabs.sendMessage(tab.id, {
+ action: MESSAGE_TYPES.SAVE_MEMORY,
+ })
+ } catch (error) {
+ console.error("Failed to send message to content script:", error)
+ }
+ }
+ }
+ })
+
+ // Send message to current active tab.
+ const sendMessageToCurrentTab = async (message: string) => {
+ const tabs = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ })
+ if (tabs.length > 0 && tabs[0].id) {
+ await browser.tabs.sendMessage(tabs[0].id, {
+ type: MESSAGE_TYPES.IMPORT_UPDATE,
+ importedMessage: message,
+ })
+ }
+ }
+
+ /**
+ * Send import completion message
+ */
+ const sendImportDoneMessage = async (totalImported: number) => {
+ const tabs = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ })
+ if (tabs.length > 0 && tabs[0].id) {
+ await browser.tabs.sendMessage(tabs[0].id, {
+ type: MESSAGE_TYPES.IMPORT_DONE,
+ totalImported,
+ })
+ }
+ }
+
+ /**
+ * Save memory to supermemory API
+ */
+ const saveMemoryToSupermemory = async (
+ data: MemoryData,
+ ): Promise<{ success: boolean; data?: unknown; error?: string }> => {
+ try {
+ 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 payload: MemoryPayload = {
+ containerTags: [containerTag],
+ content: `${data.highlightedText}\n\n${data.html}\n\n${data?.url}`,
+ metadata: { sm_source: "consumer" },
+ }
+
+ const responseData = await saveMemory(payload)
+ return { success: true, data: responseData }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ }
+ }
+ }
+
+ const getRelatedMemories = async (
+ data: string,
+ ): Promise<{ success: boolean; data?: unknown; error?: string }> => {
+ try {
+ const responseData = await searchMemories(data)
+ const response = responseData as {
+ results?: Array<{ memory?: string }>
+ }
+ let memories = "";
+ response.results?.forEach((result, index) => {
+ memories += `[${index + 1}] ${result.memory} `
+ })
+ console.log("Memories:", memories)
+ return { success: true, data: memories }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ }
+ }
+ }
+
+ /**
+ * Handle extension messages
+ */
+ browser.runtime.onMessage.addListener(
+ (message: ExtensionMessage, _sender, sendResponse) => {
+ // Handle Twitter import request
+ if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
+ const importConfig: TwitterImportConfig = {
+ onProgress: sendMessageToCurrentTab,
+ onComplete: sendImportDoneMessage,
+ onError: async (error: Error) => {
+ await sendMessageToCurrentTab(`Error: ${error.message}`)
+ },
+ }
+
+ twitterImporter = new TwitterImporter(importConfig)
+ twitterImporter.startImport().catch(console.error)
+ sendResponse({ success: true })
+ return true
+ }
+
+ // Handle regular memory save request
+ if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
+ ;(async () => {
+ try {
+ const result = await saveMemoryToSupermemory(
+ message.data as MemoryData,
+ )
+ sendResponse(result)
+ } catch (error) {
+ sendResponse({
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ })
+ }
+ })()
+ return true
+ }
+
+ if (message.action === MESSAGE_TYPES.GET_RELATED_MEMORIES) {
+ ;(async () => {
+ try {
+ const result = await getRelatedMemories(message.data as string)
+ sendResponse(result)
+ } catch (error) {
+ sendResponse({
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ })
+ }
+ })()
+ return true
+ }
+ },
+ )
+})
diff --git a/apps/browser-extension/entrypoints/content.ts b/apps/browser-extension/entrypoints/content.ts
new file mode 100644
index 00000000..97f5fc44
--- /dev/null
+++ b/apps/browser-extension/entrypoints/content.ts
@@ -0,0 +1,463 @@
+import {
+ DOMAINS,
+ ELEMENT_IDS,
+ MESSAGE_TYPES,
+ STORAGE_KEYS,
+} from "../utils/constants"
+import {
+ createChatGPTInputBarElement,
+ createSaveTweetElement,
+ createTwitterImportButton,
+ createTwitterImportUI,
+ DOMUtils,
+} from "../utils/ui-components"
+
+export default defineContentScript({
+ matches: ["<all_urls>"],
+ main() {
+ let twitterImportUI: HTMLElement | null = null
+ let isTwitterImportOpen = false
+
+ browser.runtime.onMessage.addListener(async (message) => {
+ if (message.action === MESSAGE_TYPES.SHOW_TOAST) {
+ DOMUtils.showToast(message.state)
+ } else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
+ await saveMemory()
+ } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
+ updateTwitterImportUI(message)
+ } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
+ updateTwitterImportUI(message)
+ }
+ })
+
+ const observeForMemoriesDialog = () => {
+ const observer = new MutationObserver(() => {
+ if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
+ addSupermemoryButtonToMemoriesDialog()
+ addSaveChatGPTElementBeforeComposerBtn()
+ }
+ if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
+ addTwitterImportButton()
+ //addSaveTweetElement();
+ }
+ })
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ })
+
+ if (
+ window.location.hostname === "chatgpt.com" ||
+ window.location.hostname === "chat.openai.com"
+ ) {
+ addSupermemoryButtonToMemoriesDialog()
+ addSaveChatGPTElementBeforeComposerBtn()
+ }
+ if (
+ window.location.hostname === "x.com" ||
+ window.location.hostname === "twitter.com"
+ ) {
+ addTwitterImportButton()
+ //addSaveTweetElement();
+ }
+ }
+
+ if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
+ setTimeout(() => {
+ addTwitterImportButton() // Wait 2 seconds for page to load
+ //addSaveTweetElement();
+ }, 2000)
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", observeForMemoriesDialog)
+ } else {
+ observeForMemoriesDialog()
+ }
+
+ async function saveMemory() {
+ try {
+ DOMUtils.showToast("loading")
+
+ const highlightedText = window.getSelection()?.toString() || ""
+
+ const url = window.location.href
+
+ const html = document.documentElement.outerHTML
+
+ const response = await browser.runtime.sendMessage({
+ action: MESSAGE_TYPES.SAVE_MEMORY,
+ data: {
+ html,
+ highlightedText,
+ url,
+ },
+ })
+
+ console.log("Response from enxtension:", response)
+ if (response.success) {
+ DOMUtils.showToast("success")
+ } else {
+ DOMUtils.showToast("error")
+ }
+ } catch (error) {
+ console.error("Error saving memory:", error)
+ DOMUtils.showToast("error")
+ }
+ }
+
+ async function getRelatedMemories() {
+ try {
+ const userQuery =
+ document.getElementById("prompt-textarea")?.textContent || ""
+
+ const response = await browser.runtime.sendMessage({
+ action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
+ data: userQuery,
+ })
+
+ if (response.success && response.data) {
+ const promptElement = document.getElementById("prompt-textarea")
+ if (promptElement) {
+ const currentContent = promptElement.innerHTML
+ promptElement.innerHTML = `${currentContent}<br>Supermemories: ${response.data}`
+ }
+ }
+ } catch (error) {
+ console.error("Error getting related memories:", error)
+ }
+ }
+
+ function addSupermemoryButtonToMemoriesDialog() {
+ const dialogs = document.querySelectorAll('[role="dialog"]')
+ let memoriesDialog: HTMLElement | null = null
+
+ for (const dialog of dialogs) {
+ const headerText = dialog.querySelector("h2")
+ if (headerText?.textContent?.includes("Saved memories")) {
+ memoriesDialog = dialog as HTMLElement
+ break
+ }
+ }
+
+ if (!memoriesDialog) return
+
+ if (memoriesDialog.querySelector("#supermemory-save-button")) return
+
+ const deleteAllContainer = memoriesDialog.querySelector(
+ ".mt-5.flex.justify-end",
+ )
+ if (!deleteAllContainer) return
+
+ const supermemoryButton = document.createElement("button")
+ supermemoryButton.id = "supermemory-save-button"
+ supermemoryButton.className = "btn relative btn-primary-outline mr-2"
+
+ const iconUrl = browser.runtime.getURL("/icon-16.png")
+
+ supermemoryButton.innerHTML = `
+ <div class="flex items-center justify-center gap-2">
+ <img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
+ Save to supermemory
+ </div>
+ `
+
+ supermemoryButton.style.cssText = `
+ background: #1C2026 !important;
+ color: white !important;
+ border: 1px solid #1C2026 !important;
+ border-radius: 9999px !important;
+ padding: 10px 16px !important;
+ font-weight: 500 !important;
+ font-size: 14px !important;
+ margin-right: 8px !important;
+ cursor: pointer !important;
+ `
+
+ supermemoryButton.addEventListener("mouseenter", () => {
+ supermemoryButton.style.backgroundColor = "#2B2E33"
+ })
+
+ supermemoryButton.addEventListener("mouseleave", () => {
+ supermemoryButton.style.backgroundColor = "#1C2026"
+ })
+
+ supermemoryButton.addEventListener("click", async () => {
+ await saveMemoriesToSupermemory()
+ })
+
+ deleteAllContainer.insertBefore(
+ supermemoryButton,
+ deleteAllContainer.firstChild,
+ )
+ }
+
+ async function saveMemoriesToSupermemory() {
+ try {
+ DOMUtils.showToast("loading")
+
+ const memoriesTable = document.querySelector(
+ '[role="dialog"] table tbody',
+ )
+ if (!memoriesTable) {
+ DOMUtils.showToast("error")
+ return
+ }
+
+ const memoryRows = memoriesTable.querySelectorAll("tr")
+ const memories: string[] = []
+
+ memoryRows.forEach((row) => {
+ const memoryCell = row.querySelector("td .py-2.whitespace-pre-wrap")
+ if (memoryCell?.textContent) {
+ memories.push(memoryCell.textContent.trim())
+ }
+ })
+
+ console.log("Memories:", memories)
+
+ if (memories.length === 0) {
+ DOMUtils.showToast("error")
+ return
+ }
+
+ const combinedContent = `ChatGPT Saved Memories:\n\n${memories.map((memory, index) => `${index + 1}. ${memory}`).join("\n\n")}`
+
+ const response = await browser.runtime.sendMessage({
+ action: "saveMemory",
+ data: {
+ html: combinedContent,
+ },
+ })
+
+ if (response.success) {
+ DOMUtils.showToast("success")
+ } else {
+ DOMUtils.showToast("error")
+ }
+ } catch (error) {
+ console.error("Error saving memories to supermemory:", error)
+ DOMUtils.showToast("error")
+ }
+ }
+
+ function addTwitterImportButton() {
+ if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
+ return
+ }
+
+ if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
+ return
+ }
+
+ const button = createTwitterImportButton(() => {
+ showTwitterImportUI()
+ })
+
+ document.body.appendChild(button)
+ }
+
+ function showTwitterImportUI() {
+ if (twitterImportUI) {
+ twitterImportUI.remove()
+ }
+
+ isTwitterImportOpen = true
+
+ // Check if user is authenticated
+ browser.storage.local.get([STORAGE_KEYS.BEARER_TOKEN], (result) => {
+ const isAuthenticated = !!result[STORAGE_KEYS.BEARER_TOKEN]
+
+ twitterImportUI = createTwitterImportUI(
+ hideTwitterImportUI,
+ async () => {
+ try {
+ await browser.runtime.sendMessage({
+ type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
+ })
+ } catch (error) {
+ console.error("Error starting import:", error)
+ }
+ },
+ isAuthenticated,
+ )
+
+ document.body.appendChild(twitterImportUI)
+ })
+ }
+
+ function hideTwitterImportUI() {
+ if (twitterImportUI) {
+ twitterImportUI.remove()
+ twitterImportUI = null
+ }
+ isTwitterImportOpen = false
+ }
+
+ function updateTwitterImportUI(message: {
+ type: string
+ importedMessage?: string
+ totalImported?: number
+ }) {
+ if (!isTwitterImportOpen || !twitterImportUI) return
+
+ const statusDiv = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_STATUS}`)
+ const button = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`)
+
+ if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
+ if (statusDiv) {
+ statusDiv.innerHTML = `
+ <div style="display: flex; align-items: center; gap: 8px; color: #92400e; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
+ <div style="width: 12px; height: 12px; border: 2px solid #f59e0b; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
+ <span>${message.importedMessage}</span>
+ </div>
+ `
+ }
+ if (button) {
+ ;(button as HTMLButtonElement).disabled = true
+ ;(button as HTMLButtonElement).textContent = "Importing..."
+ }
+ }
+
+ if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
+ if (statusDiv) {
+ statusDiv.innerHTML = `
+ <div style="display: flex; align-items: center; gap: 8px; color: #0369a1; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
+ <span style="color: #059669;">✓</span>
+ <span>Successfully imported ${message.totalImported} tweets!</span>
+ </div>
+ `
+ }
+
+ setTimeout(() => {
+ hideTwitterImportUI()
+ }, 3000)
+ }
+ }
+
+ function addSaveChatGPTElementBeforeComposerBtn() {
+ if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
+ return
+ }
+
+ const composerButtons = document.querySelectorAll("button.composer-btn")
+
+ composerButtons.forEach((button) => {
+ if (button.hasAttribute("data-supermemory-icon-added-before")) {
+ 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 getRelatedMemories()
+ })
+
+ 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)
+ })
+ }
+
+ // TODO: Add Tweet Capture Functionality
+ function _addSaveTweetElement() {
+ if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
+ return
+ }
+
+ const targetDivs = document.querySelectorAll(
+ "div.css-175oi2r.r-18u37iz.r-1h0z5md.r-1wron08",
+ )
+
+ targetDivs.forEach((targetDiv) => {
+ if (targetDiv.hasAttribute("data-supermemory-icon-added")) {
+ return
+ }
+
+ const previousElement = targetDiv.previousElementSibling
+ if (previousElement?.id?.startsWith(ELEMENT_IDS.SAVE_TWEET_ELEMENT)) {
+ targetDiv.setAttribute("data-supermemory-icon-added", "true")
+ return
+ }
+
+ const saveTweetElement = createSaveTweetElement(async () => {
+ await saveMemory()
+ })
+
+ saveTweetElement.id = `${ELEMENT_IDS.SAVE_TWEET_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
+
+ targetDiv.setAttribute("data-supermemory-icon-added", "true")
+
+ targetDiv.parentNode?.insertBefore(saveTweetElement, targetDiv)
+ })
+ }
+
+ document.addEventListener("keydown", async (event) => {
+ if (
+ (event.ctrlKey || event.metaKey) &&
+ event.shiftKey &&
+ event.key === "m"
+ ) {
+ event.preventDefault()
+ await saveMemory()
+ }
+ })
+
+ window.addEventListener("message", (event) => {
+ if (event.source !== window) {
+ return
+ }
+ const bearerToken = event.data.token
+
+ if (bearerToken) {
+ if (
+ !(
+ window.location.hostname === "localhost" ||
+ window.location.hostname === "supermemory.ai" ||
+ window.location.hostname === "app.supermemory.ai"
+ )
+ ) {
+ console.log(
+ "Bearer token is only allowed to be used on localhost or supermemory.ai",
+ )
+ return
+ }
+
+ chrome.storage.local.set({
+ [STORAGE_KEYS.BEARER_TOKEN]: bearerToken,
+ }, () => {})
+ }
+ })
+ },
+})
diff --git a/apps/browser-extension/entrypoints/popup/App.css b/apps/browser-extension/entrypoints/popup/App.css
new file mode 100644
index 00000000..f91be533
--- /dev/null
+++ b/apps/browser-extension/entrypoints/popup/App.css
@@ -0,0 +1,42 @@
+@import "tailwindcss";
+
+/* Custom Font Definitions */
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
+}
diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx
new file mode 100644
index 00000000..50a502d3
--- /dev/null
+++ b/apps/browser-extension/entrypoints/popup/App.tsx
@@ -0,0 +1,404 @@
+import { useQueryClient } from "@tanstack/react-query"
+import { useEffect, useState } from "react"
+import "./App.css"
+import { STORAGE_KEYS } from "../../utils/constants"
+import {
+ useDefaultProject,
+ useProjects,
+ useSetDefaultProject,
+} from "../../utils/query-hooks"
+import type { Project } from "../../utils/types"
+
+function App() {
+ const [userSignedIn, setUserSignedIn] = useState<boolean>(false)
+ const [loading, setLoading] = useState<boolean>(true)
+ const [showProjectSelector, setShowProjectSelector] = useState<boolean>(false)
+ const [currentUrl, setCurrentUrl] = useState<string>("")
+ const [currentTitle, setCurrentTitle] = useState<string>("")
+ const [saving, setSaving] = useState<boolean>(false)
+ const [activeTab, setActiveTab] = useState<"save" | "imports">("save")
+
+ const queryClient = useQueryClient()
+ const { data: projects = [], isLoading: loadingProjects } = useProjects({
+ enabled: userSignedIn,
+ })
+ const { data: defaultProject } = useDefaultProject({
+ enabled: userSignedIn,
+ })
+ const setDefaultProjectMutation = useSetDefaultProject()
+
+ useEffect(() => {
+ const checkAuthStatus = async () => {
+ try {
+ const result = await chrome.storage.local.get([
+ STORAGE_KEYS.BEARER_TOKEN,
+ ])
+ const isSignedIn = !!result[STORAGE_KEYS.BEARER_TOKEN]
+ setUserSignedIn(isSignedIn)
+ } catch (error) {
+ console.error("Error checking auth status:", error)
+ setUserSignedIn(false)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const getCurrentTab = async () => {
+ try {
+ const tabs = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ })
+ if (tabs.length > 0 && tabs[0].url && tabs[0].title) {
+ setCurrentUrl(tabs[0].url)
+ setCurrentTitle(tabs[0].title)
+ }
+ } catch (error) {
+ console.error("Error getting current tab:", error)
+ }
+ }
+
+ checkAuthStatus()
+ getCurrentTab()
+ }, [])
+
+ const handleProjectSelect = (project: Project) => {
+ setDefaultProjectMutation.mutate(project, {
+ onSuccess: () => {
+ setShowProjectSelector(false)
+ },
+ onError: (error) => {
+ console.error("Error setting default project:", error)
+ },
+ })
+ }
+
+ const handleShowProjectSelector = () => {
+ setShowProjectSelector(true)
+ }
+
+ useEffect(() => {
+ if (!defaultProject && projects.length > 0) {
+ const firstProject = projects[0]
+ setDefaultProjectMutation.mutate(firstProject)
+ }
+ }, [defaultProject, projects, setDefaultProjectMutation])
+
+ const handleSaveCurrentPage = async () => {
+ setSaving(true)
+ try {
+ const tabs = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ })
+ if (tabs.length > 0 && tabs[0].id) {
+ await chrome.tabs.sendMessage(tabs[0].id, {
+ action: "saveMemory",
+ })
+ }
+ } catch (error) {
+ console.error("Failed to save current page:", error)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleSignOut = async () => {
+ try {
+ await chrome.storage.local.remove([STORAGE_KEYS.BEARER_TOKEN])
+ setUserSignedIn(false)
+ queryClient.clear()
+ } catch (error) {
+ console.error("Error signing out:", error)
+ }
+ }
+
+ if (loading) {
+ return (
+ <div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
+ <div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
+ <img alt="supermemory" className="w-8 h-8 flex-shrink-0" src="/icon-48.png" />
+ <h1 className="m-0 text-lg font-semibold text-black flex-1">supermemory</h1>
+ </div>
+ <div className="p-4">
+ <div>Loading...</div>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
+ <div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
+ <img
+ alt="supermemory"
+ className="w-8 h-8 flex-shrink-0"
+ src="https://assets.supermemory.ai/brand/wordmark/dark-transparent.svg"
+ style={{ width: "80%", height: "45px" }}
+ />
+ {userSignedIn && (
+ <button
+ className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200 hover:text-black hover:bg-gray-100"
+ onClick={handleSignOut}
+ title="Logout"
+ type="button"
+ >
+ <svg
+ fill="none"
+ height="16"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth="2"
+ viewBox="0 0 24 24"
+ width="16"
+ >
+ <title>Logout</title>
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+ <polyline points="16,17 21,12 16,7" />
+ <line x1="21" x2="9" y1="12" y2="12" />
+ </svg>
+ </button>
+ )}
+ </div>
+ <div className="p-4">
+ {userSignedIn ? (
+ <div className="text-left">
+ {/* Tab Navigation */}
+ <div className="flex bg-gray-100 rounded-lg p-1 mb-4">
+ <button
+ className={`flex-1 py-2 px-4 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
+ activeTab === "save"
+ ? "bg-white text-black shadow-sm"
+ : "text-gray-500 hover:text-gray-700"
+ }`}
+ onClick={() => setActiveTab("save")}
+ type="button"
+ >
+ Save
+ </button>
+ <button
+ className={`flex-1 py-2 px-4 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
+ activeTab === "imports"
+ ? "bg-white text-black shadow-sm"
+ : "text-gray-500 hover:text-gray-700"
+ }`}
+ onClick={() => setActiveTab("imports")}
+ type="button"
+ >
+ Imports
+ </button>
+ </div>
+
+ {/* Tab Content */}
+ {activeTab === "save" ? (
+ <div className="flex flex-col gap-4 min-h-[200px]">
+ {/* Current Page Info */}
+ <div className="mb-0">
+ <div className="bg-gray-50 p-3 rounded-md border border-gray-200">
+ <h3 className="m-0 mb-1 text-sm font-semibold text-black overflow-hidden text-ellipsis whitespace-nowrap">
+ {currentTitle || "Current Page"}
+ </h3>
+ <p className="m-0 text-xs text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">{currentUrl}</p>
+ </div>
+ </div>
+
+ {/* Project Selection */}
+ <div className="mb-0">
+ <button
+ className="w-full bg-transparent border-none p-0 cursor-pointer text-left"
+ onClick={handleShowProjectSelector}
+ type="button"
+ >
+ <div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-200 transition-colors duration-200 hover:bg-gray-200 hover:border-gray-300">
+ <span className="text-sm font-medium text-gray-600">Save to project:</span>
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium text-black overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
+ {defaultProject
+ ? defaultProject.name
+ : "Default Project"}
+ </span>
+ <svg
+ aria-label="Select project"
+ className="text-gray-500 flex-shrink-0 transition-transform duration-200 hover:text-gray-700 hover:translate-x-0.5"
+ fill="none"
+ height="16"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth="2"
+ viewBox="0 0 24 24"
+ width="16"
+ >
+ <title>Select project</title>
+ <path d="M9 18l6-6-6-6" />
+ </svg>
+ </div>
+ </div>
+ </button>
+ </div>
+
+ {/* Save Button at Bottom */}
+ <div className="mt-auto pt-4">
+ <button
+ className="w-full py-3 px-6 bg-gray-700 text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
+ disabled={saving}
+ onClick={handleSaveCurrentPage}
+ type="button"
+ >
+ {saving ? "Saving..." : "Save Current Page"}
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="flex flex-col gap-4 min-h-[200px]">
+ {/* Import Actions */}
+ <div className="flex flex-col gap-4">
+ <div className="flex flex-col gap-2">
+ <button
+ className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-center transition-colors duration-200 hover:bg-gray-50"
+ onClick={() => {
+ chrome.tabs.create({
+ url: "https://chatgpt.com/#settings/Personalization",
+ })
+ }}
+ type="button"
+ >
+ <svg
+ aria-label="ChatGPT Logo"
+ className="w-4.5 h-4.5 flex-shrink-0 mr-2"
+ fill="currentColor"
+ role="img"
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>OpenAI</title>
+ <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
+ </svg>
+ Import ChatGPT Memories
+ </button>
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <button
+ className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-gray-50 focus:outline-none"
+ onClick={() => {
+ chrome.tabs.create({
+ url: "https://x.com/i/bookmarks",
+ })
+ }}
+ type="button"
+ >
+ <svg
+ aria-label="X Twitter Logo"
+ className="w-4.5 h-4.5 flex-shrink-0 mr-2"
+ fill="currentColor"
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>X Twitter Logo</title>
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
+ </svg>
+ Import X Bookmarks
+ </button>
+ <p className="m-0 text-xs text-gray-500 leading-tight pl-1">
+ Click on supermemory on top right to import bookmarks
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {showProjectSelector && (
+ <div className="absolute inset-0 bg-white rounded-lg z-[1000] shadow-xl flex flex-col">
+ <div className="flex justify-between items-center p-4 border-b border-gray-200 text-base font-semibold text-black flex-shrink-0">
+ <span>Select the Project</span>
+ <button
+ className="bg-transparent border-none text-xl cursor-pointer text-gray-500 p-0 w-6 h-6 flex items-center justify-center hover:text-black"
+ onClick={() => setShowProjectSelector(false)}
+ type="button"
+ >
+ ×
+ </button>
+ </div>
+ {loadingProjects ? (
+ <div className="py-8 px-4 text-center text-gray-500 text-sm">Loading projects...</div>
+ ) : (
+ <div className="flex-1 overflow-y-auto min-h-0">
+ {projects.map((project) => (
+ <button
+ className={`flex justify-between items-center py-3 px-4 cursor-pointer transition-colors duration-200 border-b border-gray-100 bg-transparent border-none w-full text-left last:border-b-0 hover:bg-gray-50 ${
+ defaultProject?.id === project.id ? "bg-blue-50" : ""
+ }`}
+ key={project.id}
+ onClick={() => handleProjectSelect(project)}
+ type="button"
+ >
+ <div className="flex flex-col flex-1 gap-0.5">
+ <span className="text-sm font-medium text-black break-words leading-tight">
+ {project.name}
+ </span>
+ <span className="text-xs text-gray-500">
+ {project.documentCount} docs
+ </span>
+ </div>
+ {defaultProject?.id === project.id && (
+ <span className="text-blue-600 font-bold text-base">✓</span>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center py-2">
+ <div className="mb-8">
+ <h2 className="m-0 mb-4 text-sm font-normal text-black leading-tight">
+ Login to unlock all chrome extension features
+ </h2>
+
+ <ul className="list-none p-0 m-0 text-left">
+ <li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Save any page to your supermemory</li>
+ <li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Import all your Twitter / X Bookmarks</li>
+ <li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Import your ChatGPT Memories</li>
+ </ul>
+ </div>
+
+ <div className="mt-8">
+ <p className="m-0 mb-4 text-sm text-gray-500">
+ Having trouble logging in?{" "}
+ <button
+ className="bg-transparent border-none text-blue-500 cursor-pointer underline text-sm p-0 hover:text-blue-700"
+ onClick={() => {
+ window.open("mailto:[email protected]", "_blank")
+ }}
+ type="button"
+ >
+ Reach Out to Us
+ </button>
+ </p>
+
+ <button
+ className="w-full py-3 px-6 bg-gray-700 text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
+ onClick={() => {
+ chrome.tabs.create({
+ url: import.meta.env.PROD
+ ? "https://app.supermemory.ai/login"
+ : "http://localhost:3000/login",
+ })
+ }}
+ type="button"
+ >
+ login in
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
+
+export default App
diff --git a/apps/browser-extension/entrypoints/popup/index.html b/apps/browser-extension/entrypoints/popup/index.html
new file mode 100644
index 00000000..ed4cb949
--- /dev/null
+++ b/apps/browser-extension/entrypoints/popup/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Default Popup Title</title>
+ <meta name="manifest.type" content="browser_action" />
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="./main.tsx"></script>
+ </body>
+</html>
diff --git a/apps/browser-extension/entrypoints/popup/main.tsx b/apps/browser-extension/entrypoints/popup/main.tsx
new file mode 100644
index 00000000..746e1018
--- /dev/null
+++ b/apps/browser-extension/entrypoints/popup/main.tsx
@@ -0,0 +1,17 @@
+import { QueryClientProvider } from "@tanstack/react-query"
+import React from "react"
+import ReactDOM from "react-dom/client"
+import { queryClient } from "../../utils/query-client"
+import App from "./App.js"
+import "./style.css"
+
+const rootElement = document.getElementById("root")
+if (rootElement) {
+ ReactDOM.createRoot(rootElement).render(
+ <React.StrictMode>
+ <QueryClientProvider client={queryClient}>
+ <App />
+ </QueryClientProvider>
+ </React.StrictMode>,
+ )
+}
diff --git a/apps/browser-extension/entrypoints/popup/style.css b/apps/browser-extension/entrypoints/popup/style.css
new file mode 100644
index 00000000..684e1ac6
--- /dev/null
+++ b/apps/browser-extension/entrypoints/popup/style.css
@@ -0,0 +1,27 @@
+:root {
+ font-family:
+ "Space Grotesk", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+}
diff --git a/apps/browser-extension/entrypoints/welcome/Welcome.tsx b/apps/browser-extension/entrypoints/welcome/Welcome.tsx
new file mode 100644
index 00000000..be59538c
--- /dev/null
+++ b/apps/browser-extension/entrypoints/welcome/Welcome.tsx
@@ -0,0 +1,105 @@
+function Welcome() {
+ return (
+ <div className="min-h-screen font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] flex items-center justify-center p-8 bg-gradient-to-br from-gray-50 to-white">
+ <div className="max-w-4xl w-full text-center">
+ {/* Header */}
+ <div className="mb-12">
+ <img
+ alt="supermemory"
+ className="h-16 mb-6 mx-auto"
+ src="https://assets.supermemory.ai/brand/wordmark/dark-transparent.svg"
+ />
+ <p className="text-gray-600 text-lg font-normal max-w-2xl mx-auto">
+ Your AI-powered second brain for saving and organizing everything
+ that matters
+ </p>
+ </div>
+
+ {/* Features Section */}
+ <div className="mb-12">
+ <h2 className="text-2xl font-semibold text-black mb-8"
+ >What can you do with supermemory
+ ?</h2>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
+ <div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
+ <div className="text-3xl mb-4 block">💾</div>
+ <h3 className="text-lg font-semibold text-black mb-3"
+ >Save Any Pag
+ e</h3>
+ <p className="text-sm text-gray-600 leading-snug">
+ Instantly save web pages, articles, and content to your personal
+ knowledge base
+ </p>
+ </div>
+
+ <div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
+ <div className="text-3xl mb-4 block">🐦</div>
+ <h3 className="text-lg font-semibold text-black mb-3">
+ Import Twitter/X Bookmarks
+ </h3>
+ <p className="text-sm text-gray-600 leading-snug">
+ Bring all your saved tweets and bookmarks into one organized
+ place
+ </p>
+ </div>
+
+ <div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
+ <div className="text-3xl mb-4 block">🤖</div>
+ <h3 className="text-lg font-semibold text-black mb-3">
+ Import ChatGPT Memories
+ </h3>
+ <p className="text-sm text-gray-600 leading-snug">
+ Keep your important AI conversations and insights accessible
+ </p>
+ </div>
+
+ <div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
+ <div className="text-3xl mb-4 block">🔍</div>
+ <h3 className="text-lg font-semibold text-black mb-3">
+ AI-Powered Search
+ </h3>
+ <p className="text-sm text-gray-600 leading-snug">
+ Find anything you've saved using intelligent semantic search
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* Actions */}
+ <div className="mb-8">
+ <button
+ className="min-w-[200px] px-8 py-4 bg-gray-700 text-white border-none rounded-3xl text-base font-semibold cursor-pointer transition-colors duration-200 mb-4 outline-none hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
+ onClick={() => {
+ chrome.tabs.create({
+ url: import.meta.env.PROD
+ ? "https://app.supermemory.ai/login"
+ : "http://localhost:3000/login",
+ })
+ }}
+ type="button"
+ >
+ Login to Get started
+ </button>
+ </div>
+
+ {/* Footer */}
+ <div className="border-t border-gray-200 pt-6 mt-8">
+ <p className="text-sm text-gray-600">
+ Learn more at{" "}
+ <a
+ className="text-blue-500 no-underline hover:underline hover:text-blue-700"
+ href="https://supermemory.ai"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ supermemory.ai
+ </a>
+ </p>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default Welcome
diff --git a/apps/browser-extension/entrypoints/welcome/index.html b/apps/browser-extension/entrypoints/welcome/index.html
new file mode 100644
index 00000000..92bb26e0
--- /dev/null
+++ b/apps/browser-extension/entrypoints/welcome/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <link rel="icon" type="image/svg+xml" href="/icon-16.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Welcome to supermemory</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="./main.tsx"></script>
+ </body>
+</html> \ No newline at end of file
diff --git a/apps/browser-extension/entrypoints/welcome/main.tsx b/apps/browser-extension/entrypoints/welcome/main.tsx
new file mode 100644
index 00000000..2df8dbf1
--- /dev/null
+++ b/apps/browser-extension/entrypoints/welcome/main.tsx
@@ -0,0 +1,17 @@
+import { QueryClientProvider } from "@tanstack/react-query"
+import React from "react"
+import ReactDOM from "react-dom/client"
+import { queryClient } from "../../utils/query-client"
+import Welcome from "./Welcome"
+import "./welcome.css"
+
+const rootElement = document.getElementById("root")
+if (rootElement) {
+ ReactDOM.createRoot(rootElement).render(
+ <React.StrictMode>
+ <QueryClientProvider client={queryClient}>
+ <Welcome />
+ </QueryClientProvider>
+ </React.StrictMode>,
+ )
+}
diff --git a/apps/browser-extension/entrypoints/welcome/welcome.css b/apps/browser-extension/entrypoints/welcome/welcome.css
new file mode 100644
index 00000000..b11f14b0
--- /dev/null
+++ b/apps/browser-extension/entrypoints/welcome/welcome.css
@@ -0,0 +1,49 @@
+@import "tailwindcss";
+
+/* Custom Font Definitions */
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Space Grotesk";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
+}
+
+/* Global Styles */
+body {
+ font-family:
+ "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ sans-serif;
+}
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf
new file mode 100644
index 00000000..8a8611a5
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf
Binary files differ
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf
new file mode 100644
index 00000000..0f03f08b
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf
Binary files differ
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf
new file mode 100644
index 00000000..e530cf83
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf
Binary files differ
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf
new file mode 100644
index 00000000..8215f81e
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf
Binary files differ
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf b/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf
new file mode 100644
index 00000000..e05b9673
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf
Binary files differ
diff --git a/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf b/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf
new file mode 100644
index 00000000..2c6cc59a
--- /dev/null
+++ b/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf
Binary files differ
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
new file mode 100644
index 00000000..68f6c50a
--- /dev/null
+++ b/apps/browser-extension/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "supermemory-browser-extension",
+ "description": "Browser extension for the supermemory app",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "wxt --port 3001",
+ "dev:firefox": "wxt -b firefox",
+ "build": "wxt build",
+ "build:firefox": "wxt build -b firefox",
+ "zip": "wxt zip",
+ "zip:firefox": "wxt zip -b firefox",
+ "compile": "tsc --noEmit",
+ "postinstall": "wxt prepare"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.12",
+ "@tanstack/react-query": "^5.85.5",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "tailwindcss": "^4.1.12"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.1.4",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.3",
+ "@wxt-dev/module-react": "^1.1.3",
+ "typescript": "^5.8.3",
+ "wxt": "^0.20.6"
+ }
+}
diff --git a/apps/browser-extension/public/icon-128.png b/apps/browser-extension/public/icon-128.png
new file mode 100644
index 00000000..059b2cce
--- /dev/null
+++ b/apps/browser-extension/public/icon-128.png
Binary files differ
diff --git a/apps/browser-extension/public/icon-16.png b/apps/browser-extension/public/icon-16.png
new file mode 100644
index 00000000..549d267d
--- /dev/null
+++ b/apps/browser-extension/public/icon-16.png
Binary files differ
diff --git a/apps/browser-extension/public/icon-48.png b/apps/browser-extension/public/icon-48.png
new file mode 100644
index 00000000..bf132193
--- /dev/null
+++ b/apps/browser-extension/public/icon-48.png
Binary files differ
diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json
new file mode 100644
index 00000000..621fa129
--- /dev/null
+++ b/apps/browser-extension/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./.wxt/tsconfig.json",
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "jsx": "react-jsx",
+ "types": ["chrome"]
+ }
+}
diff --git a/apps/browser-extension/utils/api.ts b/apps/browser-extension/utils/api.ts
new file mode 100644
index 00000000..163577b5
--- /dev/null
+++ b/apps/browser-extension/utils/api.ts
@@ -0,0 +1,156 @@
+/**
+ * API service for supermemory browser extension
+ */
+import { API_ENDPOINTS, STORAGE_KEYS } from "./constants"
+import {
+ AuthenticationError,
+ type MemoryPayload,
+ type Project,
+ type ProjectsResponse,
+ SupermemoryAPIError,
+} from "./types"
+
+/**
+ * Get bearer token from storage
+ */
+async function getBearerToken(): Promise<string> {
+ const result = await chrome.storage.local.get([STORAGE_KEYS.BEARER_TOKEN])
+ const token = result[STORAGE_KEYS.BEARER_TOKEN]
+
+ if (!token) {
+ throw new AuthenticationError("Bearer token not found")
+ }
+
+ return token
+}
+
+/**
+ * Make authenticated API request
+ */
+async function makeAuthenticatedRequest<T>(
+ endpoint: string,
+ options: RequestInit = {},
+): Promise<T> {
+ const token = await getBearerToken()
+
+ const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}${endpoint}`, {
+ ...options,
+ credentials: "omit",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ })
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ throw new AuthenticationError("Invalid or expired token")
+ }
+ throw new SupermemoryAPIError(
+ `API request failed: ${response.statusText}`,
+ response.status,
+ )
+ }
+
+ return response.json()
+}
+
+/**
+ * Fetch all projects from API
+ */
+export async function fetchProjects(): Promise<Project[]> {
+ try {
+ const response =
+ await makeAuthenticatedRequest<ProjectsResponse>("/v3/projects")
+ return response.projects
+ } catch (error) {
+ console.error("Failed to fetch projects:", error)
+ throw error
+ }
+}
+
+/**
+ * Get default project from storage
+ */
+export async function getDefaultProject(): Promise<Project | null> {
+ try {
+ const result = await chrome.storage.local.get([
+ STORAGE_KEYS.DEFAULT_PROJECT,
+ ])
+ return result[STORAGE_KEYS.DEFAULT_PROJECT] || null
+ } catch (error) {
+ console.error("Failed to get default project:", error)
+ return null
+ }
+}
+
+/**
+ * Set default project in storage
+ */
+export async function setDefaultProject(project: Project): Promise<void> {
+ try {
+ await chrome.storage.local.set({
+ [STORAGE_KEYS.DEFAULT_PROJECT]: project,
+ })
+ } catch (error) {
+ console.error("Failed to set default project:", error)
+ throw error
+ }
+}
+
+/**
+ * Save memory to Supermemory API
+ */
+export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
+ try {
+ const response = await makeAuthenticatedRequest<unknown>("/v3/memories", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ })
+ return response
+ } catch (error) {
+ console.error("Failed to save memory:", error)
+ throw error
+ }
+}
+
+/**
+ * Search memories using Supermemory API
+ */
+export async function searchMemories(query: string): Promise<unknown> {
+ try {
+ const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
+ method: "POST",
+ body: JSON.stringify({ q: query }),
+ })
+ return response
+ } catch (error) {
+ console.error("Failed to search memories:", error)
+ throw error
+ }
+}
+
+/**
+ * Save tweet to Supermemory API (specific for Twitter imports)
+ */
+export async function saveTweet(
+ content: string,
+ metadata: { sm_source: string; [key: string]: unknown },
+ containerTag = "sm_project_twitter_bookmarks",
+): Promise<void> {
+ try {
+ const payload: MemoryPayload = {
+ containerTags: [containerTag],
+ content,
+ metadata,
+ }
+ await saveMemory(payload)
+ } catch (error) {
+ if (error instanceof SupermemoryAPIError && error.statusCode === 409) {
+ // Skip if already exists (409 Conflict)
+ return
+ }
+ throw error
+ }
+}
diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts
new file mode 100644
index 00000000..b499a359
--- /dev/null
+++ b/apps/browser-extension/utils/constants.ts
@@ -0,0 +1,81 @@
+/**
+ * API Endpoints
+ */
+export const API_ENDPOINTS = {
+ SUPERMEMORY_API: import.meta.env.PROD
+ ? "https://api.supermemory.ai"
+ : "http://localhost:8787",
+ SUPERMEMORY_WEB: import.meta.env.PROD
+ ? "https://app.supermemory.ai"
+ : "http://localhost:3000",
+} as const
+
+/**
+ * Storage Keys
+ */
+export const STORAGE_KEYS = {
+ BEARER_TOKEN: "bearer-token",
+ TOKENS_LOGGED: "tokens-logged",
+ TWITTER_COOKIE: "twitter-cookie",
+ TWITTER_CSRF: "twitter-csrf",
+ TWITTER_AUTH_TOKEN: "twitter-auth-token",
+ DEFAULT_PROJECT: "sm-default-project",
+} as const
+
+/**
+ * DOM Element IDs
+ */
+export const ELEMENT_IDS = {
+ TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
+ TWITTER_IMPORT_STATUS: "sm-twitter-import-status",
+ TWITTER_CLOSE_BTN: "sm-twitter-close-btn",
+ TWITTER_IMPORT_BTN: "sm-twitter-import-btn",
+ TWITTER_SIGNIN_BTN: "sm-twitter-signin-btn",
+ SUPERMEMORY_TOAST: "sm-toast",
+ SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
+ SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
+ CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element",
+} as const
+
+/**
+ * UI Configuration
+ */
+export const UI_CONFIG = {
+ BUTTON_SHOW_DELAY: 2000, // milliseconds
+ TOAST_DURATION: 3000, // milliseconds
+ RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
+ PAGINATION_DELAY: 1000, // 1 second between requests
+} as const
+
+/**
+ * Supported Domains
+ */
+export const DOMAINS = {
+ TWITTER: ["x.com", "twitter.com"],
+ CHATGPT: ["chatgpt.com", "chat.openai.com"],
+ SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
+} as const
+
+/**
+ * Container Tags
+ */
+export const CONTAINER_TAGS = {
+ TWITTER_BOOKMARKS: "sm_project_twitter_bookmarks",
+ DEFAULT_PROJECT: "sm_project_default",
+} as const
+
+/**
+ * Message Types for extension communication
+ */
+export const MESSAGE_TYPES = {
+ SAVE_MEMORY: "sm-save-memory",
+ SHOW_TOAST: "sm-show-toast",
+ BATCH_IMPORT_ALL: "sm-batch-import-all",
+ IMPORT_UPDATE: "sm-import-update",
+ IMPORT_DONE: "sm-import-done",
+ GET_RELATED_MEMORIES: "sm-get-related-memories",
+} as const
+
+export const CONTEXT_MENU_IDS = {
+ SAVE_TO_SUPERMEMORY: "sm-save-to-supermemory",
+} as const
diff --git a/apps/browser-extension/utils/query-client.ts b/apps/browser-extension/utils/query-client.ts
new file mode 100644
index 00000000..c1839691
--- /dev/null
+++ b/apps/browser-extension/utils/query-client.ts
@@ -0,0 +1,24 @@
+/**
+ * React Query configuration for supermemory browser extension
+ */
+import { QueryClient } from "@tanstack/react-query"
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime)
+ retry: (failureCount, error) => {
+ // Don't retry on authentication errors
+ if (error?.constructor?.name === "AuthenticationError") {
+ return false
+ }
+ return failureCount < 3
+ },
+ refetchOnWindowFocus: false,
+ },
+ mutations: {
+ retry: 1,
+ },
+ },
+})
diff --git a/apps/browser-extension/utils/query-hooks.ts b/apps/browser-extension/utils/query-hooks.ts
new file mode 100644
index 00000000..721a68ad
--- /dev/null
+++ b/apps/browser-extension/utils/query-hooks.ts
@@ -0,0 +1,64 @@
+/**
+ * React Query hooks for supermemory API
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ fetchProjects,
+ getDefaultProject,
+ saveMemory,
+ searchMemories,
+ setDefaultProject,
+} from "./api"
+import type { MemoryPayload } from "./types"
+
+// Query Keys
+export const queryKeys = {
+ projects: ["projects"] as const,
+ defaultProject: ["defaultProject"] as const,
+}
+
+// Projects Query
+export function useProjects(options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: queryKeys.projects,
+ queryFn: fetchProjects,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ enabled: options?.enabled ?? true,
+ })
+}
+
+// Default Project Query
+export function useDefaultProject(options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: queryKeys.defaultProject,
+ queryFn: getDefaultProject,
+ staleTime: 2 * 60 * 1000, // 2 minutes
+ enabled: options?.enabled ?? true,
+ })
+}
+
+// Set Default Project Mutation
+export function useSetDefaultProject() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: setDefaultProject,
+ onSuccess: (_, project) => {
+ queryClient.setQueryData(queryKeys.defaultProject, project)
+ },
+ })
+}
+
+// Save Memory Mutation
+export function useSaveMemory() {
+ return useMutation({
+ mutationFn: (payload: MemoryPayload) => saveMemory(payload),
+ })
+}
+
+// Search Memories Mutation
+export function useSearchMemories() {
+ return useMutation({
+ mutationFn: (query: string) => searchMemories(query),
+ })
+}
diff --git a/apps/browser-extension/utils/twitter-auth.ts b/apps/browser-extension/utils/twitter-auth.ts
new file mode 100644
index 00000000..3dfc50f6
--- /dev/null
+++ b/apps/browser-extension/utils/twitter-auth.ts
@@ -0,0 +1,101 @@
+/**
+ * Twitter Authentication Module
+ * Handles token capture and storage for Twitter API access
+ */
+import { STORAGE_KEYS } from "./constants"
+
+export interface TwitterAuthTokens {
+ cookie: string
+ csrf: string
+ auth: string
+}
+
+/**
+ * Captures Twitter authentication tokens from web request headers
+ * @param details - Web request details containing headers
+ * @returns True if tokens were captured, false otherwise
+ */
+export function captureTwitterTokens(
+ details: chrome.webRequest.WebRequestDetails & {
+ requestHeaders?: chrome.webRequest.HttpHeader[]
+ },
+): boolean {
+ if (!(details.url.includes("x.com") || details.url.includes("twitter.com"))) {
+ return false
+ }
+
+ const authHeader = details.requestHeaders?.find(
+ (header) => header.name.toLowerCase() === "authorization",
+ )
+ const cookieHeader = details.requestHeaders?.find(
+ (header) => header.name.toLowerCase() === "cookie",
+ )
+ const csrfHeader = details.requestHeaders?.find(
+ (header) => header.name.toLowerCase() === "x-csrf-token",
+ )
+
+ if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
+ chrome.storage.session.get([STORAGE_KEYS.TOKENS_LOGGED], (result) => {
+ if (!result[STORAGE_KEYS.TOKENS_LOGGED]) {
+ console.log("Twitter auth tokens captured successfully")
+ chrome.storage.session.set({ [STORAGE_KEYS.TOKENS_LOGGED]: true })
+ }
+ })
+
+ chrome.storage.session.set({
+ [STORAGE_KEYS.TWITTER_COOKIE]: cookieHeader.value,
+ [STORAGE_KEYS.TWITTER_CSRF]: csrfHeader.value,
+ [STORAGE_KEYS.TWITTER_AUTH_TOKEN]: authHeader.value,
+ })
+
+ return true
+ }
+
+ return false
+}
+
+/**
+ * Retrieves stored Twitter authentication tokens
+ * @returns Promise resolving to tokens or null if not available
+ */
+export async function getTwitterTokens(): Promise<TwitterAuthTokens | null> {
+ const result = await chrome.storage.session.get([
+ STORAGE_KEYS.TWITTER_COOKIE,
+ STORAGE_KEYS.TWITTER_CSRF,
+ STORAGE_KEYS.TWITTER_AUTH_TOKEN,
+ ])
+
+ if (
+ !result[STORAGE_KEYS.TWITTER_COOKIE] ||
+ !result[STORAGE_KEYS.TWITTER_CSRF] ||
+ !result[STORAGE_KEYS.TWITTER_AUTH_TOKEN]
+ ) {
+ return null
+ }
+
+ return {
+ cookie: result[STORAGE_KEYS.TWITTER_COOKIE],
+ csrf: result[STORAGE_KEYS.TWITTER_CSRF],
+ auth: result[STORAGE_KEYS.TWITTER_AUTH_TOKEN],
+ }
+}
+
+/**
+ * Creates HTTP headers for Twitter API requests using stored tokens
+ * @param tokens - Twitter authentication tokens
+ * @returns Headers object ready for fetch requests
+ */
+export function createTwitterAPIHeaders(tokens: TwitterAuthTokens): Headers {
+ const headers = new Headers()
+ headers.append("Cookie", tokens.cookie)
+ headers.append("X-Csrf-Token", tokens.csrf)
+ headers.append("Authorization", tokens.auth)
+ headers.append("Content-Type", "application/json")
+ headers.append(
+ "User-Agent",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+ )
+ headers.append("Accept", "*/*")
+ headers.append("Accept-Language", "en-US,en;q=0.9")
+ return headers
+}
diff --git a/apps/browser-extension/utils/twitter-import.ts b/apps/browser-extension/utils/twitter-import.ts
new file mode 100644
index 00000000..c516e094
--- /dev/null
+++ b/apps/browser-extension/utils/twitter-import.ts
@@ -0,0 +1,192 @@
+/**
+ * Twitter Bookmarks Import Module
+ * Handles the import process for Twitter bookmarks
+ */
+
+import { saveTweet } from "./api"
+import { createTwitterAPIHeaders, getTwitterTokens } from "./twitter-auth"
+import {
+ BOOKMARKS_URL,
+ buildRequestVariables,
+ extractNextCursor,
+ getAllTweets,
+ type Tweet,
+ type TwitterAPIResponse,
+ tweetToMarkdown,
+} from "./twitter-utils"
+
+export type ImportProgressCallback = (message: string) => Promise<void>
+
+export type ImportCompleteCallback = (totalImported: number) => Promise<void>
+
+export interface TwitterImportConfig {
+ onProgress: ImportProgressCallback
+ onComplete: ImportCompleteCallback
+ onError: (error: Error) => Promise<void>
+}
+
+/**
+ * Rate limiting configuration
+ */
+class RateLimiter {
+ private waitTime = 60000 // Start with 1 minute
+
+ async handleRateLimit(onProgress: ImportProgressCallback): Promise<void> {
+ const waitTimeInSeconds = this.waitTime / 1000
+
+ await onProgress(
+ `Rate limit reached. Waiting for ${waitTimeInSeconds} seconds before retrying...`,
+ )
+
+ await new Promise((resolve) => setTimeout(resolve, this.waitTime))
+ this.waitTime *= 2 // Exponential backoff
+ }
+
+ reset(): void {
+ this.waitTime = 60000
+ }
+}
+
+/**
+ * Imports a single tweet to Supermemory
+ * @param tweetMd - Tweet content in markdown format
+ * @param tweet - Original tweet object with metadata
+ * @returns Promise that resolves when tweet is imported
+ */
+async function importTweet(tweetMd: string, tweet: Tweet): Promise<void> {
+ const metadata = {
+ sm_source: "consumer",
+ tweet_id: tweet.id_str,
+ author: tweet.user.screen_name,
+ created_at: tweet.created_at,
+ likes: tweet.favorite_count,
+ retweets: tweet.retweet_count || 0,
+ }
+
+ try {
+ await saveTweet(tweetMd, metadata)
+ } catch (error) {
+ throw new Error(
+ `Failed to save tweet: ${error instanceof Error ? error.message : "Unknown error"}`,
+ )
+ }
+}
+
+/**
+ * Main class for handling Twitter bookmarks import
+ */
+export class TwitterImporter {
+ private importInProgress = false
+ private rateLimiter = new RateLimiter()
+
+ constructor(private config: TwitterImportConfig) {}
+
+ /**
+ * Starts the import process for all Twitter bookmarks
+ * @returns Promise that resolves when import is complete
+ */
+ async startImport(): Promise<void> {
+ if (this.importInProgress) {
+ throw new Error("Import already in progress")
+ }
+
+ this.importInProgress = true
+
+ try {
+ await this.batchImportAll("", 0)
+ this.rateLimiter.reset()
+ } catch (error) {
+ await this.config.onError(error as Error)
+ } finally {
+ this.importInProgress = false
+ }
+ }
+
+ /**
+ * Recursive function to import all bookmarks with pagination
+ * @param cursor - Pagination cursor for Twitter API
+ * @param totalImported - Number of tweets imported so far
+ */
+ private async batchImportAll(cursor = "", totalImported = 0): Promise<void> {
+ try {
+ // Use a local variable to track imported count
+ let importedCount = totalImported
+
+ // Get authentication tokens
+ const tokens = await getTwitterTokens()
+ if (!tokens) {
+ await this.config.onProgress(
+ "Please visit Twitter/X first to capture authentication tokens",
+ )
+ return
+ }
+
+ // Create headers for API request
+ const headers = createTwitterAPIHeaders(tokens)
+
+ // Build API request with pagination
+ const variables = buildRequestVariables(cursor)
+ const urlWithCursor = cursor
+ ? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}`
+ : BOOKMARKS_URL
+
+ console.log("Making Twitter API request to:", urlWithCursor)
+ console.log("Request headers:", Object.fromEntries(headers.entries()))
+
+ const response = await fetch(urlWithCursor, {
+ method: "GET",
+ headers,
+ redirect: "follow",
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ console.error(`Twitter API Error ${response.status}:`, errorText)
+
+ if (response.status === 429) {
+ await this.rateLimiter.handleRateLimit(this.config.onProgress)
+ return this.batchImportAll(cursor, totalImported)
+ }
+ throw new Error(
+ `Failed to fetch data: ${response.status} - ${errorText}`,
+ )
+ }
+
+ const data: TwitterAPIResponse = await response.json()
+ const tweets = getAllTweets(data)
+
+ console.log("Tweets:", tweets)
+
+ // Process each tweet
+ for (const tweet of tweets) {
+ try {
+ const tweetMd = tweetToMarkdown(tweet)
+ await importTweet(tweetMd, tweet)
+ importedCount++
+ await this.config.onProgress(`Imported ${importedCount} tweets`)
+ } catch (error) {
+ console.error("Error importing tweet:", error)
+ // Continue with next tweet
+ }
+ }
+
+ // Handle pagination
+ const instructions =
+ data.data?.bookmark_timeline_v2?.timeline?.instructions
+ const nextCursor = extractNextCursor(instructions || [])
+
+ console.log("Next cursor:", nextCursor)
+ console.log("Tweets length:", tweets.length)
+
+ if (nextCursor && tweets.length > 0) {
+ await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting
+ await this.batchImportAll(nextCursor, importedCount)
+ } else {
+ await this.config.onComplete(importedCount)
+ }
+ } catch (error) {
+ console.error("Batch import error:", error)
+ await this.config.onError(error as Error)
+ }
+ }
+}
diff --git a/apps/browser-extension/utils/twitter-utils.ts b/apps/browser-extension/utils/twitter-utils.ts
new file mode 100644
index 00000000..7a7b86db
--- /dev/null
+++ b/apps/browser-extension/utils/twitter-utils.ts
@@ -0,0 +1,377 @@
+// Twitter API data structures and transformation utilities
+
+interface TwitterAPITweet {
+ __typename?: string
+ legacy: {
+ lang?: string
+ favorite_count: number
+ created_at: string
+ display_text_range?: [number, number]
+ entities?: {
+ hashtags?: Array<{ indices: [number, number]; text: string }>
+ urls?: Array<{
+ display_url: string
+ expanded_url: string
+ indices: [number, number]
+ url: string
+ }>
+ user_mentions?: Array<{
+ id_str: string
+ indices: [number, number]
+ name: string
+ screen_name: string
+ }>
+ symbols?: Array<{ indices: [number, number]; text: string }>
+ media?: MediaEntity[]
+ }
+ id_str: string
+ full_text: string
+ reply_count?: number
+ retweet_count?: number
+ quote_count?: number
+ }
+ core?: {
+ user_results?: {
+ result?: {
+ legacy?: {
+ id_str: string
+ name: string
+ profile_image_url_https: string
+ screen_name: string
+ verified: boolean
+ }
+ is_blue_verified?: boolean
+ }
+ }
+ }
+}
+
+interface MediaEntity {
+ type: string
+ media_url_https: string
+ sizes?: {
+ large?: {
+ w: number
+ h: number
+ }
+ }
+ video_info?: {
+ variants?: Array<{
+ url: string
+ }>
+ duration_millis?: number
+ }
+}
+
+export interface Tweet {
+ __typename?: string
+ lang?: string
+ favorite_count: number
+ created_at: string
+ display_text_range?: [number, number]
+ entities: {
+ hashtags: Array<{
+ indices: [number, number]
+ text: string
+ }>
+ urls?: Array<{
+ display_url: string
+ expanded_url: string
+ indices: [number, number]
+ url: string
+ }>
+ user_mentions: Array<{
+ id_str: string
+ indices: [number, number]
+ name: string
+ screen_name: string
+ }>
+ symbols: Array<{
+ indices: [number, number]
+ text: string
+ }>
+ }
+ id_str: string
+ text: string
+ user: {
+ id_str: string
+ name: string
+ profile_image_url_https: string
+ screen_name: string
+ verified: boolean
+ is_blue_verified?: boolean
+ }
+ conversation_count: number
+ photos?: Array<{
+ url: string
+ width: number
+ height: number
+ }>
+ videos?: Array<{
+ url: string
+ thumbnail_url: string
+ duration: number
+ }>
+ retweet_count?: number
+ quote_count?: number
+ reply_count?: number
+}
+
+export interface TwitterAPIResponse {
+ data: {
+ bookmark_timeline_v2: {
+ timeline: {
+ instructions: Array<{
+ type: string
+ entries?: Array<{
+ entryId: string
+ sortIndex: string
+ content: Record<string, unknown>
+ }>
+ }>
+ }
+ }
+ }
+}
+
+// Twitter API features configuration
+export const TWITTER_API_FEATURES = {
+ graphql_timeline_v2_bookmark_timeline: true,
+ responsive_web_graphql_exclude_directive_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ responsive_web_twitter_article_notes_tab_enabled: true,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_media_download_video_enabled: false,
+ responsive_web_text_conversations_enabled: false,
+ // Missing features that the API is complaining about
+ creator_subscriptions_quote_tweet_preview_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ tweetypie_unmention_optimization_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: true,
+ communities_web_enable_tweet_community_results_fetch: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ articles_preview_enabled: true,
+ rweb_video_timestamps_enabled: true,
+ verified_phone_label_enabled: true,
+}
+
+export const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(TWITTER_API_FEATURES))}`
+
+/**
+ * Transform raw Twitter API response data into standardized Tweet format
+ */
+export function transformTweetData(
+ input: Record<string, unknown>,
+): Tweet | null {
+ try {
+ const content = input.content as {
+ itemContent?: { tweet_results?: { result?: unknown } }
+ }
+ const tweetData = content?.itemContent?.tweet_results?.result
+
+ if (!tweetData) {
+ return null
+ }
+
+ const tweet = tweetData as TwitterAPITweet
+
+ if (!tweet.legacy) {
+ return null
+ }
+
+ // Handle media entities
+ const media = (tweet.legacy.entities?.media as MediaEntity[]) || []
+ const photos = media
+ .filter((m) => m.type === "photo")
+ .map((m) => ({
+ url: m.media_url_https,
+ width: m.sizes?.large?.w || 0,
+ height: m.sizes?.large?.h || 0,
+ }))
+
+ const videos = media
+ .filter((m) => m.type === "video")
+ .map((m) => ({
+ url: m.video_info?.variants?.[0]?.url || "",
+ thumbnail_url: m.media_url_https,
+ duration: m.video_info?.duration_millis || 0,
+ }))
+
+ const transformed: Tweet = {
+ __typename: tweet.__typename,
+ lang: tweet.legacy?.lang,
+ favorite_count: tweet.legacy.favorite_count || 0,
+ created_at: new Date(tweet.legacy.created_at).toISOString(),
+ display_text_range: tweet.legacy.display_text_range,
+ entities: {
+ hashtags: tweet.legacy.entities?.hashtags || [],
+ urls: tweet.legacy.entities?.urls || [],
+ user_mentions: tweet.legacy.entities?.user_mentions || [],
+ symbols: tweet.legacy.entities?.symbols || [],
+ },
+ id_str: tweet.legacy.id_str,
+ text: tweet.legacy.full_text,
+ user: {
+ id_str: tweet.core?.user_results?.result?.legacy?.id_str || "",
+ name: tweet.core?.user_results?.result?.legacy?.name || "Unknown",
+ profile_image_url_https:
+ tweet.core?.user_results?.result?.legacy?.profile_image_url_https ||
+ "",
+ screen_name:
+ tweet.core?.user_results?.result?.legacy?.screen_name || "unknown",
+ verified: tweet.core?.user_results?.result?.legacy?.verified || false,
+ is_blue_verified:
+ tweet.core?.user_results?.result?.is_blue_verified || false,
+ },
+ conversation_count: tweet.legacy.reply_count || 0,
+ retweet_count: tweet.legacy.retweet_count || 0,
+ quote_count: tweet.legacy.quote_count || 0,
+ reply_count: tweet.legacy.reply_count || 0,
+ }
+
+ if (photos.length > 0) {
+ transformed.photos = photos
+ }
+
+ if (videos.length > 0) {
+ transformed.videos = videos
+ }
+
+ return transformed
+ } catch (error) {
+ console.error("Error transforming tweet data:", error)
+ return null
+ }
+}
+
+/**
+ * Extract all tweets from Twitter API response
+ */
+export function getAllTweets(data: TwitterAPIResponse): Tweet[] {
+ const tweets: Tweet[] = []
+
+ try {
+ const instructions =
+ data.data?.bookmark_timeline_v2?.timeline?.instructions || []
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries" && instruction.entries) {
+ for (const entry of instruction.entries) {
+ if (entry.entryId.startsWith("tweet-")) {
+ const tweet = transformTweetData(entry)
+ if (tweet) {
+ tweets.push(tweet)
+ }
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error extracting tweets:", error)
+ }
+
+ return tweets
+}
+
+/**
+ * Extract pagination cursor from Twitter API response
+ */
+export function extractNextCursor(
+ instructions: Array<Record<string, unknown>>,
+): string | null {
+ try {
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries" && instruction.entries) {
+ const entries = instruction.entries as Array<{
+ entryId: string
+ content?: { value?: string }
+ }>
+ for (const entry of entries) {
+ if (entry.entryId.startsWith("cursor-bottom-")) {
+ return entry.content?.value || null
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error extracting cursor:", error)
+ }
+
+ return null
+}
+
+/**
+ * Convert Tweet object to markdown format for storage
+ */
+export function tweetToMarkdown(tweet: Tweet): string {
+ const username = tweet.user?.screen_name || "unknown"
+ const displayName = tweet.user?.name || "Unknown User"
+ const date = new Date(tweet.created_at).toLocaleDateString()
+ const time = new Date(tweet.created_at).toLocaleTimeString()
+
+ let markdown = `# Tweet by @${username} (${displayName})\n\n`
+ markdown += `**Date:** ${date} ${time}\n`
+ markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n`
+
+ // Add tweet text
+ markdown += `${tweet.text}\n\n`
+
+ // Add media if present
+ if (tweet.photos && tweet.photos.length > 0) {
+ markdown += "**Images:**\n"
+ tweet.photos.forEach((photo, index) => {
+ markdown += `![Image ${index + 1}](${photo.url})\n`
+ })
+ markdown += "\n"
+ }
+
+ if (tweet.videos && tweet.videos.length > 0) {
+ markdown += "**Videos:**\n"
+ tweet.videos.forEach((video, index) => {
+ markdown += `[Video ${index + 1}](${video.url})\n`
+ })
+ markdown += "\n"
+ }
+
+ // Add hashtags and mentions
+ if (tweet.entities.hashtags.length > 0) {
+ markdown += `**Hashtags:** ${tweet.entities.hashtags.map((h) => `#${h.text}`).join(", ")}\n`
+ }
+
+ if (tweet.entities.user_mentions.length > 0) {
+ markdown += `**Mentions:** ${tweet.entities.user_mentions.map((m) => `@${m.screen_name}`).join(", ")}\n`
+ }
+
+ // Add raw data for reference
+ markdown += `\n---\n<details>\n<summary>Raw Tweet Data</summary>\n\n\`\`\`json\n${JSON.stringify(tweet, null, 2)}\n\`\`\`\n</details>`
+
+ return markdown
+}
+
+/**
+ * Build Twitter API request variables for pagination
+ */
+export function buildRequestVariables(cursor?: string, count = 100) {
+ const variables = {
+ count,
+ includePromotedContent: false,
+ }
+
+ if (cursor) {
+ ;(variables as Record<string, unknown>).cursor = cursor
+ }
+
+ return variables
+}
diff --git a/apps/browser-extension/utils/types.ts b/apps/browser-extension/utils/types.ts
new file mode 100644
index 00000000..2d0981c8
--- /dev/null
+++ b/apps/browser-extension/utils/types.ts
@@ -0,0 +1,149 @@
+/**
+ * Type definitions for the browser extension
+ */
+
+/**
+ * Toast states for UI feedback
+ */
+export type ToastState = "loading" | "success" | "error"
+
+/**
+ * Message types for extension communication
+ */
+export interface ExtensionMessage {
+ action?: string
+ type?: string
+ data?: unknown
+ state?: ToastState
+ importedMessage?: string
+ totalImported?: number
+}
+
+/**
+ * Memory data structure for saving content
+ */
+export interface MemoryData {
+ html: string
+ highlightedText?: string
+ url?: string
+}
+
+/**
+ * Supermemory API payload for storing memories
+ */
+export interface MemoryPayload {
+ containerTags: string[]
+ content: string
+ metadata: {
+ sm_source: string
+ [key: string]: unknown
+ }
+}
+
+/**
+ * Twitter-specific memory metadata
+ */
+export interface TwitterMemoryMetadata {
+ sm_source: "twitter_bookmarks"
+ tweet_id: string
+ author: string
+ created_at: string
+ likes: number
+ retweets: number
+}
+
+/**
+ * Storage data structure for Chrome storage
+ */
+export interface StorageData {
+ bearerToken?: string
+ twitterAuth?: {
+ cookie: string
+ csrf: string
+ auth: string
+ }
+ tokens_logged?: boolean
+ cookie?: string
+ csrf?: string
+ auth?: string
+ defaultProject?: Project
+ projectsCache?: {
+ projects: Project[]
+ timestamp: number
+ }
+}
+
+/**
+ * Context menu click info
+ */
+export interface ContextMenuClickInfo {
+ menuItemId: string | number
+ editable?: boolean
+ frameId?: number
+ frameUrl?: string
+ linkUrl?: string
+ mediaType?: string
+ pageUrl?: string
+ parentMenuItemId?: string | number
+ selectionText?: string
+ srcUrl?: string
+ targetElementId?: number
+ wasChecked?: boolean
+}
+
+/**
+ * API Response types
+ */
+export interface APIResponse<T = unknown> {
+ success: boolean
+ data?: T
+ error?: string
+}
+
+/**
+ * Error types for better error handling
+ */
+export class ExtensionError extends Error {
+ constructor(
+ message: string,
+ public code?: string,
+ public statusCode?: number,
+ ) {
+ super(message)
+ this.name = "ExtensionError"
+ }
+}
+
+export class TwitterAPIError extends ExtensionError {
+ constructor(message: string, statusCode?: number) {
+ super(message, "TWITTER_API_ERROR", statusCode)
+ this.name = "TwitterAPIError"
+ }
+}
+
+export class SupermemoryAPIError extends ExtensionError {
+ constructor(message: string, statusCode?: number) {
+ super(message, "SUPERMEMORY_API_ERROR", statusCode)
+ this.name = "SupermemoryAPIError"
+ }
+}
+
+export class AuthenticationError extends ExtensionError {
+ constructor(message = "Authentication required") {
+ super(message, "AUTH_ERROR")
+ this.name = "AuthenticationError"
+ }
+}
+
+export interface Project {
+ id: string
+ name: string
+ containerTag: string
+ createdAt: string
+ updatedAt: string
+ documentCount: number
+}
+
+export interface ProjectsResponse {
+ projects: Project[]
+}
diff --git a/apps/browser-extension/utils/ui-components.ts b/apps/browser-extension/utils/ui-components.ts
new file mode 100644
index 00000000..c160d63e
--- /dev/null
+++ b/apps/browser-extension/utils/ui-components.ts
@@ -0,0 +1,450 @@
+/**
+ * UI Components Module
+ * Reusable UI components for the browser extension
+ */
+
+import { API_ENDPOINTS, ELEMENT_IDS, UI_CONFIG } from "./constants"
+import type { ToastState } from "./types"
+
+/**
+ * Creates a toast notification element
+ * @param state - The state of the toast (loading, success, error)
+ * @returns HTMLElement - The toast element
+ */
+export function createToast(state: ToastState): HTMLElement {
+ const toast = document.createElement("div")
+ toast.id = ELEMENT_IDS.SUPERMEMORY_TOAST
+
+ toast.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 2147483647;
+ background: #ffffff;
+ border-radius: 9999px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 14px;
+ color: #374151;
+ min-width: 200px;
+ max-width: 300px;
+ animation: slideIn 0.3s ease-out;
+ box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
+ `
+
+ // Add keyframe animations and fonts if not already present
+ if (!document.getElementById("supermemory-toast-styles")) {
+ const style = document.createElement("style")
+ style.id = "supermemory-toast-styles"
+ style.textContent = `
+ @font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Light.ttf")}') format('truetype');
+ }
+ @font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Regular.ttf")}') format('truetype');
+ }
+ @font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Medium.ttf")}') format('truetype');
+ }
+ @font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-SemiBold.ttf")}') format('truetype');
+ }
+ @font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Bold.ttf")}') format('truetype');
+ }
+ @keyframes slideIn {
+ from { transform: translateX(100%); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+ @keyframes fadeOut {
+ from { transform: translateX(0); opacity: 1; }
+ to { transform: translateX(100%); opacity: 0; }
+ }
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+ `
+ document.head.appendChild(style)
+ }
+
+ const icon = document.createElement("div")
+ icon.style.cssText = "width: 20px; height: 20px; flex-shrink: 0;"
+
+ const text = document.createElement("span")
+ text.style.fontWeight = "500"
+
+ // Configure toast based on state
+ switch (state) {
+ case "loading":
+ icon.innerHTML = `
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 6V2" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
+ <path d="M12 22V18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
+ <path d="M20.49 8.51L18.36 6.38" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
+ <path d="M5.64 17.64L3.51 15.51" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
+ <path d="M22 12H18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
+ <path d="M6 12H2" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
+ <path d="M20.49 15.49L18.36 17.62" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
+ <path d="M5.64 6.36L3.51 8.49" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
+ </svg>
+ `
+ icon.style.animation = "spin 1s linear infinite"
+ text.textContent = "Adding to Memory..."
+ break
+
+ case "success": {
+ const iconUrl = browser.runtime.getURL("/icon-16.png")
+ icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
+ text.textContent = "Added to Memory"
+ break
+ }
+
+ case "error":
+ icon.innerHTML = `
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="12" cy="12" r="10" fill="#ef4444"/>
+ <path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ `
+ text.textContent = "Failed to save memory / Make sure you are logged in"
+ break
+ }
+
+ toast.appendChild(icon)
+ toast.appendChild(text)
+
+ return toast
+}
+
+/**
+ * Creates the Twitter import button
+ * @param onClick - Click handler for the button
+ * @returns HTMLElement - The button element
+ */
+export function createTwitterImportButton(onClick: () => void): HTMLElement {
+ const button = document.createElement("div")
+ button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON
+ button.style.cssText = `
+ position: fixed;
+ top: 10px;
+ right: 10px;
+ z-index: 2147483646;
+ background: #ffffff;
+ color: black;
+ border: none;
+ border-radius: 50px;
+ padding: 12px 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: all 0.2s ease;
+ `
+
+ const iconUrl = browser.runtime.getURL("/icon-16.png")
+ button.innerHTML = `
+ <img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
+ `
+
+ button.addEventListener("mouseenter", () => {
+ button.style.transform = "scale(1.05)"
+ button.style.boxShadow = "0 4px 12px rgba(29, 155, 240, 0.4)"
+ })
+
+ button.addEventListener("mouseleave", () => {
+ button.style.transform = "scale(1)"
+ button.style.boxShadow = "0 2px 8px rgba(29, 155, 240, 0.3)"
+ })
+
+ button.addEventListener("click", onClick)
+
+ return button
+}
+
+/**
+ * Creates the Twitter import UI dialog
+ * @param onClose - Close handler
+ * @param onImport - Import handler
+ * @param isAuthenticated - Whether user is authenticated
+ * @returns HTMLElement - The dialog element
+ */
+export function createTwitterImportUI(
+ onClose: () => void,
+ onImport: () => void,
+ isAuthenticated: boolean,
+): HTMLElement {
+ const container = document.createElement("div")
+ container.style.cssText = `
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 2147483647;
+ background: #ffffff;
+ border-radius: 12px;
+ padding: 16px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ min-width: 280px;
+ max-width: 400px;
+ border: 1px solid #e1e5e9;
+ font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ `
+
+ container.innerHTML = `
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
+ <div style="display: flex; align-items: center; gap: 8px;">
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="#1d9bf0">
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
+ </svg>
+ <h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #0f1419;">
+ Import Twitter Bookmarks
+ </h3>
+ </div>
+ <button id="${ELEMENT_IDS.TWITTER_CLOSE_BTN}" style="background: none; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: #536471;">
+ ✕
+ </button>
+ </div>
+
+ ${
+ isAuthenticated
+ ? `
+ <div>
+ <p style="color: #536471; font-size: 14px; margin: 0 0 12px 0; line-height: 1.4;">
+ This will import all your Twitter bookmarks to Supermemory
+ </p>
+
+ <button id="${ELEMENT_IDS.TWITTER_IMPORT_BTN}" style="width: 100%; background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 12px 16px; cursor: pointer; font-size: 14px; font-weight: 500; margin-bottom: 12px;">
+ Import All Bookmarks
+ </button>
+
+ <div id="${ELEMENT_IDS.TWITTER_IMPORT_STATUS}"></div>
+ </div>
+ `
+ : `
+ <div style="text-align: center;">
+ <p style="color: #536471; font-size: 14px; margin: 0 0 12px 0;">
+ Please sign in to supermemory first
+ </p>
+ <button id="${ELEMENT_IDS.TWITTER_SIGNIN_BTN}" style="background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 8px 16px; cursor: pointer; font-size: 14px; font-weight: 500;">
+ Sign In
+ </button>
+ </div>
+ `
+ }
+
+ <style>
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ </style>
+ `
+
+ // Add event listeners
+ const closeBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_CLOSE_BTN}`)
+ closeBtn?.addEventListener("click", onClose)
+
+ const importBtn = container.querySelector(
+ `#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`,
+ )
+ importBtn?.addEventListener("click", onImport)
+
+ const signinBtn = container.querySelector(
+ `#${ELEMENT_IDS.TWITTER_SIGNIN_BTN}`,
+ )
+ signinBtn?.addEventListener("click", () => {
+ browser.tabs.create({ url: `${API_ENDPOINTS.SUPERMEMORY_WEB}/login` })
+ })
+
+ return container
+}
+
+/**
+ * Creates a save tweet element button for Twitter/X
+ * @param onClick - Click handler for the button
+ * @returns HTMLElement - The save button element
+ */
+export function createSaveTweetElement(onClick: () => void): HTMLElement {
+ const iconButton = document.createElement("div")
+ iconButton.style.cssText = `
+ display: inline-flex;
+ align-items: flex-end;
+ opacity: 0.7;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ cursor: pointer;
+ margin-right: 10px;
+ margin-bottom: 2px;
+ z-index: 1000;
+ `
+
+ const iconFileName = "/icon-16.png"
+ const iconUrl = browser.runtime.getURL(iconFileName)
+ iconButton.innerHTML = `
+ <img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
+ `
+
+ iconButton.addEventListener("mouseenter", () => {
+ iconButton.style.opacity = "1"
+ })
+
+ iconButton.addEventListener("mouseleave", () => {
+ iconButton.style.opacity = "0.7"
+ })
+
+ iconButton.addEventListener("click", (event) => {
+ event.stopPropagation()
+ event.preventDefault()
+ onClick()
+ })
+
+ return iconButton
+}
+
+/**
+ * Creates a save element button for ChatGPT input bar
+ * @param onClick - Click handler for the button
+ * @returns HTMLElement - The save button element
+ */
+export function createChatGPTInputBarElement(onClick: () => void): HTMLElement {
+ const iconButton = document.createElement("div")
+ iconButton.style.cssText = `
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ transition: opacity 0.2s ease;
+ border-radius: 50%;
+ `
+
+ // Use appropriate icon based on theme
+ const iconFileName = "/icon-16.png"
+ const iconUrl = browser.runtime.getURL(iconFileName)
+ iconButton.innerHTML = `
+ <img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 50%;" />
+ `
+
+ iconButton.addEventListener("mouseenter", () => {
+ iconButton.style.opacity = "0.8"
+ })
+
+ iconButton.addEventListener("mouseleave", () => {
+ iconButton.style.opacity = "1"
+ })
+
+ iconButton.addEventListener("click", (event) => {
+ event.stopPropagation()
+ event.preventDefault()
+ onClick()
+ })
+
+ return iconButton
+}
+
+/**
+ * Utility functions for DOM manipulation
+ */
+export const DOMUtils = {
+ /**
+ * Check if current page is on specified domains
+ * @param domains - Array of domain names to check
+ * @returns boolean
+ */
+ isOnDomain(domains: readonly string[]): boolean {
+ return domains.includes(window.location.hostname)
+ },
+
+ /**
+ * Detect if the page is in dark mode based on color-scheme style
+ * @returns boolean - true if dark mode, false if light mode
+ */
+ isDarkMode(): boolean {
+ const htmlElement = document.documentElement
+ const style = htmlElement.getAttribute("style")
+ return style?.includes("color-scheme: dark") || false
+ },
+
+ /**
+ * Check if element exists in DOM
+ * @param id - Element ID to check
+ * @returns boolean
+ */
+ elementExists(id: string): boolean {
+ return !!document.getElementById(id)
+ },
+
+ /**
+ * Remove element from DOM if it exists
+ * @param id - Element ID to remove
+ */
+ removeElement(id: string): void {
+ const element = document.getElementById(id)
+ element?.remove()
+ },
+
+ /**
+ * Show toast notification with auto-dismiss
+ * @param state - Toast state
+ * @param duration - Duration to show toast (default from config)
+ * @returns The toast element
+ */
+ showToast(
+ state: ToastState,
+ duration: number = UI_CONFIG.TOAST_DURATION,
+ ): HTMLElement {
+ // Remove all existing toasts more aggressively
+ const existingToasts = document.querySelectorAll(
+ `#${ELEMENT_IDS.SUPERMEMORY_TOAST}`,
+ )
+ existingToasts.forEach((toast) => {
+ toast.remove()
+ })
+
+ const toast = createToast(state)
+ document.body.appendChild(toast)
+
+ // Auto-dismiss for success and error states
+ if (state === "success" || state === "error") {
+ setTimeout(() => {
+ if (document.body.contains(toast)) {
+ toast.style.animation = "fadeOut 0.3s ease-out"
+ setTimeout(() => {
+ if (document.body.contains(toast)) {
+ toast.remove()
+ }
+ }, 300)
+ }
+ }, duration)
+ }
+
+ return toast
+ },
+}
diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts
new file mode 100644
index 00000000..028a271f
--- /dev/null
+++ b/apps/browser-extension/wxt.config.ts
@@ -0,0 +1,40 @@
+import tailwindcss from "@tailwindcss/vite"
+import { defineConfig, type WxtViteConfig } from "wxt"
+
+// See https://wxt.dev/api/config.html
+export default defineConfig({
+ modules: ["@wxt-dev/module-react"],
+ vite: () =>
+ ({
+ plugins: [tailwindcss()],
+ }) as WxtViteConfig,
+ manifest: {
+ name: "supermemory",
+ homepage_url: "https://supermemory.ai",
+ permissions: [
+ "contextMenus",
+ "storage",
+ "scripting",
+ "activeTab",
+ "webRequest",
+ "tabs",
+ ],
+ host_permissions: [
+ "*://x.com/*",
+ "*://twitter.com/*",
+ "*://supermemory.ai/*",
+ "*://api.supermemory.ai/*",
+ "*://chatgpt.com/*",
+ "*://chat.openai.com/*",
+ ],
+ web_accessible_resources: [
+ {
+ resources: ["icon-16.png", "fonts/*.ttf"],
+ matches: ["<all_urls>"],
+ },
+ ],
+ },
+ webExt: {
+ disabled: true,
+ },
+})
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 8c931b98..2118b63e 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -691,10 +691,23 @@ export default function Page() {
});
useEffect(() => {
+ // save the token for chrome extension
+ const url = new URL(window.location.href);
+ const rawToken = url.searchParams.get("token");
+
+ if (rawToken) {
+ const encodedToken = encodeURIComponent(rawToken);
+ window.postMessage({ token: encodedToken }, "*");
+ url.searchParams.delete("token");
+ window.history.replaceState({}, "", url.toString());
+ }
+ }, []);
+
+ useEffect(() => {
if (waitlistStatus && !waitlistStatus.accessGranted) {
router.push("/waitlist");
}
- }, []);
+ }, [waitlistStatus, router]);
// Show loading state while checking authentication and waitlist status
if (!user || isCheckingWaitlist) {