From 0e66ad58d1476ab2e5ed814ec56178f9d358982f Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Wed, 3 Jun 2026 23:01:22 +0530 Subject: [PATCH 1/3] fix(pipecat-sdk): handle null profile object safely --- .../src/supermemory_pipecat/service.py | 11 +- .../tests/test_empty_profile.py | 123 ++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/pipecat-sdk-python/tests/test_empty_profile.py diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py index 2aef866bf..eb9d5fb66 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py @@ -146,14 +146,17 @@ async def _retrieve_memories(self, query: str) -> Dict[str, Any]: response = await self._supermemory_client.profile(**kwargs) + profile = getattr(response, "profile", None) + search_results_response = getattr(response, "search_results", None) + search_results = [] - if response.search_results and response.search_results.results: - search_results = response.search_results.results + if search_results_response and search_results_response.results: + search_results = search_results_response.results return { "profile": { - "static": response.profile.static, - "dynamic": response.profile.dynamic, + "static": profile.static if profile is not None else [], + "dynamic": profile.dynamic if profile is not None else [], }, "search_results": search_results, } diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py new file mode 100644 index 000000000..ec3ccd261 --- /dev/null +++ b/packages/pipecat-sdk-python/tests/test_empty_profile.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + + +def _install_test_stubs() -> None: + if "loguru" not in sys.modules: + loguru_module = types.ModuleType("loguru") + + class _Logger: + def warning(self, *_args, **_kwargs): + return None + + def error(self, *_args, **_kwargs): + return None + + loguru_module.logger = _Logger() + sys.modules["loguru"] = loguru_module + + if "pydantic" not in sys.modules: + pydantic_module = types.ModuleType("pydantic") + + class BaseModel: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def Field(*, default=None, **_kwargs): + return default + + pydantic_module.BaseModel = BaseModel + pydantic_module.Field = Field + sys.modules["pydantic"] = pydantic_module + + if "pipecat" not in sys.modules: + pipecat_module = types.ModuleType("pipecat") + sys.modules["pipecat"] = pipecat_module + + frames_module = types.ModuleType("pipecat.frames.frames") + + class Frame: # pragma: no cover - import stub + pass + + class InputAudioRawFrame: # pragma: no cover - import stub + pass + + class LLMContextFrame: # pragma: no cover - import stub + pass + + class LLMMessagesFrame: # pragma: no cover - import stub + pass + + frames_module.Frame = Frame + frames_module.InputAudioRawFrame = InputAudioRawFrame + frames_module.LLMContextFrame = LLMContextFrame + frames_module.LLMMessagesFrame = LLMMessagesFrame + + llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context") + + class LLMContext: # pragma: no cover - import stub + pass + + llm_context_module.LLMContext = LLMContext + + openai_context_module = types.ModuleType( + "pipecat.processors.aggregators.openai_llm_context" + ) + + class OpenAILLMContextFrame: # pragma: no cover - import stub + pass + + openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame + + frame_processor_module = types.ModuleType("pipecat.processors.frame_processor") + + class FrameDirection: # pragma: no cover - import stub + pass + + class FrameProcessor: + def __init__(self, *args, **kwargs): + return None + + frame_processor_module.FrameDirection = FrameDirection + frame_processor_module.FrameProcessor = FrameProcessor + + sys.modules["pipecat.frames.frames"] = frames_module + sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module + sys.modules[ + "pipecat.processors.aggregators.openai_llm_context" + ] = openai_context_module + sys.modules["pipecat.processors.frame_processor"] = frame_processor_module + + +_install_test_stubs() + +from supermemory_pipecat.service import SupermemoryPipecatService + + +class _MockSupermemoryClient: + def __init__(self, response): + self.profile = AsyncMock(return_value=response) + + +class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase): + async def test_retrieve_memories_handles_null_profile(self) -> None: + service = SupermemoryPipecatService(api_key="mock_key", user_id="new_user_123") + + response = SimpleNamespace(profile=None, search_results=None) + service._supermemory_client = _MockSupermemoryClient(response) + + result = await service._retrieve_memories("Hello world") + + self.assertEqual( + result, + { + "profile": {"static": [], "dynamic": []}, + "search_results": [], + }, + ) \ No newline at end of file From 0e4c311916db0cea01839f7c0187de6c5f66e667 Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Fri, 5 Jun 2026 18:09:19 +0530 Subject: [PATCH 2/3] feat(web): add memory export and import --- apps/web/app/(app)/settings/page.tsx | 10 + apps/web/app/api/memories/_utils.ts | 62 +++ apps/web/app/api/memories/export/route.ts | 207 +++++++++ apps/web/app/api/memories/import/route.ts | 114 +++++ .../components/settings/data-portability.tsx | 399 ++++++++++++++++++ 5 files changed, 792 insertions(+) create mode 100644 apps/web/app/api/memories/_utils.ts create mode 100644 apps/web/app/api/memories/export/route.ts create mode 100644 apps/web/app/api/memories/import/route.ts create mode 100644 apps/web/components/settings/data-portability.tsx diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 51745b80a..8d221f8a8 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -8,6 +8,7 @@ import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import Account from "@/components/settings/account" import Billing from "@/components/settings/billing" +import { DataPortabilityPanel } from "@/components/settings/data-portability" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" import Support from "@/components/settings/support" @@ -24,6 +25,7 @@ import { LoaderIcon, User as UserIcon, Zap, + Download, HelpCircle, CreditCard, ShieldAlert, @@ -49,6 +51,7 @@ const TABS = [ "account", "billing", "integrations", + "portability", "connections", "support", ] as const @@ -80,6 +83,12 @@ const NAV_ITEMS: NavItem[] = [ description: "Save, sync and search across tools", icon: , }, + { + id: "portability", + label: "Data portability", + description: "Export and restore your memories", + icon: , + }, { id: "connections", label: "Connections & MCP", @@ -572,6 +581,7 @@ export default function SettingsPage() { {activeTab === "account" && } {activeTab === "billing" && } {activeTab === "integrations" && } + {activeTab === "portability" && } {activeTab === "connections" && } {activeTab === "support" && } diff --git a/apps/web/app/api/memories/_utils.ts b/apps/web/app/api/memories/_utils.ts new file mode 100644 index 000000000..bc0764a24 --- /dev/null +++ b/apps/web/app/api/memories/_utils.ts @@ -0,0 +1,62 @@ +const BACKEND_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +export function buildBackendHeaders(request: Request): Headers { + const headers = new Headers({ + "Content-Type": "application/json", + "X-App-Source": "nova", + }) + + const cookie = request.headers.get("cookie") + if (cookie) { + headers.set("cookie", cookie) + } + + const authorization = request.headers.get("authorization") + if (authorization) { + headers.set("authorization", authorization) + } + + return headers +} + +export function getBackendBaseUrl() { + return BACKEND_BASE_URL +} + +export function chunkArray(items: T[], size: number): T[][] { + if (size <= 0) return [items] + const chunks: T[][] = [] + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)) + } + return chunks +} + +export function parseContainerTags(searchParams: URLSearchParams): string[] { + const values = searchParams.getAll("containerTags") + if (values.length === 0) return [] + + return [...new Set(values.flatMap((value) => value.split(",")))] + .map((value) => value.trim()) + .filter(Boolean) +} + +export function parseDateBound(value: string, bound: "start" | "end") { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + if (!value.includes("T") && bound === "end") { + date.setUTCHours(23, 59, 59, 999) + } + + if (!value.includes("T") && bound === "start") { + date.setUTCHours(0, 0, 0, 0) + } + + return date +} + +export function escapeMarkdown(value: string) { + return value.replace(/[\\`*_{}\[\]()#+\-.!|>]/g, "\\$&") +} diff --git a/apps/web/app/api/memories/export/route.ts b/apps/web/app/api/memories/export/route.ts new file mode 100644 index 000000000..b1f9f89da --- /dev/null +++ b/apps/web/app/api/memories/export/route.ts @@ -0,0 +1,207 @@ +import { NextResponse } from "next/server" +import { + buildBackendHeaders, + escapeMarkdown, + getBackendBaseUrl, + parseContainerTags, + parseDateBound, +} from "../_utils" + +type ExportDocument = { + id: string + customId?: string | null + content?: string | null + summary?: string | null + title?: string | null + url?: string | null + source?: string | null + type?: string | null + status?: string | null + metadata?: Record | null + containerTags?: string[] | null + createdAt?: string | Date + updatedAt?: string | Date + memoryEntries?: Array<{ + id?: string + memory?: string + isStatic?: boolean + createdAt?: string | Date + }> +} + +type DocumentsResponse = { + documents?: ExportDocument[] + pagination?: { + currentPage?: number + totalPages?: number + totalItems?: number + limit?: number + } +} + +async function fetchDocumentsPage( + request: Request, + page: number, + limit: number, + containerTags: string[], +) { + const response = await fetch( + `${getBackendBaseUrl()}/v3/documents/documents`, + { + method: "POST", + headers: buildBackendHeaders(request), + body: JSON.stringify({ + page, + limit, + order: "asc", + sort: "createdAt", + ...(containerTags.length > 0 ? { containerTags } : {}), + }), + }, + ) + + if (!response.ok) { + const message = await response.text() + throw new Error(message || `Export failed with status ${response.status}`) + } + + return (await response.json()) as DocumentsResponse +} + +function buildMarkdownExport(documents: ExportDocument[], exportedAt: string) { + const lines: string[] = [ + "# supermemory memory export", + "", + `- Exported at: ${exportedAt}`, + `- Memory count: ${documents.length}`, + "", + ] + + for (const document of documents) { + const heading = document.title?.trim() || document.customId || document.id + const createdAt = + document.createdAt instanceof Date + ? document.createdAt.toISOString() + : document.createdAt + ? new Date(document.createdAt).toISOString() + : exportedAt + const tags = document.containerTags?.length + ? document.containerTags.join(", ") + : "None" + const content = (document.content ?? document.summary ?? "").trim() + const memoryLines = (document.memoryEntries ?? []).map((entry) => { + const prefix = entry.isStatic ? "static" : "dynamic" + return `- ${prefix}: ${entry.memory ?? ""}`.trim() + }) + + lines.push(`## ${escapeMarkdown(heading)}`) + lines.push("") + lines.push(`- ID: ${document.id}`) + if (document.customId) lines.push(`- Custom ID: ${document.customId}`) + lines.push(`- Created: ${createdAt}`) + if (document.updatedAt) { + const updatedAt = + document.updatedAt instanceof Date + ? document.updatedAt.toISOString() + : new Date(document.updatedAt).toISOString() + lines.push(`- Updated: ${updatedAt}`) + } + lines.push(`- Tags: ${tags}`) + if (document.type) lines.push(`- Type: ${document.type}`) + if (document.status) lines.push(`- Status: ${document.status}`) + if (document.url) lines.push(`- URL: ${document.url}`) + if (document.source) lines.push(`- Source: ${document.source}`) + lines.push("") + lines.push("### Content") + lines.push("") + lines.push(content || "No content available.") + if (memoryLines.length > 0) { + lines.push("") + lines.push("### Memories") + lines.push("") + lines.push(...memoryLines) + } + lines.push("") + lines.push("---") + lines.push("") + } + + return lines.join("\n") +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const format = searchParams.get("format") === "markdown" ? "markdown" : "json" + const startDateParam = searchParams.get("startDate") + const endDateParam = searchParams.get("endDate") + const containerTags = parseContainerTags(searchParams) + const pageSize = 100 + + const startDate = startDateParam ? parseDateBound(startDateParam, "start") : null + const endDate = endDateParam ? parseDateBound(endDateParam, "end") : null + + if (startDateParam && !startDate) { + return NextResponse.json({ error: "Invalid startDate" }, { status: 400 }) + } + + if (endDateParam && !endDate) { + return NextResponse.json({ error: "Invalid endDate" }, { status: 400 }) + } + + const firstPage = await fetchDocumentsPage(request, 1, pageSize, containerTags) + const documents = [...(firstPage.documents ?? [])] + const totalPages = firstPage.pagination?.totalPages ?? 1 + + for (let page = 2; page <= totalPages; page += 1) { + const response = await fetchDocumentsPage(request, page, pageSize, containerTags) + documents.push(...(response.documents ?? [])) + } + + const filteredDocuments = documents.filter((document) => { + const createdAt = document.createdAt ? new Date(document.createdAt) : null + if (startDate && (!createdAt || createdAt < startDate)) return false + if (endDate && (!createdAt || createdAt > endDate)) return false + return true + }) + + const exportedAt = new Date().toISOString() + const payload = { + version: 1, + exportedAt, + format, + filters: { + containerTags, + startDate: startDateParam, + endDate: endDateParam, + }, + count: filteredDocuments.length, + documents: filteredDocuments, + } + + if (format === "markdown") { + const markdown = buildMarkdownExport(filteredDocuments, exportedAt) + return new NextResponse(markdown, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="supermemory-export-${exportedAt}.md"`, + }, + }) + } + + return new NextResponse(JSON.stringify(payload, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="supermemory-export-${exportedAt}.json"`, + }, + }) + } catch (error) { + console.error("Memory export failed:", error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to export memories", + }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/memories/import/route.ts b/apps/web/app/api/memories/import/route.ts new file mode 100644 index 000000000..03cd87a84 --- /dev/null +++ b/apps/web/app/api/memories/import/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server" +import { buildBackendHeaders, chunkArray, getBackendBaseUrl } from "../_utils" + +type ExportDocument = { + content?: string | null + summary?: string | null + customId?: string | null + containerTags?: string[] | null + metadata?: Record | null + entityContext?: string | null +} + +type ImportPayload = + | { + containerTags?: string[] + targetContainerTags?: string[] + documents?: ExportDocument[] + memories?: ExportDocument[] + data?: ExportDocument[] + } + | ExportDocument[] + +function normalizeDocuments(payload: ImportPayload): ExportDocument[] { + if (Array.isArray(payload)) return payload + if (Array.isArray(payload.documents)) return payload.documents + if (Array.isArray(payload.memories)) return payload.memories + if (Array.isArray(payload.data)) return payload.data + return [] +} + +function pickTargetContainerTags(payload: ImportPayload): string[] | null { + if (Array.isArray(payload)) return null + if (Array.isArray(payload.targetContainerTags) && payload.targetContainerTags.length > 0) { + return payload.targetContainerTags + } + if (Array.isArray(payload.containerTags) && payload.containerTags.length > 0) { + return payload.containerTags + } + return null +} + +export async function POST(request: Request) { + try { + const payload = (await request.json()) as ImportPayload + const documents = normalizeDocuments(payload) + const overrideContainerTags = pickTargetContainerTags(payload) + + if (documents.length === 0) { + return NextResponse.json( + { error: "No memories were provided for import" }, + { status: 400 }, + ) + } + + const batches = chunkArray( + documents + .map((document) => { + const content = (document.content ?? document.summary ?? "").trim() + if (!content) return null + return { + content, + customId: document.customId ?? undefined, + containerTags: + overrideContainerTags ?? document.containerTags ?? undefined, + metadata: document.metadata ?? undefined, + entityContext: document.entityContext ?? undefined, + } + }) + .filter((document): document is NonNullable => document !== null), + 25, + ) + + if (batches.length === 0 || batches.every((batch) => batch.length === 0)) { + return NextResponse.json( + { error: "No importable memories were found in the payload" }, + { status: 400 }, + ) + } + + let imported = 0 + for (const batch of batches) { + if (batch.length === 0) continue + + const response = await fetch(`${getBackendBaseUrl()}/v3/documents/batch`, { + method: "POST", + headers: buildBackendHeaders(request), + body: JSON.stringify({ + documents: batch, + metadata: { + sm_source: "supermemory-export", + sm_imported_at: new Date().toISOString(), + }, + }), + }) + + if (!response.ok) { + const message = await response.text() + throw new Error(message || `Import failed with status ${response.status}`) + } + + imported += batch.length + } + + return NextResponse.json({ success: true, imported }) + } catch (error) { + console.error("Memory import failed:", error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to import memories", + }, + { status: 500 }, + ) + } +} diff --git a/apps/web/components/settings/data-portability.tsx b/apps/web/components/settings/data-portability.tsx new file mode 100644 index 000000000..cf406d2a8 --- /dev/null +++ b/apps/web/components/settings/data-portability.tsx @@ -0,0 +1,399 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { useAuth } from "@lib/auth-context" +import { useContainerTags } from "@/hooks/use-container-tags" +import { + Check, + Download, + FileDown, + LoaderIcon, + Upload, +} from "lucide-react" +import { useMemo, useRef, useState } from "react" +import { toast } from "sonner" + +type ExportFormat = "json" | "markdown" + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PillButton({ + children, + onClick, + disabled, + className, +}: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string +}) { + return ( + + ) +} + +function formatFilterLabel(value: string) { + return value.replace(/_/g, " ") +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getDownloadFilename(response: Response, fallback: string) { + const disposition = response.headers.get("content-disposition") + if (!disposition) return fallback + + const filenameMatch = disposition.match(/filename="?([^";]+)"?/i) + return filenameMatch?.[1] ?? fallback +} + +export function DataPortabilityPanel() { + const { org } = useAuth() + const { allProjects } = useContainerTags() + const [format, setFormat] = useState("json") + const [selectedTags, setSelectedTags] = useState([]) + const [startDate, setStartDate] = useState("") + const [endDate, setEndDate] = useState("") + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const fileInputRef = useRef(null) + + const availableTags = useMemo(() => { + return [...new Set((allProjects ?? []).map((project) => project.containerTag))] + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) + }, [allProjects]) + + const canExport = !isExporting + const activeTagSummary = + selectedTags.length > 0 + ? `${selectedTags.length} selected` + : org?.id + ? `Current org: ${org.name ?? org.id}` + : "All memories" + + const toggleTag = (tag: string) => { + setSelectedTags((current) => + current.includes(tag) + ? current.filter((currentTag) => currentTag !== tag) + : [...current, tag], + ) + } + + const downloadBlob = (blob: Blob, filename: string) => { + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(url) + } + + const handleExport = async (selectedFormat: ExportFormat) => { + if (!canExport) return + setIsExporting(true) + try { + const params = new URLSearchParams() + params.set("format", selectedFormat) + for (const tag of selectedTags) { + params.append("containerTags", tag) + } + if (startDate) params.set("startDate", startDate) + if (endDate) params.set("endDate", endDate) + + const response = await fetch(`/api/memories/export?${params.toString()}`, { + credentials: "include", + }) + + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error( + (isPlainObject(body) && typeof body.error === "string" + ? body.error + : null) ?? "Failed to export memories", + ) + } + + const filename = getDownloadFilename( + response, + `supermemory-export.${selectedFormat === "markdown" ? "md" : "json"}`, + ) + downloadBlob(await response.blob(), filename) + toast.success("Export downloaded") + } catch (error) { + toast.error("Failed to export memories", { + description: error instanceof Error ? error.message : undefined, + }) + } finally { + setIsExporting(false) + } + } + + const handleImportFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + + setIsImporting(true) + try { + const text = await file.text() + const parsed = JSON.parse(text) as unknown + + const response = await fetch("/api/memories/import", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(parsed), + }) + + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error( + (isPlainObject(body) && typeof body.error === "string" + ? body.error + : null) ?? "Failed to import memories", + ) + } + + const result = (await response.json()) as { imported?: number } + toast.success("Import completed", { + description: `${result.imported ?? 0} memories imported successfully.`, + }) + } catch (error) { + toast.error("Failed to import memories", { + description: error instanceof Error ? error.message : undefined, + }) + } finally { + setIsImporting(false) + } + } + + return ( +
+
+ Data portability + +
+
+

