Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<private>` 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

Expand Down Expand Up @@ -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. |
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions scripts/copy-sdk-licenses.mjs
Original file line number Diff line number Diff line change
@@ -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`));
}
12 changes: 11 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,7 +57,7 @@ const DEFAULT_KEYWORD_PATTERNS = [
"always\\s+remember",
];

const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userContainerTag" | "projectContainerTag" | "recallDirective">> = {
const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "apiVersion" | "userContainerTag" | "projectContainerTag" | "recallDirective">> = {
similarityThreshold: 0.55,
maxMemories: 5,
maxProjectMemories: 10,
Expand Down Expand Up @@ -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");

Expand Down
62 changes: 49 additions & 13 deletions src/services/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -101,17 +103,18 @@ 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))
);
}

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)))
);
}

Expand All @@ -124,7 +127,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
}

export class SupermemoryClient {
private client: Supermemory | null = null;
private client: Supermemory | V5Client | null = null;

private formatConversationMessage(message: ConversationMessage): string {
const content =
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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()) {
Expand All @@ -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;
Expand All @@ -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 });
Expand All @@ -530,7 +563,10 @@ export class SupermemoryClient {
): Promise<ListResponse> {
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,
Expand Down
Loading
Loading