fix(extension): stop fragmenting memories that contain commas or newlines

The Included Memories popup stores the memory list on a data-* attribute by
joining it into a single string (String(response.data) / response.data), then
reads it back by splitting on [,\n] in showMarkerPopover and in the T3 popup.
Memory text is free-form and frequently contains commas and newlines, so a
single memory was fragmented into several rows. In T3 that also desynced the
per-item delete: the clicked index no longer mapped to a real memory, so
removal spliced the wrong entry and rewrote the injected prompt from the
mangled list.

Store the list as JSON on the attribute and parse it back, so a memory with a
comma or newline survives as one item. serializeMemoriesForDataset returns an
empty string for an empty list so existing "memories present" truthiness checks
on the attribute are unchanged, and parseMemoriesFromDataset falls back to the
old delimiter split for any legacy value. This is confined to the popup's own
display data: the GET_RELATED_MEMORIES response and the injected
dataset.supermemories prompt text are left exactly as they were, so recall
behaviour does not change. Applied consistently across ChatGPT, Claude, Gemini
and T3, with unit tests for the round-trip.
This commit is contained in:
abhay-codes07 2026-07-22 20:37:37 +05:30
parent d5f95827ed
commit 8129fec5d6
No known key found for this signature in database
6 changed files with 125 additions and 18 deletions

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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"])
})
})

View file

@ -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")

View file

@ -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) ||