diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eb54079 --- /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. 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/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..fa353ae 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,30 @@ 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 || + !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") + } + return { id, forgotten: true } + } + const result = await this.legacy.memories.forget({ containerTag: tag, id }) log.debugResponse("memories.delete", result) return result } @@ -214,18 +305,75 @@ 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) { - 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) { + const pagination = response.pagination + const limit = 100 + if ( + !Array.isArray(documents) || + !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 - for (const doc of response.memories) { + for (const doc of documents) { if (doc.id) allIds.push(doc.id) } @@ -236,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") @@ -247,8 +398,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 .",