feat: add chat memory imports to extension (#1115)

demo shared on work-done
This commit is contained in:
ved015 2026-06-17 04:08:44 +00:00
parent 9c5a5d4991
commit da70ccc5f1
8 changed files with 1164 additions and 123 deletions

View file

@ -29,6 +29,7 @@ export function initializeClaude() {
}
setTimeout(() => {
addSupermemoryButtonToClaudeMemoryDialog()
addSupermemoryIconToClaudeInput()
setupClaudeAutoFetch()
}, 2000)
@ -59,6 +60,7 @@ function setupClaudeRouteChangeDetection() {
currentUrl = window.location.href
console.log("Claude route changed, re-adding supermemory icon")
setTimeout(() => {
addSupermemoryButtonToClaudeMemoryDialog()
addSupermemoryIconToClaudeInput()
setupClaudeAutoFetch()
}, 1000)
@ -79,10 +81,13 @@ function setupClaudeRouteChangeDetection() {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (
element.querySelector?.('[role="dialog"]') ||
element.querySelector?.('div[contenteditable="true"]') ||
element.querySelector?.("textarea") ||
element.matches?.('[role="dialog"]') ||
element.matches?.('div[contenteditable="true"]') ||
element.matches?.("textarea")
element.matches?.("textarea") ||
element.textContent?.includes("Manage memory")
) {
shouldRecheck = true
}
@ -95,6 +100,7 @@ function setupClaudeRouteChangeDetection() {
claudeObserverThrottle = setTimeout(() => {
try {
claudeObserverThrottle = null
addSupermemoryButtonToClaudeMemoryDialog()
addSupermemoryIconToClaudeInput()
setupClaudeAutoFetch()
} catch (error) {
@ -264,6 +270,190 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
}
}
function getClaudeMemoryDialog(): HTMLElement | null {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
)
for (const dialog of dialogs) {
const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find(
(element) => element.textContent?.trim() === "Manage memory",
)
if (heading) return dialog
}
const candidates = Array.from(document.querySelectorAll<HTMLElement>("div"))
.filter((element) => {
const text = element.textContent || ""
if (
!text.includes("Manage memory") ||
!text.includes("Here's what Claude remembers")
) {
return false
}
const rect = element.getBoundingClientRect()
return rect.width > 400 && rect.height > 250
})
.sort((a, b) => {
const rectA = a.getBoundingClientRect()
const rectB = b.getBoundingClientRect()
return rectA.width * rectA.height - rectB.width * rectB.height
})
return candidates[0] || null
}
function getClaudeMemoryText(dialog: HTMLElement): string {
const clonedDialog = dialog.cloneNode(true) as HTMLElement
clonedDialog.querySelector("#supermemory-save-button")?.remove()
const sanitizeClaudeMemoryText = (text: string) =>
text
.replace(/^Memories from Claude:\s*/i, "")
.split("\n")
.map((line) => line.trim())
.filter(
(line) =>
line &&
line !== "Tell Claude what to remember or forget..." &&
line !== "Save to supermemory",
)
.join("\n")
.trim()
const memorySections = Array.from(
clonedDialog.querySelectorAll<HTMLElement>(
"article, section, [class*='border'], [class*='rounded']",
),
)
.map((element) => element.innerText || element.textContent || "")
.map(sanitizeClaudeMemoryText)
.filter((text) => {
return (
text.length > 80 &&
!text.includes("Manage edits") &&
!text.includes("Save to supermemory") &&
!text.includes("Tell Claude what to remember or forget")
)
})
.sort((a, b) => b.length - a.length)
if (memorySections[0]) return memorySections[0]
return sanitizeClaudeMemoryText(
clonedDialog.innerText || clonedDialog.textContent || "",
)
}
function addSupermemoryButtonToClaudeMemoryDialog() {
const memoryDialog = getClaudeMemoryDialog()
if (!memoryDialog) return
if (memoryDialog.querySelector("#supermemory-save-button")) return
const supermemoryButton = document.createElement("button")
supermemoryButton.id = "supermemory-save-button"
const iconUrl = browser.runtime.getURL("/icon-16.png")
supermemoryButton.innerHTML = `
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
<span style="white-space: nowrap;">Save to supermemory</span>
</div>
`
supermemoryButton.style.cssText = `
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: auto !important;
min-width: 190px !important;
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;
line-height: 20px !important;
white-space: nowrap !important;
margin: 8px 0 8px 0 !important;
transform: translateX(-16px) !important;
cursor: pointer !important;
font-family: inherit !important;
`
supermemoryButton.addEventListener("mouseenter", () => {
supermemoryButton.style.backgroundColor = "#2B2E33"
})
supermemoryButton.addEventListener("mouseleave", () => {
supermemoryButton.style.backgroundColor = "#1C2026"
})
supermemoryButton.addEventListener("click", async () => {
await saveClaudeMemoriesToSupermemory(memoryDialog)
})
const introText = Array.from(
memoryDialog.querySelectorAll<HTMLElement>("p, div"),
).find((element) =>
element.textContent?.includes("Here's what Claude remembers"),
)
if (introText?.parentElement) {
introText.parentElement.insertBefore(
supermemoryButton,
introText.nextSibling,
)
return
}
const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find(
(element) => element.textContent?.trim() === "Manage memory",
)
if (heading?.parentElement) {
heading.parentElement.insertBefore(supermemoryButton, heading.nextSibling)
return
}
memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild)
}
async function saveClaudeMemoriesToSupermemory(memoryDialog: HTMLElement) {
try {
DOMUtils.showToast("loading")
const memoryText = getClaudeMemoryText(memoryDialog)
if (!memoryText) {
DOMUtils.showToast("error")
return
}
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.SAVE_MEMORY,
data: {
html: memoryText,
},
actionSource: "claude_memories_dialog",
})
console.log({ response })
if (response.success) {
DOMUtils.showToast("success")
} else {
DOMUtils.showToast("error")
}
} catch (error) {
console.error("Error saving Claude memories to supermemory:", error)
DOMUtils.showToast("error")
}
}
function updateClaudeIconFeedback(
message: string,
iconElement: HTMLElement,

View file

@ -0,0 +1,445 @@
import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
import { DOMUtils } from "../../utils/ui-components"
let grokRouteObserver: MutationObserver | null = null
let grokUrlCheckInterval: NodeJS.Timeout | null = null
let grokObserverThrottle: NodeJS.Timeout | null = null
const GROK_IMPORT_INTENT_PARAM = "sm_grok_import"
const GROK_IMPORT_INTENT_VALUE = "memories"
export function initializeGrok() {
if (!DOMUtils.isOnDomain(DOMAINS.GROK)) {
return
}
if (document.body.hasAttribute("data-grok-initialized")) {
return
}
setTimeout(() => {
addSupermemoryButtonToGrokMemoryDialog()
handleGrokImportIntent()
}, 1000)
setupGrokRouteChangeDetection()
document.body.setAttribute("data-grok-initialized", "true")
}
function setupGrokRouteChangeDetection() {
if (grokRouteObserver) {
grokRouteObserver.disconnect()
}
if (grokUrlCheckInterval) {
clearInterval(grokUrlCheckInterval)
}
if (grokObserverThrottle) {
clearTimeout(grokObserverThrottle)
grokObserverThrottle = null
}
let currentUrl = window.location.href
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href
setTimeout(() => {
addSupermemoryButtonToGrokMemoryDialog()
handleGrokImportIntent()
}, 500)
}
}
grokUrlCheckInterval = setInterval(checkForRouteChange, 2000)
grokRouteObserver = new MutationObserver((mutations) => {
if (grokObserverThrottle) {
return
}
let shouldRecheck = false
for (const mutation of mutations) {
if (mutation.type !== "childList" || mutation.addedNodes.length === 0) {
continue
}
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) {
continue
}
const element = node as Element
const text = element.textContent || ""
if (
element.querySelector?.('[role="dialog"]') ||
element.matches?.('[role="dialog"]') ||
text.includes("Data Controls") ||
text.includes("Settings") ||
text.includes("Memory from your chats")
) {
shouldRecheck = true
break
}
}
}
if (shouldRecheck) {
grokObserverThrottle = setTimeout(() => {
grokObserverThrottle = null
addSupermemoryButtonToGrokMemoryDialog()
handleGrokImportIntent()
}, 250)
}
})
try {
grokRouteObserver.observe(document.body, {
childList: true,
subtree: true,
})
} catch (error) {
console.error("Failed to set up Grok route observer:", error)
if (grokUrlCheckInterval) {
clearInterval(grokUrlCheckInterval)
}
grokUrlCheckInterval = setInterval(checkForRouteChange, 1000)
}
}
function hasGrokImportIntent() {
return (
new URLSearchParams(window.location.search).get(
GROK_IMPORT_INTENT_PARAM,
) === GROK_IMPORT_INTENT_VALUE
)
}
function clearGrokImportIntent() {
const url = new URL(window.location.href)
url.searchParams.delete(GROK_IMPORT_INTENT_PARAM)
window.history.replaceState(window.history.state, "", url.toString())
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isVisible(element: HTMLElement) {
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
Number.parseFloat(style.opacity || "1") > 0
)
}
function getNormalizedText(element: Element) {
return (element.textContent || "").replace(/\s+/g, " ").trim()
}
function clickVisibleElementByText(
labels: string[],
root: ParentNode = document,
) {
const elements = Array.from(
root.querySelectorAll<HTMLElement>(
"button, a, [role='button'], [role='tab'], [data-testid], div, span",
),
)
for (const label of labels) {
const matchingElement = elements.find((element) => {
const text = getNormalizedText(element)
return text === label && isVisible(element)
})
if (!matchingElement) {
continue
}
const clickableElement =
matchingElement.closest<HTMLElement>(
"button, a, [role='button'], [role='tab']",
) || matchingElement
clickableElement.click()
return true
}
return false
}
function getGrokSettingsDialog() {
return Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((dialog) => {
const text = getNormalizedText(dialog)
return (
isVisible(dialog) &&
text.includes("Data Controls") &&
text.includes("Appearance") &&
text.includes("Behavior")
)
})
}
function isGrokDataControlsVisible() {
const text = getNormalizedText(document.body)
return (
text.includes("Data Controls") && text.includes("Memory from your chats")
)
}
async function handleGrokImportIntent() {
if (!hasGrokImportIntent()) return
if (document.body.hasAttribute("data-grok-import-intent-running")) {
return
}
document.body.setAttribute("data-grok-import-intent-running", "true")
for (let attempt = 0; attempt < 24; attempt++) {
addSupermemoryButtonToGrokMemoryDialog()
if (getGrokMemoryDialog()) {
clearGrokImportIntent()
document.body.removeAttribute("data-grok-import-intent-running")
return
}
const settingsDialog = getGrokSettingsDialog()
if (settingsDialog) {
if (isGrokDataControlsVisible()) {
clearGrokImportIntent()
document.body.removeAttribute("data-grok-import-intent-running")
return
}
clickVisibleElementByText(["Data Controls"], settingsDialog)
} else {
clickVisibleElementByText(["Settings"], document)
}
await sleep(350)
}
document.body.removeAttribute("data-grok-import-intent-running")
}
function getGrokMemoryDialog(): HTMLElement | null {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
)
for (const dialog of dialogs) {
const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find(
(element) => element.textContent?.trim() === "Memory from your chats",
)
if (heading) return dialog
}
const candidates = Array.from(document.querySelectorAll<HTMLElement>("div"))
.filter((element) => {
const text = element.textContent || ""
if (
!text.includes("Memory from your chats") ||
!text.includes("This summary is regenerated")
) {
return false
}
const rect = element.getBoundingClientRect()
return rect.width > 400 && rect.height > 250
})
.sort((a, b) => {
const rectA = a.getBoundingClientRect()
const rectB = b.getBoundingClientRect()
return rectA.width * rectA.height - rectB.width * rectB.height
})
return candidates[0] || null
}
const GROK_MEMORY_UI_TEXT = [
"Memory from your chats",
"This summary is regenerated periodically from your conversations.",
"Save to supermemory",
"Close",
"Delete memory",
"Edit",
] as const
function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function sanitizeGrokMemoryText(text: string) {
let sanitizedText = text
for (const uiText of GROK_MEMORY_UI_TEXT) {
sanitizedText = sanitizedText.replace(
new RegExp(escapeRegExp(uiText), "g"),
"\n",
)
}
return sanitizedText
.split("\n")
.map((line) => line.trim())
.filter((line) => line)
.join("\n")
.trim()
}
function getGrokMemoryText(dialog: HTMLElement): string {
const clonedDialog = dialog.cloneNode(true) as HTMLElement
clonedDialog.querySelector("#supermemory-save-button")?.remove()
const possibleMemoryContainers = Array.from(
clonedDialog.querySelectorAll<HTMLElement>(
"article, section, [class*='overflow'], [class*='prose'], [class*='whitespace']",
),
)
.map((element) => element.innerText || element.textContent || "")
.map(sanitizeGrokMemoryText)
.filter((text) => text.length > 30)
.sort((a, b) => b.length - a.length)
if (possibleMemoryContainers[0]) {
return possibleMemoryContainers[0]
}
return sanitizeGrokMemoryText(
clonedDialog.innerText || clonedDialog.textContent || "",
)
}
function createSupermemoryButton(memoryDialog: HTMLElement) {
const supermemoryButton = document.createElement("button")
supermemoryButton.id = "supermemory-save-button"
const iconUrl = browser.runtime.getURL("/icon-16.png")
supermemoryButton.innerHTML = `
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
<span style="white-space: nowrap;">Save to supermemory</span>
</div>
`
supermemoryButton.style.cssText = `
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: auto !important;
min-width: 190px !important;
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;
line-height: 20px !important;
white-space: nowrap !important;
cursor: pointer !important;
font-family: inherit !important;
z-index: 1 !important;
`
supermemoryButton.addEventListener("mouseenter", () => {
supermemoryButton.style.backgroundColor = "#2B2E33"
})
supermemoryButton.addEventListener("mouseleave", () => {
supermemoryButton.style.backgroundColor = "#1C2026"
})
supermemoryButton.addEventListener("click", async () => {
await saveGrokMemoriesToSupermemory(memoryDialog)
})
return supermemoryButton
}
function addSupermemoryButtonToGrokMemoryDialog() {
const memoryDialog = getGrokMemoryDialog()
if (!memoryDialog) return
if (memoryDialog.querySelector("#supermemory-save-button")) return
const supermemoryButton = createSupermemoryButton(memoryDialog)
const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find(
(element) => element.textContent?.trim() === "Memory from your chats",
)
const closeButton = Array.from(
memoryDialog.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => {
const label = button.getAttribute("aria-label")?.toLowerCase() || ""
const text = button.textContent?.trim().toLowerCase() || ""
return label.includes("close") || text === "×" || text === "x"
})
if (heading?.parentElement) {
const header = heading.parentElement
header.style.display = "flex"
header.style.alignItems = "center"
header.style.gap = "12px"
const spacer = document.createElement("div")
spacer.style.flex = "1"
if (closeButton?.parentElement === header) {
header.insertBefore(spacer, closeButton)
header.insertBefore(supermemoryButton, closeButton)
} else {
header.appendChild(spacer)
header.appendChild(supermemoryButton)
}
return
}
if (closeButton?.parentElement) {
closeButton.parentElement.insertBefore(supermemoryButton, closeButton)
return
}
memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild)
}
async function saveGrokMemoriesToSupermemory(memoryDialog: HTMLElement) {
try {
DOMUtils.showToast("loading")
const memoryText = getGrokMemoryText(memoryDialog)
if (!memoryText) {
DOMUtils.showToast("error")
return
}
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.SAVE_MEMORY,
data: {
content: memoryText,
title: "Grok memories import",
},
actionSource: "grok_memories_dialog",
})
if (response.success) {
DOMUtils.showToast("success")
} else {
DOMUtils.showToast("error")
}
} catch (error) {
console.error("Error saving Grok memories to supermemory:", error)
DOMUtils.showToast("error")
}
}

