fix(extension): discard stale recall responses

This commit is contained in:
abhinav7x94 2026-08-16 08:31:49 +05:30
parent e651045ac5
commit f6e333cf60
8 changed files with 890 additions and 238 deletions

View file

@ -24,13 +24,16 @@ import {
showMemorySuggestion,
syncAcceptedSupermemoryState,
} from "./memory-suggestion"
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
let chatGPTDebounceTimeout: NodeJS.Timeout | null = null
let chatGPTRouteObserver: MutationObserver | null = null
let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null
let chatGPTObserverThrottle: NodeJS.Timeout | null = null
let chatGPTRecallInput: HTMLElement | null = null
const CHATGPT_DEBUG = false
const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]"
const chatGPTRecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
export function initializeChatGPT() {
debugChatGPT("initializeChatGPT called", {
@ -89,6 +92,9 @@ function setupChatGPTRouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
invalidateChatGPTRecallRequests()
const input = getChatGPTRecallInput()
if (input) clearPendingChatGPTRecall(input, false)
currentUrl = window.location.href
debugChatGPT("route changed, re-adding supermemory elements", currentUrl)
setTimeout(() => {
@ -102,6 +108,7 @@ function setupChatGPTRouteChangeDetection() {
chatGPTUrlCheckInterval = setInterval(checkForRouteChange, 2000)
chatGPTRouteObserver = new MutationObserver((mutations) => {
trackChatGPTRecallInput()
if (chatGPTObserverThrottle) {
return
}
@ -158,11 +165,12 @@ function setupChatGPTRouteChangeDetection() {
}
async function getRelatedMemoriesForChatGPT(actionSource: string) {
let request: ReturnType<typeof chatGPTRecallRequests.begin> | null = null
try {
const isAutoSearch =
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
const userQuery =
document.getElementById("prompt-textarea")?.textContent || ""
const state = getChatGPTRecallState()
const userQuery = state.query
const icon = document.querySelectorAll(
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
@ -175,6 +183,8 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
return
}
request = chatGPTRecallRequests.begin(state)
if (isAutoSearch) {
const promptElement = document.getElementById("prompt-textarea")
if (promptElement) {
@ -201,6 +211,10 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
timeoutPromise,
])
if (!chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState())) {
return
}
if (response?.success && response?.data) {
const promptElement = document.getElementById("prompt-textarea")
if (promptElement) {
@ -241,6 +255,12 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
}
}
} catch (error) {
if (
request &&
!chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState())
) {
return
}
console.error("Error getting related memories:", error)
try {
const icon = document.querySelectorAll(
@ -487,6 +507,79 @@ function getChatGPTPromptInput(): HTMLElement | null {
) as HTMLElement | null
}
function getChatGPTRecallInput(): HTMLElement | null {
return document.getElementById("prompt-textarea") || getChatGPTPromptInput()
}
function invalidateChatGPTRecallRequests() {
chatGPTRecallRequests.invalidate()
if (chatGPTDebounceTimeout) {
clearTimeout(chatGPTDebounceTimeout)
chatGPTDebounceTimeout = null
}
}
function trackChatGPTRecallInput() {
setChatGPTRecallInput(getChatGPTRecallInput())
}
function setChatGPTRecallInput(input: HTMLElement | null) {
if (input === chatGPTRecallInput) return
const previousInput = chatGPTRecallInput
chatGPTRecallInput = input
invalidateChatGPTRecallRequests()
if (previousInput) clearPendingChatGPTRecall(previousInput, false)
}
function clearPendingChatGPTRecall(
input: HTMLElement,
preserveAcceptedIcon = true,
) {
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
syncAcceptedSupermemoryState(input)
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
clearMemorySuggestion("chatgpt", input)
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
document
.querySelectorAll('[id*="sm-chatgpt-input-bar-element-before-composer"]')
.forEach((icon) => {
const iconElement = icon as HTMLElement
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
if (
preserveAcceptedIcon &&
hadAcceptedMarker &&
hasAcceptedContext &&
iconElement.dataset.memoriesData
) {
setMemoryMarkerStatus(iconElement, "found")
return
}
delete iconElement.dataset.memoriesData
setMemoryMarkerStatus(iconElement, "neutral")
})
}
function attachChatGPTRecallFreshness(input: HTMLElement) {
if (input.hasAttribute("data-supermemory-recall-freshness")) return
input.setAttribute("data-supermemory-recall-freshness", "true")
setChatGPTRecallInput(input)
input.addEventListener("input", () => {
invalidateChatGPTRecallRequests()
clearPendingChatGPTRecall(input)
})
}
function getChatGPTRecallState() {
const input = getChatGPTRecallInput()
return {
input,
query: input?.textContent || "",
url: window.location.href,
}
}
function findChatGPTComposerRoot(input: HTMLElement): HTMLElement {
const form = input.closest("form") as HTMLElement | null
if (form) return form
@ -611,16 +704,14 @@ function getChatGPTDomSnapshot() {
}
}
async function setupChatGPTAutoFetch() {
export async function setupChatGPTAutoFetch() {
const promptTextarea = getChatGPTRecallInput()
if (!promptTextarea) return
attachChatGPTRecallFreshness(promptTextarea)
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
if (!autoSearch) {
return
}
const promptTextarea = document.getElementById("prompt-textarea")
if (
!promptTextarea ||
!autoSearch ||
promptTextarea.hasAttribute("data-supermemory-auto-fetch")
) {
return
@ -630,26 +721,25 @@ async function setupChatGPTAutoFetch() {
const handleInput = () => {
const content = promptTextarea.textContent?.trim() || ""
syncAcceptedSupermemoryState(promptTextarea)
if (content.length === 0) {
clearMemorySuggestion("chatgpt", promptTextarea)
document
.querySelectorAll(
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
)
.forEach((icon) => {
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
})
}
if (chatGPTDebounceTimeout) {
clearTimeout(chatGPTDebounceTimeout)
}
const scheduledRequest = chatGPTRecallRequests.begin(
getChatGPTRecallState(),
)
chatGPTDebounceTimeout = setTimeout(async () => {
chatGPTDebounceTimeout = null
if (
!chatGPTRecallRequests.isCurrent(
scheduledRequest,
getChatGPTRecallState(),
)
) {
return
}
if (hasAcceptedSupermemoryContext(promptTextarea)) {
clearMemorySuggestion("chatgpt", promptTextarea)
return
}
@ -657,24 +747,6 @@ async function setupChatGPTAutoFetch() {
await getRelatedMemoriesForChatGPT(
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED,
)
} else if (content.length === 0) {
const icons = document.querySelectorAll(
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
)
icons.forEach((icon) => {
const iconElement = icon as HTMLElement
setMemoryMarkerStatus(iconElement, "neutral")
if (iconElement.dataset.originalHtml) {
iconElement.innerHTML = iconElement.dataset.originalHtml
delete iconElement.dataset.originalHtml
delete iconElement.dataset.memoriesData
}
})
if (promptTextarea.dataset.supermemories) {
clearMemorySuggestion("chatgpt", promptTextarea)
}
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}

View file

@ -24,13 +24,16 @@ import {
showMemorySuggestion,
syncAcceptedSupermemoryState,
} from "./memory-suggestion"
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
let claudeDebounceTimeout: NodeJS.Timeout | null = null
let claudeRouteObserver: MutationObserver | null = null
let claudeUrlCheckInterval: NodeJS.Timeout | null = null
let claudeObserverThrottle: NodeJS.Timeout | null = null
let claudeRecallInput: HTMLElement | null = null
const CLAUDE_DEBUG = false
const CLAUDE_LOG_PREFIX = "[supermemory:claude]"
const claudeRecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
export function initializeClaude() {
debugClaude("initializeClaude called", {
@ -89,6 +92,9 @@ function setupClaudeRouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
invalidateClaudeRecallRequests()
const input = getClaudePromptInput()
if (input) clearPendingClaudeRecall(input, false)
currentUrl = window.location.href
debugClaude("route changed, re-adding supermemory icon", currentUrl)
setTimeout(() => {
@ -102,6 +108,7 @@ function setupClaudeRouteChangeDetection() {
claudeUrlCheckInterval = setInterval(checkForRouteChange, 2000)
claudeRouteObserver = new MutationObserver((mutations) => {
trackClaudeRecallInput()
if (claudeObserverThrottle) {
return
}
@ -243,6 +250,113 @@ function getClaudePromptInput(): HTMLElement | null {
) as HTMLElement | null
}
function invalidateClaudeRecallRequests() {
claudeRecallRequests.invalidate()
if (claudeDebounceTimeout) {
clearTimeout(claudeDebounceTimeout)
claudeDebounceTimeout = null
}
}
function trackClaudeRecallInput() {
setClaudeRecallInput(getClaudePromptInput())
}
function setClaudeRecallInput(input: HTMLElement | null) {
if (input === claudeRecallInput) return
const previousInput = claudeRecallInput
claudeRecallInput = input
invalidateClaudeRecallRequests()
if (previousInput) clearPendingClaudeRecall(previousInput, false)
}
function getClaudeInputText(input: HTMLElement | null): string {
if (!input) return ""
if (
input instanceof HTMLTextAreaElement ||
input instanceof HTMLInputElement
) {
return input.value || ""
}
return input.innerText || input.textContent || ""
}
function clearPendingClaudeRecall(
input: HTMLElement,
preserveAcceptedIcon = true,
) {
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
syncAcceptedSupermemoryState(input)
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
clearMemorySuggestion("claude", input)
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
document
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
.forEach((icon) => {
const iconElement = icon as HTMLElement
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
if (
preserveAcceptedIcon &&
hadAcceptedMarker &&
hasAcceptedContext &&
iconElement.dataset.memoriesData
) {
setMemoryMarkerStatus(iconElement, "found")
return
}
delete iconElement.dataset.memoriesData
setMemoryMarkerStatus(iconElement, "neutral")
})
}
function attachClaudeRecallFreshness(input: HTMLElement) {
if (input.hasAttribute("data-supermemory-recall-freshness")) return
input.setAttribute("data-supermemory-recall-freshness", "true")
setClaudeRecallInput(input)
input.addEventListener("input", () => {
invalidateClaudeRecallRequests()
clearPendingClaudeRecall(input)
})
}
function getClaudeRecallState() {
let input = getClaudePromptInput()
let query = ""
const supermemoryContainer = document.querySelector(
'[data-supermemory-icon-added="true"]',
)
if (supermemoryContainer?.parentElement?.previousElementSibling) {
const paragraph =
supermemoryContainer.parentElement.previousElementSibling.querySelector(
"p",
)
query = paragraph?.innerText || paragraph?.textContent || ""
}
if (!query.trim()) {
query = getClaudeInputText(input)
}
if (!query.trim()) {
const inputElements = document.querySelectorAll<HTMLElement>(
'div[contenteditable="true"], textarea, input[type="text"]',
)
for (const candidate of inputElements) {
const text = getClaudeInputText(candidate)
if (text.trim()) {
input = candidate
query = text.trim()
break
}
}
}
return { input, query, url: window.location.href }
}
function findComposerRoot(input: HTMLElement): HTMLElement {
return (
(input.closest("form") as HTMLElement | null) ||
@ -358,44 +472,12 @@ function getClaudeDomSnapshot() {
}
async function getRelatedMemoriesForClaude(actionSource: string) {
let request: ReturnType<typeof claudeRecallRequests.begin> | null = null
try {
const isAutoSearch =
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
let userQuery = ""
const supermemoryContainer = document.querySelector(
'[data-supermemory-icon-added="true"]',
)
if (supermemoryContainer?.parentElement?.previousElementSibling) {
const pTag =
supermemoryContainer.parentElement.previousElementSibling.querySelector(
"p",
)
userQuery = pTag?.innerText || pTag?.textContent || ""
}
if (!userQuery.trim()) {
const textareaElement = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement
userQuery =
textareaElement?.innerText || textareaElement?.textContent || ""
}
if (!userQuery.trim()) {
const inputElements = document.querySelectorAll(
'div[contenteditable="true"], textarea, input[type="text"]',
)
for (const element of inputElements) {
const text =
(element as HTMLElement).innerText ||
(element as HTMLInputElement).value
if (text?.trim()) {
userQuery = text.trim()
break
}
}
}
const state = getClaudeRecallState()
const userQuery = state.query
debugClaude("query extracted", {
queryLength: userQuery.length,
@ -415,6 +497,8 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
return
}
request = claudeRecallRequests.begin(state)
if (isAutoSearch) {
const input = getClaudePromptInput()
if (input) {
@ -441,14 +525,16 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
timeoutPromise,
])
if (!claudeRecallRequests.isCurrent(request, getClaudeRecallState())) {
return
}
debugClaude("memory search response", {
success: response?.success,
})
if (response?.success && response?.data) {
const textareaElement = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement
const textareaElement = state.input
if (textareaElement) {
const memoryText = showMemorySuggestion(
@ -488,6 +574,12 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
}
}
} catch (error) {
if (
request &&
!claudeRecallRequests.isCurrent(request, getClaudeRecallState())
) {
return
}
console.error("Error getting related memories for Claude:", error)
try {
const icon = document.querySelector(
@ -838,17 +930,13 @@ function setupClaudePromptCapture() {
}
async function setupClaudeAutoFetch() {
const textareaElement = getClaudePromptInput()
if (!textareaElement) return
attachClaudeRecallFreshness(textareaElement)
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
if (!autoSearch) {
return
}
const textareaElement = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement
if (
!textareaElement ||
!autoSearch ||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
) {
return
@ -857,25 +945,24 @@ async function setupClaudeAutoFetch() {
textareaElement.setAttribute("data-supermemory-auto-fetch", "true")
const handleInput = () => {
const content = textareaElement.textContent?.trim() || ""
syncAcceptedSupermemoryState(textareaElement)
if (content.length === 0) {
clearMemorySuggestion("claude", textareaElement)
document
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
.forEach((icon) => {
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
})
}
const content = getClaudeInputText(textareaElement).trim()
if (claudeDebounceTimeout) {
clearTimeout(claudeDebounceTimeout)
}
const scheduledRequest = claudeRecallRequests.begin(getClaudeRecallState())
claudeDebounceTimeout = setTimeout(async () => {
claudeDebounceTimeout = null
if (
!claudeRecallRequests.isCurrent(
scheduledRequest,
getClaudeRecallState(),
)
) {
return
}
if (hasAcceptedSupermemoryContext(textareaElement)) {
clearMemorySuggestion("claude", textareaElement)
return
}
@ -883,24 +970,6 @@ async function setupClaudeAutoFetch() {
await getRelatedMemoriesForClaude(
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED,
)
} else if (content.length === 0) {
const icons = document.querySelectorAll(
'[id*="sm-claude-input-bar-element"]',
)
icons.forEach((icon) => {
const iconElement = icon as HTMLElement
setMemoryMarkerStatus(iconElement, "neutral")
if (iconElement.dataset.originalHtml) {
iconElement.innerHTML = iconElement.dataset.originalHtml
delete iconElement.dataset.originalHtml
delete iconElement.dataset.memoriesData
}
})
if (textareaElement.dataset.supermemories) {
clearMemorySuggestion("claude", textareaElement)
}
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}

View file

@ -24,13 +24,16 @@ import {
showMemorySuggestion,
syncAcceptedSupermemoryState,
} from "./memory-suggestion"
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
let geminiDebounceTimeout: NodeJS.Timeout | null = null
let geminiRouteObserver: MutationObserver | null = null
let geminiUrlCheckInterval: NodeJS.Timeout | null = null
let geminiObserverThrottle: NodeJS.Timeout | null = null
let geminiRecallInput: GeminiInput | null = null
const GEMINI_DEBUG = false
const GEMINI_LOG_PREFIX = "[supermemory:gemini]"
const geminiRecallRequests = createRecallRequestFreshnessGuard<GeminiInput>()
type GeminiInput = HTMLElement | HTMLTextAreaElement
@ -94,6 +97,9 @@ function setupGeminiRouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
invalidateGeminiRecallRequests()
const input = getGeminiPromptInput()
if (input) clearPendingGeminiRecall(input, false)
currentUrl = window.location.href
debugGemini("route changed, rechecking UI", currentUrl)
setTimeout(recheckGeminiUI, 1000)
@ -103,6 +109,7 @@ function setupGeminiRouteChangeDetection() {
geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000)
geminiRouteObserver = new MutationObserver((mutations) => {
trackGeminiRecallInput()
if (geminiObserverThrottle) {
return
}
@ -123,7 +130,6 @@ function setupGeminiRouteChangeDetection() {
)
}),
)
if (shouldRecheck) {
geminiObserverThrottle = setTimeout(() => {
geminiObserverThrottle = null
@ -364,12 +370,83 @@ function getInputText(input: GeminiInput | null): string {
return input.innerText || input.textContent || ""
}
function invalidateGeminiRecallRequests() {
geminiRecallRequests.invalidate()
if (geminiDebounceTimeout) {
clearTimeout(geminiDebounceTimeout)
geminiDebounceTimeout = null
}
}
function trackGeminiRecallInput() {
setGeminiRecallInput(getGeminiPromptInput())
}
function setGeminiRecallInput(input: GeminiInput | null) {
if (input === geminiRecallInput) return
const previousInput = geminiRecallInput
geminiRecallInput = input
invalidateGeminiRecallRequests()
if (previousInput) clearPendingGeminiRecall(previousInput, false)
}
function clearPendingGeminiRecall(
input: GeminiInput,
preserveAcceptedIcon = true,
) {
const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true"
syncAcceptedSupermemoryState(input)
const hasAcceptedContext = hasAcceptedSupermemoryContext(input)
clearMemorySuggestion("gemini", input)
if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true"
document
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
.forEach((icon) => {
const iconElement = icon as HTMLElement
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
if (
preserveAcceptedIcon &&
hadAcceptedMarker &&
hasAcceptedContext &&
iconElement.dataset.memoriesData
) {
setMemoryMarkerStatus(iconElement, "found")
return
}
delete iconElement.dataset.memoriesData
delete iconElement.dataset.supermemories
setMemoryMarkerStatus(iconElement, "neutral")
})
}
function attachGeminiRecallFreshness(input: GeminiInput) {
if (input.hasAttribute("data-supermemory-recall-freshness")) return
input.setAttribute("data-supermemory-recall-freshness", "true")
setGeminiRecallInput(input)
input.addEventListener("input", () => {
invalidateGeminiRecallRequests()
clearPendingGeminiRecall(input)
})
}
function getGeminiRecallState() {
const input = getGeminiPromptInput()
return {
input,
query: getInputText(input).trim(),
url: window.location.href,
}
}
async function getRelatedMemoriesForGemini(actionSource: string) {
let request: ReturnType<typeof geminiRecallRequests.begin> | null = null
try {
const isAutoSearch =
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
const input = getGeminiPromptInput()
const userQuery = getInputText(input).trim()
const state = getGeminiRecallState()
const { input, query: userQuery } = state
debugGemini("manual/auto memory search requested", {
actionSource,
hasInput: !!input,
@ -390,6 +467,8 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
return
}
request = geminiRecallRequests.begin(state)
if (input && isAutoSearch) {
showLoadingSuggestion("gemini", input)
}
@ -414,6 +493,10 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
timeoutPromise,
])) as { success?: boolean; data?: string }
if (!geminiRecallRequests.isCurrent(request, getGeminiRecallState())) {
return
}
debugGemini("memory search response", response)
if (response?.success && response?.data && input) {
@ -436,6 +519,12 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
updateGeminiIconFeedback("No memories found", iconElement, 1800)
}
} catch (error) {
if (
request &&
!geminiRecallRequests.isCurrent(request, getGeminiRecallState())
) {
return
}
console.error("Error getting related memories for Gemini:", error)
const iconElement = document.querySelector(
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
@ -592,18 +681,18 @@ function setupGeminiPromptCapture() {
}
async function setupGeminiAutoFetch() {
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
debugGemini("setup auto fetch", { autoSearch })
if (!autoSearch) {
const input = getGeminiPromptInput()
if (!input) {
debugGemini("auto fetch skipped", {
hasInput: false,
})
return
}
attachGeminiRecallFreshness(input)
const input = getGeminiPromptInput()
if (!input || input.hasAttribute("data-supermemory-auto-fetch")) {
debugGemini("auto fetch skipped", {
hasInput: !!input,
alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"),
})
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
debugGemini("setup auto fetch", { autoSearch })
if (!autoSearch || input.hasAttribute("data-supermemory-auto-fetch")) {
return
}
@ -612,24 +701,23 @@ async function setupGeminiAutoFetch() {
const handleInput = () => {
const content = getInputText(input).trim()
syncAcceptedSupermemoryState(input)
if (content.length === 0) {
clearMemorySuggestion("gemini", input)
document
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
.forEach((icon) => {
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
})
}
if (geminiDebounceTimeout) {
clearTimeout(geminiDebounceTimeout)
}
const scheduledRequest = geminiRecallRequests.begin(getGeminiRecallState())
geminiDebounceTimeout = setTimeout(async () => {
geminiDebounceTimeout = null
if (
!geminiRecallRequests.isCurrent(
scheduledRequest,
getGeminiRecallState(),
)
) {
return
}
if (hasAcceptedSupermemoryContext(input)) {
clearMemorySuggestion("gemini", input)
return
}
@ -637,25 +725,6 @@ async function setupGeminiAutoFetch() {
await getRelatedMemoriesForGemini(
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED,
)
} else if (content.length === 0) {
const icons = document.querySelectorAll(
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
)
icons.forEach((icon) => {
const iconElement = icon as HTMLElement
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
delete iconElement.dataset.supermemoryStatus
delete iconElement.dataset.memoriesData
if (iconElement.dataset.originalHtml) {
iconElement.innerHTML = iconElement.dataset.originalHtml
delete iconElement.dataset.originalHtml
}
})
if (input.dataset.supermemories) {
clearMemorySuggestion("gemini", input)
}
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}

View file

@ -198,9 +198,9 @@ export function acceptMemorySuggestion(
event.stopPropagation()
const text = input.dataset.supermemories
appendTextToInput(input, text)
delete input.dataset.supermemories
input.dataset.supermemoriesInjected = "true"
appendTextToInput(input, text)
removeMemorySuggestion(platform)
return true

View file

@ -0,0 +1,245 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { GlobalWindow } from "happy-dom"
mock.module("#imports", () => ({
storage: {
defineItem: () => ({
getValue: async () => false,
setValue: async () => {},
}),
},
}))
const { setupChatGPTAutoFetch } = await import("./chatgpt")
const { acceptMemorySuggestion } = await import("./memory-suggestion")
const { getRelatedMemoriesForT3, setupT3AutoFetch } = await import("./t3")
const installedGlobals = [
"browser",
"document",
"Event",
"HTMLElement",
"HTMLInputElement",
"HTMLTextAreaElement",
"KeyboardEvent",
"Node",
"window",
] as const
const originalGlobals = new Map(
installedGlobals.map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
)
let page: GlobalWindow | null = null
function installDom() {
page = new GlobalWindow({ url: "https://t3.chat/chat/test" })
const values = {
browser: undefined,
document: page.document,
Event: page.Event,
HTMLElement: page.HTMLElement,
HTMLInputElement: page.HTMLInputElement,
HTMLTextAreaElement: page.HTMLTextAreaElement,
KeyboardEvent: page.KeyboardEvent,
Node: page.Node,
window: page,
}
for (const name of installedGlobals) {
if (name === "browser") continue
Object.defineProperty(globalThis, name, {
configurable: true,
value: values[name],
writable: true,
})
}
}
afterEach(() => {
page?.happyDOM.close()
page = null
for (const name of installedGlobals) {
const descriptor = originalGlobals.get(name)
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor)
} else {
Reflect.deleteProperty(globalThis, name)
}
}
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe("T3 recall freshness handler", () => {
it("clears stale auto-send data on edit even when auto-search is off", async () => {
installDom()
const input = document.createElement("textarea")
input.dataset.supermemories = "stale memory A"
const icon = document.createElement("div")
icon.id = "sm-t3-input-bar-element-test"
icon.innerHTML = "<span>Included Memories</span>"
icon.dataset.originalHtml = "<button>Search memories</button>"
icon.dataset.memoriesData = '["stale memory A"]'
document.body.append(input, icon)
const responseA = deferred<unknown>()
Object.defineProperty(globalThis, "browser", {
configurable: true,
value: {
runtime: {
sendMessage: () => responseA.promise,
},
},
writable: true,
})
await setupT3AutoFetch()
input.value = "prompt A"
const inFlightA = getRelatedMemoriesForT3("test")
input.value = "prompt B"
input.dispatchEvent(new Event("input", { bubbles: true }))
expect(input.dataset.supermemories).toBeUndefined()
expect(icon.dataset.memoriesData).toBeUndefined()
expect(icon.dataset.originalHtml).toBeUndefined()
expect(icon.textContent).toBe("Search memories")
expect(input.dataset.supermemoryRecallFreshness).toBe("true")
expect(input.dataset.supermemoryAutoFetch).toBeUndefined()
responseA.resolve({ success: true, data: ["memory A"] })
await inFlightA
expect(input.dataset.supermemories).toBeUndefined()
expect(icon.dataset.memoriesData).toBeUndefined()
})
it("keeps fast B when slow A resolves last", async () => {
installDom()
const prompt = document.createElement("div")
const input = document.createElement("textarea")
input.value = "prompt A"
prompt.appendChild(input)
const controls = document.createElement("div")
const iconContainer = document.createElement("div")
iconContainer.dataset.supermemoryIconAdded = "true"
const icon = document.createElement("div")
icon.id = "sm-t3-input-bar-element-test"
icon.innerHTML = "<button>Search memories</button>"
iconContainer.appendChild(icon)
controls.appendChild(iconContainer)
document.body.append(prompt, controls)
const pending = new Map<string, ReturnType<typeof deferred<unknown>>>()
Object.defineProperty(globalThis, "browser", {
configurable: true,
value: {
runtime: {
sendMessage: ({ data }: { data: string }) => {
const request = deferred<unknown>()
pending.set(data, request)
return request.promise
},
},
},
writable: true,
})
const slowA = getRelatedMemoriesForT3("test")
input.value = "prompt B"
const fastB = getRelatedMemoriesForT3("test")
pending.get("prompt B")?.resolve({ success: true, data: ["memory B"] })
await fastB
pending.get("prompt A")?.resolve({ success: true, data: ["memory A"] })
await slowA
expect(input.dataset.supermemories).toContain("memory B")
expect(input.dataset.supermemories).not.toContain("memory A")
expect(icon.dataset.memoriesData).toBe('["memory B"]')
})
it("clears replacement-composer loading state", async () => {
installDom()
const firstInput = document.createElement("textarea")
const icon = document.createElement("div")
icon.id = "sm-t3-input-bar-element-test"
icon.innerHTML = "<span>Searching memories</span>"
icon.dataset.originalHtml = "<button>Search memories</button>"
icon.dataset.memoriesData = '["memory A"]'
document.body.append(firstInput, icon)
await setupT3AutoFetch()
const replacementInput = document.createElement("textarea")
firstInput.replaceWith(replacementInput)
await setupT3AutoFetch()
expect(icon.textContent).toBe("Search memories")
expect(icon.dataset.originalHtml).toBeUndefined()
expect(icon.dataset.memoriesData).toBeUndefined()
expect(replacementInput.dataset.supermemoryRecallFreshness).toBe("true")
})
})
describe("accepted recall context", () => {
it("preserves included memories through the synchronous acceptance input", async () => {
installDom()
const form = document.createElement("form")
const input = document.createElement("textarea")
input.dataset.testid = "prompt-textarea"
input.value = "prompt"
input.dataset.supermemories =
"\n\nSupermemories of user (only for the reference): 1. memory M1"
form.appendChild(input)
const icon = document.createElement("div")
icon.id = "sm-chatgpt-input-bar-element-before-composer-test"
icon.dataset.memoriesData = '["memory M1"]'
icon.dataset.supermemoryStatus = "searching"
document.body.append(form, icon)
await setupChatGPTAutoFetch()
const accepted = acceptMemorySuggestion(
new KeyboardEvent("keydown", {
cancelable: true,
key: "Tab",
}),
"chatgpt",
input,
)
expect(accepted).toBe(true)
expect(input.value).toContain("memory M1")
expect(input.dataset.supermemories).toBeUndefined()
expect(input.dataset.supermemoriesInjected).toBe("true")
expect(icon.dataset.memoriesData).toBe('["memory M1"]')
expect(icon.dataset.supermemoryStatus).toBe("found")
expect(icon.querySelector("[data-supermemory-status-badge]")).not.toBeNull()
icon.dataset.supermemoryStatus = "searching"
input.value = input.value.replace("prompt", "edited prompt")
input.dispatchEvent(new Event("input", { bubbles: true }))
expect(icon.dataset.memoriesData).toBe('["memory M1"]')
expect(icon.dataset.supermemoryStatus).toBe("found")
input.dataset.supermemories =
"\n\nSupermemories of user (only for the reference): 1. memory M2"
delete input.dataset.supermemoriesInjected
icon.dataset.memoriesData = '["memory M2"]'
input.value = input.value.replace("edited", "edited again")
input.dispatchEvent(new Event("input", { bubbles: true }))
expect(input.value).toContain("memory M1")
expect(input.dataset.supermemories).toBeUndefined()
expect(input.dataset.supermemoriesInjected).toBe("true")
expect(icon.dataset.memoriesData).toBeUndefined()
expect(icon.dataset.supermemoryStatus).toBeUndefined()
input.dispatchEvent(new Event("input", { bubbles: true }))
expect(icon.dataset.supermemoryStatus).toBeUndefined()
expect(icon.querySelector("[data-supermemory-status-badge]")).toBeNull()
})
})

View file

@ -0,0 +1,107 @@
import { describe, expect, it } from "bun:test"
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe("recall request freshness", () => {
it("lets fast B commit and discards slow A", async () => {
const guard = createRecallRequestFreshnessGuard<{ id: string }>()
const input = { id: "composer" }
let state = { input, query: "prompt A", url: "/chat" }
const commits: string[] = []
const slowA = deferred<string>()
const fastB = deferred<string>()
const requestA = guard.begin(state)
const settleA = slowA.promise.then((value) => {
if (guard.isCurrent(requestA, state)) commits.push(value)
})
state = { ...state, query: "prompt B" }
guard.invalidate()
const requestB = guard.begin(state)
const settleB = fastB.promise.then((value) => {
if (guard.isCurrent(requestB, state)) commits.push(value)
})
fastB.resolve("memory B")
await settleB
slowA.resolve("memory A")
await settleA
expect(commits).toEqual(["memory B"])
})
it("keeps a T3 edit-gap from auto-sending stale memory", async () => {
const guard = createRecallRequestFreshnessGuard<{
dataset: { supermemories?: string }
}>()
const input: { dataset: { supermemories?: string } } = { dataset: {} }
let state = { input, query: "prompt A", url: "/chat" }
const result = deferred<string>()
const request = guard.begin(state)
const settle = result.promise.then((value) => {
if (guard.isCurrent(request, state)) {
input.dataset.supermemories = value
}
})
state = { ...state, query: "prompt B" }
guard.invalidate()
result.resolve("stale memory A")
await settle
const promptSent = `${state.query}${input.dataset.supermemories || ""}`
expect(promptSent).toBe("prompt B")
})
it("keeps an edit invalidated even when the user restores the same text", async () => {
const guard = createRecallRequestFreshnessGuard<{ dataset: string }>()
const input = { dataset: "" }
let state = { input, query: "prompt A", url: "/chat" }
const result = deferred<string>()
const request = guard.begin(state)
const settle = result.promise.then((value) => {
if (guard.isCurrent(request, state)) input.dataset = value
})
state = { ...state, query: "temporary edit" }
guard.invalidate()
state = { ...state, query: "prompt A" }
result.resolve("stale memory A")
await settle
expect(input.dataset).toBe("")
})
it("rejects stale errors, route changes, and replaced composers", async () => {
const guard = createRecallRequestFreshnessGuard<{ id: string }>()
const oldInput = { id: "old" }
let state = { input: oldInput, query: "prompt", url: "/chat/one" }
const failure = deferred<never>()
const errors: unknown[] = []
const request = guard.begin(state)
const settle = failure.promise.catch((error) => {
if (guard.isCurrent(request, state)) errors.push(error)
})
state = {
input: { id: "new" },
query: "prompt",
url: "/chat/two",
}
guard.invalidate()
failure.reject(new Error("stale failure"))
await settle
expect(errors).toEqual([])
})
})

View file

@ -0,0 +1,38 @@
export interface RecallRequestState<TInput extends object> {
input: TInput | null
query: string
url: string
}
export interface RecallRequestToken<TInput extends object>
extends RecallRequestState<TInput> {
generation: number
}
export function createRecallRequestFreshnessGuard<TInput extends object>() {
let generation = 0
return {
invalidate() {
generation += 1
},
begin(state: RecallRequestState<TInput>): RecallRequestToken<TInput> {
generation += 1
return { ...state, generation }
},
isCurrent(
request: RecallRequestToken<TInput>,
state: RecallRequestState<TInput>,
) {
return (
request.generation === generation &&
request.input !== null &&
request.input === state.input &&
request.query === state.query &&
request.url === state.url
)
},
}
}

View file

@ -16,11 +16,14 @@ import {
renumberIncludedMemories,
serializeMemoriesForDataset,
} from "./memory-suggestion"
import { createRecallRequestFreshnessGuard } from "./recall-request-freshness"
let t3DebounceTimeout: NodeJS.Timeout | null = null
let t3RouteObserver: MutationObserver | null = null
let t3UrlCheckInterval: NodeJS.Timeout | null = null
let t3ObserverThrottle: NodeJS.Timeout | null = null
let t3RecallInput: HTMLElement | null = null
const t3RecallRequests = createRecallRequestFreshnessGuard<HTMLElement>()
let t3IncludedPopup: {
el: HTMLElement
onClick: (event: MouseEvent) => void
@ -72,7 +75,9 @@ function setupT3RouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
disposeT3IncludedPopup()
invalidateT3RecallRequests()
const input = getT3PromptInput()
if (input) clearT3Recall(input)
currentUrl = window.location.href
setTimeout(() => {
addSupermemoryIconToT3Input()
@ -84,6 +89,7 @@ function setupT3RouteChangeDetection() {
t3UrlCheckInterval = setInterval(checkForRouteChange, 2000)
t3RouteObserver = new MutationObserver((mutations) => {
trackT3RecallInput()
if (t3ObserverThrottle) {
return
}
@ -167,39 +173,112 @@ function addSupermemoryIconToT3Input() {
container.insertBefore(supermemoryIcon, container.firstChild)
}
async function getRelatedMemoriesForT3(actionSource: string) {
try {
let userQuery = ""
function getT3InputText(input: HTMLElement | null): string {
if (!input) return ""
if (input instanceof HTMLTextAreaElement) return input.value || ""
return input.innerText || input.textContent || ""
}
const supermemoryContainer = document.querySelector(
'[data-supermemory-icon-added="true"]',
)
if (supermemoryContainer?.parentElement?.previousElementSibling) {
const textareaElement =
supermemoryContainer.parentElement.previousElementSibling.querySelector(
"textarea",
)
userQuery = textareaElement?.value || ""
function getT3PromptInput(): HTMLElement | null {
return (
(document.querySelector("textarea") as HTMLTextAreaElement | null) ||
(document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement | null)
)
}
function invalidateT3RecallRequests() {
t3RecallRequests.invalidate()
if (t3DebounceTimeout) {
clearTimeout(t3DebounceTimeout)
t3DebounceTimeout = null
}
}
function trackT3RecallInput() {
setT3RecallInput(getT3PromptInput())
}
function setT3RecallInput(input: HTMLElement | null) {
if (input === t3RecallInput) return
const previousInput = t3RecallInput
t3RecallInput = input
invalidateT3RecallRequests()
if (previousInput) clearT3Recall(previousInput)
}
function clearT3Recall(input: HTMLElement) {
delete input.dataset.supermemories
disposeT3IncludedPopup()
document
.querySelectorAll('[id*="sm-t3-input-bar-element"]')
.forEach((icon) => {
const iconElement = icon as HTMLElement
if (iconElement.dataset.originalHtml) {
iconElement.innerHTML = iconElement.dataset.originalHtml
delete iconElement.dataset.originalHtml
}
delete iconElement.dataset.memoriesData
})
}
export function attachT3RecallFreshnessHandler(input: HTMLElement) {
if (input.hasAttribute("data-supermemory-recall-freshness")) return
input.setAttribute("data-supermemory-recall-freshness", "true")
setT3RecallInput(input)
input.addEventListener("input", () => {
invalidateT3RecallRequests()
clearT3Recall(input)
})
}
function getT3RecallState() {
let input: HTMLElement | null = null
let query = ""
const supermemoryContainer = document.querySelector(
'[data-supermemory-icon-added="true"]',
)
if (supermemoryContainer?.parentElement?.previousElementSibling) {
input =
supermemoryContainer.parentElement.previousElementSibling.querySelector(
"textarea",
)
query = getT3InputText(input)
}
if (!query.trim()) {
const contentEditable = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement | null
const contentEditableText = getT3InputText(contentEditable)
if (contentEditableText.trim()) {
input = contentEditable
query = contentEditableText
}
}
if (!userQuery.trim()) {
const textareaElement = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement
userQuery =
textareaElement?.innerText || textareaElement?.textContent || ""
}
if (!userQuery.trim()) {
const textareas = document.querySelectorAll("textarea")
for (const textarea of textareas) {
const text = (textarea as HTMLTextAreaElement).value
if (text?.trim()) {
userQuery = text.trim()
break
}
if (!query.trim()) {
for (const textarea of document.querySelectorAll("textarea")) {
const text = textarea.value || ""
if (text.trim()) {
input = textarea
query = text.trim()
break
}
}
}
return { input, query, url: window.location.href }
}
export async function getRelatedMemoriesForT3(actionSource: string) {
let request: ReturnType<typeof t3RecallRequests.begin> | null = null
try {
const state = getT3RecallState()
const userQuery = state.query
if (!userQuery.trim()) {
return
@ -214,6 +293,8 @@ async function getRelatedMemoriesForT3(actionSource: string) {
return
}
request = t3RecallRequests.begin(state)
updateT3IconFeedback("Searching memories...", iconElement)
const timeoutPromise = new Promise((_, reject) =>
@ -232,23 +313,12 @@ async function getRelatedMemoriesForT3(actionSource: string) {
timeoutPromise,
])
if (response?.success && response?.data) {
let textareaElement = null
const supermemoryContainer = document.querySelector(
'[data-supermemory-icon-added="true"]',
)
if (supermemoryContainer?.parentElement?.previousElementSibling) {
textareaElement =
supermemoryContainer.parentElement.previousElementSibling.querySelector(
"textarea",
)
}
if (!t3RecallRequests.isCurrent(request, getT3RecallState())) {
return
}
if (!textareaElement) {
textareaElement = document.querySelector(
'div[contenteditable="true"]',
) as HTMLElement
}
if (response?.success && response?.data) {
const textareaElement = state.input
if (textareaElement) {
textareaElement.dataset.supermemories = buildSupermemoryText(
@ -269,6 +339,9 @@ async function getRelatedMemoriesForT3(actionSource: string) {
updateT3IconFeedback("No memories found", iconElement)
}
} catch (error) {
if (request && !t3RecallRequests.isCurrent(request, getT3RecallState())) {
return
}
console.error("Error getting related memories for T3:", error)
try {
const icon = document.querySelector(
@ -681,19 +754,16 @@ function setupT3PromptCapture() {
document.addEventListener("keydown", handleT3EnterKey, true)
}
async function setupT3AutoFetch() {
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
if (!autoSearch) {
export async function setupT3AutoFetch() {
const textareaElement = getT3PromptInput()
if (!textareaElement) {
return
}
attachT3RecallFreshnessHandler(textareaElement)
const textareaElement =
(document.querySelector("textarea") as HTMLTextAreaElement) ||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
if (
!textareaElement ||
!autoSearch ||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
) {
return
@ -705,37 +775,19 @@ async function setupT3AutoFetch() {
if (t3DebounceTimeout) {
clearTimeout(t3DebounceTimeout)
}
const content = getT3InputText(textareaElement).trim()
const scheduledRequest = t3RecallRequests.begin(getT3RecallState())
t3DebounceTimeout = setTimeout(async () => {
let content = ""
if (textareaElement.tagName === "TEXTAREA") {
content = (textareaElement as HTMLTextAreaElement).value?.trim() || ""
} else {
content = textareaElement.textContent?.trim() || ""
t3DebounceTimeout = null
if (!t3RecallRequests.isCurrent(scheduledRequest, getT3RecallState())) {
return
}
if (content.length > 2) {
await getRelatedMemoriesForT3(
POSTHOG_EVENT_KEY.T3_CHAT_MEMORIES_AUTO_SEARCHED,
)
} else if (content.length === 0) {
const icons = document.querySelectorAll(
'[id*="sm-t3-input-bar-element"]',
)
icons.forEach((icon) => {
const iconElement = icon as HTMLElement
if (iconElement.dataset.originalHtml) {
iconElement.innerHTML = iconElement.dataset.originalHtml
delete iconElement.dataset.originalHtml
delete iconElement.dataset.memoriesData
}
})
if (textareaElement.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
disposeT3IncludedPopup()
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}