diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 921af5d..10f9f43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Check if version changed id: version-check diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9e737a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 2.0.16 + +- Use the official pinned Supermemory 5.0.1 SDK for hosted content, preserving namespace strings, batch IDs, metadata, search defaults, capture cadence, and permissions. +- Keep the bundled official 4.0.0 SDK for custom servers by default, with explicit `apiVersion` / `SUPERMEMORY_API_VERSION` selection and no cross-version or hosted fallback. +- Normalize v5 profiles, search timestamps and document-list envelopes; require confirmed write acceptance and exact deletion outcomes. +- Retain the legacy settings and account boundaries instead of replacing organization context or dropping existing configuration. +- Rebuild all plugin/CLI entrypoints with both SDKs and their license texts; use the checked-in dependency lock for release builds. diff --git a/README.md b/README.md index cd331ed..19da222 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Completed conversations are captured automatically: - Any remaining turns are flushed when the session is deleted or the OpenCode instance shuts down. - Synthetic plugin context is excluded and `` content is redacted. -- Stable capture IDs make repeated lifecycle events idempotent. +- Stable capture IDs retain batch identity across repeated lifecycle events; they do not guarantee exactly-once processing or billing after an ambiguous response. ### Keyword Detection @@ -298,6 +298,7 @@ does not require a migration. | --- | --- | | `SUPERMEMORY_API_KEY` | Your Supermemory API key (takes precedence over the config file). | | `SUPERMEMORY_API_URL` / `SUPERMEMORY_BASE_URL` | Override the Supermemory API base URL. | +| `SUPERMEMORY_API_VERSION` | Select `legacy` or `v5`, overriding `apiVersion` in the config file. | | `SUPERMEMORY_AUTH_URL` | Override the browser-auth base URL. | | `SUPERMEMORY_AUTH_TIMEOUT` | Browser-auth timeout in milliseconds (default 5 minutes). | | `SUPERMEMORY_REPO_TAG` | Explicit project-container override, checked before the config value. | @@ -306,6 +307,33 @@ does not require a migration. ### `~/.config/opencode/supermemory.jsonc` +Hosted content calls use the official Supermemory v5 SDK. Custom base URLs default +to the bundled legacy SDK so existing self-hosted servers, including 0.0.8, keep +working. After independently upgrading a custom server to support v5, opt in with +`"apiVersion": "v5"` or `SUPERMEMORY_API_VERSION=v5`. Set `legacy` explicitly to +keep the old content protocol on any endpoint. Failed requests never switch API +versions or reroute to hosted Supermemory, and existing container names, IDs, +credentials, capture cadence, and configuration files are not migrated or renamed. + +The existing best-effort `filterPrompt` / `shouldLLMFilter` settings update remains +on the legacy settings endpoint for both protocols. v5 has no equivalent filter +toggle; the plugin does not replace organization context or request administrator +permissions. A server without that legacy endpoint cannot apply these settings; +capture and recall still work, and the failure is recorded in the existing log. +Browser login and account status also retain their separate legacy auth/session +boundary. + +v5 capture uses document POST append/diff with `dreaming: "dynamic"`; accepted +documents may take minutes to form recallable memories. The plugin does not enable +the extra-billable instant mode. Profiles normalize fact objects to text and issue +a separate search only when a query is requested. Document lists fetch canonical +content per document to retain the previous content-inclusive response, so lists +and compaction can require additional requests under the existing timeout budget. +Individual failed, invalid or late content reads retain the listed document's +identity, summary and lifecycle fields; hydration uses the remaining list budget +with a return grace. Content is accepted only from the matching document ID and +scope, not from a different or malformed response. + ```jsonc { // API key (can also use SUPERMEMORY_API_KEY env var) diff --git a/bun.lock b/bun.lock index e1c9c36..9ea3c6c 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,8 @@ "@types/bun": "latest", "jsonc-parser": "3.3.1", "solid-js": "1.9.15", - "supermemory": "^4.0.0", + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.0.0", "typescript": "^5.7.3", }, "peerDependencies": { @@ -696,7 +697,9 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "supermemory": ["supermemory@4.0.0", "", {}, "sha512-xMN05PQ8kTv8DuXa2qf8h/9LaRI7v1Kz3Tutt97JPq+PzhGabKLv5YVbSgqHiPX5yXcSUBVBNYPPbhAQMF6GYQ=="], + "supermemory": ["supermemory@5.0.1", "", { "bin": { "supermemory": "bin/cli.mjs" } }, "sha512-XCgFEioj4PGREU/J3pOK7L4U/akZroPR6XCDK6/pxGrrJa28gH1kNPB/Cxhq6nVL3fwxdhwciyPbRImaDzq8/Q=="], + + "supermemory-legacy": ["supermemory@4.0.0", "", {}, "sha512-xMN05PQ8kTv8DuXa2qf8h/9LaRI7v1Kz3Tutt97JPq+PzhGabKLv5YVbSgqHiPX5yXcSUBVBNYPPbhAQMF6GYQ=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], diff --git a/package.json b/package.json index 270fe74..f9f3c4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory", - "version": "2.0.15", + "version": "2.0.16", "description": "OpenCode plugin that gives coding agents persistent memory using Supermemory", "type": "module", "main": "dist/index.js", @@ -38,7 +38,7 @@ }, "scripts": { "generate:version": "node scripts/sync-version.mjs", - "build": "node scripts/sync-version.mjs && bun build ./src/index.ts ./src/server.ts ./src/rpc.ts --outdir ./dist --target node && bun build ./src/tui.tsx --outfile ./dist/tui.js --target bun --external @opentui/core --external @opentui/solid --external solid-js && bun build ./src/v2/index.ts --outfile ./dist/v2/index.js --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", + "build": "node scripts/sync-version.mjs && bun build ./src/index.ts ./src/server.ts ./src/rpc.ts --outdir ./dist --target node && bun build ./src/tui.tsx --outfile ./dist/tui.js --target bun --external @opentui/core --external @opentui/solid --external solid-js && bun build ./src/v2/index.ts --outfile ./dist/v2/index.js --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly && node scripts/copy-sdk-licenses.mjs", "dev": "tsc --watch", "typecheck": "node scripts/sync-version.mjs && tsc --noEmit", "test": "node scripts/sync-version.mjs && bun test" @@ -67,7 +67,8 @@ "@types/bun": "latest", "jsonc-parser": "3.3.1", "solid-js": "1.9.15", - "supermemory": "^4.0.0", + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.0.0", "typescript": "^5.7.3" }, "peerDependencies": { diff --git a/scripts/copy-sdk-licenses.mjs b/scripts/copy-sdk-licenses.mjs new file mode 100644 index 0000000..4f2938c --- /dev/null +++ b/scripts/copy-sdk-licenses.mjs @@ -0,0 +1,10 @@ +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const destination = "dist/licenses"; +mkdirSync(destination, { recursive: true }); +for (const dependency of ["supermemory", "supermemory-legacy"]) { + const directory = join("node_modules", dependency); + const { name, version } = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + copyFileSync(join(directory, "LICENSE"), join(destination, `${name}-${version}.txt`)); +} diff --git a/src/config.ts b/src/config.ts index b342f7b..2946693 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export type RecallMode = "direct" | "advisory" | "off"; interface SupermemoryConfig { apiKey?: string; baseUrl?: string; + apiVersion?: "legacy" | "v5"; similarityThreshold?: number; maxMemories?: number; maxProjectMemories?: number; @@ -56,7 +57,7 @@ const DEFAULT_KEYWORD_PATTERNS = [ "always\\s+remember", ]; -const DEFAULTS: Required> = { +const DEFAULTS: Required> = { similarityThreshold: 0.55, maxMemories: 5, maxProjectMemories: 10, @@ -172,6 +173,15 @@ export function getApiBaseUrl(): string { return normalized; } +export function getApiVersion(): "legacy" | "v5" { + const version = process.env.SUPERMEMORY_API_VERSION ?? fileConfig.apiVersion; + if (version !== undefined) { + if (version === "legacy" || version === "v5") return version; + throw new Error('Invalid apiVersion: expected "legacy" or "v5"'); + } + return getApiBaseUrl() === DEFAULT_BASE_URL ? "v5" : "legacy"; +} + export const CONFIG_FILE = CONFIG_FILES[1]; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(CONFIG_DIR, "supermemory.json"); diff --git a/src/services/client.ts b/src/services/client.ts index 73f4d81..0604f7a 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,9 +1,11 @@ -import Supermemory from "supermemory"; +import Supermemory from "supermemory-legacy"; +import { V5Client } from "./v5-client.js"; import { CONFIG, PLUGIN_VERSION, SUPERMEMORY_API_KEY, getApiBaseUrl, + getApiVersion, isConfigured, } from "../config.js"; import { log } from "./logger.js"; @@ -101,8 +103,8 @@ function isNotFoundError(error: unknown): boolean { return ( typeof error === "object" && error !== null && - "status" in error && - error.status === 404 + (("status" in error && error.status === 404) || + ("statusCode" in error && error.statusCode === 404)) ); } @@ -110,8 +112,9 @@ function isAuthorizationError(error: unknown): boolean { return ( typeof error === "object" && error !== null && - "status" in error && - (error.status === 401 || error.status === 403) + (("status" in error && (error.status === 401 || error.status === 403)) || + ("statusCode" in error && + (error.statusCode === 401 || error.statusCode === 403))) ); } @@ -124,7 +127,7 @@ function withTimeout(promise: Promise, ms: number): Promise { } export class SupermemoryClient { - private client: Supermemory | null = null; + private client: Supermemory | V5Client | null = null; private formatConversationMessage(message: ConversationMessage): string { const content = @@ -156,16 +159,25 @@ export class SupermemoryClient { .join("\n"); } - private getClient(): Supermemory { + private getClient(): Supermemory | V5Client { if (!this.client) { if (!isConfigured()) { throw new Error("SUPERMEMORY_API_KEY not set"); } - this.client = new Supermemory({ + const legacyClient = new Supermemory({ apiKey: SUPERMEMORY_API_KEY, baseURL: getApiBaseUrl(), defaultHeaders: { "x-sm-source": OPENCODE_SOURCE }, }); + this.client = + getApiVersion() === "v5" + ? new V5Client( + SUPERMEMORY_API_KEY!, + getApiBaseUrl(), + legacyClient.settings, + TIMEOUT_MS - TIMEOUT_BACKSTOP_GRACE_MS, + ) + : legacyClient; try { void this.client.settings .update( @@ -198,7 +210,11 @@ export class SupermemoryClient { log("searchMemories: start", { containerTag, scope }); try { const hookTimeout = options?.timeoutMs; - const result = await withTimeout( + const result = await withTimeout<{ + results: unknown[]; + total: number; + timing: number; + }>( this.getClient().search.memories( { q: query, @@ -316,7 +332,10 @@ export class SupermemoryClient { log("getProfile: start", { containerTag, scope }); try { const hookTimeout = options?.timeoutMs; - const result = await withTimeout( + const result = await withTimeout<{ + profile: { static: string[]; dynamic: string[] } | null; + searchResults?: { results: unknown[]; total: number; timing?: number }; + }>( this.getClient().profile( { containerTag, @@ -465,6 +484,13 @@ export class SupermemoryClient { ? requestTimeout + TIMEOUT_BACKSTOP_GRACE_MS : TIMEOUT_MS, ); + if ( + typeof result.id !== "string" || + !result.id.trim() || + result.status === "failed" + ) { + throw new Error("Document acceptance was invalid or processing failed"); + } log("addMemory: success", { id: result.id }); return { success: true as const, ...result }; } catch (error) { @@ -478,6 +504,7 @@ export class SupermemoryClient { async deleteMemory(memoryId: string, containerTags: string[] = []) { log("deleteMemory: start", { memoryId }); const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const confirmedMemoryMisses: string[] = []; let retainedAuthorizationError: unknown; for (const [index, containerTag] of uniqueTags.entries()) { @@ -489,7 +516,10 @@ export class SupermemoryClient { log("deleteMemory: forgotten", { memoryId }); return { success: true as const }; } catch (error) { - if (isNotFoundError(error)) continue; + if (isNotFoundError(error)) { + confirmedMemoryMisses.push(containerTag); + continue; + } if (index > 0 && isAuthorizationError(error)) { retainedAuthorizationError ??= error; continue; @@ -503,8 +533,11 @@ export class SupermemoryClient { } try { + const client = this.getClient(); await withTimeout( - this.getClient().memories.delete(memoryId), + client instanceof V5Client + ? client.deleteDocument(memoryId, confirmedMemoryMisses) + : client.memories.delete(memoryId), TIMEOUT_MS, ); log("deleteMemory: deleted document", { memoryId }); @@ -530,7 +563,10 @@ export class SupermemoryClient { ): Promise { log("listMemories: start", { containerTag, limit, scope }); try { - const result = await withTimeout( + const result = await withTimeout<{ + memories: unknown[]; + pagination: ListResponse["pagination"]; + }>( this.getClient().memories.list({ containerTags: [containerTag], filters: scope ? getScopeFilters(scope) : undefined, diff --git a/src/services/v5-client.ts b/src/services/v5-client.ts new file mode 100644 index 0000000..1c8b54a --- /dev/null +++ b/src/services/v5-client.ts @@ -0,0 +1,329 @@ +import { + Supermemory, + type FilterExpression, + type RequestOptions, +} from "supermemory"; +import type { + ListMemoryItem, + ListResponse, + ProfileResponse, + SearchResultItem, +} from "./client.js"; +import { log } from "./logger.js"; + +type LegacyOptions = { timeout?: number; maxRetries?: number }; +type ScopedRequest = { + filters?: { AND: { key: string; value: string; filterType: "metadata" }[] }; +}; + +function requestOptions(options?: LegacyOptions): RequestOptions { + return { + timeoutInSeconds: (options?.timeout ?? 60_000) / 1000, + maxRetries: options?.maxRetries ?? 2, + }; +} + +function scopeFilter(request: ScopedRequest): FilterExpression | undefined { + if (!request.filters) return undefined; + return { + operator: "and", + operands: request.filters.AND.map(({ key, value }) => ({ + field: key, + operator: "eq", + value, + })), + }; +} + +function notFound(message: string): Error & { statusCode: number } { + return Object.assign(new Error(message), { statusCode: 404 }); +} + +function deletionError( + count: number, + errors: { id: string; error: string }[], + id: string, +): never { + const error = + count === 0 && errors.length === 1 && errors[0]?.id === id + ? errors[0].error + : undefined; + if ( + error && + /^(?:memory|document)?\s*(?:not found|does not exist)[.!]?$/i.test( + error.trim(), + ) + ) { + throw notFound(error); + } + throw new Error("Deletion was not confirmed for the requested ID"); +} + +export class V5Client { + private sdk: Supermemory; + + constructor( + apiKey: string, + baseUrl: string, + readonly settings: { + update( + body: { shouldLLMFilter: boolean; filterPrompt: string }, + options?: LegacyOptions, + ): Promise; + }, + private readonly listBudgetMs: number, + ) { + this.sdk = new Supermemory({ + apiKey, + baseUrl, + headers: { "x-sm-source": "opencode" }, + timeoutInSeconds: 60, + maxRetries: 2, + }); + } + + readonly search = { + memories: async ( + request: ScopedRequest & { + q: string; + containerTag: string; + threshold: number; + limit: number; + searchMode: "hybrid" | "memories"; + }, + options?: LegacyOptions, + ) => { + const result = await this.sdk.search( + request.containerTag, + { + query: request.q, + filter: scopeFilter(request), + threshold: request.threshold, + limit: request.limit, + searchMode: request.searchMode, + rerank: "none", + rewriteQuery: false, + }, + requestOptions(options), + ); + const results: SearchResultItem[] = result.results.map((item) => ({ + ...item, + updatedAt: item.system?.updatedAt, + })); + return { results, total: results.length, timing: result.searchTime }; + }, + }; + + async profile( + request: ScopedRequest & { containerTag: string; q?: string }, + options?: LegacyOptions, + ): Promise> { + const [result, searchResults] = await Promise.all([ + this.sdk.profile( + request.containerTag, + { filter: scopeFilter(request) }, + requestOptions(options), + ), + request.q + ? this.search.memories( + { + ...request, + q: request.q, + searchMode: "memories", + threshold: 0.6, + limit: 10, + }, + options, + ) + : undefined, + ]); + return { + profile: { + static: result.profile.static.map((fact) => fact.memory), + dynamic: result.profile.dynamic.map((fact) => fact.memory), + }, + searchResults, + }; + } + + readonly memories = { + add: async ( + request: { + content: string; + containerTag: string; + customId?: string; + entityContext?: string; + metadata: Record; + }, + options?: LegacyOptions, + ): Promise<{ id: string; status: string }> => { + const result = await this.sdk.add( + request.containerTag, + { + content: request.content, + id: request.customId, + supportingContext: request.entityContext, + metadata: request.metadata, + taskType: "memory", + dreaming: "dynamic", + }, + requestOptions(options), + ); + if ( + !result.id?.trim() || + ![ + "queued", + "extracting", + "chunking", + "embedding", + "indexing", + "done", + ].includes(result.status) + ) { + throw new Error("Document acceptance was invalid or processing failed"); + } + return result; + }, + forget: async (request: { id: string; containerTag: string }) => { + const result = await this.sdk.memories.forget( + request.containerTag, + { + ids: [request.id], + }, + requestOptions(), + ); + if ( + result.count !== 1 || + result.matches?.length !== 1 || + result.matches[0]?.id !== request.id || + result.errors?.length !== 0 + ) { + deletionError( + result.matches?.length === 0 ? result.count : -1, + result.errors ?? [], + request.id, + ); + } + return { id: request.id, forgotten: true }; + }, + list: async ( + request: ScopedRequest & { + containerTags: string[]; + limit: number; + order: "desc"; + sort: "createdAt"; + includeContent: boolean; + }, + ): Promise<{ + memories: ListMemoryItem[]; + pagination: ListResponse["pagination"]; + }> => { + const namespace = request.containerTags[0]; + if (!namespace || request.containerTags.length !== 1) { + throw new Error("Document list requires one namespace"); + } + const deadline = Date.now() + this.listBudgetMs; + const result = await this.sdk.list( + namespace, + "documents", + { + limit: request.limit, + sort: request.sort, + order: request.order, + filter: scopeFilter(request), + }, + requestOptions(), + ); + const memories: ListMemoryItem[] = await Promise.all( + result.documents + .filter( + (item) => typeof item?.id === "string" && item.id.trim().length > 0, + ) + .map(async (item) => { + let content: string | null | undefined; + if (request.includeContent) { + const controller = new AbortController(); + let timer: ReturnType | undefined; + try { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) + throw new Error("Document hydration budget exhausted"); + const document = await Promise.race([ + this.sdk.documents.get(namespace, item.id, undefined, { + ...requestOptions(), + timeoutInSeconds: remainingMs / 1000, + abortSignal: controller.signal, + }), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error("Document hydration timed out")); + }, remainingMs); + }), + ]); + if ( + document.id === item.id && + (typeof document.content === "string" || + document.content === null) && + (request.filters?.AND.every( + ({ key, value }) => document.metadata?.[key] === value, + ) ?? + true) + ) { + content = document.content; + } else { + log("listMemories: content hydration skipped", { + id: item.id, + reason: "Invalid document response", + }); + } + } catch { + log("listMemories: content hydration skipped", { + id: item.id, + reason: "Document read failed", + }); + } finally { + if (timer) clearTimeout(timer); + } + } + return { + ...item, + ...(content !== undefined ? { content } : {}), + status: item.system?.status, + createdAt: item.system?.createdAt, + updatedAt: item.system?.updatedAt, + containerTags: [namespace], + }; + }), + ); + return { memories, pagination: result.pagination }; + }, + }; + + async deleteDocument(id: string, namespaces: string[]): Promise { + if (namespaces.length === 0) + throw new Error("Document deletion requires an explicit namespace"); + let retainedAuthorizationError: unknown; + for (const [index, namespace] of namespaces.entries()) { + try { + const result = await this.sdk.documents.delete( + namespace, + { ids: [id] }, + requestOptions(), + ); + if (result.count !== 1 || result.errors?.length !== 0) + deletionError(result.count, result.errors ?? [], id); + return; + } catch (error) { + const status = (error as { statusCode?: number })?.statusCode; + if (status === 404) continue; + if (index > 0 && (status === 401 || status === 403)) { + retainedAuthorizationError ??= error; + continue; + } + throw error; + } + } + throw retainedAuthorizationError ?? notFound("Document not found"); + } +} diff --git a/src/version.ts b/src/version.ts index df3ccbb..33a1c36 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // AUTO-GENERATED by scripts/sync-version.mjs — do not edit by hand. // Sourced from package.json "version" so the two cannot drift. -export const PLUGIN_VERSION = "2.0.15"; +export const PLUGIN_VERSION = "2.0.16";