Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/browser-extension/entrypoints/content/chatgpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion apps/browser-extension/entrypoints/content/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion apps/browser-extension/entrypoints/content/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
)
})
})
59 changes: 55 additions & 4 deletions apps/browser-extension/entrypoints/content/memory-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
110 changes: 66 additions & 44 deletions apps/browser-extension/entrypoints/content/t3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>
} | 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)) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -268,6 +291,8 @@ function updateT3IconFeedback(
iconElement.dataset.originalHtml = iconElement.innerHTML
}

disposeT3IncludedPopup()

const feedbackDiv = document.createElement("div")
feedbackDiv.style.cssText = `
display: flex;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 = ""
Expand Down