Skip to content
Merged
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
38 changes: 34 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,39 @@ 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)
}

export function serializeMemoriesForDataset(memories: unknown): string {
const list = normalizeMemoryList(memories)
return list.length > 0 ? JSON.stringify(list) : ""
}

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,
Expand Down Expand Up @@ -305,10 +338,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
28 changes: 17 additions & 11 deletions apps/browser-extension/entrypoints/content/t3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) ||
Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/highlights-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ export function HighlightsCard({
if (isReplyOpen) replyInputRef.current?.focus()
}, [isReplyOpen])

// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
setIsReplyOpen(false)
setReplyText("")
setIsExpanded(false)
Expand Down
14 changes: 11 additions & 3 deletions apps/web/lib/billing-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,26 @@ export function getBrainTrialInfo(
}

/**
* Format a number with K/M suffix for display
* Format a number with K/M/B suffix for display
* @example formatUsageNumber(1500000) => "1.5M"
* @example formatUsageNumber(50000) => "50K"
* @example formatUsageNumber(999950) => "1.0M"
*/
export function formatUsageNumber(value: number): string {
const withSuffix = (n: number, suffix: string) =>
n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}`

if (value >= 1_000_000) {
const millions = value / 1_000_000
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
return millions >= 999.95
? withSuffix(value / 1_000_000_000, "B")
: withSuffix(millions, "M")
}
if (value >= 1_000) {
const thousands = value / 1_000
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
return thousands >= 999.95
? withSuffix(value / 1_000_000, "M")
: withSuffix(thousands, "K")
}
return value.toString()
}
Expand Down
7 changes: 7 additions & 0 deletions packages/memory-graph/src/__tests__/graph-data-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => {
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
})

it("does not treat an already-elapsed forgetAfter as expiring", () => {
const past = new Date(Date.now() - 60 * 1000).toISOString()
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ forgetAfter: past, createdAt: old })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
})

it("returns recent color for memories created within 24 hours", () => {
const recent = new Date(Date.now() - 1000).toISOString()
const mem = makeMemory({ createdAt: recent })
Expand Down
4 changes: 2 additions & 2 deletions packages/memory-graph/src/components/memory-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,10 +467,10 @@ export function MemoryGraph({
n.x,
n.y,
containerSize.width,
containerSize.height,
graphFitHeight,
)
},
[nodes, containerSize.width, containerSize.height],
[nodes, containerSize.width, graphFitHeight],
)

const navigateUp = useCallback(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/memory-graph/src/hooks/use-graph-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function getMemoryBorderColor(
if (mem.isForgotten) return colors.memBorderForgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return colors.memBorderRecent
Expand Down
4 changes: 2 additions & 2 deletions packages/openai-sdk-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "supermemory-openai-sdk"
version = "1.0.4"
version = "1.0.5"
description = "Memory tools for OpenAI function calling with supermemory"
readme = "README.md"
license = "MIT"
Expand All @@ -26,7 +26,7 @@ classifiers = [
requires-python = ">=3.8.1"
dependencies = [
"openai>=1.102.0",
"supermemory>=3.1.0",
"supermemory>=3.1.0,<3.5.0",
"typing-extensions>=4.0.0",
"requests>=2.25.0",
]
Expand Down
74 changes: 73 additions & 1 deletion packages/validation/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
import {
DocumentsWithMemoriesQuerySchema,
ListMemoriesQuerySchema,
SearchRequestSchema,
Searchv4RequestSchema,
} from "./api"

describe("search threshold schemas", () => {
it("do not contain redundant number transforms or unreachable range guards", () => {
Expand Down Expand Up @@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
).toBe(false)
})
})

describe("pagination query schemas", () => {
it("preserve page/limit defaults", () => {
const listed = ListMemoriesQuerySchema.parse({})
expect(listed.page).toBe(1)
expect(listed.limit).toBe(10)

const docs = DocumentsWithMemoriesQuerySchema.parse({})
expect(docs.page).toBe(1)
expect(docs.limit).toBe(10)
})

it.each([
1, 50, 1100,
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
})

it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
expect(parsed.page).toBe(3)
expect(parsed.limit).toBe(25)
})

it.each([
0, -5, 2.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
})

it.each([
0, -1, 1.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
})

it("ListMemoriesQuerySchema still caps limit at 1100", () => {
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
false,
)
})

it.each([
0, -1, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
false,
)
})

it.each([
0, -10, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
false,
)
})

it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
const parsed = DocumentsWithMemoriesQuerySchema.parse({
page: 2,
limit: 50,
})
expect(parsed.page).toBe(2)
expect(parsed.limit).toBe(50)
})
})
10 changes: 8 additions & 2 deletions packages/validation/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Limit must be a positive integer",
})
.refine((value) => value <= 1100, {
message: "Limit cannot be greater than 1100",
})
Expand All @@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Page must be a positive integer",
})
.default("1")
.openapi({ description: "Page number to fetch", example: "1" }),
sort: z
Expand Down Expand Up @@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z

export const DocumentsWithMemoriesQuerySchema = z
.object({
page: z.number().default(1).openapi({
page: z.number().int().min(1).default(1).openapi({
description: "Page number to fetch",
example: 1,
}),
limit: z.number().default(10).openapi({
limit: z.number().int().min(1).default(10).openapi({
description: "Number of items per page",
example: 10,
}),
Expand Down
Loading