diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 80bafdf..d79a3b9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,13 +27,19 @@ jobs: - run: npm ci - name: Bump patch version + env: + BEFORE_REF: ${{ github.event.before }} run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - npm version patch --no-git-tag-version VERSION="$(node -p "require('./package.json').version")" - git add package.json package-lock.json - git commit -m "chore(release): ${VERSION}" + PREVIOUS_VERSION="$(git show "${BEFORE_REF}:package.json" | node -e 'let text=""; process.stdin.on("data", chunk => text += chunk); process.stdin.on("end", () => console.log(JSON.parse(text).version))')" + if [ "$VERSION" = "$PREVIOUS_VERSION" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + npm version patch --no-git-tag-version + VERSION="$(node -p "require('./package.json').version")" + git add package.json package-lock.json + git commit -m "chore(release): ${VERSION}" + fi # Build after the bump so the embedded PLUGIN_VERSION matches the publish. - run: npm run build --if-present diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..753e585 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 1.0.20 + +- Use the official Supermemory 5.0.1 SDK for hosted capture, profile, search, and + connectivity checks; rebuild standalone hooks and status with the SDK included. +- Preserve existing namespace strings, configuration, credentials, session IDs, + metadata, cursors, recall budgets, fan-out, and MCP routing/approvals. +- Retain an exact-pinned SDK4 compatibility path for older custom servers and + legacy-only ancillary operations. Support explicit `v5`/`legacy` selection + without failed-request fallback or hosted rerouting. +- Validate document acceptance before committing a capture cursor and preserve + POST append/diff semantics for subsequent session deltas. +- Include SDK licenses with both npm artifacts and installed standalone scripts. +- Respect a PR-supplied release version instead of bumping it a second time; + ordinary main changes still trigger the publisher's next patch bump. diff --git a/README.md b/README.md index aac678a..fc7220d 100644 --- a/README.md +++ b/README.md @@ -96,12 +96,60 @@ write destination. Older user/personal overrides remain in the legacy read set. ## Configuration +### API v5 and existing installations + +Version 1.0.20 bundles the official `supermemory@5.0.1` SDK for document capture, +profiles, search, and the status connectivity probe. Existing container-tag +strings become namespace paths without renaming stored data. Config keys, +credential files, session document IDs, capture cursors, legacy namespace reads, +and recall approvals stay in place; no historical backfill or namespace move runs. +Re-run `npx codex-supermemory@latest install` after upgrading to refresh the copied +standalone hooks and status script. Updating npm dependencies alone doesn't +update scripts already installed in `~/.codex/supermemory`. + +The hosted root `https://api.supermemory.ai` defaults to v5, including equivalent +host casing and default-port spellings. Custom REST URLs, including URL prefixes, +default to the bundled official SDK4 compatibility path so servers older than +0.0.9 remain usable. After upgrading a custom server, set +`SUPERMEMORY_API_VERSION=v5` or `apiVersion: "v5"` in the existing config. Select +`legacy` explicitly to use v3/v4 where the server still supports them. Invalid +version selections fail the operation; a failed v5 request never switches API +versions or sends data to another host. REST URL selection remains independent +of the hosted MCP and browser-auth endpoints, including their existing env fields. + +The v5 profile has no query; prompt recall runs profile and memory search in +parallel. Separated search explicitly uses the guide's legacy defaults (memories, +0.6 threshold, 10 candidates, no reranking/query rewriting), then preserves the +local 0.55 floor, cross-namespace deduplication, and top-five/configured cap. The +SDK search helper retains its existing hybrid mode and configured threshold and +limit. Server-side ranking and historical-data availability aren't verified by +local contract tests. Capture keeps its three-second budget and zero SDK retries, +uses POST append/diff with the existing session ID, and advances the cursor only +after valid acceptance. Dynamic processing is asynchronous; acceptance doesn't +prove extraction completion or exactly-once billing after a lost response. + +The retained `dist/services/client.js` library exports still have their original +30-second local wait and two-retry SDK cap for calls without hook options. SDK5's +retryable statuses/backoff differ from SDK4, including no automatic 409 retry. +Installed automatic hooks don't use that unbounded policy: a conflict keeps the +capture cursor for a later attempt without an immediate retry. Explicit MCP +operations still use their independent transport rather than these SDK helpers. + +Display-name updates (`/v3/container-tags/...`), account details (`/v3/session`), +and the latent exact-content forget helper (`/v4/memories`) remain legacy-only +ancillary operations against the configured REST server. They have no mechanical +v5 equivalent. A v5-only server may leave names/account details unavailable or +return an explicit forget error; the plugin does not substitute semantic deletion. +Interactive MCP tools keep their independent protocol, endpoints, and approvals. + ### Environment variables | Variable | Purpose | | ------------------------------ | ------------------------------------------------------ | | `SUPERMEMORY_CODEX_API_KEY` | Your Supermemory API key (browser auth is preferred). | | `SUPERMEMORY_API_URL` | Override the Supermemory API base URL (takes precedence over config). | +| `SUPERMEMORY_BASE_URL` | Older REST URL alias, used when `SUPERMEMORY_API_URL` is unset. | +| `SUPERMEMORY_API_VERSION` | Select `v5` or `legacy`; overrides `apiVersion` in config. | | `SUPERMEMORY_DEBUG` | Set to any truthy value to enable debug logging to `~/.codex-supermemory.log`. | ### `~/.codex/supermemory.json` (optional) @@ -112,6 +160,7 @@ Drop this file in to override defaults: | ------------------------ | ---------- | -------------- | -------------------------------------------------------------------------------------------- | | `apiKey` | `string` | — | API key (env var takes precedence, browser auth is preferred). | | `baseUrl` | `string` | `https://api.supermemory.ai` | Supermemory API base URL (`SUPERMEMORY_API_URL`/`SUPERMEMORY_BASE_URL` env vars take precedence). | +| `apiVersion` | `"v5" \| "legacy"` | auto | Hosted root API uses v5; custom REST URLs default to legacy compatibility. | | `similarityThreshold` | `number` | `0.6` | Minimum similarity score for retrieved memories. | | `maxMemories` | `number` | `5` | Max memories injected per prompt. | | `maxProfileItems` | `number` | `5` | Max profile items considered from each persistent/recent section. | diff --git a/build.mjs b/build.mjs index fe23375..3bf67c3 100644 --- a/build.mjs +++ b/build.mjs @@ -9,6 +9,14 @@ if (typeof packageJson.version !== "string" || !packageJson.version) { throw new Error("package.json must contain a version"); } +const sdkLicenses = ["supermemory", "supermemory-legacy"].map((name) => { + const sdkPackage = JSON.parse(readFileSync(`node_modules/${name}/package.json`, "utf-8")); + if (sdkPackage.version !== (name === "supermemory" ? "5.0.1" : "4.0.0")) { + throw new Error(`Unexpected ${name} version: ${sdkPackage.version}`); + } + return `${name} ${sdkPackage.version}\n${readFileSync(`node_modules/${name}/LICENSE`, "utf-8")}`; +}).join("\n\n"); + const sharedConfig = { bundle: true, platform: "node", @@ -36,6 +44,8 @@ const executableEntries = [ ]; rmSync("dist", { recursive: true, force: true }); +mkdirSync("dist", { recursive: true }); +writeFileSync("dist/THIRD_PARTY_LICENSES", sdkLicenses); const libraryEntries = [ { in: "src/services/session.ts", out: "dist/services/session.js" }, diff --git a/bun.lock b/bun.lock index 081d5c4..a62c873 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,8 @@ "name": "codex-supermemory", "dependencies": { "@iarna/toml": "^2.2.5", - "supermemory": "4.0.0", + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.0.0", }, "devDependencies": { "@types/iarna__toml": "^2.0.5", @@ -77,7 +78,9 @@ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "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=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package-lock.json b/package-lock.json index 6c5e4d7..a14a305 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "codex-supermemory", - "version": "1.0.17", + "version": "1.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-supermemory", - "version": "1.0.17", + "version": "1.0.20", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", - "supermemory": "4.0.0" + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.0.0" }, "bin": { "codex-supermemory": "dist/cli.js" @@ -533,6 +534,19 @@ } }, "node_modules/supermemory": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/supermemory/-/supermemory-5.0.1.tgz", + "integrity": "sha512-XCgFEioj4PGREU/J3pOK7L4U/akZroPR6XCDK6/pxGrrJa28gH1kNPB/Cxhq6nVL3fwxdhwciyPbRImaDzq8/Q==", + "license": "Apache-2.0", + "bin": { + "supermemory": "bin/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/supermemory-legacy": { + "name": "supermemory", "version": "4.0.0", "resolved": "https://registry.npmjs.org/supermemory/-/supermemory-4.0.0.tgz", "integrity": "sha512-xMN05PQ8kTv8DuXa2qf8h/9LaRI7v1Kz3Tutt97JPq+PzhGabKLv5YVbSgqHiPX5yXcSUBVBNYPPbhAQMF6GYQ==", diff --git a/package.json b/package.json index 861ee9c..eefb81c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-supermemory", - "version": "1.0.18", + "version": "1.0.20", "description": "Persistent memory for OpenAI Codex CLI \u2014 powered by Supermemory", "type": "module", "main": "dist/cli.js", @@ -31,7 +31,8 @@ ], "dependencies": { "@iarna/toml": "^2.2.5", - "supermemory": "4.0.0" + "supermemory": "5.0.1", + "supermemory-legacy": "npm:supermemory@4.0.0" }, "devDependencies": { "@types/iarna__toml": "^2.0.5", diff --git a/src/cli.ts b/src/cli.ts index 302e31f..5b9f46d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -474,6 +474,7 @@ function install() { copyFileSync(mcpProxySrc, MCP_PROXY_SCRIPT); copyFileSync(flushSrc, FLUSH_SCRIPT); copyFileSync(sessionStartSrc, SESSION_START_SCRIPT); + copyFileSync(join(SCRIPT_DIR, "THIRD_PARTY_LICENSES"), join(SUPERMEMORY_HOOKS_DIR, "THIRD_PARTY_LICENSES")); // Remove script names left by older package layouts. for (const script of LEGACY_SUPERMEMORY_SCRIPTS) { diff --git a/src/config.ts b/src/config.ts index 7c93dd3..f1d3395 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ export const DEFAULT_RECALL_DIRECTIVE = interface CodexSupermemoryConfig { apiKey?: string; baseUrl?: string; + apiVersion?: "v5" | "legacy"; similarityThreshold?: number; maxMemories?: number; maxProfileItems?: number; @@ -193,6 +194,19 @@ export function getBaseUrl(): string { return normalized; } +export function getApiVersion(): "v5" | "legacy" { + const configured = process.env.SUPERMEMORY_API_VERSION ?? fileConfig.apiVersion; + if (configured !== undefined) { + if (configured === "v5" || configured === "legacy") return configured; + throw new Error('Invalid apiVersion: expected "v5" or "legacy"'); + } + const url = new URL(getBaseUrl()); + return url.origin === DEFAULT_BASE_URL && url.pathname === "/" && + !url.username && !url.password && !url.search && !url.hash + ? "v5" + : "legacy"; +} + // Backwards-compatible alias for the credential-based accessor introduced on main. export function getApiBaseUrl(): string { return getBaseUrl(); diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..d0600ca --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,81 @@ +import Supermemory, { SupermemoryError, type FilterExpression, type RequestOptions } from "supermemory"; +import { getApiKeyValue, getBaseUrl } from "../config.js"; +import type { ProfileWithSearchResult, SearchResultItem, MemoryScope } from "./client.js"; +import { memoryText, recallProvenance } from "./resultText.js"; + +export function createV5Client(fetchImpl?: typeof fetch): Supermemory { + return new Supermemory({ + apiKey: getApiKeyValue(), + baseUrl: getBaseUrl(), + headers: { "x-sm-source": "codex" }, + timeoutInSeconds: 60, + maxRetries: 2, + fetch: fetchImpl, + }); +} + +export function apiErrorMessage(error: unknown): string { + if (error instanceof SupermemoryError) { + return typeof error.statusCode === "number" + ? `API request failed (${error.statusCode})` + : "API request failed"; + } + return error instanceof Error ? error.message : String(error); +} + +export function scopeFilter(scope?: MemoryScope): FilterExpression | undefined { + return scope ? { field: "sm_scope", operator: "eq", value: scope } : undefined; +} + +export function profileFacts(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((fact) => typeof fact === "string" + ? fact + : fact && typeof fact === "object" ? memoryText(fact) : "") + .filter((fact) => fact.trim().length > 0); +} + +export function normalizeSearchItem(item: SearchResultItem): SearchResultItem { + const provenance = recallProvenance(item); + return { + ...item, + title: provenance.title, + filepath: provenance.filepath, + updatedAt: item.updatedAt ?? item.system?.updatedAt, + }; +} + +export async function readV5Profile( + client: Supermemory, + namespace: string, + query?: string, + scope?: MemoryScope, + requestOptions?: RequestOptions, +): Promise { + const filter = scopeFilter(scope); + const [profile, search] = await Promise.all([ + client.profile(namespace, { filter }, requestOptions), + query ? client.search(namespace, { + query, + filter, + searchMode: "memories", + threshold: 0.6, + limit: 10, + rerank: "none", + rewriteQuery: false, + include: { documents: false, related: false, forgotten: false }, + }, requestOptions) : undefined, + ]); + const results = search?.results.map(normalizeSearchItem).map((item) => ({ + ...item, + memory: memoryText(item), + })).filter((item) => item.memory.length > 0); + return { + success: true, + profile: { + static: profileFacts(profile.profile?.static), + dynamic: profileFacts(profile.profile?.dynamic), + }, + searchResults: results ? { results, total: results.length, timing: search?.searchTime } : undefined, + }; +} diff --git a/src/services/client.ts b/src/services/client.ts index 41ed7c5..7cb5ea9 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,14 +1,11 @@ import Supermemory from "supermemory"; -import type { ProfileParams } from "supermemory/resources/top-level.js"; -import { CONFIG, isConfigured, getApiKeyValue, getBaseUrl, PLUGIN_VERSION } from "../config.js"; +import LegacySupermemory from "supermemory-legacy"; +import { CONFIG, isConfigured, getApiKeyValue, getBaseUrl, getApiVersion, PLUGIN_VERSION } from "../config.js"; import { log } from "./logger.js"; import type { MemoryType } from "../types/index.js"; import { mergeProfileResults, mergeSearchResponses } from "./resultMerge.js"; import { memoryText, recallProvenance } from "./resultText.js"; - -type ProfileParamsWithFilters = ProfileParams & { - filters?: ReturnType; -}; +import { createV5Client, readV5Profile, profileFacts, normalizeSearchItem, scopeFilter, apiErrorMessage } from "./api.js"; const TIMEOUT_MS = 30000; export const HOOK_API_TIMEOUT_MS = 3000; @@ -39,11 +36,7 @@ interface BoundedRequestOptions { timeoutMs: number; } -/** - * Hook requests need both SDK-level cancellation and a local backstop. Passing - * timeout/maxRetries/signal makes the SDK abort the real fetch rather than only - * abandoning its promise, while the race still protects us from SDK regressions. - */ +// Cancel the transport as well as bounding the hook's wait. function withBoundedSdkRequest( request: (signal: AbortSignal) => Promise, timeoutMs: number, @@ -77,6 +70,7 @@ export interface SearchResultItem { updatedAt?: string; metadata?: Record | null; containerTag?: string; + system?: { updatedAt?: string; filepath?: string; filePath?: string; path?: string }; } /** Response shape returned by search APIs. */ @@ -138,50 +132,76 @@ export const PROJECT_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT; export class SupermemoryClient { private client: Supermemory | null = null; + private legacyClient: LegacySupermemory | null = null; private getClient(): Supermemory { if (!this.client) { if (!isConfigured()) { throw new Error("SUPERMEMORY_API_KEY not set"); } - // `x-sm-source` is read by mono's API to attribute searches and - // writes to the Codex plugin in PostHog / `document.source`. - this.client = new Supermemory({ + this.client = createV5Client(); + } + return this.client; + } + + private getLegacyClient(): LegacySupermemory { + if (!this.legacyClient) { + if (!isConfigured()) throw new Error("SUPERMEMORY_API_KEY not set"); + this.legacyClient = new LegacySupermemory({ apiKey: getApiKeyValue(), baseURL: getBaseUrl(), defaultHeaders: { "x-sm-source": CODEX_SOURCE }, + timeout: 60000, + maxRetries: 2, }); } - return this.client; + return this.legacyClient; } - /** - * Get a profile with embedded search results from one container. A scope is - * optional because default recall searches the unified project container. - */ - async getProfileWithSearch( + private async requestProfile( containerTag: string, query?: string, scope?: MemoryScope, requestOptions?: BoundedRequestOptions, - ): Promise { - log("getProfileWithSearch: start", { containerTag, hasQuery: !!query }); - try { + ) { + if (getApiVersion() === "legacy") { const profileParams = { containerTag, q: query, filters: scope ? getScopeFilters(scope) : undefined, - } satisfies ProfileParamsWithFilters as ProfileParams; - const result = requestOptions + }; + return requestOptions ? await withBoundedSdkRequest( - (signal) => this.getClient().profile(profileParams, { + (signal) => this.getLegacyClient().profile(profileParams, { timeout: requestOptions.timeoutMs, maxRetries: 0, signal, }), requestOptions.timeoutMs, ) - : await withTimeout(this.getClient().profile(profileParams), TIMEOUT_MS); + : await withTimeout(this.getLegacyClient().profile(profileParams), TIMEOUT_MS); + } + return requestOptions + ? await withBoundedSdkRequest( + (abortSignal) => readV5Profile(this.getClient(), containerTag, query, scope, { + timeoutInSeconds: requestOptions.timeoutMs / 1000, + maxRetries: 0, + abortSignal, + }), + requestOptions.timeoutMs, + ) + : await withTimeout(readV5Profile(this.getClient(), containerTag, query, scope), TIMEOUT_MS); + } + + async getProfileWithSearch( + containerTag: string, + query?: string, + scope?: MemoryScope, + requestOptions?: BoundedRequestOptions, + ): Promise { + log("getProfileWithSearch: start", { containerTag, hasQuery: !!query }); + try { + const result = await this.requestProfile(containerTag, query, scope, requestOptions); // Dedupe across static, dynamic, and search results const seen = new Set(); @@ -193,8 +213,8 @@ export class SupermemoryClient { return true; }); - const staticFacts = dedupeWithSeen(result.profile?.static || [], (x) => x); - const dynamicFacts = dedupeWithSeen(result.profile?.dynamic || [], (x) => x); + const staticFacts = dedupeWithSeen(profileFacts(result.profile?.static)); + const dynamicFacts = dedupeWithSeen(profileFacts(result.profile?.dynamic)); let searchResults: ProfileWithSearchResult["searchResults"]; if (result.searchResults) { @@ -232,7 +252,7 @@ export class SupermemoryClient { searchResults, }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = apiErrorMessage(error); log("getProfileWithSearch: error", { error: errorMessage }); return { success: false, error: errorMessage, profile: null }; } @@ -274,8 +294,6 @@ export class SupermemoryClient { return mergeProfileResults(results, CONFIG.maxMemories); } - // Keep old methods for backward compatibility - async searchMemories( query: string, containerTag: string, @@ -283,25 +301,40 @@ export class SupermemoryClient { ): Promise { log("searchMemories: start", { containerTag }); try { - const result = await withTimeout( - this.getClient().search.memories({ + const legacy = getApiVersion() === "legacy"; + const result = await withTimeout< + Awaited> | + Awaited> + >( + legacy ? this.getLegacyClient().search.memories({ q: query, containerTag, threshold: CONFIG.similarityThreshold, limit: CONFIG.maxMemories, searchMode: "hybrid", filters: scope ? getScopeFilters(scope) : undefined, + }) : this.getClient().search(containerTag, { + query, + filter: scopeFilter(scope), + threshold: CONFIG.similarityThreshold, + limit: CONFIG.maxMemories, + searchMode: "hybrid", + rerank: "none", + rewriteQuery: false, + include: { documents: false, related: false, forgotten: false }, }), TIMEOUT_MS ); log("searchMemories: success", { count: result.results?.length || 0 }); const results = (result.results as SearchResultItem[]).map((item) => ({ - ...item, + ...normalizeSearchItem(item), containerTag, })); - return { success: true, results, total: result.total, timing: result.timing }; + return { success: true, results, + total: "total" in result ? result.total : results.length, + timing: "searchTime" in result ? result.searchTime : result.timing }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = apiErrorMessage(error); log("searchMemories: error", { error: errorMessage }); return { success: false, error: errorMessage, results: [], total: 0, timing: 0 }; } @@ -338,20 +371,11 @@ export class SupermemoryClient { } async getProfile(containerTag: string, query?: string, scope?: MemoryScope) { - log("getProfile: start", { containerTag }); try { - const result = await withTimeout( - this.getClient().profile({ - containerTag, - q: query, - filters: scope ? getScopeFilters(scope) : undefined, - } satisfies ProfileParamsWithFilters as ProfileParams), - TIMEOUT_MS - ); - log("getProfile: success", { hasProfile: !!result?.profile }); - return { success: true as const, ...result }; + const result = await this.requestProfile(containerTag, query, scope); + return { ...result, success: true as const }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = apiErrorMessage(error); log("getProfile: error", { error: errorMessage }); return { success: false as const, error: errorMessage, profile: null }; } @@ -392,9 +416,6 @@ export class SupermemoryClient { hasEntityContext: !!options?.entityContext, }); try { - // Always stamp `sm_source` so mono's `document.source` column attributes - // these writes to the Codex plugin. Caller-provided metadata wins on - // conflicts so a tool can override the source if it ever needs to. const mergedMetadata = { sm_source: CODEX_SOURCE, sm_client: CODEX_SOURCE, @@ -419,20 +440,43 @@ export class SupermemoryClient { if (options?.entityContext) { payload.entityContext = options.entityContext; } - const result = options?.timeoutMs + const legacy = getApiVersion() === "legacy"; + const v5Payload = { + content, + id: options?.customId, + supportingContext: options?.entityContext, + metadata: mergedMetadata, + taskType: "memory" as const, + dreaming: "dynamic" as const, + }; + const result = legacy ? options?.timeoutMs ? await withBoundedSdkRequest( - (signal) => this.getClient().memories.add(payload, { + (signal) => this.getLegacyClient().memories.add(payload, { timeout: options.timeoutMs, maxRetries: 0, signal, }), options.timeoutMs, ) - : await withTimeout(this.getClient().memories.add(payload), TIMEOUT_MS); + : await withTimeout(this.getLegacyClient().memories.add(payload), TIMEOUT_MS) + : options?.timeoutMs + ? await withBoundedSdkRequest( + (abortSignal) => this.getClient().add(containerTag, v5Payload, { + timeoutInSeconds: options.timeoutMs! / 1000, + maxRetries: 0, + abortSignal, + }), + options.timeoutMs, + ) + : await withTimeout(this.getClient().add(containerTag, v5Payload), TIMEOUT_MS); + if (typeof result.id !== "string" || !result.id.trim() || result.status === "failed" || (!legacy && + !["unknown", "queued", "extracting", "chunking", "embedding", "indexing", "done"].includes(result.status))) { + throw new Error("Document was not accepted for processing"); + } log("addMemory: success", { id: result.id }); return { success: true as const, ...result }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = apiErrorMessage(error); log("addMemory: error", { error: errorMessage }); return { success: false as const, error: errorMessage }; } @@ -441,7 +485,8 @@ export class SupermemoryClient { async updateContainerTagName(containerTag: string, name: string) { log("updateContainerTagName: start", { containerTag, name }); try { - const baseUrl = getBaseUrl(); + getApiVersion(); + const baseUrl = getBaseUrl().replace(/\/+$/, ""); const currentResponse = await withTimeout( fetch(`${baseUrl}/v3/container-tags/${encodeURIComponent(containerTag)}`, { headers: { @@ -507,10 +552,14 @@ export class SupermemoryClient { async forgetMemory(content: string, containerTag: string): Promise<{ success: true; message: string; id?: string } | { success: false; error: string }> { log("forgetMemory: start", { containerTag, contentLength: content.length }); try { + getApiVersion(); const result = await withTimeout( - this.getClient().memories.forget({ containerTag, content }), + this.getLegacyClient().memories.forget({ containerTag, content }), TIMEOUT_MS ); + if (result.forgotten !== true || typeof result.id !== "string" || !result.id.trim()) { + throw new Error("Memory was not forgotten"); + } log("forgetMemory: success", { id: result.id }); return { success: true, message: "Memory forgotten", id: result.id }; } catch (error) { diff --git a/src/services/hookRecallClient.ts b/src/services/hookRecallClient.ts index 2f46d0d..555cf8a 100644 --- a/src/services/hookRecallClient.ts +++ b/src/services/hookRecallClient.ts @@ -1,7 +1,8 @@ -import { CONFIG, getApiKeyValue, getBaseUrl } from "../config.js"; +import { CONFIG, getApiKeyValue, getBaseUrl, getApiVersion } from "../config.js"; import type { ProfileWithSearchResult, SearchResultItem } from "./client.js"; import { mergeProfileResults } from "./resultMerge.js"; import { memoryText, recallProvenance } from "./resultText.js"; +import { createV5Client, readV5Profile, apiErrorMessage } from "./api.js"; export const HOOK_RECALL_TIMEOUT_MS = 3000; // Self-hosted backends need a longer cap than hosted; see #61. @@ -85,6 +86,13 @@ async function fetchProfile( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutMs); try { + if (getApiVersion() === "v5") { + return await readV5Profile(createV5Client(options.fetchImpl), containerTag, query, undefined, { + timeoutInSeconds: options.timeoutMs / 1000, + maxRetries: 0, + abortSignal: controller.signal, + }); + } const response = await options.fetchImpl(`${getBaseUrl().replace(/\/+$/, "")}/v4/profile`, { method: "POST", headers: { @@ -102,7 +110,7 @@ async function fetchProfile( } catch (error) { return { success: false, - error: error instanceof Error ? error.message : String(error), + error: apiErrorMessage(error), profile: null, }; } finally { diff --git a/src/services/resultText.ts b/src/services/resultText.ts index 9ebc3ea..05467d2 100644 --- a/src/services/resultText.ts +++ b/src/services/resultText.ts @@ -9,6 +9,7 @@ export interface MemoryTextShape { filePath?: unknown; path?: unknown; metadata?: unknown; + system?: { updatedAt?: string; filepath?: string; filePath?: string; path?: string }; } export const RECALL_MIN_SIMILARITY = 0.55; @@ -56,6 +57,9 @@ export function recallProvenance( result.filepath, result.filePath, result.path, + result.system?.filepath, + result.system?.filePath, + result.system?.path, metadata.filepath, metadata.filePath, metadata.path, diff --git a/src/skills/status.ts b/src/skills/status.ts index 71200be..a1b4faa 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { CONFIG, getApiBaseUrl, getApiKeyValue, isConfigured } from "../config.js"; +import { CONFIG, getApiBaseUrl, getApiKeyValue, getApiVersion, isConfigured } from "../config.js"; import { loadCredentials } from "../services/auth.js"; import { getTags } from "../services/tags.js"; +import { createV5Client } from "../services/api.js"; const API_URL = getApiBaseUrl(); @@ -87,6 +88,14 @@ async function probeApi(containerTag: string): Promise<{ if (!apiKey) return { ok: false, detail: "not checked (missing API key)" }; try { + if (getApiVersion() === "v5") { + await createV5Client().profile(containerTag, {}, { + timeoutInSeconds: 8, + maxRetries: 0, + abortSignal: AbortSignal.timeout(8_000), + }); + return { ok: true, status: 200, detail: "reachable, key valid" }; + } const response = await fetch(`${API_URL.replace(/\/+$/, "")}/v4/profile`, { method: "POST", headers: { @@ -105,9 +114,14 @@ async function probeApi(containerTag: string): Promise<{ } return { ok: false, status: response.status, detail: "API returned an error" }; } catch (error) { + const status = error && typeof error === "object" && "statusCode" in error + ? error.statusCode : undefined; return { ok: false, - detail: error instanceof Error ? error.message : String(error), + status: typeof status === "number" ? status : undefined, + detail: status === 401 || status === 403 + ? "reachable, key invalid or revoked" + : "API request failed", }; } } @@ -123,7 +137,8 @@ async function main(): Promise { lines.push(`Authenticated: ${isConfigured() ? "yes" : "no"}`); lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); - lines.push(`API URL: ${API_URL}`); + const displayUrl = new URL(API_URL); + lines.push(`API URL: ${displayUrl.origin}${displayUrl.pathname}`); lines.push(`Memory scope: one project container with metadata scopes`); lines.push(`Auto-recall: ${getAutoRecallStatus()}`); lines.push("Auto-capture: after completed turns");