diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 444e3ac87..cf7a30028 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 7bff4dfc2..f31c2bb60 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index 6ece78dff..f819d3d6f 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) { if (response?.success && response?.data && input) { const memoryText = showMemorySuggestion("gemini", input, response.data) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) iconElement.dataset.supermemories = memoryText if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts new file mode 100644 index 000000000..278cde70f --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test" +import { + buildSupermemoryText, + parseMemoriesFromDataset, + renumberIncludedMemories, + serializeMemoriesForDataset, +} from "./memory-suggestion" + +describe("memory dataset serialization", () => { + it("round-trips a memory that contains a comma as a single item", () => { + const memories = ["Lives in Austin, Texas", "Prefers dark mode"] + const stored = serializeMemoriesForDataset(memories) + expect(parseMemoriesFromDataset(stored)).toEqual(memories) + }) + + it("round-trips a memory that contains a newline as a single item", () => { + const memories = ["Shipping address:\n123 Main St", "Likes coffee"] + const stored = serializeMemoriesForDataset(memories) + expect(parseMemoriesFromDataset(stored)).toEqual(memories) + }) + + it("trims and drops empty entries when serializing", () => { + const stored = serializeMemoriesForDataset([" keep ", "", " "]) + expect(parseMemoriesFromDataset(stored)).toEqual(["keep"]) + }) + + it("serializes an empty list to an empty string for truthiness checks", () => { + expect(serializeMemoriesForDataset([])).toBe("") + expect(serializeMemoriesForDataset(undefined)).toBe("") + }) + + it("returns an empty array for empty or missing input", () => { + expect(parseMemoriesFromDataset("")).toEqual([]) + expect(parseMemoriesFromDataset(null)).toEqual([]) + expect(parseMemoriesFromDataset(undefined)).toEqual([]) + }) + + it("falls back to the legacy comma/newline split for non-JSON values", () => { + expect(parseMemoriesFromDataset("first,second\nthird")).toEqual([ + "first", + "second", + "third", + ]) + }) + + it("wraps a single non-array value into one item", () => { + expect( + parseMemoriesFromDataset(serializeMemoriesForDataset("solo")), + ).toEqual(["solo"]) + }) + + it("renumbers remaining memories after a removal", () => { + const remaining = renumberIncludedMemories([ + "2. Prefers TypeScript, strict mode \n", + "3. Uses Biome \n", + ]) + expect(remaining).toEqual([ + "1. Prefers TypeScript, strict mode \n", + "2. Uses Biome \n", + ]) + expect(buildSupermemoryText(remaining)).toBe( + "\n\nSupermemories of user (only for the reference): 1. Prefers TypeScript, strict mode \n2. Uses Biome", + ) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 1722e71ef..fd7ef0db4 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -12,6 +12,60 @@ export function buildSupermemoryText(memories: unknown): string { return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}` } +function normalizeMemoryList(memories: unknown): string[] { + const list = Array.isArray(memories) + ? memories + : memories == null + ? [] + : [memories] + return list + .map((memory) => (typeof memory === "string" ? memory : String(memory))) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0) +} + +/** + * Serialize the memory list for storage on a `data-*` attribute. Memories are + * free text that can contain commas and newlines, so they are stored as JSON + * rather than joined into a single string, otherwise a memory with a comma in + * it is split into fragments when the popup reads it back. Returns an empty + * string for an empty list so existing truthiness checks on the attribute + * (memories present vs not) keep working. + */ +export function serializeMemoriesForDataset(memories: unknown): string { + const list = normalizeMemoryList(memories) + return list.length > 0 ? JSON.stringify(list) : "" +} + +/** + * Read back a memory list written by {@link serializeMemoriesForDataset}. + * Falls back to the legacy comma/newline split so any value written by older + * code (or a plain joined string) still renders. + */ +export function parseMemoriesFromDataset( + raw: string | null | undefined, +): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return normalizeMemoryList(parsed) + } catch { + // Not JSON — fall through to the legacy delimiter split. + } + return raw + .split(/[,\n]/) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0 && memory !== ",") +} + +/** Strip stale `N. ` prefixes and renumber after a popup removal. */ +export function renumberIncludedMemories(memories: string[]): string[] { + return memories.map((memory, index) => { + const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "") + return `${index + 1}. ${text} \n` + }) +} + export function showMemorySuggestion( platform: string, input: SuggestionInput, @@ -305,10 +359,7 @@ export function showMarkerPopover( color: rgba(255, 255, 255, 0.76); ` - memories - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + parseMemoriesFromDataset(memories) .slice(0, 5) .forEach((memory) => { const item = document.createElement("div") diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 66a11235c..1df5b97a8 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -10,11 +10,30 @@ import { autoCapturePromptsEnabled, } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" +import { + buildSupermemoryText, + parseMemoriesFromDataset, + renumberIncludedMemories, + serializeMemoriesForDataset, +} from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null let t3UrlCheckInterval: NodeJS.Timeout | null = null let t3ObserverThrottle: NodeJS.Timeout | null = null +let t3IncludedPopup: { + el: HTMLElement + onClick: (event: MouseEvent) => void + timer: ReturnType +} | null = null + +function disposeT3IncludedPopup() { + if (!t3IncludedPopup) return + document.removeEventListener("click", t3IncludedPopup.onClick) + clearTimeout(t3IncludedPopup.timer) + t3IncludedPopup.el.remove() + t3IncludedPopup = null +} export function initializeT3() { if (!DOMUtils.isOnDomain(DOMAINS.T3)) { @@ -231,9 +250,13 @@ async function getRelatedMemoriesForT3(actionSource: string) { } if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` + textareaElement.dataset.supermemories = buildSupermemoryText( + response.data, + ) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) updateT3IconFeedback("Included Memories", iconElement) } else { @@ -268,6 +291,8 @@ function updateT3IconFeedback( iconElement.dataset.originalHtml = iconElement.innerHTML } + disposeT3IncludedPopup() + const feedbackDiv = document.createElement("div") feedbackDiv.style.cssText = ` display: flex; @@ -329,11 +354,9 @@ function updateT3IconFeedback( overflow-y: auto; ` - const memoriesText = iconElement.dataset.memoriesData || "" - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const individualMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) individualMemories.forEach((memory, index) => { const memoryItem = document.createElement("div") @@ -405,66 +428,65 @@ function updateT3IconFeedback( popup.style.display = "block" }) - document.addEventListener("click", (e) => { + const onClick = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) { popup.style.display = "none" } - }) + } + document.addEventListener("click", onClick) + t3IncludedPopup = { + el: popup, + onClick, + timer: setTimeout(disposeT3IncludedPopup, 300000), + } content.querySelectorAll("button[data-memory-index]").forEach((button) => { const htmlButton = button as HTMLButtonElement htmlButton.addEventListener("click", () => { const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10) - const memoryItem = htmlButton.parentElement - - if (memoryItem) { - content.removeChild(memoryItem) - } - - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) + htmlButton.parentElement?.remove() - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = updatedMemories + const remainingMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) + remainingMemories.splice(index, 1) + const remaining = renumberIncludedMemories(remainingMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || (document.querySelector('div[contenteditable="true"]') as HTMLElement) + + // Only wipe when nothing remains — `<= 1` used to discard the last kept memory. + if (remaining.length === 0) { + if (textareaElement?.dataset.supermemories) { + delete textareaElement.dataset.supermemories + } + delete iconElement.dataset.memoriesData + iconElement.innerHTML = iconElement.dataset.originalHtml || "" + delete iconElement.dataset.originalHtml + disposeT3IncludedPopup() + return + } + + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(remaining) if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` + textareaElement.dataset.supermemories = + buildSupermemoryText(remaining) } content .querySelectorAll("button[data-memory-index]") .forEach((btn, newIndex) => { const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() + htmlBtn.dataset.memoryIndex = String(newIndex) + const label = htmlBtn.previousElementSibling + if (label) { + label.textContent = remaining[newIndex].trim() + } }) - - if (currentMemories.length <= 1) { - if (textareaElement?.dataset.supermemories) { - delete textareaElement.dataset.supermemories - delete iconElement.dataset.memoriesData - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - } - popup.style.display = "none" - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - } }) }) - - setTimeout(() => { - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - }, 300000) } iconElement.innerHTML = ""