View file

@ -2,6 +2,7 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
import { DOMUtils } from "../../utils/ui-components"
import { initializeChatGPT } from "./chatgpt"
import { initializeClaude } from "./claude"
import { initializeGrok } from "./grok"
import {
saveMemory,
setupGlobalKeyboardShortcut,
@ -48,6 +49,9 @@ export default defineContentScript({
if (DOMUtils.isOnDomain(DOMAINS.CLAUDE)) {
initializeClaude()
}
if (DOMUtils.isOnDomain(DOMAINS.GROK)) {
initializeGrok()
}
if (DOMUtils.isOnDomain(DOMAINS.T3)) {
initializeT3()
}
@ -65,6 +69,7 @@ export default defineContentScript({
// Initialize platform-specific functionality
initializeChatGPT()
initializeClaude()
initializeGrok()
initializeT3()
initializeTwitter()

View file

@ -70,6 +70,167 @@ const Tooltip = ({
)
}
const cardShadow =
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset"
type ManualImportProvider = "gemini"
const manualImportProviderConfig: Record<
ManualImportProvider,
{ label: string; actionSource: string }
> = {
gemini: {
label: "Gemini",
actionSource: "gemini_manual_memory_import",
},
}
const manualMemoryImportPrompt = `Export all of my stored memories and any context you've learned about me from past conversations. Preserve my words verbatim where possible, especially for instructions and preferences.
## Categories (output in this order):
1. **Instructions**: Rules I've explicitly asked you to follow going forward - tone, format, style, "always do X", "never do Y", and corrections to your behavior. Only include rules from stored memories, not from conversations.
2. **Identity**: Name, age, location, education, family, relationships, languages, and personal interests.
3. **Career**: Current and past roles, companies, and general skill areas.
4. **Projects**: Projects I meaningfully built or committed to. Ideally ONE entry per project. Include what it does, current status, and any key decisions. Use the project name or a short descriptor as the first words of the entry.
5. **Preferences**: Opinions, tastes, and working-style preferences that apply broadly.
## Format:
Use section headers for each category. Within each category, list one entry per line, sorted by oldest date first. Format each line as:
[YYYY-MM-DD] - Entry content here.
If no date is known, use [unknown] instead.
## Output:
- Wrap the entire export in a single code block for easy copying.
- After the code block, state whether this is the complete set or if more remain.`
const normalizeManualMemoryImport = (value: string) => {
const trimmed = value.trim()
const codeBlockMatch = trimmed.match(/```(?:[\w-]+)?\s*([\s\S]*?)```/)
return (codeBlockMatch?.[1] ?? trimmed).trim()
}
const OpenAILogo = ({ className }: { className?: string }) => (
<svg
aria-label="ChatGPT Logo"
className={className}
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>
)
const ClaudeLogo = ({ className }: { className?: string }) => (
<img alt="Claude" className={className} src="./claude.png" />
)
const GeminiLogo = ({ className }: { className?: string }) => (
<img alt="Gemini" className={className} src="./gemini.png" />
)
const XLogo = ({ className }: { className?: string }) => (
<svg
aria-label="X Twitter Logo"
className={className}
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>
)
const GrokLogo = ({ className }: { className?: string }) => (
<svg
aria-label="Grok Logo"
className={className}
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Grok</title>
<path
d="M17.85 6.35A7.3 7.3 0 0 0 6.2 14.75"
stroke="white"
strokeLinecap="square"
strokeWidth="2.7"
/>
<path
d="M6.15 17.65A7.3 7.3 0 0 0 17.8 9.25"
stroke="white"
strokeLinecap="square"
strokeWidth="2.7"
/>
<path
d="M3.8 20.2L20.2 3.8"
stroke="white"
strokeLinecap="round"
strokeWidth="2.4"
/>
</svg>
)
const ChatAppsLogo = ({ className }: { className?: string }) => (
<div className={`relative h-5 w-[42px] shrink-0 ${className || ""}`}>
<div className="absolute left-0 top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#214E54] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
<OpenAILogo className="h-3 w-3 text-white" />
</div>
<div className="absolute left-[13px] top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#2A1710] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
<ClaudeLogo className="h-3 w-3" />
</div>
<div className="absolute left-[26px] top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#111820] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
<GrokLogo className="h-3 w-3" />
</div>
</div>
)
const ImportCard = ({
icon,
title,
description,
onClick,
}: {
icon: React.ReactNode
title: string
description?: string
onClick: () => void
}) => (
<button
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-between gap-3 transition-colors duration-200 hover:bg-[#5B7EF520]"
style={{
boxShadow: cardShadow,
}}
onClick={onClick}
type="button"
>
<div className="text-left min-w-0">
<p className="flex items-center gap-2 font-medium">
{icon}
{title}
</p>
{description && (
<p className="m-0 text-[14px] text-[#737373] leading-tight">
{description}
</p>
)}
</div>
<RightArrow className="size-4 shrink-0 mt-1" />
</button>
)
function App() {
const [userSignedIn, setUserSignedIn] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(true)
@ -80,6 +241,14 @@ function App() {
const [activeTab, setActiveTab] = useState<"save" | "imports" | "settings">(
"save",
)
const [showChatAppImports, setShowChatAppImports] = useState<boolean>(false)
const [manualImportProvider, setManualImportProvider] =
useState<ManualImportProvider | null>(null)
const [manualImportText, setManualImportText] = useState<string>("")
const [manualImportSaving, setManualImportSaving] = useState<boolean>(false)
const [manualImportSaved, setManualImportSaved] = useState<boolean>(false)
const [manualImportCopied, setManualImportCopied] = useState<boolean>(false)
const [manualImportError, setManualImportError] = useState<string>("")
const [autoSearchEnabled, setAutoSearchEnabled] = useState<boolean>(false)
const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] =
useState<boolean>(false)
@ -196,6 +365,12 @@ function App() {
// biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes
useEffect(() => {
setShowProjectSelector(false)
setShowChatAppImports(false)
setManualImportProvider(null)
setManualImportText("")
setManualImportSaved(false)
setManualImportCopied(false)
setManualImportError("")
}, [activeTab])
const handleSaveCurrentPage = async () => {
@ -263,6 +438,125 @@ function App() {
}
}
const handleTwitterBookmarksImport = async () => {
const targetUrl = "https://x.com/i/bookmarks"
try {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
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) {
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)
try {
await chrome.tabs.create({
url: targetUrl,
})
} catch (fallbackError) {
console.error("Failed to open bookmarks page:", fallbackError)
}
}
}
const handleOpenManualMemoryImport = (provider: ManualImportProvider) => {
setManualImportProvider(provider)
setManualImportText("")
setManualImportSaved(false)
setManualImportCopied(false)
setManualImportError("")
}
const handleCloseManualMemoryImport = () => {
setManualImportProvider(null)
setManualImportText("")
setManualImportSaved(false)
setManualImportCopied(false)
setManualImportError("")
}
const handleCopyManualImportPrompt = async () => {
try {
await navigator.clipboard.writeText(manualMemoryImportPrompt)
setManualImportCopied(true)
window.setTimeout(() => setManualImportCopied(false), 1600)
} catch (error) {
console.error("Failed to copy memory import prompt:", error)
setManualImportError(
"Could not copy prompt. Select and copy it manually.",
)
}
}
const handleManualMemoryImportSave = async () => {
if (!manualImportProvider) return
const content = normalizeManualMemoryImport(manualImportText)
if (!content) {
setManualImportError("Paste the exported memories first.")
return
}
setManualImportSaving(true)
setManualImportError("")
try {
const providerConfig = manualImportProviderConfig[manualImportProvider]
const response = await chrome.runtime.sendMessage({
action: MESSAGE_TYPES.SAVE_MEMORY,
actionSource: providerConfig.actionSource,
data: {
content,
title: `${providerConfig.label} memories import`,
},
})
if (!response?.success) {
throw new Error(response?.error || "Could not add memories")
}
setManualImportSaved(true)
window.setTimeout(() => {
handleCloseManualMemoryImport()
}, 1000)
} catch (error) {
console.error("Failed to add manual memory import:", error)
setManualImportError(
error instanceof Error ? error.message : "Could not add memories",
)
} finally {
setManualImportSaving(false)
}
}
const handleSignOut = async () => {
try {
await Promise.all([
@ -641,136 +935,238 @@ function App() {
</div>
) : activeTab === "imports" ? (
<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 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",
{manualImportProvider ? (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="m-0 text-base font-semibold text-white">
Import{" "}
{
manualImportProviderConfig[manualImportProvider]
.label
}{" "}
memories
</h3>
<p className="m-0 mt-1 text-xs leading-tight text-[#737373]">
Copy the prompt, paste the response here, then add it
to supermemory.
</p>
</div>
<button
aria-label="Close manual import"
className="shrink-0 bg-transparent border-none cursor-pointer p-1 text-[#737373] transition-colors hover:text-white"
onClick={handleCloseManualMemoryImport}
type="button"
>
<svg
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="18"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black text-xs">
1
</span>
<span>Copy this prompt into chat</span>
</div>
<div
className="relative overflow-hidden rounded-xl bg-black/70 p-3"
style={{ boxShadow: cardShadow }}
>
<pre className="m-0 max-h-28 overflow-y-auto whitespace-pre-wrap pb-9 pr-1 text-xs leading-snug text-[#B7B7B7] font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif]">
{manualMemoryImportPrompt}
</pre>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-linear-to-t from-black/80 to-transparent" />
<button
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-lg border-none bg-[#FFFFFF1A] px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-[#FFFFFF26]"
onClick={handleCopyManualImportPrompt}
type="button"
>
<svg
aria-hidden="true"
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<rect
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
{manualImportCopied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black text-xs">
2
</span>
<span>Paste results below</span>
</div>
<textarea
className="min-h-32 w-full resize-none rounded-xl border border-[#FFFFFF14] bg-[#FFFFFF08] p-3 text-sm leading-snug text-white outline-none placeholder:text-[#737373] focus:border-[#5B7EF566]"
onChange={(event) => {
setManualImportText(event.target.value)
setManualImportError("")
}}
placeholder="Paste your memory details here"
value={manualImportText}
/>
</div>
{manualImportError && (
<p className="m-0 text-xs leading-tight text-red-300">
{manualImportError}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<button
className="rounded-lg border-none bg-transparent px-4 py-2 text-sm font-medium text-[#8A8C90] transition-colors hover:bg-[#FFFFFF0D] hover:text-white"
onClick={handleCloseManualMemoryImport}
type="button"
>
Cancel
</button>
<button
className="rounded-xl border-none px-3.5 py-2 text-xs font-medium text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-80"
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
disabled={manualImportSaving || manualImportSaved}
onClick={handleManualMemoryImportSave}
type="button"
>
<span className="flex items-center gap-2 whitespace-nowrap">
{manualImportSaved ? (
"Done"
) : (
<>
<svg
aria-hidden="true"
className="h-4 w-5 shrink-0"
fill="none"
viewBox="0 0 20 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.4295 6.3108H12.1691V0H9.82324V6.84734C9.82324 7.57459 10.1103 8.27304 10.6206 8.78766L16.549 14.7664L18.2077 13.0936L13.8291 8.6779H19.4309V6.31219L19.4295 6.3108Z"
fill="currentColor"
/>
<path
d="M1.08945 2.90808L5.46808 7.32387H-0.133789V9.68958H7.12669V16.0003H9.4725V9.15304C9.4725 8.42574 9.18541 7.72728 8.67512 7.21272L2.74809 1.23535L1.08945 2.90808Z"
fill="currentColor"
/>
</svg>
{manualImportSaving
? "Saving..."
: "Save to supermemory"}
</>
)}
{manualImportSaved && (
<svg
aria-hidden="true"
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.4"
viewBox="0 0 24 24"
width="14"
>
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</span>
</button>
</div>
</div>
) : showChatAppImports ? (
<div className="flex flex-col gap-3">
<ImportCard
icon={<ClaudeLogo className="w-4 h-4 shrink-0" />}
title="Import Claude Memories"
description="Open 'view and manage' > save your memories to supermemory"
onClick={() => {
chrome.tabs.create({
url: "https://claude.ai/settings/capabilities",
})
}}
/>
<ImportCard
icon={<OpenAILogo className="w-3 h-3.5 shrink-0" />}
title="Import ChatGPT Memories"
description="Open 'manage' > save your memories to supermemory"
onClick={() => {
chrome.tabs.create({
url: "https://chatgpt.com/#settings/Personalization",
})
}}
type="button"
>
<div className="text-left">
<p className="flex items-center gap-2 font-medium">
<svg
aria-label="ChatGPT Logo"
className="w-3 h-3.5 shrink-0"
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
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
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-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",
/>
<ImportCard
icon={<GrokLogo className="w-4 h-4 shrink-0" />}
title="Import Grok Memories"
description="Open 'Memory from your chats' > save your memories to supermemory"
onClick={() => {
chrome.tabs.create({
url: "https://grok.com/?_s=data&sm_grok_import=memories",
})
}}
onClick={async () => {
const targetUrl = "https://x.com/i/bookmarks"
try {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
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"
>
<div className="text-left">
<p className="flex items-center gap-2 font-medium">
<svg
aria-label="X Twitter Logo"
className="w-3 h-3.5 shrink-0"
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/Twitter Bookmarks
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
Opens import dialog automatically
</p>
</div>
<RightArrow className="size-4" />
</button>
/>
<ImportCard
icon={
<GeminiLogo className="w-4 h-4 shrink-0 rounded-[4px]" />
}
title="Import Gemini Memories"
description="Paste memories exported from Gemini chat"
onClick={() => handleOpenManualMemoryImport("gemini")}
/>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<ImportCard
icon={<ChatAppsLogo />}
title="Import Chat Memories"
description="Import your ChatGPT, Claude, Grok, and Gemini memories"
onClick={() => setShowChatAppImports(true)}
/>
<ImportCard
icon={<XLogo className="w-3 h-3.5 shrink-0" />}
title="Import X/Twitter Bookmarks"
description="Opens import dialog automatically"
onClick={handleTwitterBookmarksImport}
/>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[200px] pl-1">

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -58,6 +58,7 @@ export const DOMAINS = {
TWITTER: ["x.com", "twitter.com"],
CHATGPT: ["chatgpt.com", "chat.openai.com"],
CLAUDE: ["claude.ai"],
GROK: ["grok.com", "x.ai"],
T3: ["t3.chat"],
SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
} as const

View file

@ -38,6 +38,10 @@ export default defineConfig({
"*://api.supermemory.ai/*",
"*://chatgpt.com/*",
"*://chat.openai.com/*",
"*://grok.com/*",
"*://*.grok.com/*",
"*://x.ai/*",
"*://*.x.ai/*",
"https://*.posthog.com/*",
],
web_accessible_resources: [