From e524b014e019eb9d42951b20114da1b4c1556395 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Fri, 9 Oct 2026 21:29:17 +0000 Subject: [PATCH 1/2] feat: migrate OpenClaw to API v5 with legacy compatibility Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 ++ README.md | 1 + bun.lock | 7 +- client.ts | 260 ++++++++++++++++++++++++++++++++----------- config.ts | 25 ++++- index.ts | 3 +- openclaw.plugin.json | 6 + package.json | 8 +- 8 files changed, 248 insertions(+), 73 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3b4f59a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 2.1.19 + +- Use official `supermemory@5.0.1` for the hosted API. Existing container tags become namespace paths without changing their values, configuration, credentials, or six-hour document IDs. Repeated saves use POST append/diff, not source replacement. Processing stays `memory` / `dynamic`; an accepted save is not proof that extraction has finished. +- Preserve memories-only search with threshold 0.6, no reranking or query rewriting, and existing tool limits. Queried profiles now run profile and search concurrently; profile facts become strings and result timestamps are read from `system.updatedAt`. Split profile search requests return up to ten results; parity with the legacy profile endpoint's undocumented server-side search limit/ranking has not been verified. +- Keep custom endpoints on official `supermemory@4.15.0` through the `supermemory-legacy` runtime dependency, including servers before 0.0.9. Set `apiVersion: "v5"` (or `SUPERMEMORY_API_VERSION=v5`) only after upgrading your custom server. Explicit configuration takes precedence over the environment. `"legacy"` selects v3/v4 where the server still supports it. There is no failed-write fallback or hosted rerouting. +- Reject malformed or failed save acknowledgments, unconfirmed v5 exact-ID forgetting, and incomplete document wipes instead of reporting success. Wipe still lists root documents before deleting them, and partial successful deletions are not rolled back. No bulk semantic forgetting was introduced. Legacy forget behavior and the separate proposed search/forget hardening in PR #25 are unchanged. +- Retain the existing 60-second per-attempt SDK timeout and two retries. The SDK timeout bounds receipt of response headers, not a stalled response body; no new whole-operation deadline was added. Stable document IDs do not guarantee exactly-once ingestion or billing after an ambiguous transport failure. A 409 is surfaced by the v5 SDK rather than automatically retried. + +Upgrade through your existing plugin installation/update flow; do not rerun setup to migrate. Setup replaces advanced plugin options. This release does not rewrite local configuration, migrate backend data, rename namespaces, or backfill conversations. Historical-data availability, backend ranking, eventual processing, and server-upgrade rollback still require validation on your own authorized nonproduction deployment. diff --git a/README.md b/README.md index 830b2a4..d3e10d6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Or configure in `~/.openclaw/openclaw.json`: | ----------------------------- | --------- | --------------------- | --------------------------------------------------------- | | `apiKey` | `string` | — | Supermemory API key. | | `baseUrl` | `string` | `https://api.supermemory.ai` | API endpoint. Set to a self-hosted / local URL to point at your own instance; leave blank for the cloud. | +| `apiVersion` | `"v5" \| "legacy"` | Auto-selected | Protocol override. The hosted root URL uses v5; custom endpoints default to legacy. | | `containerTag` | `string` | `openclaw_{hostname}` | Root memory namespace. | | `autoRecall` | `boolean` | `true` | Inject relevant memories before every AI turn. | | `autoCapture` | `boolean` | `true` | Store conversations after every turn. | diff --git a/bun.lock b/bun.lock index 60267ee..c413556 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,8 @@ "name": "@supermemory/openclaw-supermemory", "dependencies": { "@sinclair/typebox": "0.34.47", - "supermemory": "^4.0.0", + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.15.0", }, "devDependencies": { "esbuild": "^0.27.2", @@ -970,7 +971,9 @@ "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], - "supermemory": ["supermemory@4.15.0", "", {}, "sha512-FNBBOEi1HMvbBecXC6gn84nGy7ZEdFW5kLv7VlWirHDPEM08omBBBHQ7H2cwBIGWUenBMDaao3kPKe9W6Ki+UQ=="], + "supermemory": ["supermemory@5.0.1", "", { "bin": { "supermemory": "bin/cli.mjs" } }, "sha512-XCgFEioj4PGREU/J3pOK7L4U/akZroPR6XCDK6/pxGrrJa28gH1kNPB/Cxhq6nVL3fwxdhwciyPbRImaDzq8/Q=="], + + "supermemory-legacy": ["supermemory@4.15.0", "", {}, "sha512-FNBBOEi1HMvbBecXC6gn84nGy7ZEdFW5kLv7VlWirHDPEM08omBBBHQ7H2cwBIGWUenBMDaao3kPKe9W6Ki+UQ=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/client.ts b/client.ts index 47d32d1..6dc9983 100644 --- a/client.ts +++ b/client.ts @@ -1,4 +1,6 @@ -import Supermemory from "supermemory" +import { type RequestOptions, Supermemory } from "supermemory" +import LegacySupermemory from "supermemory-legacy" +import { type ApiVersion, resolveApiVersion, resolveBaseUrl } from "./config.ts" import { sanitizeContent, validateApiKeyFormat, @@ -13,6 +15,7 @@ export type SearchResult = { memory?: string similarity?: number metadata?: Record + updatedAt?: string } export type ProfileSearchResult = { @@ -34,9 +37,15 @@ function limitText(text: string, max: number): string { export class SupermemoryClient { private client: Supermemory + private legacy: LegacySupermemory | undefined private containerTag: string - constructor(apiKey: string, containerTag: string, baseUrl?: string) { + constructor( + apiKey: string, + containerTag: string, + baseUrl?: string, + apiVersion?: ApiVersion, + ) { const keyCheck = validateApiKeyFormat(apiKey) if (!keyCheck.valid) { throw new Error(`invalid API key: ${keyCheck.reason}`) @@ -47,21 +56,34 @@ export class SupermemoryClient { log.warn(`container tag warning: ${tagCheck.reason}`) } - // `x-sm-source` is read by mono's API to attribute searches and - // writes to the OpenClaw plugin in PostHog / `document.source`. + const endpoint = resolveBaseUrl(baseUrl) + const version = resolveApiVersion(apiVersion, endpoint) this.client = new Supermemory({ apiKey, - // Only override the SDK default when a base URL is configured, so - // an unset value keeps the SDK's own default (env / cloud) resolution. - ...(baseUrl ? { baseURL: baseUrl } : {}), - defaultHeaders: { "x-sm-source": "openclaw" }, + baseUrl: endpoint, + headers: { "x-sm-source": "openclaw" }, + timeoutInSeconds: 60, + maxRetries: 2, }) + if (version === "legacy") { + this.legacy = new LegacySupermemory({ + apiKey, + baseURL: endpoint, + defaultHeaders: { "x-sm-source": "openclaw" }, + timeout: 60_000, + maxRetries: 2, + }) + } this.containerTag = containerTag log.info( - `initialized (container: ${containerTag}, endpoint: ${baseUrl ?? "default"})`, + `initialized (container: ${containerTag}, endpoint: ${new URL(endpoint).origin}, API: ${version})`, ) } + private requestOptions(): RequestOptions { + return { timeoutInSeconds: 60, maxRetries: 2 } + } + async addMemory( content: string, metadata?: Record, @@ -72,10 +94,6 @@ export class SupermemoryClient { const cleaned = sanitizeContent(content) const tag = containerTag ?? this.containerTag - // Always stamp `sm_source` so mono's `document.source` column attributes - // these writes to the OpenClaw plugin. Existing callers can still pass - // extra metadata (e.g. `source: "openclaw_tool"`) and it is preserved - // underneath the canonical `sm_source` key. const mergedMetadata: Record = { sm_source: "openclaw", ...(metadata ?? {}), @@ -92,24 +110,50 @@ export class SupermemoryClient { ? clampEntityContext(entityContext) : undefined - const result = await this.client.add({ - content: cleaned, - containerTag: tag, - metadata: mergedMetadata, - ...(customId && { customId }), - ...(clampedCtx && { entityContext: clampedCtx }), - }) - - log.debugResponse("add", { id: result.id, status: result.status }) - - if (result.status === "failed") { - log.warn( - `add returned status="failed" for id=${result.id}` + - (customId ? ` customId=${customId}` : "") + - ` (contentLength=${cleaned.length}, containerTag=${tag})`, + const result = this.legacy + ? await this.legacy.add({ + content: cleaned, + containerTag: tag, + metadata: mergedMetadata, + ...(customId && { customId }), + ...(clampedCtx && { entityContext: clampedCtx }), + }) + : await this.client.add( + tag, + { + content: cleaned, + metadata: mergedMetadata, + ...(customId && { id: customId }), + ...(clampedCtx && { supportingContext: clampedCtx }), + taskType: "memory", + dreaming: "dynamic", + }, + this.requestOptions(), + ) + + if ( + typeof result.id !== "string" || + !result.id.trim() || + typeof result.status !== "string" || + !result.status.trim() || + result.status === "failed" || + (!this.legacy && + ![ + "queued", + "extracting", + "chunking", + "embedding", + "indexing", + "done", + ].includes(result.status)) + ) { + throw new Error( + "Memory store did not return a valid processing acceptance", ) } + log.debugResponse("add", { id: result.id, status: result.status }) + return { id: result.id, status: result.status } } @@ -126,20 +170,37 @@ export class SupermemoryClient { containerTag: tag, }) - const response = await this.client.search.memories({ - q: query, - containerTag: tag, - limit, + const response = this.legacy + ? await this.legacy.search.memories({ + q: query, + containerTag: tag, + limit, + }) + : await this.client.search( + tag, + { + query, + limit, + searchMode: "memories", + threshold: 0.6, + rerank: "none", + rewriteQuery: false, + }, + this.requestOptions(), + ) + + const results: SearchResult[] = (response.results ?? []).map((r) => { + const system = "system" in r ? r.system : undefined + return { + id: r.id, + content: r.memory ?? "", + memory: r.memory, + similarity: r.similarity, + metadata: r.metadata ?? undefined, + updatedAt: system?.updatedAt, + } }) - const results: SearchResult[] = (response.results ?? []).map((r) => ({ - id: r.id, - content: r.memory ?? "", - memory: r.memory, - similarity: r.similarity, - metadata: r.metadata ?? undefined, - })) - log.debugResponse("search.memories", { count: results.length }) return results } @@ -152,18 +213,28 @@ export class SupermemoryClient { log.debugRequest("profile", { containerTag: tag, query }) - const response = await this.client.profile({ - containerTag: tag, - ...(query && { q: query }), - }) - - log.debugResponse("profile.raw", response) - - const result: ProfileResult = { - static: response.profile?.static ?? [], - dynamic: response.profile?.dynamic ?? [], - searchResults: (response.searchResults?.results ?? - []) as ProfileSearchResult[], + let result: ProfileResult + if (this.legacy) { + const response = await this.legacy.profile({ + containerTag: tag, + ...(query && { q: query }), + }) + result = { + static: response.profile?.static ?? [], + dynamic: response.profile?.dynamic ?? [], + searchResults: (response.searchResults?.results ?? + []) as ProfileSearchResult[], + } + } else { + const [response, searchResults] = await Promise.all([ + this.client.profile(tag, {}, this.requestOptions()), + query ? this.search(query, 10, tag) : Promise.resolve([]), + ]) + result = { + static: (response.profile?.static ?? []).map((fact) => fact.memory), + dynamic: (response.profile?.dynamic ?? []).map((fact) => fact.memory), + searchResults, + } } log.debugResponse("profile", { @@ -184,10 +255,23 @@ export class SupermemoryClient { id, containerTag: tag, }) - const result = await this.client.memories.forget({ - containerTag: tag, - id, - }) + if (!this.legacy) { + const response = await this.client.memories.forget( + tag, + { ids: [id] }, + this.requestOptions(), + ) + if ( + response.count !== 1 || + response.errors.length !== 0 || + response.matches.length !== 1 || + response.matches[0].id !== id + ) { + throw new Error("Memory forget did not confirm the requested ID") + } + return { id, forgotten: true } + } + const result = await this.legacy.memories.forget({ containerTag: tag, id }) log.debugResponse("memories.delete", result) return result } @@ -217,15 +301,42 @@ export class SupermemoryClient { let page = 1 while (true) { - const response = await this.client.documents.list({ - containerTags: [this.containerTag], - limit: 100, - page, - }) - - if (!response.memories || response.memories.length === 0) break + const response = this.legacy + ? await this.legacy.documents.list({ + containerTags: [this.containerTag], + limit: 100, + page, + }) + : await this.client.list( + this.containerTag, + "documents", + { limit: 100, page, sort: "createdAt", order: "desc" }, + this.requestOptions(), + ) + if (!this.legacy && !("documents" in response)) { + throw new Error("Document list did not return documents; wipe stopped") + } + const documents = + "documents" in response ? response.documents : response.memories + if ("documents" in response) { + if ( + !Array.isArray(documents) || + !response.pagination || + response.pagination.currentPage !== page || + !Number.isInteger(response.pagination.totalPages) || + response.pagination.totalPages < 0 || + (documents.length > 0 && response.pagination.totalPages < page) || + (documents.length === 0 && response.pagination.totalItems !== 0) || + documents.some((doc) => typeof doc.id !== "string" || !doc.id.trim()) + ) { + throw new Error( + "Document list did not return a valid page; wipe stopped", + ) + } + } + if (!documents || documents.length === 0) break - for (const doc of response.memories) { + for (const doc of documents) { if (doc.id) allIds.push(doc.id) } @@ -247,8 +358,25 @@ export class SupermemoryClient { let deletedCount = 0 for (let i = 0; i < allIds.length; i += 100) { const batch = allIds.slice(i, i + 100) - await this.client.documents.deleteBulk({ ids: batch }) - deletedCount += batch.length + const response = this.legacy + ? await this.legacy.documents.deleteBulk({ ids: batch }) + : await this.client.documents.delete( + this.containerTag, + { ids: batch }, + this.requestOptions(), + ) + const count = "count" in response ? response.count : response.deletedCount + if ( + count !== batch.length || + ("count" in response && !Array.isArray(response.errors)) || + (response.errors?.length ?? 0) !== 0 || + ("success" in response && !response.success) + ) { + throw new Error( + `Document wipe was incomplete (${deletedCount + (Number.isInteger(count) && count >= 0 && count <= batch.length ? count : 0)} confirmed deletions); successful deletions are not rolled back`, + ) + } + deletedCount += count } log.debugResponse("wipe", { deletedCount }) diff --git a/config.ts b/config.ts index cf08727..673fa28 100644 --- a/config.ts +++ b/config.ts @@ -2,6 +2,7 @@ import { hostname } from "node:os" import { DEFAULT_ENTITY_CONTEXT } from "./memory.ts" export type CaptureMode = "everything" | "all" +export type ApiVersion = "v5" | "legacy" export type CustomContainer = { tag: string @@ -11,6 +12,7 @@ export type CustomContainer = { export type SupermemoryConfig = { apiKey: string | undefined baseUrl: string + apiVersion?: ApiVersion containerTag: string autoRecall: boolean autoCapture: boolean @@ -28,6 +30,7 @@ export type SupermemoryConfig = { const ALLOWED_KEYS = [ "apiKey", "baseUrl", + "apiVersion", "containerTag", "autoRecall", "autoCapture", @@ -65,6 +68,23 @@ function resolveEnvVars(value: string): string { export const DEFAULT_BASE_URL = "https://api.supermemory.ai" +export function resolveApiVersion(raw: unknown, baseUrl: string): ApiVersion { + const explicit = raw ?? process.env.SUPERMEMORY_API_VERSION + if (explicit !== undefined) { + if (explicit === "v5" || explicit === "legacy") return explicit + throw new Error('apiVersion must be "v5" or "legacy"') + } + const url = new URL(baseUrl) + return url.origin === DEFAULT_BASE_URL && + url.pathname === "/" && + !url.search && + !url.hash && + !url.username && + !url.password + ? "v5" + : "legacy" +} + // Resolve the API endpoint. Precedence: explicit config `baseUrl` (supports // `${ENV}` interpolation) > SUPERMEMORY_BASE_URL env var > the Supermemory // cloud default. Anything that isn't a valid http(s) URL falls back to the @@ -142,9 +162,11 @@ export function parseConfig(raw: unknown): SupermemoryConfig { } } + const baseUrl = resolveBaseUrl(cfg.baseUrl) return { apiKey, - baseUrl: resolveBaseUrl(cfg.baseUrl), + baseUrl, + apiVersion: resolveApiVersion(cfg.apiVersion, baseUrl), containerTag: cfg.containerTag ? sanitizeTag(cfg.containerTag as string) : defaultContainerTag(), @@ -179,6 +201,7 @@ export const supermemoryConfigSchema = { properties: { apiKey: { type: "string" }, baseUrl: { type: "string" }, + apiVersion: { type: "string", enum: ["v5", "legacy"] }, containerTag: { type: "string" }, autoRecall: { type: "boolean" }, autoCapture: { type: "boolean" }, diff --git a/index.ts b/index.ts index c528268..354c551 100644 --- a/index.ts +++ b/index.ts @@ -16,7 +16,7 @@ import { registerProfileTool } from "./tools/profile.ts" import { registerSearchTool } from "./tools/search.ts" import { registerStoreTool } from "./tools/store.ts" -const PLUGIN_VERSION = "2.1.18" +const PLUGIN_VERSION = "2.1.19" const UPDATE_COMMAND = "openclaw plugins install @supermemory/openclaw-supermemory" @@ -55,6 +55,7 @@ export default { cfg.apiKey, cfg.containerTag, cfg.baseUrl, + cfg.apiVersion, ) registerCli(api, client) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b48d2cd..be7b324 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -33,6 +33,11 @@ "help": "API endpoint. Defaults to Supermemory production (https://api.supermemory.ai). Set to a self-hosted / local URL (e.g. http://localhost:8000) to point at your own instance. Leave blank for the cloud.", "advanced": true }, + "apiVersion": { + "label": "API Version", + "help": "Protocol override: v5 or legacy. Hosted defaults to v5; custom servers default to legacy.", + "advanced": true + }, "containerTag": { "label": "Container Tag", "placeholder": "openclaw_myhostname", @@ -102,6 +107,7 @@ "properties": { "apiKey": { "type": "string" }, "baseUrl": { "type": "string" }, + "apiVersion": { "type": "string", "enum": ["v5", "legacy"] }, "containerTag": { "type": "string" }, "autoRecall": { "type": "boolean" }, "autoCapture": { "type": "boolean" }, diff --git a/package.json b/package.json index 2746f7b..53258a4 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { "name": "@supermemory/openclaw-supermemory", - "version": "2.1.18", + "version": "2.1.19", "type": "module", "description": "OpenClaw Supermemory memory plugin", "license": "MIT", "dependencies": { - "supermemory": "^4.0.0", + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.15.0", "@sinclair/typebox": "0.34.47" }, "files": [ "README.md", + "CHANGELOG.md", "openclaw.plugin.json", "index.ts", "client.ts", @@ -29,7 +31,7 @@ ], "scripts": { "build": "npm run build:runtime && npm run build:lib", - "build:runtime": "esbuild index.ts --bundle --format=esm --platform=node --target=es2022 --external:openclaw --external:openclaw/* --external:supermemory --outfile=dist/index.js", + "build:runtime": "esbuild index.ts --bundle --format=esm --platform=node --target=es2022 --external:openclaw --external:openclaw/* --external:supermemory --external:supermemory-legacy --outfile=dist/index.js", "check-types": "tsc --noEmit", "lint": "bunx @biomejs/biome ci .", "lint:fix": "bunx @biomejs/biome check --write .", From 5d5e0244d495dce3a1cb256e00150f14be928a42 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Fri, 9 Oct 2026 21:43:02 +0000 Subject: [PATCH 2/2] fix: fail closed on incomplete destructive API envelopes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- client.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4f59a..eb54079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Use official `supermemory@5.0.1` for the hosted API. Existing container tags become namespace paths without changing their values, configuration, credentials, or six-hour document IDs. Repeated saves use POST append/diff, not source replacement. Processing stays `memory` / `dynamic`; an accepted save is not proof that extraction has finished. - Preserve memories-only search with threshold 0.6, no reranking or query rewriting, and existing tool limits. Queried profiles now run profile and search concurrently; profile facts become strings and result timestamps are read from `system.updatedAt`. Split profile search requests return up to ten results; parity with the legacy profile endpoint's undocumented server-side search limit/ranking has not been verified. - Keep custom endpoints on official `supermemory@4.15.0` through the `supermemory-legacy` runtime dependency, including servers before 0.0.9. Set `apiVersion: "v5"` (or `SUPERMEMORY_API_VERSION=v5`) only after upgrading your custom server. Explicit configuration takes precedence over the environment. `"legacy"` selects v3/v4 where the server still supports it. There is no failed-write fallback or hosted rerouting. -- Reject malformed or failed save acknowledgments, unconfirmed v5 exact-ID forgetting, and incomplete document wipes instead of reporting success. Wipe still lists root documents before deleting them, and partial successful deletions are not rolled back. No bulk semantic forgetting was introduced. Legacy forget behavior and the separate proposed search/forget hardening in PR #25 are unchanged. +- Reject malformed or failed save acknowledgments, unconfirmed v5 exact-ID forgetting, and incomplete document wipes instead of reporting success. V5 wipes require consistent pagination totals, exact page sizes and complete unique document enumeration before any delete; exact forgetting requires documented arrays and match fields. Partial successful deletions are not rolled back. No bulk semantic forgetting was introduced. Legacy forget behavior and the separate proposed search/forget hardening in PR #25 are unchanged. - Retain the existing 60-second per-attempt SDK timeout and two retries. The SDK timeout bounds receipt of response headers, not a stalled response body; no new whole-operation deadline was added. Stable document IDs do not guarantee exactly-once ingestion or billing after an ambiguous transport failure. A 409 is surfaced by the v5 SDK rather than automatically retried. Upgrade through your existing plugin installation/update flow; do not rerun setup to migrate. Setup replaces advanced plugin options. This release does not rewrite local configuration, migrate backend data, rename namespaces, or backfill conversations. Historical-data availability, backend ranking, eventual processing, and server-upgrade rollback still require validation on your own authorized nonproduction deployment. diff --git a/client.ts b/client.ts index 6dc9983..fa353ae 100644 --- a/client.ts +++ b/client.ts @@ -263,8 +263,15 @@ export class SupermemoryClient { ) if ( response.count !== 1 || + !Array.isArray(response.errors) || response.errors.length !== 0 || + !Array.isArray(response.matches) || response.matches.length !== 1 || + !response.matches[0] || + typeof response.matches[0] !== "object" || + typeof response.matches[0].id !== "string" || + !response.matches[0].id.trim() || + typeof response.matches[0].memory !== "string" || response.matches[0].id !== id ) { throw new Error("Memory forget did not confirm the requested ID") @@ -298,6 +305,8 @@ export class SupermemoryClient { log.debugRequest("wipe", { containerTag: this.containerTag }) const allIds: string[] = [] + const uniqueIds = new Set() + let totalItems: number | undefined let page = 1 while (true) { @@ -319,20 +328,48 @@ export class SupermemoryClient { const documents = "documents" in response ? response.documents : response.memories if ("documents" in response) { + const pagination = response.pagination + const limit = 100 if ( !Array.isArray(documents) || - !response.pagination || - response.pagination.currentPage !== page || - !Number.isInteger(response.pagination.totalPages) || - response.pagination.totalPages < 0 || - (documents.length > 0 && response.pagination.totalPages < page) || - (documents.length === 0 && response.pagination.totalItems !== 0) || - documents.some((doc) => typeof doc.id !== "string" || !doc.id.trim()) + !pagination || + pagination.currentPage !== page || + (pagination.limit !== undefined && pagination.limit !== limit) || + !Number.isSafeInteger(pagination.totalItems) || + pagination.totalItems < 0 || + !Number.isSafeInteger(pagination.totalPages) || + pagination.totalPages < 0 || + (pagination.totalItems === 0 + ? pagination.totalPages > 1 + : pagination.totalPages !== + Math.ceil(pagination.totalItems / limit)) || + documents.length !== + Math.min( + limit, + Math.max(0, pagination.totalItems - (page - 1) * limit), + ) || + (totalItems !== undefined && pagination.totalItems !== totalItems) || + documents.some( + (doc) => + !doc || + typeof doc !== "object" || + typeof doc.id !== "string" || + !doc.id.trim(), + ) ) { throw new Error( "Document list did not return a valid page; wipe stopped", ) } + totalItems = pagination.totalItems + for (const doc of documents) { + if (uniqueIds.has(doc.id)) { + throw new Error( + "Document list returned duplicate IDs; wipe stopped", + ) + } + uniqueIds.add(doc.id) + } } if (!documents || documents.length === 0) break @@ -347,6 +384,9 @@ export class SupermemoryClient { break page++ } + if (!this.legacy && uniqueIds.size !== totalItems) { + throw new Error("Document list was incomplete; wipe stopped") + } if (allIds.length === 0) { log.debug("wipe: no documents found")