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..81d221cac --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import { + parseMemoriesFromDataset, + 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"]) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 1722e71ef..81af3f80b 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -12,6 +12,52 @@ 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 !== ",") +} + export function showMemorySuggestion( platform: string, input: SuggestionInput, @@ -305,10 +351,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..39a05f03e 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -10,6 +10,10 @@ import { autoCapturePromptsEnabled, } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" +import { + parseMemoriesFromDataset, + serializeMemoriesForDataset, +} from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null @@ -233,7 +237,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { if (textareaElement) { textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) updateT3IconFeedback("Included Memories", iconElement) } else { @@ -329,11 +335,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") @@ -421,15 +425,17 @@ function updateT3IconFeedback( content.removeChild(memoryItem) } - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const currentMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) currentMemories.splice(index, 1) + // Injected prompt keeps its existing joined-text form; the popup's + // own data is stored as JSON so comma-bearing memories stay intact. const updatedMemories = currentMemories.join(" ,") - iconElement.dataset.memoriesData = updatedMemories + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(currentMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) ||