+ Backup, migrate, or archive your memories. +

+

+ Export memories as JSON or Markdown and import previous JSON + archives later. +

+
+ +
+
+
+
+

Export memories

+

+ Choose a format, then optionally narrow by date or tags. +

+
+ +
+ +
+ setFormat("json")} + className={format === "json" ? "bg-white text-black" : "bg-white/5 text-white"} + > + JSON + + setFormat("markdown")} + className={format === "markdown" ? "bg-white text-black" : "bg-white/5 text-white"} + > + Markdown + +
+ +
+ + +
+ +
+
+

Filter by tags

+

{activeTagSummary}

+
+
+ {availableTags.length > 0 ? ( + availableTags.map((tag) => { + const isSelected = selectedTags.includes(tag) + return ( + + ) + }) + ) : ( +

+ No container tags are available yet. +

+ )} +
+
+ +
+

+ Markdown is best for human-readable backups. JSON is best for + round-tripping. +

+ handleExport(format)} + disabled={!canExport} + className="bg-[#4BA0FA] text-[#00171A]" + > + {isExporting ? ( + + ) : ( + + )} + {isExporting ? "Exporting..." : "Download export"} + +
+
+ +
+
+
+

Import memories

+

+ Upload a previously exported JSON archive to restore memories. +

+
+ +
+ +
+ +
+ +

