This commit is contained in:
Dhravya Shah 2026-01-18 16:50:50 -08:00
commit 492024bd48
16 changed files with 1632 additions and 573 deletions

View file

@ -0,0 +1,18 @@
export function RightArrow({ className }: { className?: string }) {
return (
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Right arrow</title>
<path
d="M-1.26511e-05 5.82399V4.53599H7.81199L3.90599 0.895994L4.78799 -6.19888e-06L9.79999 4.77399V5.54399L4.78799 10.332L3.90599 9.43599L7.78399 5.82399H-1.26511e-05Z"
fill="#737373"
/>
</svg>
)
}

View file

@ -15,6 +15,7 @@ import { initializeT3 } from "./t3"
import {
handleTwitterNavigation,
initializeTwitter,
openImportModal,
updateTwitterImportUI,
} from "./twitter"
@ -29,6 +30,8 @@ export default defineContentScript({
await saveMemory()
} else if (message.action === MESSAGE_TYPES.OPEN_SEARCH_PANEL) {
handleOpenSearchPanel(message.data as string)
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
await openImportModal()
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
updateTwitterImportUI(message)
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {

View file

@ -3,10 +3,11 @@ import {
ELEMENT_IDS,
MESSAGE_TYPES,
POSTHOG_EVENT_KEY,
STORAGE_KEYS,
UI_CONFIG,
} from "../../utils/constants"
import { trackEvent } from "../../utils/posthog"
import {
createTwitterImportButton,
createProjectSelectionModal,
createSaveTweetElement,
DOMUtils,
@ -27,46 +28,105 @@ async function loadSpaceGroteskFonts(): Promise<void> {
await document.fonts.ready
}
export function initializeTwitter() {
/**
* Check if import intent is valid (exists and not expired)
*/
async function checkAndConsumeImportIntent(): Promise<boolean> {
try {
const result = await browser.storage.local.get(
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
)
const intentUntil = result[
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL
] as number | undefined
if (intentUntil && Date.now() < intentUntil) {
await browser.storage.local.remove(
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
)
return true
}
return false
} catch (error) {
console.error("Error checking import intent:", error)
return false
}
}
/**
* Check if onboarding toast has been shown before
*/
async function hasOnboardingBeenShown(): Promise<boolean> {
try {
const result = await browser.storage.local.get(
STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN,
)
return !!result[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]
} catch (error) {
console.error("Error checking onboarding status:", error)
return true // Default to true to avoid showing toast on error
}
}
/**
* Mark onboarding toast as shown
*/
async function markOnboardingAsShown(): Promise<void> {
try {
await browser.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]: true,
})
} catch (error) {
console.error("Error marking onboarding as shown:", error)
}
}
export async function initializeTwitter() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
// Initial setup
if (window.location.pathname === "/i/bookmarks") {
setTimeout(() => {
addTwitterImportButton()
addTwitterImportButtonForFolders()
setTimeout(async () => {
if (window.location.pathname === "/i/bookmarks") {
await handleBookmarksPageLoad()
}
}, 2000)
} else {
// Remove button if not on bookmarks page
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
// Clean up any injected UI if navigating away
removeAllTwitterUI()
}
}
function addTwitterImportButton() {
/**
* Handle what to show when user lands on bookmarks page
*/
async function handleBookmarksPageLoad() {
if (window.location.pathname !== "/i/bookmarks") {
return
}
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
addTwitterImportButtonForFolders() // Add buttons to bookmark folders
const hasIntent = await checkAndConsumeImportIntent()
if (hasIntent) {
await openImportModal()
return
}
const button = createTwitterImportButton(async () => {
try {
await handleAllBookmarksImportClick()
} catch (error) {
console.error("Error starting import:", error)
}
})
const onboardingShown = await hasOnboardingBeenShown()
document.body.appendChild(button)
if (!onboardingShown) {
await showOnboardingToast()
await markOnboardingAsShown()
}
}
async function handleAllBookmarksImportClick() {
/**
* Opens the import modal and handles the import flow
*/
export async function openImportModal() {
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
@ -85,14 +145,13 @@ async function handleAllBookmarksImportClick() {
await showAllBookmarksProjectModal(projects)
}
} catch (error) {
console.error("Error handling all bookmarks import:", error)
console.error("Error opening import modal:", error)
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
})
}
}
async function showAllBookmarksProjectModal(
projects: Array<{ id: string; name: string; containerTag: string }>,
) {
@ -124,6 +183,420 @@ async function showAllBookmarksProjectModal(
document.body.appendChild(modal)
}
/**
* Shows the one-time onboarding toast with progress bar
*/
async function showOnboardingToast() {
await loadSpaceGroteskFonts()
// Remove any existing toast
const existingToast = document.getElementById(
ELEMENT_IDS.TWITTER_ONBOARDING_TOAST,
)
if (existingToast) {
existingToast.remove()
}
const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION
// Create toast container
const toast = document.createElement("div")
toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #374151;
min-width: 320px;
max-width: 380px;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
animation: smSlideInUp 0.3s ease-out;
overflow: hidden;
`
// Add keyframe animations if not already present
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
const style = document.createElement("style")
style.id = "supermemory-onboarding-toast-styles"
style.textContent = `
@keyframes smSlideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes smFadeOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
}
@keyframes smProgressGrow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes smPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`
document.head.appendChild(style)
}
// Header with icon, text and close button
const header = document.createElement("div")
header.style.cssText =
"display: flex; align-items: flex-start; gap: 12px; position: relative;"
const iconUrl = browser.runtime.getURL("/icon-16.png")
const icon = document.createElement("img")
icon.src = iconUrl
icon.alt = "Supermemory"
icon.style.cssText = "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
const textContainer = document.createElement("div")
textContainer.style.cssText = "display: flex; flex-direction: column; gap: 4px; flex: 1;"
const title = document.createElement("span")
title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;"
title.textContent = "Import X/Twitter Bookmarks"
const description = document.createElement("span")
description.style.cssText = "font-size: 13px; color: #6b7280; line-height: 1.4;"
description.textContent =
"You can import all your Twitter bookmarks to Supermemory with one click."
textContainer.appendChild(title)
textContainer.appendChild(description)
// Close button
const closeButton = document.createElement("button")
closeButton.setAttribute("aria-label", "Close onboarding toast")
closeButton.style.cssText = `
position: absolute;
top: 0;
right: 0;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
`
closeButton.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`
closeButton.addEventListener("mouseenter", () => {
closeButton.style.backgroundColor = "#f3f4f6"
})
closeButton.addEventListener("mouseleave", () => {
closeButton.style.backgroundColor = "transparent"
})
closeButton.addEventListener("click", () => {
dismissToast(toast)
})
header.appendChild(icon)
header.appendChild(textContainer)
header.appendChild(closeButton)
// Action buttons
const buttonsContainer = document.createElement("div")
buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;"
const importButton = document.createElement("button")
importButton.style.cssText = `
padding: 8px 16px;
border: none;
border-radius: 8px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
font-family: inherit;
`
importButton.textContent = "Import now"
importButton.addEventListener("mouseenter", () => {
importButton.style.opacity = "0.9"
})
importButton.addEventListener("mouseleave", () => {
importButton.style.opacity = "1"
})
importButton.addEventListener("click", async () => {
dismissToast(toast)
await openImportModal()
})
const learnMoreButton = document.createElement("button")
learnMoreButton.style.cssText = `
padding: 8px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
`
learnMoreButton.textContent = "Learn more"
learnMoreButton.addEventListener("mouseenter", () => {
learnMoreButton.style.backgroundColor = "#f9fafb"
})
learnMoreButton.addEventListener("mouseleave", () => {
learnMoreButton.style.backgroundColor = "transparent"
})
learnMoreButton.addEventListener("click", () => {
window.open(
"https://docs.supermemory.ai/connectors/twitter",
"_blank",
)
})
buttonsContainer.appendChild(importButton)
buttonsContainer.appendChild(learnMoreButton)
// Progress bar container
const progressBarContainer = document.createElement("div")
progressBarContainer.setAttribute("role", "progressbar")
progressBarContainer.setAttribute("aria-valuemin", "0")
progressBarContainer.setAttribute("aria-valuemax", "100")
progressBarContainer.setAttribute("aria-valuenow", "0")
progressBarContainer.setAttribute("aria-label", "Onboarding toast auto-dismiss progress")
progressBarContainer.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #e5e7eb;
`
const progressBar = document.createElement("div")
progressBar.style.cssText = `
height: 100%;
background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0);
transform-origin: left;
animation: smProgressGrow ${duration}ms linear forwards;
`
// Update progress bar ARIA value as animation progresses
const startTime = Date.now()
const updateProgress = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(100, Math.round((elapsed / duration) * 100))
progressBarContainer.setAttribute("aria-valuenow", String(progress))
if (progress < 100) {
requestAnimationFrame(updateProgress)
}
}
requestAnimationFrame(updateProgress)
progressBarContainer.appendChild(progressBar)
// Assemble toast
toast.appendChild(header)
toast.appendChild(buttonsContainer)
toast.appendChild(progressBarContainer)
document.body.appendChild(toast)
// Auto-dismiss after duration
setTimeout(() => {
if (document.body.contains(toast)) {
dismissToast(toast)
}
}, duration)
}
/**
* Dismiss the toast with animation
*/
function dismissToast(toast: HTMLElement) {
toast.style.animation = "smFadeOut 0.3s ease-out forwards"
setTimeout(() => {
if (document.body.contains(toast)) {
toast.remove()
}
}, 300)
}
/**
* Remove all Twitter-specific injected UI
*/
function removeAllTwitterUI() {
// Remove import button (legacy)
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
// Remove onboarding toast
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)
}
// Remove import progress toast
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
}
// Remove any folder buttons
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
button.remove()
})
}
/**
* Shows or updates the import progress toast in the bottom-right
*/
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
if (!toast) {
// Ensure animation styles are available
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
const style = document.createElement("style")
style.id = "supermemory-onboarding-toast-styles"
style.textContent = `
@keyframes smSlideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes smFadeOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
}
@keyframes smPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`
document.head.appendChild(style)
}
// Create new toast
toast = document.createElement("div")
toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 12px;
padding: 14px 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: 280px;
max-width: 360px;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
animation: smSlideInUp 0.3s ease-out;
`
const iconUrl = browser.runtime.getURL("/icon-16.png")
const icon = document.createElement("img")
icon.src = iconUrl
icon.alt = "Supermemory"
icon.id = "sm-import-progress-icon"
icon.style.cssText =
"width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;"
const textSpan = document.createElement("span")
textSpan.id = "sm-import-progress-text"
textSpan.style.cssText = "font-weight: 500; flex: 1;"
textSpan.textContent = message
toast.appendChild(icon)
toast.appendChild(textSpan)
document.body.appendChild(toast)
} else {
// Update existing toast
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (textSpan) {
textSpan.textContent = message
}
}
// Style for completion
if (isComplete) {
const icon = toast.querySelector(
"#sm-import-progress-icon",
) as HTMLImageElement
if (icon) {
icon.style.animation = "none"
icon.style.opacity = "1"
}
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (textSpan) {
textSpan.style.color = "#059669"
}
// Auto-dismiss after 4 seconds on completion
setTimeout(() => {
const existingToast = document.getElementById(
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
)
if (existingToast) {
dismissToast(existingToast)
}
}, 4000)
}
}
export function updateTwitterImportUI(message: {
type: string
importedMessage?: string
totalImported?: number
}) {
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
showOrUpdateImportProgressToast(message.importedMessage, false)
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
showOrUpdateImportProgressToast(
`✓ Imported ${message.totalImported} tweets!`,
true,
)
}
}
export async function handleTwitterNavigation() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
if (window.location.pathname === "/i/bookmarks") {
addTwitterImportButtonForFolders()
await handleBookmarksPageLoad()
} else {
removeAllTwitterUI()
}
}
/**
* Adds import buttons to bookmark folders
*/
function addTwitterImportButtonForFolders() {
if (window.location.pathname !== "/i/bookmarks") {
return
@ -138,6 +611,9 @@ function addTwitterImportButtonForFolders() {
})
}
/**
* Adds an import button to a bookmark folder element
*/
function addButtonToElement(element: HTMLElement) {
if (element.querySelector("[data-supermemory-button]")) {
return
@ -148,9 +624,8 @@ function addButtonToElement(element: HTMLElement) {
const button = createSaveTweetElement(async () => {
const url = element.getAttribute("href")
const bookmarkCollectionId = url?.split("/").pop()
console.log("Bookmark collection ID:", bookmarkCollectionId)
if (bookmarkCollectionId) {
await showProjectSelectionModal(bookmarkCollectionId)
await showFolderProjectSelectionModal(bookmarkCollectionId)
}
})
@ -164,132 +639,55 @@ function addButtonToElement(element: HTMLElement) {
element.style.padding = "10px"
}
export function updateTwitterImportUI(message: {
type: string
importedMessage?: string
totalImported?: number
}) {
const importButton = document.getElementById(
ELEMENT_IDS.TWITTER_IMPORT_BUTTON,
)
if (!importButton) return
const existingImg = importButton.querySelector("img")
if (existingImg) {
existingImg.remove()
const iconUrl = browser.runtime.getURL("/icon-16.png")
importButton.style.backgroundImage = `url("${iconUrl}")`
importButton.style.backgroundRepeat = "no-repeat"
importButton.style.backgroundSize = "20px 20px"
importButton.style.backgroundPosition = "8px center"
importButton.style.padding = "10px 16px 10px 32px"
}
let textSpan = importButton.querySelector(
"#sm-import-text",
) as HTMLSpanElement
if (!textSpan) {
textSpan = document.createElement("span")
textSpan.id = "sm-import-text"
textSpan.style.cssText = "font-weight: 500; font-size: 14px;"
importButton.appendChild(textSpan)
}
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
textSpan.textContent = message.importedMessage || ""
importButton.style.cursor = "default"
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
textSpan.textContent = `✓ Imported ${message.totalImported} tweets!`
textSpan.style.color = "#059669"
setTimeout(() => {
textSpan.textContent = "Import Bookmarks"
textSpan.style.color = ""
importButton.style.cursor = "pointer"
}, 3000)
}
}
export function handleTwitterNavigation() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
if (window.location.pathname === "/i/bookmarks") {
addTwitterImportButton()
addTwitterImportButtonForFolders()
} else {
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
button.remove()
})
}
}
/**
* Shows the project selection modal for folder imports
* @param bookmarkCollectionId - The ID of the bookmark collection to import
*/
async function showProjectSelectionModal(bookmarkCollectionId: string) {
try {
const modal = createProjectSelectionModal(
[],
async (selectedProject) => {
modal.remove()
async function showFolderProjectSelectionModal(bookmarkCollectionId: string) {
await loadSpaceGroteskFonts()
try {
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
isFolderImport: true,
bookmarkCollectionId: bookmarkCollectionId,
selectedProject: selectedProject,
})
} catch (error) {
console.error("Error importing bookmarks:", error)
}
},
() => {
modal.remove()
},
)
const modal = createProjectSelectionModal(
[],
async (selectedProject) => {
modal.remove()
document.body.appendChild(modal)
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
})
if (response.success && response.data) {
const projects = response.data
if (projects.length === 0) {
console.warn("No projects available for import")
updateModalWithProjects(modal, [])
} else {
updateModalWithProjects(modal, projects)
}
} else {
console.error("Failed to fetch projects:", response.error)
updateModalWithProjects(modal, [])
try {
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
isFolderImport: true,
bookmarkCollectionId: bookmarkCollectionId,
selectedProject: selectedProject,
})
} catch (error) {
console.error("Error importing bookmarks:", error)
}
} catch (error) {
console.error("Error fetching projects:", error)
},
() => {
modal.remove()
},
)
document.body.appendChild(modal)
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
})
if (response.success && response.data) {
const projects = response.data
updateModalWithProjects(modal, projects)
} else {
console.error("Failed to fetch projects:", response.error)
updateModalWithProjects(modal, [])
}
} catch (error) {
console.error("Error showing project selection modal:", error)
console.error("Error fetching projects:", error)
updateModalWithProjects(modal, [])
}
}
/**
* Updates the modal with fetched projects
* @param modal - The modal element
* @param projects - Array of projects to populate the dropdown
*/
function updateModalWithProjects(
modal: HTMLElement,
@ -316,10 +714,10 @@ function updateModalWithProjects(
importButton.disabled = true
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #d1d5db;
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;

View file

@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import "./App.css"
import { validateAuthToken } from "../../utils/api"
import { MESSAGE_TYPES } from "../../utils/constants"
import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants"
import {
useDefaultProject,
useProjects,
@ -17,6 +17,7 @@ import {
userData as userDataStorage,
} from "../../utils/storage"
import type { Project } from "../../utils/types"
import { RightArrow } from "@/components/icons"
const Tooltip = ({
children,
@ -181,6 +182,11 @@ function App() {
}
}, [defaultProject, projects, setDefaultProjectMutation])
// biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes
useEffect(() => {
setShowProjectSelector(false)
}, [activeTab])
const handleSaveCurrentPage = async () => {
setSaving(true)
@ -262,25 +268,51 @@ function App() {
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 shrink-0"
src="./dark-transparent.svg"
style={{ width: "80%", height: "45px" }}
/>
<div
className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow:
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
}}
>
<div
id="popup-header"
className="flex items-center justify-between p-2.5 relative"
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
>
<img
alt="supermemory"
src="./icon-48.png"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
/>
</div>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-[#737373] leading-normal">
Your
</span>
<img
alt="supermemory"
src="./logo-fullmark.svg"
className="h-[14.5px] w-auto"
/>
</div>
</div>
</div>
<div className="p-4 text-black min-h-[300px] flex items-center justify-center">
<div className="flex items-center gap-3 text-base text-black">
<div className="p-4 min-h-[300px] flex items-center justify-center">
<div className="flex items-center gap-3 text-sm text-[#737373]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
className="text-black"
className="text-[#737373]"
aria-hidden="true"
>
<path d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">
@ -294,7 +326,7 @@ function App() {
</path>
</svg>
<span className="font-semibold">Loading...</span>
<span className="font-medium">Loading...</span>
</div>
</div>
</div>
@ -303,26 +335,56 @@ function App() {
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"
className="w-80 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden p-4"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow:
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
}}
>
<div className="flex items-center justify-between gap-3 p-2.5 relative">
<div className="text-white text-lg font-semibold ml-2">supermemory</div>
<div
id="popup-header"
className="flex items-center justify-between p-2.5 relative"
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
>
<img
alt="supermemory"
src="./icon-48.png"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
/>
</div>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-[#737373] leading-normal">
{(() => {
const name =
userData?.name?.split(" ")[0] ||
userData?.email?.split("@")[0]
if (!name) return "Your"
return name.endsWith("s") ? `${name}'` : `${name}'s`
})()}
</span>
<img
alt="supermemory"
src="./logo-fullmark.svg"
className="h-[14.5px] w-auto"
/>
</div>
</div>
{userSignedIn && (
<button
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200"
className="bg-transparent border-none cursor-pointer p-1 rounded transition-colors duration-200"
onClick={handleSignOut}
title="Logout"
aria-label="Logout"
type="button"
>
<svg
width="19"
height="18"
viewBox="0 0 19 18"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
@ -330,15 +392,15 @@ function App() {
<path
d="M17 9H7.5M15 12L18 9L15 6M10 4V3C10 2.46957 9.78929 1.96086 9.41421 1.58579C9.03914 1.21071 8.53043 1 8 1H3C2.46957 1 1.96086 1.21071 1.58579 1.58579C1.21071 1.96086 1 2.46957 1 3V15C1 15.5304 1.21071 16.0391 1.58579 16.4142C1.96086 16.7893 2.46957 17 3 17H8C8.53043 17 9.03914 16.7893 9.41421 16.4142C9.78929 16.0391 10 15.5304 10 15V14"
stroke="#FAFAFA"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
<div className="p-4 min-h-[250px] pt-1">
<div className="min-h-[250px] pt-1">
{userSignedIn ? (
<div className="text-left">
{/* Tab Navigation */}
@ -408,43 +470,119 @@ function App() {
</div>
</div>
{/* Project Selection */}
<div className="mb-0">
<button
className="w-full bg-[#5B7EF50A] border-none p-4 cursor-pointer text-left rounded-xl flex justify-between items-center transition-colors duration-200 hover:bg-[#5B7EF520]"
onClick={handleShowProjectSelector}
type="button"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
{/* Space Selection */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between pl-1">
<span className="text-sm font-medium text-[#737373]">
Save to project:
Save to Space
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
{showProjectSelector && (
<button
id="close-space-selector"
className="bg-transparent border-none cursor-pointer p-0 text-[#737373] hover:text-white transition-colors"
onClick={() => setShowProjectSelector(false)}
type="button"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{showProjectSelector ? (
<div className="flex flex-col gap-1 max-h-[180px] overflow-y-auto">
{loadingProjects ? (
<div className="h-11 flex items-center justify-center text-sm text-[#737373]">
Loading spaces...
</div>
) : (
projects.map((project) => (
<button
id={`space-option-${project.id}`}
className={`w-full h-11 flex items-center justify-between px-4 rounded-lg bg-transparent border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF510] ${
defaultProject?.id === project.id
? "bg-[#5B7EF50A]"
: ""
}`}
style={
defaultProject?.id === project.id
? {
boxShadow:
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}
: undefined
}
key={project.id}
onClick={() => handleProjectSelect(project)}
type="button"
>
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
{project.name}
</span>
{defaultProject?.id === project.id && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white shrink-0"
>
<title>Selected</title>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))
)}
</div>
) : (
<button
id="space-selector-trigger"
className="w-full h-11 flex items-center justify-between px-4 rounded-lg bg-[#5B7EF50A] border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF520]"
onClick={handleShowProjectSelector}
type="button"
style={{
boxShadow:
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
{defaultProject
? defaultProject.name
: "Default Project"}
: "Select a space"}
</span>
<svg
aria-label="Select project"
className="text-white shrink-0 transition-transform duration-200 hover:text-gray-700 transform rotate-90"
fill="none"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
className="text-white shrink-0"
>
<title>Select project</title>
<path d="M9 18l6-6-6-6" />
<title>Expand</title>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</button>
</button>
)}
</div>
{/* Save Button at Bottom */}
@ -496,7 +634,7 @@ function App() {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<button
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
@ -524,34 +662,79 @@ function App() {
Import ChatGPT Memories
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
open 'manage', save your memories to supermemory
open 'manage' &gt; save your memories to supermemory
</p>
</div>
<RightArrow className="size-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<button
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
onClick={async () => {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
const targetUrl = "https://x.com/i/bookmarks"
if (activeTab?.url === targetUrl) {
return
}
try {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
await chrome.tabs.create({
url: targetUrl,
})
const isOnBookmarksPage =
activeTab?.url?.includes("x.com/i/bookmarks") ||
activeTab?.url?.includes("twitter.com/i/bookmarks")
if (isOnBookmarksPage && activeTab?.id) {
try {
await chrome.tabs.sendMessage(activeTab.id, {
action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL,
})
} catch (error) {
// Content script may not be loaded yet, fall back to intent-based approach
console.error(
"Failed to send message to content script:",
error,
)
const intentExpiry =
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
await chrome.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
intentExpiry,
})
await chrome.tabs.create({
url: targetUrl,
})
}
} else {
const intentExpiry =
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
await chrome.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
intentExpiry,
})
await chrome.tabs.create({
url: targetUrl,
})
}
} catch (error) {
console.error("Error opening Twitter import:", error)
// Fallback: try to open the bookmarks page anyway
try {
await chrome.tabs.create({
url: targetUrl,
})
} catch (fallbackError) {
console.error(
"Failed to open bookmarks page:",
fallbackError,
)
}
}
}}
type="button"
>
@ -570,9 +753,10 @@ function App() {
Import X/Twitter Bookmarks
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
Click on supermemory on top right to import bookmarks
Opens import dialog automatically
</p>
</div>
<RightArrow className="size-4" />
</button>
</div>
</div>
@ -588,7 +772,9 @@ function App() {
</div>
) : userData?.email ? (
<>
<span className="font-medium text-base">Email</span>
<span className="font-medium text-base text-white">
Email
</span>
<span
className="text-sm text-[#525966] p-3 rounded-xl bg-[#5B7EF50A]"
style={{
@ -608,7 +794,7 @@ function App() {
{/* Chat Integration Section */}
<div className="mb-4">
<h3 className="text-base font-semibold mb-3">
<h3 className="text-base font-semibold mb-3 text-white">
Chat Integration
</h3>
<div className="flex items-center justify-between p-3 rounded-xl bg-[#5B7EF50A] mb-3">
@ -654,53 +840,6 @@ function App() {
</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 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 wrap-break-word 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">
@ -718,18 +857,18 @@ function App() {
</div>
) : (
<div className="mb-8">
<h2 className="m-0 mb-4 text-sm font-normal text-black leading-tight">
<h2 className="m-0 mb-4 text-sm font-normal 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] before:font-bold">
Import your ChatGPT Memories
</li>
</ul>
@ -737,7 +876,7 @@ function App() {
)}
<div className="mt-8">
<p className="m-0 mb-4 text-sm text-gray-500">
<p className="m-0 mb-4 text-sm text-[#737373]">
Having trouble logging in?{" "}
<button
className="bg-transparent border-none text-blue-500 cursor-pointer underline text-sm p-0 hover:text-blue-700"

View file

@ -7,6 +7,7 @@
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
border: 1px solid #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -18,7 +19,6 @@
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -114,7 +114,7 @@ export async function validateAuthToken(): Promise<boolean> {
/**
* Get user data from storage
*/
export async function getUserData(): Promise<{ email?: string } | null> {
export async function getUserData(): Promise<{ email?: string; name?: string } | null> {
try {
return (await userData.getValue()) || null
} catch (error) {

View file

@ -15,6 +15,8 @@ export const API_ENDPOINTS = {
*/
export const ELEMENT_IDS = {
TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
TWITTER_ONBOARDING_TOAST: "sm-twitter-onboarding-toast",
TWITTER_IMPORT_PROGRESS_TOAST: "sm-twitter-import-progress-toast",
SUPERMEMORY_TOAST: "sm-toast",
SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
@ -26,12 +28,22 @@ export const ELEMENT_IDS = {
SELECTION_SEARCH_PANEL: "sm-selection-search-panel",
} as const
/**
* Storage Keys for local
*/
export const STORAGE_KEYS = {
TWITTER_BOOKMARKS_ONBOARDING_SEEN: "sm_twitter_bookmarks_onboarding_seen",
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL: "sm_twitter_bookmarks_import_intent_until",
} as const
/**
* UI Configuration
*/
export const UI_CONFIG = {
BUTTON_SHOW_DELAY: 2000, // milliseconds
TOAST_DURATION: 3000, // milliseconds
ONBOARDING_TOAST_DURATION: 6000, // milliseconds (6 seconds for progress bar)
IMPORT_INTENT_TTL: 2 * 60 * 1000, // 2 minutes TTL for import intent
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
PAGINATION_DELAY: 1000, // 1 second between requests
AUTO_SEARCH_DEBOUNCE_DELAY: 1500, // milliseconds to wait after user stops typing
@ -75,6 +87,7 @@ export const MESSAGE_TYPES = {
FETCH_PROJECTS: "sm-fetch-projects",
SEARCH_SELECTION: "sm-search-selection",
OPEN_SEARCH_PANEL: "sm-open-search-panel",
TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal",
} as const
export const CONTEXT_MENU_IDS = {

View file

@ -545,10 +545,10 @@ export function createProjectSelectionModal(
importButton.textContent = "Import"
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: #d1d5db;
color: #9ca3af;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;
@ -577,10 +577,10 @@ export function createProjectSelectionModal(
importButton.disabled = true
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #d1d5db;
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;

View file

@ -11,7 +11,7 @@ export default defineConfig({
manifest: {
name: "supermemory",
homepage_url: "https://supermemory.ai",
version: "6.0.105",
version: "6.1.0",
permissions: ["contextMenus", "storage", "activeTab", "webRequest", "tabs"],
host_permissions: [
"*://x.com/*",

294
apps/docs/install.md Normal file
View file

@ -0,0 +1,294 @@
You are integrating Supermemory into my application. Supermemory provides user memory, semantic search, and automatic knowledge extraction for AI applications.
You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
## STEP 1: ASK ME THESE QUESTIONS
1. What are you building?
- Personal chatbot/assistant
- Team knowledge base
- Customer support bot
- Document Q&A
- Other
2. How do you want to integrate?
- Vercel AI SDK (@supermemory/tools)
- OpenAI plugins
- Direct SDK (supermemory npm/pip)
- Direct API calls
3. Data model?
- Individual users only → containerTag: userId
- Organizations only → containerTag: orgId
- Both users AND orgs → ask for strategy
4. Do you want USER PROFILES?
User profiles are automatically-maintained facts about users (what they like, what they're working on, preferences).
- Yes (RECOMMENDED) → Use client.profile() to get context
- No → Just use search
5. How should I retrieve context?
- OPTION A: One call with search included → profile({ containerTag, q: userMessage })
- OPTION B: Separate calls → profile() for facts, search() for memories
## STEP 2: INSTALL
```bash
# Get API key: https://console.supermemory.ai
npm install supermemory # or: pip install supermemory
# For Vercel AI SDK: npm install @supermemory/tools
export SUPERMEMORY_API_KEY="sm_..."
```
## STEP 3: CONFIGURE SETTINGS (DO THIS FIRST)
```typescript
// PATCH https://api.supermemory.ai/v3/settings
fetch('https://api.supermemory.ai/v3/settings', {
method: 'PATCH',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
body: JSON.stringify({
shouldLLMFilter: true,
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
})
})
```
## STEP 4: CONTAINER TAG STRATEGY
Based on their data model answer:
**USER-ONLY APP:**
```typescript
containerTag: userId
```
**ORG-ONLY APP:**
```typescript
containerTag: orgId // Org members share memories
```
**BOTH (ask which):**
- Option A: `containerTag: \`\${userId}-\${orgId}\``
- Option B: `containerTag: orgId, metadata: { userId }`
- Option C: `containerTag: userId, metadata: { orgId }`
## STEP 5: INTEGRATION CODE
Based on their integration choice:
### VERCEL AI SDK
```typescript
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
import { supermemoryTools } from '@supermemory/tools/ai-sdk'
// Option 1: Agent tools (recommended for agentic flows)
const result = await streamText({
model: anthropic('claude-3-5-sonnet-20241022'),
prompt: userMessage,
tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, {
containerTags: [userId]
})
})
// Agent gets searchMemories, addMemory, fetchMemory tools
// Option 2: Profile middleware (automatic context injection)
import { withSupermemory } from '@supermemory/tools/ai-sdk'
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), userId)
const result = await generateText({
model: modelWithMemory,
messages: [{ role: 'user', content: userMessage }]
})
// Profile is automatically injected into context
```
### DIRECT SDK (WITH PROFILES)
```typescript
import Supermemory from 'supermemory'
const client = new Supermemory()
// Before each LLM call:
const { profile, searchResults } = await client.profile({
containerTag: userId,
q: userMessage // Include this if they chose OPTION A (one call)
// Omit if they chose OPTION B (separate calls)
})
// Build context
const context = `
Static facts: ${profile.static.join('\n')}
Recent context: ${profile.dynamic.join('\n')}
${searchResults ? `Memories: ${searchResults.results.map(r => r.content).join('\n')}` : ''}
`
// Send to LLM
const messages = [
{ role: 'system', content: `User context:\n${context}` },
{ role: 'user', content: userMessage }
]
// After LLM responds:
await client.memories.add({
content: `user: ${userMessage}\nassistant: ${response}`,
containerTag: userId
})
```
### DIRECT SDK (NO PROFILES)
```typescript
import Supermemory from 'supermemory'
const client = new Supermemory()
// Search for relevant memories
const results = await client.search({
q: userMessage,
containerTag: userId,
searchMode: 'hybrid', // Searches memories + document chunks
limit: 5
})
// Build context
const context = results.results.map(r => r.content).join('\n')
// Send to LLM with context
const messages = [
{ role: 'system', content: `Relevant context:\n${context}` },
{ role: 'user', content: userMessage }
]
// Store the conversation
await client.memories.add({
content: `user: ${userMessage}\nassistant: ${response}`,
containerTag: userId
})
```
### PYTHON VERSION
```python
from supermemory import Supermemory
client = Supermemory()
# With profiles (if they want it)
profile_data = client.profile(
container_tag=user_id,
q=user_message # Include if OPTION A, omit if OPTION B
)
context = f"""
Static: {chr(10).join(profile_data.profile.static)}
Dynamic: {chr(10).join(profile_data.profile.dynamic)}
"""
# Store conversation
client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id)
```
### DIRECT API
```bash
# Add memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"content": "conversation", "containerTag": "userId"}'
# Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"containerTag": "userId", "q": "search query"}'
# Search
curl -X POST https://api.supermemory.ai/v4/search \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
```
## STEP 6: FILE UPLOADS (if they need it)
```typescript
// Files are automatically extracted (PDFs, images with OCR, videos with transcription)
const formData = new FormData()
formData.append('file', fileBlob)
formData.append('containerTag', userId)
await fetch('https://api.supermemory.ai/v3/documents/file', {
method: 'POST',
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
body: formData
})
// Processing is async - check status before assuming searchable
// GET /v3/documents/{documentId}
```
## STEP 7: SEARCH MODES
```typescript
// HYBRID (recommended) - searches memories + document chunks
searchMode: 'hybrid'
// MEMORIES ONLY - just extracted memories, no original text
searchMode: 'memories'
```
## STEP 8: METADATA FILTERS (if they need secondary filtering)
```typescript
await client.search({
q: query,
containerTag: userId,
filters: {
AND: [
{ key: 'type', value: 'conversation', type: 'string_equal' },
{ key: 'timestamp', value: '2024', type: 'string_contains' }
]
}
})
```
## KEY POINTS:
1. Configure settings FIRST with filterPrompt
2. User profiles = automatic facts about users (profile.static + profile.dynamic)
3. profile({ containerTag, q }) combines profile + search in ONE call
4. Search modes: 'hybrid' (recommended) or 'memories'
5. File extraction is automatic - no config needed
6. Store conversations after each interaction
7. containerTag should match what you put in filterPrompt
## TESTING:
```bash
# 1. Configure settings
curl -X PATCH https://api.supermemory.ai/v3/settings \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
# 2. Add test memory
curl -X POST https://api.supermemory.ai/v3/documents \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"content": "Test", "containerTag": "test_user"}'
# 3. Get profile
curl -X POST https://api.supermemory.ai/v4/profile \
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
-d '{"containerTag": "test_user"}'
```
## NOW:
1. Ask me the 5 questions above
2. Generate complete working code based on my answers
3. Include installation, settings config, and full integration
**DOCS:** https://supermemory.ai/docs

View file

@ -9,8 +9,8 @@
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
"@modelcontextprotocol/sdk": "^1.12.1",
"agents": "^0.2.32",
"@modelcontextprotocol/sdk": "^1.25.2",
"agents": "^0.3.5",
"hono": "^4.11.1",
"posthog-node": "^5.18.0",
"supermemory": "^4.0.0",

View file

@ -1,8 +1,9 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { Hono } from "hono"
import { SupermemoryMCP } from "./server"
import { isApiKey, validateApiKey, validateOAuthToken } from "./auth"
import { initPosthog } from "./posthog"
import type { ContentfulStatusCode } from "hono/utils/http-status"
type Bindings = {
MCP_SERVER: DurableObjectNamespace
@ -78,7 +79,7 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
if (!response.ok) {
return c.json(
{ error: "Failed to fetch authorization server metadata" },
response.status,
{ status: response.status as ContentfulStatusCode },
)
}

View file

@ -20,6 +20,7 @@ type Props = {
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
server = new McpServer({
name: "supermemory",
@ -37,7 +38,6 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
initPosthog(this.env.POSTHOG_API_KEY)
// Hook MCP McpAgent to capture client info
this.server.server.oninitialized = async () => {
const clientVersion = this.server.server.getClientVersion()
@ -49,6 +49,11 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
await this.ctx.storage.put("clientInfo", this.clientInfo)
}
}
await this.refreshContainerTags() // Fetch available projects for schema descriptions
const containerTagDescription = this.getContainerTagDescription()
const memorySchema = z.object({
content: z
.string()
@ -58,7 +63,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.describe("Optional container tag")
.describe(containerTagDescription)
.optional(),
})
@ -71,7 +76,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.describe("Optional container tag")
.describe(containerTagDescription)
.optional(),
})
@ -79,7 +84,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.describe("Optional container tag to scope the profile")
.describe(containerTagDescription)
.optional(),
includeRecent: z
.boolean()
@ -176,6 +181,63 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
},
)
// Register listProjects tool
this.server.registerTool(
"listProjects",
{
description:
"List all available projects for organizing memories. Use this to discover valid project names for memory/recall operations.",
inputSchema: z.object({
refresh: z
.boolean()
.optional()
.default(true)
.describe("Refresh the list from the server (default: true)"),
}),
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: { refresh?: boolean }) => {
try {
if (args.refresh !== false) {
await this.refreshContainerTags()
}
const projects = this.cachedContainerTags
if (projects.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No projects found. Memories will use the default project.",
},
],
}
}
return {
content: [
{
type: "text" as const,
text: `Available projects:\n${projects.map((p) => `- ${p}`).join("\n")}`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
return {
content: [
{
type: "text" as const,
text: `Error listing projects: ${message}`,
},
],
isError: true,
}
}
},
)
// Register whoAmI tool
this.server.registerTool(
"whoAmI",
@ -222,6 +284,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
"User profile and preferences for system context injection. Returns a formatted system message with user's stable preferences and recent activity.",
//argsSchema: contextPromptSchema.shape, TODO: commenting out for now as it will add more friction to the user
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: ContextPromptArgs) => {
try {
const { containerTag, includeRecent = true } = args
@ -543,4 +606,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private getMcpSessionId(): string {
return this.ctx.id.name || "unknown"
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
this.cachedContainerTags = await client.getProjects()
} catch (error) {
console.error("Failed to fetch container tags:", error)
}
}
private getContainerTagDescription(): string {
const baseDescription = "Optional project to scope memories"
if (this.cachedContainerTags.length === 0) {
return baseDescription
}
return `${baseDescription}. Available projects: ${this.cachedContainerTags.join(", ")}`
}
}

688
bun.lock

File diff suppressed because it is too large Load diff