+ Import keeps any container tags stored in the archive. If you want to + move memories into a different workspace, export the JSON and adjust + tags before importing. +

+ { + fileInputRef.current?.click() + }} + className="mt-5 bg-white/5 text-white" + disabled={isImporting} + > + {isImporting ? ( + + ) : ( + + )} + {isImporting ? "Importing..." : "Import JSON archive"} + +
+
+
+
+
+
+ ) +} From f5bff0b332f62bd102026b35a9b49367231a1ea2 Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Sat, 13 Jun 2026 23:39:06 +0530 Subject: [PATCH 3/3] chore: remove unrelated pipecat changes from portability PR --- apps/web/app/api/memories/_utils.ts | 2 +- apps/web/app/api/memories/export/route.ts | 24 +++- apps/web/app/api/memories/import/route.ts | 59 +++++---- .../components/settings/data-portability.tsx | 62 +++++---- apps/web/lib/analytics.ts | 8 +- .../src/supermemory_pipecat/service.py | 11 +- .../tests/test_empty_profile.py | 123 ------------------ 7 files changed, 107 insertions(+), 182 deletions(-) delete mode 100644 packages/pipecat-sdk-python/tests/test_empty_profile.py diff --git a/apps/web/app/api/memories/_utils.ts b/apps/web/app/api/memories/_utils.ts index bc0764a24..e03447dab 100644 --- a/apps/web/app/api/memories/_utils.ts +++ b/apps/web/app/api/memories/_utils.ts @@ -58,5 +58,5 @@ export function parseDateBound(value: string, bound: "start" | "end") { } export function escapeMarkdown(value: string) { - return value.replace(/[\\`*_{}\[\]()#+\-.!|>]/g, "\\$&") + return value.replace(/[\\`*_{}[\]()#+\-.!|>]/g, "\\$&") } diff --git a/apps/web/app/api/memories/export/route.ts b/apps/web/app/api/memories/export/route.ts index b1f9f89da..f6abdc144 100644 --- a/apps/web/app/api/memories/export/route.ts +++ b/apps/web/app/api/memories/export/route.ts @@ -132,13 +132,16 @@ function buildMarkdownExport(documents: ExportDocument[], exportedAt: string) { export async function GET(request: Request) { try { const { searchParams } = new URL(request.url) - const format = searchParams.get("format") === "markdown" ? "markdown" : "json" + const format = + searchParams.get("format") === "markdown" ? "markdown" : "json" const startDateParam = searchParams.get("startDate") const endDateParam = searchParams.get("endDate") const containerTags = parseContainerTags(searchParams) const pageSize = 100 - const startDate = startDateParam ? parseDateBound(startDateParam, "start") : null + const startDate = startDateParam + ? parseDateBound(startDateParam, "start") + : null const endDate = endDateParam ? parseDateBound(endDateParam, "end") : null if (startDateParam && !startDate) { @@ -149,12 +152,22 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Invalid endDate" }, { status: 400 }) } - const firstPage = await fetchDocumentsPage(request, 1, pageSize, containerTags) + const firstPage = await fetchDocumentsPage( + request, + 1, + pageSize, + containerTags, + ) const documents = [...(firstPage.documents ?? [])] const totalPages = firstPage.pagination?.totalPages ?? 1 for (let page = 2; page <= totalPages; page += 1) { - const response = await fetchDocumentsPage(request, page, pageSize, containerTags) + const response = await fetchDocumentsPage( + request, + page, + pageSize, + containerTags, + ) documents.push(...(response.documents ?? [])) } @@ -199,7 +212,8 @@ export async function GET(request: Request) { console.error("Memory export failed:", error) return NextResponse.json( { - error: error instanceof Error ? error.message : "Failed to export memories", + error: + error instanceof Error ? error.message : "Failed to export memories", }, { status: 500 }, ) diff --git a/apps/web/app/api/memories/import/route.ts b/apps/web/app/api/memories/import/route.ts index 03cd87a84..25b366b96 100644 --- a/apps/web/app/api/memories/import/route.ts +++ b/apps/web/app/api/memories/import/route.ts @@ -12,12 +12,12 @@ type ExportDocument = { type ImportPayload = | { - containerTags?: string[] - targetContainerTags?: string[] - documents?: ExportDocument[] - memories?: ExportDocument[] - data?: ExportDocument[] - } + containerTags?: string[] + targetContainerTags?: string[] + documents?: ExportDocument[] + memories?: ExportDocument[] + data?: ExportDocument[] + } | ExportDocument[] function normalizeDocuments(payload: ImportPayload): ExportDocument[] { @@ -30,10 +30,16 @@ function normalizeDocuments(payload: ImportPayload): ExportDocument[] { function pickTargetContainerTags(payload: ImportPayload): string[] | null { if (Array.isArray(payload)) return null - if (Array.isArray(payload.targetContainerTags) && payload.targetContainerTags.length > 0) { + if ( + Array.isArray(payload.targetContainerTags) && + payload.targetContainerTags.length > 0 + ) { return payload.targetContainerTags } - if (Array.isArray(payload.containerTags) && payload.containerTags.length > 0) { + if ( + Array.isArray(payload.containerTags) && + payload.containerTags.length > 0 + ) { return payload.containerTags } return null @@ -66,7 +72,10 @@ export async function POST(request: Request) { entityContext: document.entityContext ?? undefined, } }) - .filter((document): document is NonNullable => document !== null), + .filter( + (document): document is NonNullable => + document !== null, + ), 25, ) @@ -81,21 +90,26 @@ export async function POST(request: Request) { for (const batch of batches) { if (batch.length === 0) continue - const response = await fetch(`${getBackendBaseUrl()}/v3/documents/batch`, { - method: "POST", - headers: buildBackendHeaders(request), - body: JSON.stringify({ - documents: batch, - metadata: { - sm_source: "supermemory-export", - sm_imported_at: new Date().toISOString(), - }, - }), - }) + const response = await fetch( + `${getBackendBaseUrl()}/v3/documents/batch`, + { + method: "POST", + headers: buildBackendHeaders(request), + body: JSON.stringify({ + documents: batch, + metadata: { + sm_source: "supermemory-export", + sm_imported_at: new Date().toISOString(), + }, + }), + }, + ) if (!response.ok) { const message = await response.text() - throw new Error(message || `Import failed with status ${response.status}`) + throw new Error( + message || `Import failed with status ${response.status}`, + ) } imported += batch.length @@ -106,7 +120,8 @@ export async function POST(request: Request) { console.error("Memory import failed:", error) return NextResponse.json( { - error: error instanceof Error ? error.message : "Failed to import memories", + error: + error instanceof Error ? error.message : "Failed to import memories", }, { status: 500 }, ) diff --git a/apps/web/components/settings/data-portability.tsx b/apps/web/components/settings/data-portability.tsx index cf406d2a8..ced097237 100644 --- a/apps/web/components/settings/data-portability.tsx +++ b/apps/web/components/settings/data-portability.tsx @@ -4,13 +4,7 @@ import { dmSans125ClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { useAuth } from "@lib/auth-context" import { useContainerTags } from "@/hooks/use-container-tags" -import { - Check, - Download, - FileDown, - LoaderIcon, - Upload, -} from "lucide-react" +import { Check, Download, FileDown, LoaderIcon, Upload } from "lucide-react" import { useMemo, useRef, useState } from "react" import { toast } from "sonner" @@ -97,7 +91,9 @@ export function DataPortabilityPanel() { const fileInputRef = useRef(null) const availableTags = useMemo(() => { - return [...new Set((allProjects ?? []).map((project) => project.containerTag))] + return [ + ...new Set((allProjects ?? []).map((project) => project.containerTag)), + ] .filter(Boolean) .sort((a, b) => a.localeCompare(b)) }, [allProjects]) @@ -141,9 +137,12 @@ export function DataPortabilityPanel() { if (startDate) params.set("startDate", startDate) if (endDate) params.set("endDate", endDate) - const response = await fetch(`/api/memories/export?${params.toString()}`, { - credentials: "include", - }) + const response = await fetch( + `/api/memories/export?${params.toString()}`, + { + credentials: "include", + }, + ) if (!response.ok) { const body = await response.json().catch(() => ({})) @@ -169,7 +168,9 @@ export function DataPortabilityPanel() { } } - const handleImportFile = async (event: React.ChangeEvent) => { + const handleImportFile = async ( + event: React.ChangeEvent, + ) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return @@ -240,7 +241,9 @@ export function DataPortabilityPanel() {
-

Export memories

+

+ Export memories +

Choose a format, then optionally narrow by date or tags.

@@ -251,13 +254,21 @@ export function DataPortabilityPanel() {
setFormat("json")} - className={format === "json" ? "bg-white text-black" : "bg-white/5 text-white"} + className={ + format === "json" + ? "bg-white text-black" + : "bg-white/5 text-white" + } > JSON setFormat("markdown")} - className={format === "markdown" ? "bg-white text-black" : "bg-white/5 text-white"} + className={ + format === "markdown" + ? "bg-white text-black" + : "bg-white/5 text-white" + } > Markdown @@ -286,7 +297,9 @@ export function DataPortabilityPanel() {
-

Filter by tags

+

+ Filter by tags +

{activeTagSummary}

@@ -320,8 +333,8 @@ export function DataPortabilityPanel() {

- Markdown is best for human-readable backups. JSON is best for - round-tripping. + Markdown is best for human-readable backups. JSON is best + for round-tripping.

handleExport(format)} @@ -341,9 +354,12 @@ export function DataPortabilityPanel() {
-

Import memories

+

+ Import memories +

- Upload a previously exported JSON archive to restore memories. + Upload a previously exported JSON archive to restore + memories.

@@ -371,9 +387,9 @@ export function DataPortabilityPanel() {

- Import keeps any container tags stored in the archive. If you want to - move memories into a different workspace, export the JSON and adjust - tags before importing. + Import keeps any container tags stored in the archive. If you + want to move memories into a different workspace, export the + JSON and adjust tags before importing.

{ diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 132ebeb33..afe76f077 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -153,7 +153,13 @@ export const analytics = { // settings / spaces / docs analytics settingsTabChanged: (props: { - tab: "account" | "billing" | "integrations" | "connections" | "support" + tab: + | "account" + | "billing" + | "integrations" + | "portability" + | "connections" + | "support" }) => safeCapture("settings_tab_changed", props), spaceCreated: () => safeCapture("space_created"), diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py index eb9d5fb66..2aef866bf 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py @@ -146,17 +146,14 @@ async def _retrieve_memories(self, query: str) -> Dict[str, Any]: response = await self._supermemory_client.profile(**kwargs) - profile = getattr(response, "profile", None) - search_results_response = getattr(response, "search_results", None) - search_results = [] - if search_results_response and search_results_response.results: - search_results = search_results_response.results + if response.search_results and response.search_results.results: + search_results = response.search_results.results return { "profile": { - "static": profile.static if profile is not None else [], - "dynamic": profile.dynamic if profile is not None else [], + "static": response.profile.static, + "dynamic": response.profile.dynamic, }, "search_results": search_results, } diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py deleted file mode 100644 index ec3ccd261..000000000 --- a/packages/pipecat-sdk-python/tests/test_empty_profile.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import sys -import types -import unittest -from types import SimpleNamespace -from unittest.mock import AsyncMock - - -def _install_test_stubs() -> None: - if "loguru" not in sys.modules: - loguru_module = types.ModuleType("loguru") - - class _Logger: - def warning(self, *_args, **_kwargs): - return None - - def error(self, *_args, **_kwargs): - return None - - loguru_module.logger = _Logger() - sys.modules["loguru"] = loguru_module - - if "pydantic" not in sys.modules: - pydantic_module = types.ModuleType("pydantic") - - class BaseModel: - def __init__(self, **kwargs): - for key, value in kwargs.items(): - setattr(self, key, value) - - def Field(*, default=None, **_kwargs): - return default - - pydantic_module.BaseModel = BaseModel - pydantic_module.Field = Field - sys.modules["pydantic"] = pydantic_module - - if "pipecat" not in sys.modules: - pipecat_module = types.ModuleType("pipecat") - sys.modules["pipecat"] = pipecat_module - - frames_module = types.ModuleType("pipecat.frames.frames") - - class Frame: # pragma: no cover - import stub - pass - - class InputAudioRawFrame: # pragma: no cover - import stub - pass - - class LLMContextFrame: # pragma: no cover - import stub - pass - - class LLMMessagesFrame: # pragma: no cover - import stub - pass - - frames_module.Frame = Frame - frames_module.InputAudioRawFrame = InputAudioRawFrame - frames_module.LLMContextFrame = LLMContextFrame - frames_module.LLMMessagesFrame = LLMMessagesFrame - - llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context") - - class LLMContext: # pragma: no cover - import stub - pass - - llm_context_module.LLMContext = LLMContext - - openai_context_module = types.ModuleType( - "pipecat.processors.aggregators.openai_llm_context" - ) - - class OpenAILLMContextFrame: # pragma: no cover - import stub - pass - - openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame - - frame_processor_module = types.ModuleType("pipecat.processors.frame_processor") - - class FrameDirection: # pragma: no cover - import stub - pass - - class FrameProcessor: - def __init__(self, *args, **kwargs): - return None - - frame_processor_module.FrameDirection = FrameDirection - frame_processor_module.FrameProcessor = FrameProcessor - - sys.modules["pipecat.frames.frames"] = frames_module - sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module - sys.modules[ - "pipecat.processors.aggregators.openai_llm_context" - ] = openai_context_module - sys.modules["pipecat.processors.frame_processor"] = frame_processor_module - - -_install_test_stubs() - -from supermemory_pipecat.service import SupermemoryPipecatService - - -class _MockSupermemoryClient: - def __init__(self, response): - self.profile = AsyncMock(return_value=response) - - -class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase): - async def test_retrieve_memories_handles_null_profile(self) -> None: - service = SupermemoryPipecatService(api_key="mock_key", user_id="new_user_123") - - response = SimpleNamespace(profile=None, search_results=None) - service._supermemory_client = _MockSupermemoryClient(response) - - result = await service._retrieve_memories("Hello world") - - self.assertEqual( - result, - { - "profile": {"static": [], "dynamic": []}, - "search_results": [], - }, - ) \ No newline at end of file